diff --git a/README.md b/README.md index 008a537..3f1a833 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,9 @@ contextzip [OPTIONS] | Option | Description | |---|---| | `-i`, `--include PATH` | Only include files under this path. Repeatable. | -| `-e`, `--exclude PATTERN` | Extra exclusion patterns (gitignore syntax). Space-separated or repeatable: `-e '*.log' file1 file2` or `-e '*.log' -e file1` | +| `-e`, `--exclude PATTERN` | Extra exclusion patterns (gitignore syntax). Repeatable. | +| `exclude` | Subcommand: exclude specific files/patterns. `contextzip exclude CHANGELOG.md LICENSE .github/` | +| `include` | Subcommand: include only specific paths. `contextzip include src/ app/` | | `--git-changes` | Only include files reported by git as modified, staged, or untracked. | | `-n`, `--dry-run` | Preview what would be included, no ZIP created. | | `-o`, `--output FILE` | Custom output path for the ZIP file. | @@ -109,7 +111,22 @@ contextzip --include src --include app **Exclude additional patterns beyond the auto-rules:** ```bash -contextzip -e "*.log" "*.sqlite" "tests/" +contextzip --exclude "*.log" --exclude "*.sqlite" --exclude "tests/" +``` + +**Exclude files using the subcommand (space-separated, no repetition):** +```bash +contextzip exclude CHANGELOG.md CONTRIBUTING.md LICENSE .github/ +``` + +**Exclude with flags:** +```bash +contextzip exclude CHANGELOG.md --dry-run --verbose +``` + +**Include only specific directories using the subcommand:** +```bash +contextzip include src/ app/ ``` **Only package files changed in git:** @@ -206,7 +223,7 @@ contextzip surfaces issues before they waste your time: contextzip/ ├── contextzip/ │ ├── __init__.py # version string -│ ├── cli.py # Click entry point, all flags, rich output +│ ├── cli.py # Click entry point, all flags, subcommands (exclude, include), rich output │ ├── detector.py # framework/language detection engine │ ├── filters.py # pathspec-based file filtering + ResolveResult │ ├── git.py # git status parsing + changed-file detection diff --git a/contextzip/cli.py b/contextzip/cli.py index 8f3093d..68fbf31 100644 --- a/contextzip/cli.py +++ b/contextzip/cli.py @@ -1,5 +1,27 @@ """ cli.py — contextzip entry point. Phases 1–5 complete. + +Command surface +─────────────── +Main command (unchanged, fully backwards-compatible): + contextzip [OPTIONS] + -i / --include PATH only include files under these paths (repeatable) + -e / --exclude PATTERN extra exclusion patterns (repeatable) + -n / --dry-run preview without creating ZIP + -o / --output FILE custom output path + --no-clipboard skip clipboard / folder-open step + --no-gitignore ignore project .gitignore + --git-changes only package files changed in git + -v / --verbose show every file decision + +Subcommands (new — all modifier flags available on each): + contextzip exclude PATTERN… [OPTIONS] + contextzip include PATH… [OPTIONS] + +Both subcommands accept the same modifier flags as the main command so that +the flags naturally follow the verb, matching the git / docker / cargo UX: + contextzip exclude CHANGELOG.md --dry-run --verbose + contextzip include src/ app/ --output ~/Desktop/out.zip """ from __future__ import annotations @@ -30,55 +52,88 @@ console = Console() # --------------------------------------------------------------------------- -# CLI +# Shared modifier-flag decorator +# --------------------------------------------------------------------------- +# 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. + + Applied to both the main command and every subcommand so that flags + always follow the verb — matching the git / docker / cargo convention: + contextzip exclude CHANGELOG.md --dry-run --verbose + """ + decorators = [ + click.option( + "--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", + help="Output ZIP path. Defaults to _context_.zip in temp dir.", + ), + click.option( + "--no-clipboard", + is_flag=True, default=False, + help="Skip clipboard / folder-open step after creating the ZIP.", + ), + click.option( + "--no-gitignore", + 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, + 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, + help="Show every included and excluded file.", + ), + ] + for dec in reversed(decorators): + f = dec(f) + return f + + +# --------------------------------------------------------------------------- +# CLI group # --------------------------------------------------------------------------- -@click.command(context_settings={"help_option_names": ["-h", "--help"]}) +@click.group( + invoke_without_command=True, + context_settings={"help_option_names": ["-h", "--help"]}, +) @click.option( "--include", "-i", multiple=True, metavar="PATH", - help="Only include files under these paths (relative to project root). " - "Repeatable: --include src --include app", + 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...", - help="Extra exclusion patterns on top of auto-rules (gitignore syntax). " - "Space-separated or repeatable: -e '*.log' file1 file2 OR -e '*.log' -e file1", -) -@click.option( - "--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", - help="Output ZIP path. Defaults to _context_.zip in temp dir.", -) -@click.option( - "--no-clipboard", - is_flag=True, default=False, - help="Skip clipboard / folder-open step after creating the ZIP.", -) -@click.option( - "--no-gitignore", - 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, - 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, - help="Show every included and excluded file.", + 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" + ), ) +@_modifier_options @click.version_option(version=__version__, prog_name="contextzip") +@click.pass_context def main( + ctx: click.Context, include: tuple[str, ...], exclude: tuple[str, ...], dry_run: bool, @@ -94,8 +149,132 @@ def main( Run from your project root to produce a smart, lightweight ZIP ready to paste directly into Claude, ChatGPT, or any AI interface. - """ + \b + SUBCOMMANDS + contextzip exclude CHANGELOG.md LICENSE .github/ + contextzip include src/ app/ + + Both subcommands accept the same flags as the main command: + contextzip exclude CHANGELOG.md --dry-run --verbose + """ + # A subcommand was invoked — let it handle everything. + if ctx.invoked_subcommand is not None: + return + + _run( + extra_exclude=list(exclude), + include_only=list(include), + dry_run=dry_run, + output=output, + no_clipboard=no_clipboard, + no_gitignore=no_gitignore, + git_changes=git_changes, + verbose=verbose, + ) + + +# --------------------------------------------------------------------------- +# Subcommand: exclude +# --------------------------------------------------------------------------- + +@main.command("exclude") +@click.argument("patterns", nargs=-1, required=True, metavar="PATTERN…") +@_modifier_options +def cmd_exclude( + patterns: tuple[str, ...], + dry_run: bool, + output: str | None, + no_clipboard: bool, + no_gitignore: bool, + git_changes: bool, + verbose: bool, +) -> None: + """ + Exclude specific files or patterns and package everything else. + + \b + EXAMPLES + contextzip exclude CHANGELOG.md CONTRIBUTING.md LICENSE + contextzip exclude .github/ tests/ '*.log' + contextzip exclude CHANGELOG.md --dry-run --verbose + contextzip exclude .github/ CHANGELOG.md --output ~/Desktop/out.zip + + Patterns follow gitignore syntax. Folders are matched with or without + a trailing slash: both '.github' and '.github/' work. + """ + _run( + extra_exclude=list(patterns), + include_only=None, + dry_run=dry_run, + output=output, + no_clipboard=no_clipboard, + no_gitignore=no_gitignore, + git_changes=git_changes, + verbose=verbose, + ) + + +# --------------------------------------------------------------------------- +# Subcommand: include +# --------------------------------------------------------------------------- + +@main.command("include") +@click.argument("paths", nargs=-1, required=True, metavar="PATH…") +@_modifier_options +def cmd_include( + paths: tuple[str, ...], + dry_run: bool, + output: str | None, + no_clipboard: bool, + no_gitignore: bool, + git_changes: bool, + verbose: bool, +) -> None: + """ + Package only the specified paths and skip everything else. + + \b + EXAMPLES + contextzip include src/ app/ + contextzip include src/ app/ --dry-run + contextzip include src/ --output ~/Desktop/out.zip --verbose + + Paths are matched as exact prefixes at directory boundaries: + 'src' matches 'src/index.ts' but not 'src2/index.ts'. + """ + _run( + extra_exclude=None, + include_only=list(paths), + dry_run=dry_run, + output=output, + no_clipboard=no_clipboard, + no_gitignore=no_gitignore, + git_changes=git_changes, + verbose=verbose, + ) + + +# --------------------------------------------------------------------------- +# Core execution logic (shared by main command + all subcommands) +# --------------------------------------------------------------------------- + +def _run( + *, + extra_exclude: list[str] | None, + include_only: list[str] | None, + dry_run: bool, + output: str | None, + no_clipboard: bool, + no_gitignore: bool, + git_changes: bool, + verbose: bool, +) -> None: + """ + All actual work lives here. The main command and every subcommand + delegate to this function after collecting their arguments/flags, + keeping the CLI surface thin and the logic testable in isolation. + """ project_dir = Path(os.getcwd()).resolve() # ── Header ─────────────────────────────────────────────────────────────── @@ -153,20 +332,12 @@ def main( and gitignore_path.is_file() ) + normalized_exclude = ( + [_normalize_pattern(p) for p in extra_exclude] + if extra_exclude else [] + ) + with console.status("[cyan]Building exclusion rules…[/]", spinner="dots"): - # Flatten: each -e invocation may itself contain space-separated tokens - raw_exclude = exclude - flat_exclude: list[str] = [] - for token in raw_exclude: - flat_exclude.extend(token.split()) - - # Normalize Windows-style paths (.\foo\bar → foo/bar) - def _normalize_pattern(p: str) -> str: - p = p.lstrip(".\\/") # strip leading .\ or ./ - p = p.replace("\\", "/") # backslash → forward slash - return p - - normalized_exclude = [_normalize_pattern(p) for p in flat_exclude] spec = build_spec( rule_modules=detection.rule_modules, extra_exclude=normalized_exclude if normalized_exclude else None, @@ -182,7 +353,7 @@ def main( resolved = resolve_files( project_dir=project_dir, spec=spec, - include_only=list(include) if include else None, + include_only=include_only if include_only else None, ) # ── File scan summary ──────────────────────────────────────────────────── @@ -202,7 +373,8 @@ def main( if len(resolved.large_files) > 5: console.print(f" [dim]… and {len(resolved.large_files) - 5} more[/]") console.print( - f" [dim] Use --exclude to drop them if unneeded.[/]" + " [dim] Use [cyan]-e PATTERN[/] or [cyan]contextzip exclude PATTERN[/] " + "to drop them if unneeded.[/]" ) # ── Warnings: binary files ─────────────────────────────────────────────── @@ -245,7 +417,7 @@ def main( if not resolved.included: console.print( "\n[red]Nothing to package.[/] All files were excluded — " - "try [cyan]--include[/] to override." + "try [cyan]contextzip include PATH[/] or [cyan]-i PATH[/] to override." ) return @@ -285,6 +457,33 @@ def main( _print_clipboard_result(cb) +# --------------------------------------------------------------------------- +# Normalisation +# --------------------------------------------------------------------------- + +def _normalize_pattern(p: str) -> str: + """ + Canonicalise a user-supplied exclusion pattern. + + Rules applied in order: + 1. Strip a leading ``./`` or ``.\\`` so that ``./CHANGELOG.md`` + and ``CHANGELOG.md`` are treated identically. + 2. Replace every backslash with a forward slash for cross-platform + consistency (Windows paths entered on the CLI). + 3. Collapse ``folder/*`` → ``folder/`` so that gitignore-style + directory globs work as expected. + """ + # 1. Strip leading ./ or .\ + if p.startswith("./") or p.startswith(".\\"): + p = p[2:] + # 2. Normalise path separators + p = p.replace("\\", "/") + # 3. folder/* → folder/ + if p.endswith("/*"): + p = p[:-1] + return p + + # --------------------------------------------------------------------------- # Display helpers # --------------------------------------------------------------------------- @@ -449,4 +648,4 @@ def _human_size(n: int) -> str: if n < 1024: return f"{n:.0f} {unit}" n /= 1024 - return f"{n:.1f} TB" + return f"{n:.1f} TB" \ No newline at end of file