Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# ctxify 🎉
**Turn Your Git Repo into a Clipboard-Ready Context Machine**

*Built mostly with the help of xAI's Grok model—AI-powered coding at its finest!*
*Vibe coded using xAI's based Grok model*

![GitHub release (latest by date)](https://img.shields.io/github/v/release/MQ37/ctxify?color=brightgreen)
![Code Checks](https://github.com/mq37/ctxify/actions/workflows/code_checks.yml/badge.svg)
Expand All @@ -28,7 +28,9 @@ Ever wanted to:
- 📋 **Clipboard Ready**: Copies the tree *and* file contents instantly.
- 🚫 **Smart Filtering**: Ignores non-code files (e.g., `uv.lock`, `.txt`) by default.
- 📝 **Markdown Support**: Optionally include `.md` files with a flag.
- 🎮 **Interactive Mode**: Pick files with fuzzy tab autocompletion.
- 🎮 **Interactive Modes**:
- **Selection Mode**: Pick files with fuzzy tab autocompletion.
- **Exclusion Mode**: Exclude files or directories interactively.
- 🌳 **Structure-Only Mode**: Output just the tree, no contents.
- 📏 **Token Count**: Estimates tokens (1 token ≈ 4 chars) for the full output.

Expand Down Expand Up @@ -74,6 +76,10 @@ ctxify
```bash
ctxify -i
```
- `-e` / `--exclude`: Exclude files or directories interactively with tab autocompletion.
```bash
ctxify -e
```
- `-s` / `--structure`: Output only the project structure, no contents.
```bash
ctxify -s
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "ctxify"
version = "0.1.8"
version = "0.2.0"
description = "A tool to print git repository files with tree structure"
readme = "README.md"
requires-python = ">=3.8"
Expand Down
2 changes: 1 addition & 1 deletion src/ctxify/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '0.1.8'
__version__ = '0.2.0'
47 changes: 31 additions & 16 deletions src/ctxify/cli.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import sys

import click

from ctxify.main import (
copy_to_clipboard,
interactive_file_selection,
print_git_contents,
)
from ctxify.content import print_git_contents
from ctxify.interactive import interactive_file_exclusion, interactive_file_selection
from ctxify.utils import GitRepositoryError, copy_to_clipboard

CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])


@click.command()
@click.command(context_settings=CONTEXT_SETTINGS)
@click.argument('directory', default='.', type=click.Path(exists=True, file_okay=False))
@click.option(
'--md', '-md', is_flag=True, help='Include README and other .md files in output'
Expand All @@ -18,23 +20,36 @@
is_flag=True,
help='Interactively select files to include with tab autocompletion',
)
@click.option(
'-e',
'--exclude',
is_flag=True,
help='Interactively select files to exclude with tab autocompletion',
)
@click.option(
'-s',
'--structure',
is_flag=True,
help='Output only the project structure without file contents',
)
def main(directory: str, md: bool, interactive: bool, structure: bool) -> None:
def main(
directory: str, md: bool, interactive: bool, exclude: bool, structure: bool
) -> None:
"""A tool to print all tracked files in a git repository directory with tree structure and copy to clipboard."""
output: str
if interactive:
output = interactive_file_selection(directory, include_md=md)
else:
output = print_git_contents(
root_dir=directory, include_md=md, structure_only=structure
)
if copy_to_clipboard(output):
click.echo('Project context copied to clipboard!')
try:
output: str
if interactive:
output = interactive_file_selection(directory, include_md=md)
elif exclude:
output = interactive_file_exclusion(directory, include_md=md)
else:
output = print_git_contents(
root_dir=directory, include_md=md, structure_only=structure
)
if copy_to_clipboard(output):
click.echo('Project context copied to clipboard!')
except GitRepositoryError:
sys.exit(1)


if __name__ == '__main__':
Expand Down
71 changes: 71 additions & 0 deletions src/ctxify/content.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from pathlib import Path
from typing import List, Optional

from ctxify.utils import (
GitRepositoryError,
check_git_repo,
estimate_tokens,
get_git_files,
print_filtered_tree,
)


def print_git_contents(
root_dir: str = '.',
include_md: bool = False,
structure_only: bool = False,
excluded_items: Optional[List[str]] = None,
) -> str:
"""Build output for clipboard, print tree with all files and token count to stdout."""
if not check_git_repo(root_dir):
print(
f'Error: {root_dir} is not within a git repository. This tool requires a git repository.'
)
raise GitRepositoryError(f'{root_dir} is not within a git repository')

output_lines: List[str] = []
tree_lines: List[str] = []

target_dir = Path(root_dir).resolve()
errors, all_files, code_files = get_git_files(root_dir, include_md=include_md)
if errors:
tree_lines.extend(errors)
print('\n'.join(tree_lines))
return '\n'.join(tree_lines)

if excluded_items:
excluded_files = set()
for item in excluded_items:
if item in all_files:
excluded_files.add(item)
else:
excluded_files.update(f for f in all_files if f.startswith(item + '/'))
all_files = [f for f in all_files if f not in excluded_files]
code_files = [f for f in code_files if f not in excluded_files]

tree_lines.append(f'\nFiles Included in Context (from {root_dir}):')
print_filtered_tree(all_files, tree_lines)
output_lines.extend(tree_lines)

if not structure_only:
output_lines.append('\n' + '-' * 50 + '\n')
for file_path in code_files:
full_path = target_dir / file_path
if full_path.is_file():
output_lines.append(f'{file_path}:')
try:
with open(full_path, 'r', encoding='utf-8') as f:
content = f.read()
output_lines.append(content)
except Exception as e:
output_lines.append(f'Error reading file: {e}')
output_lines.append('')

full_output = '\n'.join(output_lines)
token_count = estimate_tokens(full_output)
token_info = (
f'\nApproximate token count: {token_count} (based on 1 token ≈ 4 chars)'
)
tree_lines.append(token_info)
print('\n'.join(tree_lines))
return full_output
175 changes: 175 additions & 0 deletions src/ctxify/interactive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
from pathlib import Path
from typing import List

from prompt_toolkit import PromptSession
from prompt_toolkit.completion import FuzzyWordCompleter

from ctxify.utils import (
GitRepositoryError,
check_git_repo,
estimate_tokens,
get_git_files,
print_filtered_tree,
)


def interactive_file_selection(root_dir: str = '.', include_md: bool = False) -> str:
"""Interactively select files or directories to include with fuzzy tab autocompletion."""
if not check_git_repo(root_dir):
print(
f'Error: {root_dir} is not within a git repository. This tool requires a git repository.'
)
raise GitRepositoryError(f'{root_dir} is not within a git repository')

output_lines: List[str] = []
tree_lines: List[str] = []

errors, all_files, code_files = get_git_files(root_dir, include_md=include_md)
if errors:
tree_lines.extend(errors)
print('\n'.join(tree_lines))
return '\n'.join(tree_lines)

all_dirs = {
str(parent)
for f in all_files
for parent in Path(f).parents
if str(parent) != '.'
}
completion_options = sorted(all_files + list(all_dirs))

completer = FuzzyWordCompleter(completion_options)
session = PromptSession(completer=completer, complete_while_typing=True)

tree_lines.append(
f'\nFiles and Directories Available in Context (from {root_dir}):'
)
print_filtered_tree(all_files, tree_lines)
print('\n'.join(tree_lines))
print('\nEnter file or directory paths to include (press Enter twice to finish):')

selected_items: List[str] = []
while True:
try:
input_path = session.prompt('> ')
if not input_path:
if not selected_items:
continue
break
if input_path in completion_options:
if input_path not in selected_items:
selected_items.append(input_path)
print(f'Added: {input_path}')
else:
print(f'Already added: {input_path}')
else:
print(f'Path not found: {input_path}')
except KeyboardInterrupt:
break

output_lines.extend(tree_lines)
output_lines.append('\n' + '-' * 50 + '\n')
target_dir = Path(root_dir).resolve()

for item_path in selected_items:
full_path = target_dir / item_path
if full_path.is_file():
output_lines.append(f'{item_path}:')
try:
with open(full_path, 'r', encoding='utf-8') as f:
content = f.read()
output_lines.append(content)
except Exception as e:
output_lines.append(f'Error reading file: {e}')
output_lines.append('')
elif full_path.is_dir():
dir_files = [
f for f in all_files if f.startswith(item_path + '/') or f == item_path
]
if not dir_files:
output_lines.append(f'No tracked files found in {item_path}')
continue
for file_path in dir_files:
full_file_path = target_dir / file_path
if full_file_path.is_file():
output_lines.append(f'{file_path}:')
try:
with open(full_file_path, 'r', encoding='utf-8') as f:
content = f.read()
output_lines.append(content)
except Exception as e:
output_lines.append(f'Error reading file: {e}')
output_lines.append('')

full_output = '\n'.join(output_lines)
token_count = estimate_tokens(full_output)
token_info = (
f'\nApproximate token count: {token_count} (based on 1 token ≈ 4 chars)'
)
tree_lines.append(token_info)
print('\n'.join(tree_lines[len(tree_lines) - 1 :]))
return full_output


def interactive_file_exclusion(root_dir: str = '.', include_md: bool = False) -> str:
"""Interactively select files or directories to exclude with fuzzy tab autocompletion."""
if not check_git_repo(root_dir):
print(
f'Error: {root_dir} is not within a git repository. This tool requires a git repository.'
)
raise GitRepositoryError(f'{root_dir} is not within a git repository')

tree_lines: List[str] = []

errors, all_files, code_files = get_git_files(root_dir, include_md=include_md)
if errors:
tree_lines.extend(errors)
print('\n'.join(tree_lines))
return '\n'.join(tree_lines)

all_dirs = {
str(parent)
for f in all_files
for parent in Path(f).parents
if str(parent) != '.'
}
completion_options = sorted(all_files + list(all_dirs))

completer = FuzzyWordCompleter(completion_options)
session = PromptSession(completer=completer, complete_while_typing=True)

tree_lines.append(
f'\nFiles and Directories Available in Context (from {root_dir}):'
)
print_filtered_tree(all_files, tree_lines)
print('\n'.join(tree_lines))
print('\nEnter file or directory paths to exclude (press Enter twice to finish):')

excluded_items: List[str] = []
while True:
try:
input_path = session.prompt('> ')
if not input_path:
if not excluded_items:
continue
break
if input_path in completion_options:
if input_path not in excluded_items:
excluded_items.append(input_path)
print(f'Excluded: {input_path}')
else:
print(f'Already excluded: {input_path}')
else:
print(f'Path not found: {input_path}')
except KeyboardInterrupt:
break

from .content import print_git_contents

output = print_git_contents(
root_dir=root_dir,
include_md=include_md,
structure_only=False,
excluded_items=excluded_items,
)
return output
Loading