(formatting) : format code with ruff formatter.

This commit is contained in:
akadeepesh
2026-05-24 02:23:12 +05:30
parent 15192e2048
commit eacf85f0b8
16 changed files with 381 additions and 217 deletions
+1 -1
View File
@@ -1 +1 @@
"""contextzip.ai — AI-powered file selection for --prompt mode."""
"""contextzip.ai — AI-powered file selection for --prompt mode."""
+13 -14
View File
@@ -14,10 +14,10 @@ Design principles:
from __future__ import annotations
import json
from pathlib import Path
try:
import httpx
_HTTPX_AVAILABLE = True
except ImportError:
_HTTPX_AVAILABLE = False
@@ -45,6 +45,7 @@ _TIMEOUT_SECONDS = 30
# Exceptions
# ---------------------------------------------------------------------------
class GeminiError(Exception):
"""Raised when the Gemini API call fails for any reason."""
@@ -61,11 +62,12 @@ class GeminiRateLimitError(GeminiError):
# Public API
# ---------------------------------------------------------------------------
def select_files(
*,
api_key: str,
prompt: str,
file_tree: list[tuple[str, int]], # [(rel_path, size_bytes), ...]
file_tree: list[tuple[str, int]], # [(rel_path, size_bytes), ...]
ecosystem: str,
model: str = DEFAULT_MODEL,
) -> list[str]:
@@ -109,7 +111,7 @@ def select_files(
)
system_prompt = _build_system_prompt()
user_message = _build_user_message(prompt, file_tree, ecosystem)
user_message = _build_user_message(prompt, file_tree, ecosystem)
url = _API_URL.format(model=model, key=api_key)
@@ -121,8 +123,8 @@ def select_files(
}
],
"generationConfig": {
"temperature": 0.0, # deterministic — this is a ranking task
"maxOutputTokens": 512, # a list of paths needs very few tokens
"temperature": 0.0, # deterministic — this is a ranking task
"maxOutputTokens": 512, # a list of paths needs very few tokens
"responseMimeType": "application/json",
},
}
@@ -148,8 +150,7 @@ def select_files(
)
if response.status_code != 200:
raise GeminiError(
f"Gemini API returned HTTP {response.status_code}: "
f"{response.text[:200]}"
f"Gemini API returned HTTP {response.status_code}: {response.text[:200]}"
)
return _parse_response(response.json(), file_tree)
@@ -159,6 +160,7 @@ def select_files(
# Prompt construction
# ---------------------------------------------------------------------------
def _build_system_prompt() -> str:
return """\
You are a precise file relevance assistant for software projects.
@@ -186,10 +188,7 @@ def _build_user_message(
file_tree: list[tuple[str, int]],
ecosystem: str,
) -> str:
tree_lines = "\n".join(
f"{path} ({_human_size(size)})"
for path, size in file_tree
)
tree_lines = "\n".join(f"{path} ({_human_size(size)})" for path, size in file_tree)
return f"""\
Framework: {ecosystem}
@@ -206,6 +205,7 @@ Return only a JSON array of the most relevant file paths for this task.\
# Response parsing and validation
# ---------------------------------------------------------------------------
def _parse_response(
data: dict,
file_tree: list[tuple[str, int]],
@@ -220,9 +220,7 @@ def _parse_response(
"""
# Navigate the Gemini response structure
try:
text = (
data["candidates"][0]["content"]["parts"][0]["text"]
)
text = data["candidates"][0]["content"]["parts"][0]["text"]
except (KeyError, IndexError) as exc:
raise GeminiError(f"Unexpected API response structure: {exc}\n{data}")
@@ -275,6 +273,7 @@ def _parse_response(
# Helpers
# ---------------------------------------------------------------------------
def _human_size(n: int) -> str:
for unit in ("B", "KB", "MB"):
if n < 1024:
+149 -30
View File
@@ -36,50 +36,167 @@ from pathlib import Path
MAX_FILES = 8
# Words that carry no signal — filtered out before scoring
_STOPWORDS = frozenset({
"i", "a", "an", "the", "to", "in", "on", "at", "of", "for",
"and", "or", "but", "is", "it", "be", "do", "my", "this",
"that", "with", "from", "want", "need", "make", "update",
"change", "fix", "add", "get", "use", "set", "new", "old",
"file", "code", "function", "method", "class", "variable",
})
_STOPWORDS = frozenset(
{
"i",
"a",
"an",
"the",
"to",
"in",
"on",
"at",
"of",
"for",
"and",
"or",
"but",
"is",
"it",
"be",
"do",
"my",
"this",
"that",
"with",
"from",
"want",
"need",
"make",
"update",
"change",
"fix",
"add",
"get",
"use",
"set",
"new",
"old",
"file",
"code",
"function",
"method",
"class",
"variable",
}
)
# Directory names that suggest source code worth including
_SOURCE_DIRS = frozenset({
"src", "app", "lib", "libs", "components", "component",
"utils", "util", "helpers", "helper", "routes", "route",
"pages", "page", "server", "api", "services", "service",
"hooks", "hook", "store", "stores", "context", "contexts",
"middleware", "handlers", "handler", "controllers", "controller",
"views", "view", "models", "model", "core", "common", "shared",
})
_SOURCE_DIRS = frozenset(
{
"src",
"app",
"lib",
"libs",
"components",
"component",
"utils",
"util",
"helpers",
"helper",
"routes",
"route",
"pages",
"page",
"server",
"api",
"services",
"service",
"hooks",
"hook",
"store",
"stores",
"context",
"contexts",
"middleware",
"handlers",
"handler",
"controllers",
"controller",
"views",
"view",
"models",
"model",
"core",
"common",
"shared",
}
)
# Directory names that suggest low relevance for most coding tasks
_NOISE_DIRS = frozenset({
"test", "tests", "__tests__", "spec", "specs",
"docs", "doc", "documentation", "examples", "example",
"fixtures", "mocks", "mock", "stubs", "stub",
"scripts", "bin", "dist", "build", "out", "coverage",
})
_NOISE_DIRS = frozenset(
{
"test",
"tests",
"__tests__",
"spec",
"specs",
"docs",
"doc",
"documentation",
"examples",
"example",
"fixtures",
"mocks",
"mock",
"stubs",
"stub",
"scripts",
"bin",
"dist",
"build",
"out",
"coverage",
}
)
# Extensions that suggest runnable/editable source code
_CODE_EXTENSIONS = frozenset({
".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".rb",
".java", ".kt", ".swift", ".cs", ".cpp", ".c", ".h",
".vue", ".svelte", ".astro",
})
_CODE_EXTENSIONS = frozenset(
{
".py",
".ts",
".tsx",
".js",
".jsx",
".go",
".rs",
".rb",
".java",
".kt",
".swift",
".cs",
".cpp",
".c",
".h",
".vue",
".svelte",
".astro",
}
)
# Extensions that are config-like (lower relevance unless prompt mentions them)
_CONFIG_EXTENSIONS = frozenset({
".json", ".toml", ".yaml", ".yml", ".ini", ".cfg", ".env",
".md", ".txt", ".lock", ".sum",
})
_CONFIG_EXTENSIONS = frozenset(
{
".json",
".toml",
".yaml",
".yml",
".ini",
".cfg",
".env",
".md",
".txt",
".lock",
".sum",
}
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def select_files(
*,
prompt: str,
@@ -124,6 +241,7 @@ def select_files(
# Scoring
# ---------------------------------------------------------------------------
def _score(rel_path: str, tokens: set[str], size_bytes: int) -> float:
"""Compute a relevance score for a single file path."""
path = Path(rel_path)
@@ -183,6 +301,7 @@ def _score(rel_path: str, tokens: set[str], size_bytes: int) -> float:
# Text helpers
# ---------------------------------------------------------------------------
def _tokenize(text: str) -> set[str]:
"""
Extract meaningful lowercase tokens from *text*.
+6 -10
View File
@@ -12,11 +12,11 @@ from __future__ import annotations
from pathlib import Path
from contextzip.ai.gemini import select_files, GeminiError, GeminiRateLimitError
from contextzip.ai.gemini import select_files, GeminiRateLimitError
from contextzip.filters import ResolveResult
# Sentinels — tell the CLI which selection path was taken
USED_GEMINI = "gemini"
USED_GEMINI = "gemini"
USED_HEURISTIC = "heuristic"
@@ -24,6 +24,7 @@ USED_HEURISTIC = "heuristic"
# Public API
# ---------------------------------------------------------------------------
def ai_select(
*,
resolved: ResolveResult,
@@ -66,14 +67,9 @@ def ai_select(
# Map relative path strings back to absolute Path objects
rel_to_abs: dict[str, Path] = {
p.relative_to(project_dir).as_posix(): p
for p in resolved.included
p.relative_to(project_dir).as_posix(): p for p in resolved.included
}
selected_paths = [
rel_to_abs[rel]
for rel in selected_rel
if rel in rel_to_abs
]
selected_paths = [rel_to_abs[rel] for rel in selected_rel if rel in rel_to_abs]
prompt_txt = _build_prompt_txt(prompt, selected_rel, ecosystem, method)
@@ -89,6 +85,7 @@ def build_prompt_only_txt(prompt: str, ecosystem: str) -> str:
# Internal helpers
# ---------------------------------------------------------------------------
def _build_file_tree(
included: list[Path],
project_dir: Path,
@@ -128,7 +125,6 @@ def _build_prompt_txt(
Any AI tool that receives the ZIP immediately sees the task description,
the framework, and exactly which files were selected and why.
"""
from contextzip import __version__
selector_label = (
"contextzip AI (Gemini)"
+117 -71
View File
@@ -37,7 +37,13 @@ from rich.table import Table
from rich import box
from contextzip import __version__
from contextzip.config import get_api_key, save_api_key, delete_api_key, config_path, diagnose_api_key
from contextzip.config import (
get_api_key,
save_api_key,
delete_api_key,
config_path,
diagnose_api_key,
)
from contextzip.detector import detect
from contextzip.filters import (
build_spec,
@@ -59,6 +65,7 @@ console = Console()
# Defined once so the main command and every subcommand declare exactly the
# same flags without duplicating help strings.
def _modifier_options(f):
"""
Attach all run-modifier flags to a command.
@@ -69,8 +76,10 @@ def _modifier_options(f):
"""
decorators = [
click.option(
"--prompt", "-p",
default=None, metavar="TEXT",
"--prompt",
"-p",
default=None,
metavar="TEXT",
help=(
"Describe your task in natural language. contextzip uses Gemini AI "
"to select only the files relevant to that task. "
@@ -78,36 +87,45 @@ def _modifier_options(f):
),
),
click.option(
"--dry-run", "-n",
is_flag=True, default=False,
"--dry-run",
"-n",
is_flag=True,
default=False,
help="Show what would be included without creating the ZIP.",
),
click.option(
"--output", "-o",
default=None, metavar="FILE",
"--output",
"-o",
default=None,
metavar="FILE",
help="Output ZIP path. Bypasses .contextzip/ workspace — writes directly to FILE.",
),
click.option(
"--no-clipboard",
is_flag=True, default=False,
is_flag=True,
default=False,
help="Skip clipboard / folder-open step after creating the ZIP.",
),
click.option(
"--no-gitignore",
is_flag=True, default=False,
is_flag=True,
default=False,
help="Ignore the project's .gitignore file (use only built-in rules).",
),
click.option(
"--git-changes",
is_flag=True, default=False,
is_flag=True,
default=False,
help=(
"Only include files that git reports as modified, added, or untracked. "
"Requires the project to be inside a git repository."
),
),
click.option(
"--verbose", "-v",
is_flag=True, default=False,
"--verbose",
"-v",
is_flag=True,
default=False,
help="Show every included and excluded file.",
),
]
@@ -120,21 +138,26 @@ def _modifier_options(f):
# CLI group
# ---------------------------------------------------------------------------
@click.group(
invoke_without_command=True,
context_settings={"help_option_names": ["-h", "--help"]},
)
@click.option(
"--include", "-i",
multiple=True, metavar="PATH",
"--include",
"-i",
multiple=True,
metavar="PATH",
help=(
"Only include files under these paths (relative to project root). "
"Repeatable: --include src --include app | or use: contextzip include src app"
),
)
@click.option(
"--exclude", "-e",
multiple=True, metavar="PATTERN",
"--exclude",
"-e",
multiple=True,
metavar="PATTERN",
help=(
"Extra exclusion patterns on top of auto-rules (gitignore syntax). "
"Repeatable: -e '*.log' -e CHANGELOG.md | or use: contextzip exclude CHANGELOG.md *.log"
@@ -191,6 +214,7 @@ def main(
# Subcommand: exclude
# ---------------------------------------------------------------------------
@main.command("exclude")
@click.argument("patterns", nargs=-1, required=True, metavar="PATTERN…")
@_modifier_options
@@ -234,6 +258,7 @@ def cmd_exclude(
# Subcommand: include
# ---------------------------------------------------------------------------
@main.command("include")
@click.argument("paths", nargs=-1, required=True, metavar="PATH…")
@_modifier_options
@@ -276,6 +301,7 @@ def cmd_include(
# Core execution logic (shared by main command + all subcommands)
# ---------------------------------------------------------------------------
def _run(
*,
extra_exclude: list[str] | None,
@@ -301,7 +327,8 @@ def _run(
Panel.fit(
f"[bold cyan]contextzip[/] [dim]v{__version__}[/]\n"
f"[dim]Project:[/] [white]{project_dir}[/]",
border_style="cyan", padding=(0, 2),
border_style="cyan",
padding=(0, 2),
)
)
console.print()
@@ -321,7 +348,8 @@ def _run(
console.print(
Panel.fit(
f"[red]Git error:[/] {git_result.message}",
border_style="red", padding=(0, 2),
border_style="red",
padding=(0, 2),
)
)
raise SystemExit(1)
@@ -345,14 +373,11 @@ def _run(
# ── Build exclusion spec ─────────────────────────────────────────────
gitignore_path = None if no_gitignore else (project_dir / ".gitignore")
used_gitignore = (
not no_gitignore
and gitignore_path is not None
and gitignore_path.is_file()
not no_gitignore and gitignore_path is not None and gitignore_path.is_file()
)
normalized_exclude = (
[_normalize_pattern(p) for p in extra_exclude]
if extra_exclude else []
[_normalize_pattern(p) for p in extra_exclude] if extra_exclude else []
)
with console.status("[cyan]Building exclusion rules…[/]", spinner="dots"):
@@ -363,7 +388,7 @@ def _run(
)
if used_gitignore:
console.print(f" [dim]↳ .gitignore patterns applied[/]")
console.print("[dim]↳ .gitignore patterns applied[/]")
console.print()
# ── Resolve files ─────────────────────────────────────────────────────
@@ -387,7 +412,9 @@ def _run(
)
for p, size in resolved.large_files[:5]:
rel = p.relative_to(project_dir).as_posix()
console.print(f" [yellow]·[/] [dim]{rel}[/] [yellow]{_human_size(size)}[/]")
console.print(
f" [yellow]·[/] [dim]{rel}[/] [yellow]{_human_size(size)}[/]"
)
if len(resolved.large_files) > 5:
console.print(f" [dim]… and {len(resolved.large_files) - 5} more[/]")
console.print(
@@ -446,7 +473,8 @@ def _run(
Panel.fit(
"[yellow]Dry run — no ZIP created.[/]\n"
"[dim]Remove --dry-run to produce the archive.[/]",
border_style="yellow", padding=(0, 2),
border_style="yellow",
padding=(0, 2),
)
)
return
@@ -528,7 +556,6 @@ def _run(
_print_clipboard_result(cb)
# ---------------------------------------------------------------------------
# API key onboarding
# ---------------------------------------------------------------------------
@@ -546,6 +573,7 @@ def _open_browser_silent(url: str) -> None:
to /dev/null to keep the terminal clean.
"""
import subprocess
try:
subprocess.Popen(
["xdg-open", url],
@@ -590,9 +618,7 @@ def _onboard_api_key() -> str | None:
)
if open_browser:
_open_browser_silent(_GEMINI_KEY_URL)
console.print(
" [dim]Browser opened. Generate a key, then come back here.[/]"
)
console.print(" [dim]Browser opened. Generate a key, then come back here.[/]")
console.print()
console.print(" [dim]─────────────────────────────────────────────[/]")
@@ -642,6 +668,7 @@ def _onboard_api_key() -> str | None:
# AI selection helpers
# ---------------------------------------------------------------------------
def _run_ai_selection(
*,
resolved,
@@ -674,7 +701,8 @@ def _run_ai_selection(
console.print(
Panel.fit(
f"[red]Gemini error:[/] {exc}",
border_style="red", padding=(0, 2),
border_style="red",
padding=(0, 2),
)
)
raise SystemExit(1)
@@ -720,7 +748,8 @@ def _run_ai_selection_preview(
console.print(
Panel.fit(
f"[red]Gemini error:[/] {exc}",
border_style="red", padding=(0, 2),
border_style="red",
padding=(0, 2),
)
)
raise SystemExit(1)
@@ -737,7 +766,8 @@ def _run_ai_selection_preview(
Panel.fit(
"[yellow]Dry run — no ZIP created.[/]\n"
"[dim]Remove --dry-run to package these files.[/]",
border_style="yellow", padding=(0, 2),
border_style="yellow",
padding=(0, 2),
)
)
@@ -746,15 +776,18 @@ def _run_ai_selection_preview(
# Subcommand: config
# ---------------------------------------------------------------------------
@main.command("config")
@click.option(
"--reset-key",
is_flag=True, default=False,
is_flag=True,
default=False,
help="Clear the stored Gemini API key and re-run the setup prompt.",
)
@click.option(
"--show-key-path",
is_flag=True, default=False,
is_flag=True,
default=False,
help="Print the path to the config file and exit.",
)
def cmd_config(reset_key: bool, show_key_path: bool) -> None:
@@ -774,8 +807,7 @@ def cmd_config(reset_key: bool, show_key_path: bool) -> None:
removed = delete_api_key()
if removed:
console.print(
"\n [green]✓[/] API key removed from "
f"[dim]{config_path()}[/]"
f"\n [green]✓[/] API key removed from [dim]{config_path()}[/]"
)
else:
console.print("\n [dim]No API key was stored.[/]")
@@ -795,23 +827,29 @@ def cmd_config(reset_key: bool, show_key_path: bool) -> None:
if key:
masked = key[:8] + "…" + key[-4:] if len(key) > 12 else "****"
source = "[dim](from environment variable)[/]" if from_env else f"[dim]({config_path()})[/]"
source = (
"[dim](from environment variable)[/]"
if from_env
else f"[dim]({config_path()})[/]"
)
console.print(
Panel(
f" [dim]Gemini API key:[/] [green]{masked}[/] {source}\n\n"
" [dim]Run [cyan]contextzip config --reset-key[/] to change it.[/]",
title="[bold]contextzip config[/]",
border_style="cyan", padding=(0, 2),
border_style="cyan",
padding=(0, 2),
)
)
else:
console.print(
Panel(
" No Gemini API key configured.\n\n"
" Run [cyan]contextzip --prompt \"your task\"[/] to set one up,\n"
' Run [cyan]contextzip --prompt "your task"[/] to set one up,\n'
" or [cyan]contextzip config --reset-key[/] to go through setup now.",
title="[bold]contextzip config[/]",
border_style="yellow", padding=(0, 2),
border_style="yellow",
padding=(0, 2),
)
)
console.print()
@@ -821,6 +859,7 @@ def cmd_config(reset_key: bool, show_key_path: bool) -> None:
# Normalisation
# ---------------------------------------------------------------------------
def _normalize_pattern(p: str) -> str:
"""
Canonicalise a user-supplied exclusion pattern.
@@ -848,20 +887,22 @@ def _normalize_pattern(p: str) -> str:
# Display helpers
# ---------------------------------------------------------------------------
def _print_detection(detection) -> None:
if detection.is_unknown:
ecosystem_line = "[yellow]Unknown[/] — applying base rules only"
else:
colours = {
"Next.js": "bright_blue", "Node.js": "green",
"Python": "yellow", "Django": "green",
"FastAPI": "cyan", "Rust": "red",
"Go": "cyan", "Ruby": "red",
"Next.js": "bright_blue",
"Node.js": "green",
"Python": "yellow",
"Django": "green",
"FastAPI": "cyan",
"Rust": "red",
"Go": "cyan",
"Ruby": "red",
}
parts = [
f"[{colours.get(n, 'white')}]{n}[/]"
for n in detection.ecosystems
]
parts = [f"[{colours.get(n, 'white')}]{n}[/]" for n in detection.ecosystems]
ecosystem_line = " [dim]+[/] ".join(parts)
conf_colour = {"high": "green", "medium": "yellow", "low": "dim"}.get(
@@ -873,7 +914,8 @@ def _print_detection(detection) -> None:
f" [dim]Confidence:[/] [{conf_colour}]{detection.confidence}[/]\n"
f" [dim]Rules :[/] [dim]{', '.join(detection.rule_modules)}[/]",
title="[bold]Detection[/]",
border_style="blue", padding=(0, 1),
border_style="blue",
padding=(0, 1),
)
)
console.print()
@@ -884,8 +926,8 @@ def _print_git_summary(changes: GitChanges, project_dir: Path, verbose: bool) ->
table = Table(box=box.ROUNDED, show_header=False, padding=(0, 2))
table.add_column(style="dim")
table.add_column()
table.add_row("Staged", f"[green]{len(changes.staged)}[/]")
table.add_row("Unstaged", f"[yellow]{len(changes.unstaged)}[/]")
table.add_row("Staged", f"[green]{len(changes.staged)}[/]")
table.add_row("Unstaged", f"[yellow]{len(changes.unstaged)}[/]")
table.add_row("Untracked", f"[cyan]{len(changes.untracked)}[/]")
if changes.deleted:
table.add_row("Deleted (skipped)", f"[dim]{len(changes.deleted)}[/]")
@@ -897,7 +939,8 @@ def _print_git_summary(changes: GitChanges, project_dir: Path, verbose: bool) ->
Panel(
table,
title="[bold]Git Changes[/]",
border_style="magenta", padding=(0, 1),
border_style="magenta",
padding=(0, 1),
)
)
console.print()
@@ -905,8 +948,8 @@ def _print_git_summary(changes: GitChanges, project_dir: Path, verbose: bool) ->
if verbose and changes.files:
console.print("[bold]Git-changed files:[/]")
for category, paths, colour in (
("Staged", changes.staged, "green"),
("Unstaged", changes.unstaged, "yellow"),
("Staged", changes.staged, "green"),
("Unstaged", changes.unstaged, "yellow"),
("Untracked", changes.untracked, "cyan"),
):
for rel in paths:
@@ -921,7 +964,7 @@ def _print_scan_summary(
*,
git_mode: bool = False,
) -> None:
total = len(resolved.included) + len(resolved.excluded)
total = len(resolved.included) + len(resolved.excluded)
included_size = sum(p.stat().st_size for p in resolved.included if p.exists())
table = Table(box=box.ROUNDED, show_header=False, padding=(0, 2))
@@ -929,15 +972,15 @@ def _print_scan_summary(
table.add_column()
if not git_mode:
table.add_row("Files scanned", str(total + len(resolved.skipped)))
table.add_row("Files scanned", str(total + len(resolved.skipped)))
table.add_row(
"To be included",
f"[green]{len(resolved.included)}[/] [dim]({_human_size(included_size)})[/]",
)
if not git_mode:
table.add_row("Excluded", f"[red]{len(resolved.excluded)}[/]")
table.add_row("Excluded", f"[red]{len(resolved.excluded)}[/]")
if resolved.skipped:
table.add_row("Skipped", f"[yellow]{len(resolved.skipped)}[/]")
table.add_row("Skipped", f"[yellow]{len(resolved.skipped)}[/]")
console.print(table)
if verbose and resolved.included:
@@ -945,7 +988,7 @@ def _print_scan_summary(
console.print("[bold]Included files:[/]")
for p in resolved.included:
size_str = _human_size(p.stat().st_size) if p.exists() else "?"
rel = p.relative_to(project_dir).as_posix()
rel = p.relative_to(project_dir).as_posix()
console.print(f" [green]✓[/] {rel} [dim]{size_str}[/]")
if not git_mode and resolved.excluded:
@@ -961,7 +1004,7 @@ def _print_scan_summary(
def _print_package_result(result) -> None:
ratio_colour = "green" if result.compression_ratio >= 0.3 else "yellow"
size_detail = (
size_detail = (
"[dim](ZIP overhead on tiny project)[/]"
if result.grew
else f"[{ratio_colour}](↓ {result.compression_pct} smaller)[/]"
@@ -970,19 +1013,20 @@ def _print_package_result(result) -> None:
table = Table(box=box.ROUNDED, show_header=False, padding=(0, 2))
table.add_column(style="dim", min_width=18)
table.add_column()
table.add_row("Files packed", f"[green]{result.file_count}[/]")
table.add_row("Original size", _human_size(result.uncompressed_bytes))
table.add_row("Files packed", f"[green]{result.file_count}[/]")
table.add_row("Original size", _human_size(result.uncompressed_bytes))
table.add_row(
"Compressed size",
f"[bold]{_human_size(result.compressed_bytes)}[/] {size_detail}",
)
table.add_row("Saved to", f"[cyan]{result.zip_path}[/]")
table.add_row("Saved to", f"[cyan]{result.zip_path}[/]")
console.print(
Panel(
table,
title="[bold green]✓ ZIP created[/]",
border_style="green", padding=(0, 1),
border_style="green",
padding=(0, 1),
)
)
@@ -1009,8 +1053,9 @@ def _print_ai_selection(
console.print(
Panel(
table,
title=f"[bold cyan]AI Selected — [dim]\"{prompt}\"[/][/]",
border_style="cyan", padding=(0, 1),
title=f'[bold cyan]AI Selected — [dim]"{prompt}"[/][/]',
border_style="cyan",
padding=(0, 1),
)
)
console.print(
@@ -1022,16 +1067,17 @@ def _print_ai_selection(
def _print_clipboard_result(cb) -> None:
tier_style = {
Tier.FILE_ON_CLIPBOARD: ("green", "✓ Ready to paste"),
Tier.FOLDER_OPENED: ("yellow", "✓ Folder opened"),
Tier.PATH_ONLY: ("dim", "↳ Manual copy needed"),
Tier.FILE_ON_CLIPBOARD: ("green", "✓ Ready to paste"),
Tier.FOLDER_OPENED: ("yellow", "✓ Folder opened"),
Tier.PATH_ONLY: ("dim", "↳ Manual copy needed"),
}
border, title = tier_style.get(cb.tier, ("dim", "Clipboard"))
console.print(
Panel.fit(
cb.message,
title=f"[bold {border}]{title}[/]",
border_style=border, padding=(0, 2),
border_style=border,
padding=(0, 2),
)
)
+15 -10
View File
@@ -33,16 +33,17 @@ from pathlib import Path
# Result model
# ---------------------------------------------------------------------------
class Tier(Enum):
FILE_ON_CLIPBOARD = 1 # actual file object on clipboard — paste into browser
FOLDER_OPENED = 2 # folder opened / file highlighted in file manager
PATH_ONLY = 3 # nothing automatic; path printed for manual copy
FILE_ON_CLIPBOARD = 1 # actual file object on clipboard — paste into browser
FOLDER_OPENED = 2 # folder opened / file highlighted in file manager
PATH_ONLY = 3 # nothing automatic; path printed for manual copy
@dataclass
class ClipboardResult:
tier: Tier
message: str # human-readable outcome line
message: str # human-readable outcome line
success: bool = True # False only on unexpected errors worth surfacing
@@ -50,6 +51,7 @@ class ClipboardResult:
# Public API
# ---------------------------------------------------------------------------
def handle(zip_path: Path) -> ClipboardResult:
"""
Attempt to put *zip_path* on the clipboard using the best available tier.
@@ -89,6 +91,7 @@ def handle(zip_path: Path) -> ClipboardResult:
# Tier 1 — file object on clipboard
# ---------------------------------------------------------------------------
def _tier1_macos(zip_path: Path) -> ClipboardResult | None:
"""
Use osascript to ask Finder to copy the file to the clipboard.
@@ -103,7 +106,9 @@ def _tier1_macos(zip_path: Path) -> ClipboardResult | None:
try:
proc = subprocess.run(
["osascript", "-e", script],
capture_output=True, text=True, timeout=8,
capture_output=True,
text=True,
timeout=8,
)
if proc.returncode == 0:
return ClipboardResult(
@@ -145,12 +150,14 @@ def _tier1_linux(zip_path: Path) -> ClipboardResult | None:
# Tier 2 — open folder / highlight file
# ---------------------------------------------------------------------------
def _tier2_macos(zip_path: Path) -> ClipboardResult | None:
"""open -R reveals and selects the file in Finder."""
try:
proc = subprocess.run(
["open", "-R", str(zip_path)],
capture_output=True, timeout=6,
capture_output=True,
timeout=6,
)
if proc.returncode == 0:
return ClipboardResult(
@@ -218,11 +225,9 @@ def _tier2_windows(zip_path: Path) -> ClipboardResult | None:
# Tier 3 — path only (always succeeds)
# ---------------------------------------------------------------------------
def _tier3(zip_path: Path) -> ClipboardResult:
return ClipboardResult(
tier=Tier.PATH_ONLY,
message=(
f"📄 Copy this path and open it manually:\n"
f" [cyan]{zip_path}[/]"
),
message=(f"📄 Copy this path and open it manually:\n [cyan]{zip_path}[/]"),
)
+3
View File
@@ -22,6 +22,7 @@ from pathlib import Path
# Config directory resolution (no platformdirs dependency)
# ---------------------------------------------------------------------------
def _config_dir() -> Path:
"""
Return the platform-appropriate config directory for contextzip.
@@ -47,6 +48,7 @@ _CONFIG_FILE = _config_dir() / "config.json"
# Public API
# ---------------------------------------------------------------------------
def get_api_key() -> str | None:
"""
Return the stored Gemini API key, or None if not configured.
@@ -148,6 +150,7 @@ def config_path() -> Path:
# Internal helpers
# ---------------------------------------------------------------------------
def _read_config() -> dict:
"""Read and parse the config file. Returns {} if missing or malformed."""
if not _CONFIG_FILE.is_file():
+12 -13
View File
@@ -17,13 +17,14 @@ from typing import Callable
# Data model
# ---------------------------------------------------------------------------
@dataclass
class DetectionResult:
"""Everything the detector knows about a project directory."""
ecosystems: list[str] = field(default_factory=list) # e.g. ["Node.js", "Next.js"]
ecosystems: list[str] = field(default_factory=list) # e.g. ["Node.js", "Next.js"]
rule_modules: list[str] = field(default_factory=list) # e.g. ["base", "node"]
confidence: str = "low" # "low" | "medium" | "high"
confidence: str = "low" # "low" | "medium" | "high"
@property
def display_name(self) -> str:
@@ -40,13 +41,15 @@ class DetectionResult:
# Detection rules — ordered from most specific to least specific
# ---------------------------------------------------------------------------
@dataclass
class _Rule:
"""A single detection rule."""
name: str # Human-readable ecosystem name e.g. "Next.js"
module: str # Rule module key e.g. "node"
name: str # Human-readable ecosystem name e.g. "Next.js"
module: str # Rule module key e.g. "node"
check: Callable[[Path], bool]
weight: int = 1 # Higher = stronger signal
weight: int = 1 # Higher = stronger signal
_RULES: list[_Rule] = [
@@ -62,7 +65,6 @@ _RULES: list[_Rule] = [
),
weight=3,
),
# ── Node.js / generic JS+TS ───────────────────────────────────────────
_Rule(
name="Node.js",
@@ -70,7 +72,6 @@ _RULES: list[_Rule] = [
check=lambda p: _file_exists(p, "package.json"),
weight=2,
),
# ── Django (must come before generic Python) ──────────────────────────
_Rule(
name="Django",
@@ -78,7 +79,6 @@ _RULES: list[_Rule] = [
check=lambda p: _file_exists(p, "manage.py") or _dep_present_py(p, "django"),
weight=3,
),
# ── FastAPI ───────────────────────────────────────────────────────────
_Rule(
name="FastAPI",
@@ -86,7 +86,6 @@ _RULES: list[_Rule] = [
check=lambda p: _dep_present_py(p, "fastapi"),
weight=3,
),
# ── Generic Python ────────────────────────────────────────────────────
_Rule(
name="Python",
@@ -100,7 +99,6 @@ _RULES: list[_Rule] = [
),
weight=2,
),
# ── Rust ──────────────────────────────────────────────────────────────
_Rule(
name="Rust",
@@ -108,7 +106,6 @@ _RULES: list[_Rule] = [
check=lambda p: _file_exists(p, "Cargo.toml"),
weight=3,
),
# ── Go ────────────────────────────────────────────────────────────────
_Rule(
name="Go",
@@ -116,7 +113,6 @@ _RULES: list[_Rule] = [
check=lambda p: _file_exists(p, "go.mod"),
weight=3,
),
# ── Ruby ─────────────────────────────────────────────────────────────
_Rule(
name="Ruby",
@@ -131,6 +127,7 @@ _RULES: list[_Rule] = [
# Public API
# ---------------------------------------------------------------------------
def detect(project_dir: Path) -> DetectionResult:
"""
Analyse *project_dir* and return a :class:`DetectionResult`.
@@ -173,6 +170,7 @@ def detect(project_dir: Path) -> DetectionResult:
# Helpers
# ---------------------------------------------------------------------------
def _file_exists(project_dir: Path, filename: str) -> bool:
return (project_dir / filename).exists()
@@ -184,6 +182,7 @@ def _dep_present(project_dir: Path, dep: str) -> bool:
return False
try:
import json
data = json.loads(pkg.read_text(encoding="utf-8", errors="ignore"))
all_deps = {
**data.get("dependencies", {}),
@@ -215,4 +214,4 @@ def _dep_present_py(project_dir: Path, dep: str) -> bool:
except Exception:
pass
return False
return False
+18 -15
View File
@@ -30,16 +30,16 @@ import pathspec
# ---------------------------------------------------------------------------
_RULE_REGISTRY: dict[str, str] = {
"base": "contextzip.rules.base",
"node": "contextzip.rules.node",
"base": "contextzip.rules.base",
"node": "contextzip.rules.node",
"python": "contextzip.rules.python",
"rust": "contextzip.rules.rust",
"go": "contextzip.rules.go",
"ruby": "contextzip.rules.ruby",
"rust": "contextzip.rules.rust",
"go": "contextzip.rules.go",
"ruby": "contextzip.rules.ruby",
}
# Files larger than this trigger a warning (but are still included)
LARGE_FILE_WARN_BYTES = 1 * 1024 * 1024 # 1 MB
LARGE_FILE_WARN_BYTES = 1 * 1024 * 1024 # 1 MB
# Peek this many bytes to detect binary files
_BINARY_PEEK = 512
@@ -49,12 +49,13 @@ _BINARY_PEEK = 512
# Result model
# ---------------------------------------------------------------------------
@dataclass
class ResolveResult:
included: list[Path] = field(default_factory=list)
excluded: list[Path] = field(default_factory=list)
skipped: list[tuple[Path, str]] = field(default_factory=list) # (path, reason)
large_files: list[tuple[Path, int]] = field(default_factory=list) # (path, bytes)
included: list[Path] = field(default_factory=list)
excluded: list[Path] = field(default_factory=list)
skipped: list[tuple[Path, str]] = field(default_factory=list) # (path, reason)
large_files: list[tuple[Path, int]] = field(default_factory=list) # (path, bytes)
binary_files: list[Path] = field(default_factory=list)
@@ -62,6 +63,7 @@ class ResolveResult:
# Public API
# ---------------------------------------------------------------------------
def build_spec(
rule_modules: list[str],
extra_exclude: list[str] | None = None,
@@ -86,7 +88,9 @@ def build_spec(
# Respect the project's own .gitignore if present
if gitignore_path and gitignore_path.is_file():
try:
lines = gitignore_path.read_text(encoding="utf-8", errors="ignore").splitlines()
lines = gitignore_path.read_text(
encoding="utf-8", errors="ignore"
).splitlines()
# Strip comments and blank lines; pathspec handles the rest
patterns.extend(l for l in lines if l.strip() and not l.startswith("#"))
except OSError:
@@ -111,13 +115,12 @@ def resolve_files(
result = ResolveResult()
for abs_path in sorted(project_dir.rglob("*")):
# ── Resolve symlinks safely ──────────────────────────────────────────
if abs_path.is_symlink():
try:
real = abs_path.resolve(strict=True)
if not real.is_file():
continue # symlink to a dir or missing — skip silently
continue # symlink to a dir or missing — skip silently
abs_path = real
except (OSError, RuntimeError):
# Dangling symlink or resolution loop
@@ -205,7 +208,6 @@ def resolve_files_from_git(
result = ResolveResult()
for abs_path in sorted(git_files):
# ── Resolve symlinks safely ──────────────────────────────────────────
if abs_path.is_symlink():
try:
@@ -270,6 +272,7 @@ def summarise_exclusions(excluded: list[Path], project_dir: Path) -> dict[str, i
# Helpers
# ---------------------------------------------------------------------------
def _any_parent_excluded(rel: Path, spec: pathspec.PathSpec) -> bool:
"""True if any directory component of *rel* is matched by *spec*."""
parts = rel.parts[:-1]
@@ -304,4 +307,4 @@ def _is_binary(path: Path) -> bool:
chunk = fh.read(_BINARY_PEEK)
return b"\x00" in chunk
except OSError:
return False
return False
+21 -14
View File
@@ -47,27 +47,29 @@ _INCLUDE_CODES = {"M", "A", "R", "C", "U", "?"}
# Result / error models
# ---------------------------------------------------------------------------
class GitErrorKind(Enum):
NOT_A_REPO = "not_a_repo"
NOT_A_REPO = "not_a_repo"
GIT_NOT_FOUND = "git_not_found"
COMMAND_ERROR = "command_error"
@dataclass
class GitError:
kind: GitErrorKind
message: str # human-readable, ready to print
kind: GitErrorKind
message: str # human-readable, ready to print
@dataclass
class GitChanges:
"""Successful result from :func:`get_changed_files`."""
files: list[Path] = field(default_factory=list) # absolute paths
staged: list[str] = field(default_factory=list) # rel paths (display)
unstaged: list[str] = field(default_factory=list) # rel paths (display)
untracked: list[str] = field(default_factory=list) # rel paths (display)
deleted: list[str] = field(default_factory=list) # rel paths (skipped)
submodules: list[str] = field(default_factory=list) # rel paths (skipped)
files: list[Path] = field(default_factory=list) # absolute paths
staged: list[str] = field(default_factory=list) # rel paths (display)
unstaged: list[str] = field(default_factory=list) # rel paths (display)
untracked: list[str] = field(default_factory=list) # rel paths (display)
deleted: list[str] = field(default_factory=list) # rel paths (skipped)
submodules: list[str] = field(default_factory=list) # rel paths (skipped)
@property
def is_empty(self) -> bool:
@@ -78,6 +80,7 @@ class GitChanges:
# Public API
# ---------------------------------------------------------------------------
def get_changed_files(project_dir: Path) -> GitChanges | GitError:
"""
Run ``git status --porcelain`` in *project_dir* and return a
@@ -127,7 +130,9 @@ def get_changed_files(project_dir: Path) -> GitChanges | GitError:
text=True,
timeout=8,
)
repo_root = Path(root_proc.stdout.strip()) if root_proc.returncode == 0 else project_dir
repo_root = (
Path(root_proc.stdout.strip()) if root_proc.returncode == 0 else project_dir
)
# ── Run git status --porcelain ───────────────────────────────────────────
try:
@@ -159,6 +164,7 @@ def get_changed_files(project_dir: Path) -> GitChanges | GitError:
# Parser
# ---------------------------------------------------------------------------
def _parse_porcelain(
output: str,
repo_root: Path,
@@ -177,15 +183,15 @@ def _parse_porcelain(
if not raw_line or len(raw_line) < 4:
continue
xy = raw_line[:2] # e.g. "M ", " M", "??", "A "
xy = raw_line[:2] # e.g. "M ", " M", "??", "A "
rel_path = raw_line[3:].strip() # path relative to repo root
# Strip surrounding quotes git adds for paths with spaces/special chars
if rel_path.startswith('"') and rel_path.endswith('"'):
rel_path = rel_path[1:-1]
x = xy[0] # staged status
y = xy[1] # unstaged status
x = xy[0] # staged status
y = xy[1] # unstaged status
# Skip ignored files
if x == "!" and y == "!":
@@ -232,6 +238,7 @@ def _parse_porcelain(
# Helpers
# ---------------------------------------------------------------------------
def _is_submodule(path: Path) -> bool:
"""
A submodule shows up as a directory in the working tree but as a
@@ -241,4 +248,4 @@ def _is_submodule(path: Path) -> bool:
if not path.is_dir():
return False
git_marker = path / ".git"
return git_marker.exists()
return git_marker.exists()
+13 -11
View File
@@ -43,13 +43,14 @@ _GITIGNORE_ENTRY = ".contextzip/"
# Result model
# ---------------------------------------------------------------------------
@dataclass
class PackageResult:
zip_path: Path
file_count: int
uncompressed_bytes: int
compressed_bytes: int
skipped_in_zip: list[tuple[Path, str]] = field(default_factory=list)
zip_path: Path
file_count: int
uncompressed_bytes: int
compressed_bytes: int
skipped_in_zip: list[tuple[Path, str]] = field(default_factory=list)
@property
def compression_ratio(self) -> float:
@@ -70,6 +71,7 @@ class PackageResult:
# Public API
# ---------------------------------------------------------------------------
def create_zip(
resolve_result: ResolveResult,
project_dir: Path,
@@ -119,14 +121,12 @@ def create_zip(
console=console,
transient=True,
) as progress:
total_bytes = sum(
p.stat().st_size for p in included if p.is_file()
)
total_bytes = sum(p.stat().st_size for p in included if p.is_file())
task = progress.add_task("Compressing…", total=max(total_bytes, 1))
with zipfile.ZipFile(
zip_path, "w",
zip_path,
"w",
compression=zipfile.ZIP_DEFLATED,
compresslevel=6,
) as zf:
@@ -176,6 +176,7 @@ def create_zip(
# Workspace helpers
# ---------------------------------------------------------------------------
def _find_git_root(start: Path) -> Path | None:
"""
Walk up the directory tree from *start* looking for a .git directory.
@@ -279,6 +280,7 @@ def _workspace_output_path(
# Legacy helper (kept for any internal callers that may reference it)
# ---------------------------------------------------------------------------
def _safe_name(name: str) -> str:
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in name)
return safe[:48] or "project"
return safe[:48] or "project"
+1 -9
View File
@@ -7,12 +7,10 @@ PATTERNS = [
".git/",
".svn/",
".hg/",
# OS artifacts
".DS_Store",
"Thumbs.db",
"desktop.ini",
# Editor & IDE
".vscode/",
".idea/",
@@ -20,12 +18,10 @@ PATTERNS = [
"*.swo",
"*.swn",
".vim/",
# Logs
"*.log",
"logs/",
"log/",
# Temp & cache
".cache/",
"tmp/",
@@ -33,7 +29,6 @@ PATTERNS = [
"*.tmp",
"*.bak",
"*.orig",
# Secrets & env
".env",
".env.*",
@@ -41,7 +36,6 @@ PATTERNS = [
"*.key",
"*.p12",
"*.pfx",
# Binaries & media (rarely useful for AI context)
"*.exe",
"*.dll",
@@ -60,10 +54,8 @@ PATTERNS = [
"*.tar",
"*.gz",
"*.rar",
# contextzip workspace directory — always excluded regardless of .gitignore state
".contextzip/",
# GitHub / repo governance (not useful as AI context)
"CHANGELOG.md",
"CHANGELOG",
@@ -77,4 +69,4 @@ PATTERNS = [
".github/ISSUE_TEMPLATE/",
".github/PULL_REQUEST_TEMPLATE.md",
".github/PULL_REQUEST_TEMPLATE/",
]
]
+1 -1
View File
@@ -24,6 +24,6 @@ PATTERNS = [
".yarn/",
"storybook-static/",
".storybook/",
"*.d.ts", # generated type declarations
"*.d.ts", # generated type declarations
"tsconfig.tsbuildinfo",
]
+4 -4
View File
@@ -26,11 +26,11 @@ PATTERNS = [
".coverage",
"*.sqlite3",
"*.db",
"site/", # mkdocs build output
"site/", # mkdocs build output
".tox/",
"poetry.lock",
"uv.lock",
"migrations/", # Django migrations (auto-generated)
"staticfiles/", # Django collected static
"media/", # Django uploaded media
"migrations/", # Django migrations (auto-generated)
"staticfiles/", # Django collected static
"media/", # Django uploaded media
]
+6 -13
View File
@@ -7,35 +7,28 @@ PATTERNS = [
"vendor/bundle/",
".bundle/",
"Gemfile.lock",
# Ruby version managers
".ruby-version",
".ruby-gemset",
".rvmrc",
# Built gems
"*.gem",
"pkg/",
# Rails-specific
"tmp/", # runtime temp (also in base, fine to repeat)
"log/", # request logs (also in base)
"public/assets/", # precompiled assets (sprockets / propshaft)
"tmp/", # runtime temp (also in base, fine to repeat)
"log/", # request logs (also in base)
"public/assets/", # precompiled assets (sprockets / propshaft)
"public/packs/", # webpacker output
"storage/", # ActiveStorage blobs
"storage/", # ActiveStorage blobs
".byebug_history",
# Test coverage
"coverage/",
# Documentation
"doc/",
"rdoc/",
# Spring (Rails app preloader)
".spring/",
# Compiled C extensions
"*.bundle",
"*.so", # also in base, explicit here for clarity
]
"*.so", # also in base, explicit here for clarity
]
+1 -1
View File
@@ -4,7 +4,7 @@ Rust (Cargo) exclusion rules.
PATTERNS = [
"target/",
"Cargo.lock", # exclude for libraries; keep for binaries — we exclude for AI context
"Cargo.lock", # exclude for libraries; keep for binaries — we exclude for AI context
"*.rlib",
"*.rmeta",
]