contextzip 0.3.9__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. contextzip-0.4.1/PKG-INFO +98 -0
  2. contextzip-0.4.1/README.md +69 -0
  3. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/__init__.py +1 -1
  4. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/ai/selector.py +33 -11
  5. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/applier.py +30 -14
  6. contextzip-0.4.1/contextzip/cleanup.py +207 -0
  7. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/cli.py +179 -227
  8. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/cli_ai.py +11 -35
  9. contextzip-0.4.1/contextzip/cli_display.py +409 -0
  10. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/cli_onboard.py +13 -35
  11. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/filters.py +10 -2
  12. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/packager.py +77 -16
  13. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/project_config.py +196 -9
  14. contextzip-0.4.1/contextzip/report.py +167 -0
  15. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/watcher.py +30 -65
  16. contextzip-0.4.1/contextzip/webui/assets.py +1185 -0
  17. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/webui/persist.py +32 -4
  18. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/webui/server.py +127 -24
  19. contextzip-0.4.1/contextzip.egg-info/PKG-INFO +98 -0
  20. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip.egg-info/SOURCES.txt +2 -0
  21. {contextzip-0.3.9 → contextzip-0.4.1}/pyproject.toml +1 -1
  22. contextzip-0.3.9/PKG-INFO +0 -350
  23. contextzip-0.3.9/README.md +0 -321
  24. contextzip-0.3.9/contextzip/cli_display.py +0 -443
  25. contextzip-0.3.9/contextzip/webui/assets.py +0 -1012
  26. contextzip-0.3.9/contextzip.egg-info/PKG-INFO +0 -350
  27. {contextzip-0.3.9 → contextzip-0.4.1}/LICENSE +0 -0
  28. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/ai/__init__.py +0 -0
  29. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/ai/gemini.py +0 -0
  30. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/ai/heuristic.py +0 -0
  31. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/api.py +0 -0
  32. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/clipboard.py +0 -0
  33. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/config.py +0 -0
  34. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/detector.py +0 -0
  35. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/error_parser.py +0 -0
  36. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/git.py +0 -0
  37. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/__init__.py +0 -0
  38. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/base.py +0 -0
  39. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/errors/__init__.py +0 -0
  40. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/errors/node.py +0 -0
  41. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/errors/python.py +0 -0
  42. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/go.py +0 -0
  43. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/node.py +0 -0
  44. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/python.py +0 -0
  45. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/ruby.py +0 -0
  46. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/rules/rust.py +0 -0
  47. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/webui/__init__.py +0 -0
  48. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip/webui/suggestions.py +0 -0
  49. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip.egg-info/dependency_links.txt +0 -0
  50. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip.egg-info/entry_points.txt +0 -0
  51. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip.egg-info/requires.txt +0 -0
  52. {contextzip-0.3.9 → contextzip-0.4.1}/contextzip.egg-info/top_level.txt +0 -0
  53. {contextzip-0.3.9 → contextzip-0.4.1}/setup.cfg +0 -0
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: contextzip
3
+ Version: 0.4.1
4
+ Summary: Intelligently package your codebase for AI tools
5
+ Author-email: Deepesh <akadeepesh@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/akadeepesh/contextzip
8
+ Project-URL: Repository, https://github.com/akadeepesh/contextzip
9
+ Project-URL: Issues, https://github.com/akadeepesh/contextzip/issues
10
+ Keywords: ai,cli,developer-tools,context,zip,llm,claude,chatgpt,code-packaging
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Build Tools
20
+ Classifier: Topic :: Utilities
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: click>=8.1
25
+ Requires-Dist: rich>=13.0
26
+ Requires-Dist: pathspec>=0.12
27
+ Requires-Dist: httpx>=0.27
28
+ Dynamic: license-file
29
+
30
+ # contextzip
31
+
32
+ > Package exactly the right parts of your codebase and paste it into any AI tool — in one command.
33
+
34
+ ```bash
35
+ pip install contextzip
36
+ ```
37
+
38
+ **📖 Full docs, every flag, every config option: [contextzip.vercel.app](https://contextzip.vercel.app)**
39
+
40
+ ---
41
+
42
+ ## Why contextzip
43
+
44
+ Every AI session starts the same way: hunt down the relevant files, skip `node_modules` and build artifacts by hand, zip it, find the zip, upload it — then do it all again next session.
45
+
46
+ contextzip eliminates that. Run it from your project root — it detects your stack, applies smart exclusions, and produces a lean ZIP ready to paste into Claude, ChatGPT, or any AI tool. When the AI hands changes back, `contextzip apply-zip` writes them into your project safely.
47
+
48
+ ## Quick start
49
+
50
+ ```bash
51
+ cd ~/projects/my-app
52
+ contextzip
53
+ ```
54
+
55
+ That's it — no flags required. contextzip detects your framework, excludes the noise, and writes a ZIP to `.contextzip/`.
56
+
57
+ ## The essentials
58
+
59
+ | Command | What it does |
60
+ |---|---|
61
+ | `contextzip` | Detect stack, exclude noise, package the project |
62
+ | `contextzip --prompt "task"` | Let Gemini pick only the files relevant to a task |
63
+ | `contextzip --git-changes` | Package only modified/staged/untracked files |
64
+ | `contextzip apply-zip` | Write an AI-returned ZIP back into your project, safely |
65
+ | `contextzip watch -- npm run dev` | Auto-package debug context the moment an error appears |
66
+ | `contextzip config --ui` | Set include/exclude rules visually in a local browser tab |
67
+
68
+ Every command supports `--dry-run`, `--verbose`, `--include`, `--exclude`, and more — see the [full CLI reference](https://contextzip.vercel.app/cli).
69
+
70
+ contextzip respects your `.gitignore`, detects Node.js, Next.js, Python, Django, FastAPI, Rust, Go, and Ruby (including in monorepos), and never packages secrets — SSH keys, cloud credentials, and Terraform state are always excluded. Details: [contextzip.vercel.app/features](https://contextzip.vercel.app/features).
71
+
72
+ ## Also a Python library
73
+
74
+ ```python
75
+ from contextzip import get_git_changes, create_zip
76
+
77
+ collection = get_git_changes()
78
+ pkg = create_zip(collection, output="/tmp/changes.zip")
79
+ ```
80
+
81
+ Full function reference: [contextzip.vercel.app/cli#python-api](https://contextzip.vercel.app/cli#python-api)
82
+
83
+ ## Learn more
84
+
85
+ - [Packaging & detection](https://contextzip.vercel.app/features) — frameworks, exclusion rules, safety
86
+ - [AI-powered file selection](https://contextzip.vercel.app/ai-selection) — `--prompt`, Gemini setup
87
+ - [Terminal error watcher](https://contextzip.vercel.app/watch) — `contextzip watch`
88
+ - [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`, backups, safety
89
+ - [Configuration](https://contextzip.vercel.app/configuration) — personal & project config, visual config UI
90
+ - [CLI & Python API reference](https://contextzip.vercel.app/cli)
91
+
92
+ ## Contributing
93
+
94
+ Contributions are welcome — especially new framework rule sets and edge-case fixes. See [CONTRIBUTING.md](https://github.com/akadeepesh/contextzip/blob/main/CONTRIBUTING.md). Please open an issue before starting a large PR.
95
+
96
+ ## License
97
+
98
+ MIT — see [LICENSE](https://github.com/akadeepesh/contextzip/blob/main/LICENSE) for details.
@@ -0,0 +1,69 @@
1
+ # contextzip
2
+
3
+ > Package exactly the right parts of your codebase and paste it into any AI tool — in one command.
4
+
5
+ ```bash
6
+ pip install contextzip
7
+ ```
8
+
9
+ **📖 Full docs, every flag, every config option: [contextzip.vercel.app](https://contextzip.vercel.app)**
10
+
11
+ ---
12
+
13
+ ## Why contextzip
14
+
15
+ Every AI session starts the same way: hunt down the relevant files, skip `node_modules` and build artifacts by hand, zip it, find the zip, upload it — then do it all again next session.
16
+
17
+ contextzip eliminates that. Run it from your project root — it detects your stack, applies smart exclusions, and produces a lean ZIP ready to paste into Claude, ChatGPT, or any AI tool. When the AI hands changes back, `contextzip apply-zip` writes them into your project safely.
18
+
19
+ ## Quick start
20
+
21
+ ```bash
22
+ cd ~/projects/my-app
23
+ contextzip
24
+ ```
25
+
26
+ That's it — no flags required. contextzip detects your framework, excludes the noise, and writes a ZIP to `.contextzip/`.
27
+
28
+ ## The essentials
29
+
30
+ | Command | What it does |
31
+ |---|---|
32
+ | `contextzip` | Detect stack, exclude noise, package the project |
33
+ | `contextzip --prompt "task"` | Let Gemini pick only the files relevant to a task |
34
+ | `contextzip --git-changes` | Package only modified/staged/untracked files |
35
+ | `contextzip apply-zip` | Write an AI-returned ZIP back into your project, safely |
36
+ | `contextzip watch -- npm run dev` | Auto-package debug context the moment an error appears |
37
+ | `contextzip config --ui` | Set include/exclude rules visually in a local browser tab |
38
+
39
+ Every command supports `--dry-run`, `--verbose`, `--include`, `--exclude`, and more — see the [full CLI reference](https://contextzip.vercel.app/cli).
40
+
41
+ contextzip respects your `.gitignore`, detects Node.js, Next.js, Python, Django, FastAPI, Rust, Go, and Ruby (including in monorepos), and never packages secrets — SSH keys, cloud credentials, and Terraform state are always excluded. Details: [contextzip.vercel.app/features](https://contextzip.vercel.app/features).
42
+
43
+ ## Also a Python library
44
+
45
+ ```python
46
+ from contextzip import get_git_changes, create_zip
47
+
48
+ collection = get_git_changes()
49
+ pkg = create_zip(collection, output="/tmp/changes.zip")
50
+ ```
51
+
52
+ Full function reference: [contextzip.vercel.app/cli#python-api](https://contextzip.vercel.app/cli#python-api)
53
+
54
+ ## Learn more
55
+
56
+ - [Packaging & detection](https://contextzip.vercel.app/features) — frameworks, exclusion rules, safety
57
+ - [AI-powered file selection](https://contextzip.vercel.app/ai-selection) — `--prompt`, Gemini setup
58
+ - [Terminal error watcher](https://contextzip.vercel.app/watch) — `contextzip watch`
59
+ - [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`, backups, safety
60
+ - [Configuration](https://contextzip.vercel.app/configuration) — personal & project config, visual config UI
61
+ - [CLI & Python API reference](https://contextzip.vercel.app/cli)
62
+
63
+ ## Contributing
64
+
65
+ Contributions are welcome — especially new framework rule sets and edge-case fixes. See [CONTRIBUTING.md](https://github.com/akadeepesh/contextzip/blob/main/CONTRIBUTING.md). Please open an issue before starting a large PR.
66
+
67
+ ## License
68
+
69
+ MIT — see [LICENSE](https://github.com/akadeepesh/contextzip/blob/main/LICENSE) for details.
@@ -1,6 +1,6 @@
1
1
  """contextzip — intelligent codebase packager for AI tools."""
2
2
 
3
- __version__ = "0.3.9"
3
+ __version__ = "0.4.1"
4
4
 
5
5
  from contextzip.api import (
6
6
  FileCollection,
@@ -33,6 +33,7 @@ def ai_select(
33
33
  ecosystem: str,
34
34
  api_key: str,
35
35
  max_files: int | None = None,
36
+ prompt_template: str = "",
36
37
  ) -> tuple[list[Path], str, str]:
37
38
  """
38
39
  Select the minimum relevant files for *prompt* from *resolved.included*.
@@ -41,6 +42,10 @@ def ai_select(
41
42
  project's `ai.max_files` preference (.contextzip/config.json). None
42
43
  falls back to each backend's own built-in default cap.
43
44
 
45
+ *prompt_template*, if non-empty, is a project's `ai.prompt_template`
46
+ preference — house conventions prepended to the generated prompt.txt
47
+ ahead of the task description.
48
+
44
49
  Returns
45
50
  -------
46
51
  (selected_paths, prompt_txt, method)
@@ -78,14 +83,20 @@ def ai_select(
78
83
  }
79
84
  selected_paths = [rel_to_abs[rel] for rel in selected_rel if rel in rel_to_abs]
80
85
 
81
- prompt_txt = _build_prompt_txt(prompt, selected_rel, ecosystem, method)
86
+ prompt_txt = _build_prompt_txt(
87
+ prompt, selected_rel, ecosystem, method, prompt_template=prompt_template
88
+ )
82
89
 
83
90
  return selected_paths, prompt_txt, method
84
91
 
85
92
 
86
- def build_prompt_only_txt(prompt: str, ecosystem: str) -> str:
93
+ def build_prompt_only_txt(
94
+ prompt: str, ecosystem: str, prompt_template: str = ""
95
+ ) -> str:
87
96
  """Build a minimal prompt.txt when no AI selection was performed."""
88
- return _build_prompt_txt(prompt, [], ecosystem, USED_GEMINI)
97
+ return _build_prompt_txt(
98
+ prompt, [], ecosystem, USED_GEMINI, prompt_template=prompt_template
99
+ )
89
100
 
90
101
 
91
102
  # ---------------------------------------------------------------------------
@@ -125,12 +136,14 @@ def _build_prompt_txt(
125
136
  selected_rel: list[str],
126
137
  ecosystem: str,
127
138
  method: str,
139
+ prompt_template: str = "",
128
140
  ) -> str:
129
141
  """
130
142
  Build the prompt.txt to include inside the ZIP.
131
143
 
132
- Any AI tool that receives the ZIP immediately sees the task description,
133
- the framework, and exactly which files were selected and why.
144
+ Any AI tool that receives the ZIP immediately sees the project's own
145
+ conventions (if any are configured), the task description, the
146
+ framework, and exactly which files were selected and why.
134
147
  """
135
148
 
136
149
  selector_label = (
@@ -139,12 +152,21 @@ def _build_prompt_txt(
139
152
  else "contextzip (keyword heuristic — Gemini was rate limited)"
140
153
  )
141
154
 
142
- lines: list[str] = [
143
- f"Task: {prompt}",
144
- "",
145
- f"Framework: {ecosystem}",
146
- "",
147
- ]
155
+ lines: list[str] = []
156
+
157
+ if prompt_template:
158
+ lines.append("Project conventions:")
159
+ lines.append(prompt_template)
160
+ lines.append("")
161
+
162
+ lines.extend(
163
+ [
164
+ f"Task: {prompt}",
165
+ "",
166
+ f"Framework: {ecosystem}",
167
+ "",
168
+ ]
169
+ )
148
170
 
149
171
  if selected_rel:
150
172
  lines.append(f"Files selected by {selector_label}:")
@@ -291,10 +291,12 @@ def find_latest_manifest(
291
291
  Resolve which manifest to diff against.
292
292
 
293
293
  An explicit path always wins (if it exists). Otherwise picks the most
294
- recently modified *.manifest.json under .contextzip/output/ — in the
295
- normal one-round-trip-at-a-time flow, that's the zip you most recently
296
- generated, which is almost always the right baseline. Returns None if
297
- nothing is found (apply-zip still works, just more conservatively).
294
+ recently modified *.manifest.json anywhere under .contextzip/output/
295
+ — including its per-mode subfolders (output/codebase/, output/git-changes/,
296
+ output/prompt/) — since in the normal one-round-trip-at-a-time flow,
297
+ that's the zip you most recently generated regardless of which mode
298
+ produced it, which is almost always the right baseline. Returns None
299
+ if nothing is found (apply-zip still works, just more conservatively).
298
300
  """
299
301
  if explicit_manifest is not None:
300
302
  path = Path(explicit_manifest).expanduser().resolve()
@@ -304,7 +306,7 @@ def find_latest_manifest(
304
306
  if not out_dir.is_dir():
305
307
  return None
306
308
 
307
- candidates = [p for p in out_dir.glob(f"*{_MANIFEST_SUFFIX}") if p.is_file()]
309
+ candidates = [p for p in out_dir.rglob(f"*{_MANIFEST_SUFFIX}") if p.is_file()]
308
310
  if not candidates:
309
311
  return None
310
312
  return max(candidates, key=lambda p: p.stat().st_mtime)
@@ -573,18 +575,21 @@ def _backup_entries(
573
575
  return backup_dir
574
576
 
575
577
 
576
- def _move_zip_to_applied(zip_path: Path, project_dir: Path, workspace_root: Path) -> Path:
578
+ def _move_zip_to_applied(
579
+ zip_path: Path, project_dir: Path, workspace_root: Path, retain: int = 1
580
+ ) -> Path:
577
581
  """
578
582
  Move a consumed inbox zip into .contextzip/inbox/applied/, timestamped,
579
583
  so it can't be accidentally re-applied. Zips passed by an explicit path
580
584
  outside the inbox are left where the user put them rather than being
581
585
  moved unexpectedly.
582
586
 
583
- Only the most recently applied zip is kept: anything already in
584
- applied/ from a prior run is removed first, so this folder holds one
585
- zip at most and doesn't grow across sessions. If keeping a full
586
- history ever matters, that's a deliberate future feature, not a
587
- default behavior.
587
+ Keeps at most *retain* applied zips (default 1 — only the most recent).
588
+ Filenames are timestamp-prefixed, so a lexicographic sort is also a
589
+ chronological one; the oldest are pruned first, before this run's zip
590
+ is moved in, so the folder never holds more than *retain* at once.
591
+ Typically a project's `applied_zip_retention` preference
592
+ (.contextzip/config.json) — raise it to keep a longer audit trail.
588
593
  """
589
594
  if zip_path.parent.resolve() != inbox_dir(project_dir).resolve():
590
595
  return zip_path
@@ -592,7 +597,12 @@ def _move_zip_to_applied(zip_path: Path, project_dir: Path, workspace_root: Path
592
597
  applied_dir = workspace_root / _INBOX_DIRNAME / _APPLIED_DIRNAME
593
598
  applied_dir.mkdir(parents=True, exist_ok=True)
594
599
 
595
- for old in applied_dir.glob("*.zip"):
600
+ retain = max(1, retain)
601
+ existing = sorted(applied_dir.glob("*.zip"))
602
+ # Keep room for the zip we're about to add: prune down to retain - 1
603
+ # existing entries before moving the new one in.
604
+ overflow = len(existing) - (retain - 1)
605
+ for old in existing[: max(0, overflow)]:
596
606
  try:
597
607
  old.unlink()
598
608
  except OSError:
@@ -607,13 +617,17 @@ def _move_zip_to_applied(zip_path: Path, project_dir: Path, workspace_root: Path
607
617
  return dest
608
618
 
609
619
 
610
- def execute_plan(plan: ApplyPlan, project_dir: Path) -> ApplyResult:
620
+ def execute_plan(plan: ApplyPlan, project_dir: Path, retain: int = 1) -> ApplyResult:
611
621
  """
612
622
  Write every non-unchanged entry from *plan* into the project.
613
623
 
614
624
  Backs up whatever's about to be overwritten first. Assumes the caller
615
625
  has already decided to proceed — dry-run and confirmation prompts are
616
626
  handled upstream (see cli.py's `apply-zip` command).
627
+
628
+ *retain* caps how many applied zips are kept in
629
+ .contextzip/inbox/applied/ — typically a project's
630
+ `applied_zip_retention` preference (.contextzip/config.json).
617
631
  """
618
632
  workspace_root = _workspace_dir(project_dir)
619
633
  to_write = plan.writable_entries
@@ -627,7 +641,9 @@ def execute_plan(plan: ApplyPlan, project_dir: Path) -> ApplyResult:
627
641
  shutil.copy2(entry.extracted_path, dest)
628
642
  written.append(entry.rel_path)
629
643
 
630
- applied_zip_path = _move_zip_to_applied(plan.zip_path, project_dir, workspace_root)
644
+ applied_zip_path = _move_zip_to_applied(
645
+ plan.zip_path, project_dir, workspace_root, retain=retain
646
+ )
631
647
  shutil.rmtree(plan.extraction_dir, ignore_errors=True)
632
648
 
633
649
  return ApplyResult(
@@ -0,0 +1,207 @@
1
+ """
2
+ cleanup.py — Automatic workspace housekeeping for contextzip.
3
+
4
+ .contextzip/ accumulates disposable artifacts over time: a zip+manifest+report
5
+ set every run, a timestamped backup folder every risky `apply-zip`, and (until
6
+ pruned) old archived applied-zips. None of it is precious — every zip is
7
+ trivially reproducible by just re-running contextzip, and a backup only
8
+ matters until the apply that made it is confirmed good. So cleanup here is
9
+ deliberately brutal rather than cautious: it runs automatically after every
10
+ successful command (see `_auto_cleanup` in cli.py), with no prompt and no
11
+ separate command to remember, and keeps only the most recent
12
+ `cleanup.keep_recent` item(s) in each category — everything else is deleted
13
+ immediately, every time.
14
+
15
+ The heavier caution used elsewhere in contextzip (backing up before overwrite
16
+ in apply-zip, never inferring deletions) is reserved for the project's own
17
+ files, where losing something can mean losing real work. Nothing here ever
18
+ touches the project — only disposable metadata under .contextzip/ that costs
19
+ nothing to regenerate.
20
+
21
+ Never touched, under any settings:
22
+ - .contextzip/config.json (project preferences)
23
+ - .contextzip/inbox/*.zip (zips waiting to be applied — never assumed
24
+ stale just because they've been sitting there; only inbox/applied/,
25
+ the archive of zips already consumed, is ever pruned)
26
+ - anything outside .contextzip/
27
+
28
+ Kept only-the-most-recent-N, everything older deleted immediately:
29
+ - zip/manifest/report sets under .contextzip/output/<mode>/, per mode
30
+ folder
31
+ - timestamped folders under .contextzip/backups/
32
+ - archived zips under .contextzip/inbox/applied/ (this is also governed
33
+ by `applied_zip_retention`, already enforced on every apply-zip run —
34
+ scanning it here too just catches the case where zips piled up before
35
+ that setting existed, or the setting was since lowered)
36
+
37
+ Scanning only ever touches .contextzip/ directory listings and file mtimes —
38
+ no hashing, no reading file contents — so this stays fast even called on
39
+ every command, on a large project.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import shutil
45
+ from dataclasses import dataclass, field
46
+ from pathlib import Path
47
+
48
+
49
+ _OUTPUT_DIRNAME = "output"
50
+ _BACKUPS_DIRNAME = "backups"
51
+ _INBOX_DIRNAME = "inbox"
52
+ _APPLIED_DIRNAME = "applied"
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Data model
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ @dataclass
61
+ class CleanupItem:
62
+ path: Path
63
+ kind: str # "zip-set" | "backup" | "applied-zip"
64
+ size_bytes: int
65
+
66
+
67
+ @dataclass
68
+ class CleanupPlan:
69
+ workspace: Path
70
+ items: list[CleanupItem] = field(default_factory=list)
71
+
72
+ @property
73
+ def total_bytes(self) -> int:
74
+ return sum(i.size_bytes for i in self.items)
75
+
76
+ @property
77
+ def is_empty(self) -> bool:
78
+ return not self.items
79
+
80
+
81
+ @dataclass
82
+ class CleanupResult:
83
+ removed: list[Path] = field(default_factory=list)
84
+ failed: list[tuple[Path, str]] = field(default_factory=list)
85
+ freed_bytes: int = 0
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Helpers
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ def _size_of(path: Path) -> int:
94
+ """Size of a file, or total size of everything under a directory."""
95
+ if path.is_file():
96
+ try:
97
+ return path.stat().st_size
98
+ except OSError:
99
+ return 0
100
+ if not path.is_dir():
101
+ return 0
102
+ total = 0
103
+ for p in path.rglob("*"):
104
+ if p.is_file():
105
+ try:
106
+ total += p.stat().st_size
107
+ except OSError:
108
+ pass
109
+ return total
110
+
111
+
112
+ def _zip_set_files(zip_path: Path) -> list[Path]:
113
+ """The zip and any sidecar manifest/report files that sit next to it."""
114
+ files = [zip_path]
115
+ for suffix in (".manifest.json", ".report.txt", ".apply-report.txt"):
116
+ sidecar = zip_path.with_name(zip_path.stem + suffix)
117
+ if sidecar.is_file():
118
+ files.append(sidecar)
119
+ return files
120
+
121
+
122
+ def _newest_first(paths: list[Path]) -> list[Path]:
123
+ return sorted(paths, key=lambda p: p.stat().st_mtime, reverse=True)
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Scan
128
+ # ---------------------------------------------------------------------------
129
+
130
+
131
+ def scan(
132
+ project_dir: Path,
133
+ *,
134
+ keep_recent: int = 1,
135
+ applied_zip_retention: int = 1,
136
+ ) -> CleanupPlan:
137
+ """
138
+ Build a plan of everything beyond the most recent `keep_recent` item(s)
139
+ per category. Read-only — never deletes anything itself; pass the
140
+ result to `execute()` to actually do so.
141
+ """
142
+ from contextzip.packager import _workspace_dir
143
+
144
+ workspace, _is_git_repo = _workspace_dir(project_dir)
145
+ items: list[CleanupItem] = []
146
+ keep = max(0, keep_recent)
147
+
148
+ # ── output/ — per-mode subfolders, plus any flat legacy files left
149
+ # behind by a contextzip version predating the per-mode split ──────
150
+ output_root = workspace / _OUTPUT_DIRNAME
151
+ if output_root.is_dir():
152
+ for mode_dir in sorted(p for p in output_root.iterdir() if p.is_dir()):
153
+ zips = _newest_first([p for p in mode_dir.glob("*.zip") if p.is_file()])
154
+ for zp in zips[keep:]:
155
+ for f in _zip_set_files(zp):
156
+ items.append(CleanupItem(path=f, kind="zip-set", size_bytes=_size_of(f)))
157
+
158
+ legacy_zips = _newest_first([p for p in output_root.glob("*.zip") if p.is_file()])
159
+ for zp in legacy_zips[keep:]:
160
+ for f in _zip_set_files(zp):
161
+ items.append(CleanupItem(path=f, kind="zip-set", size_bytes=_size_of(f)))
162
+
163
+ # ── backups/<timestamp>/ ─────────────────────────────────────────────
164
+ backups_root = workspace / _BACKUPS_DIRNAME
165
+ if backups_root.is_dir():
166
+ backups = _newest_first([p for p in backups_root.iterdir() if p.is_dir()])
167
+ for backup_dir in backups[keep:]:
168
+ items.append(
169
+ CleanupItem(path=backup_dir, kind="backup", size_bytes=_size_of(backup_dir))
170
+ )
171
+
172
+ # ── inbox/applied/ ───────────────────────────────────────────────────
173
+ applied_root = workspace / _INBOX_DIRNAME / _APPLIED_DIRNAME
174
+ if applied_root.is_dir():
175
+ applied_zips = _newest_first([p for p in applied_root.glob("*.zip") if p.is_file()])
176
+ retain = max(1, applied_zip_retention)
177
+ for zp in applied_zips[retain:]:
178
+ items.append(CleanupItem(path=zp, kind="applied-zip", size_bytes=_size_of(zp)))
179
+
180
+ return CleanupPlan(workspace=workspace, items=items)
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Execute
185
+ # ---------------------------------------------------------------------------
186
+
187
+
188
+ def execute(plan: CleanupPlan) -> CleanupResult:
189
+ """
190
+ Delete everything in *plan*, immediately, no confirmation. Best-effort:
191
+ one failure doesn't stop the rest, and every failure is reported rather
192
+ than silently swallowed.
193
+ """
194
+ result = CleanupResult()
195
+ for item in plan.items:
196
+ try:
197
+ if item.path.is_dir():
198
+ shutil.rmtree(item.path)
199
+ elif item.path.is_file():
200
+ item.path.unlink()
201
+ else:
202
+ continue # already gone — nothing to do, not a failure
203
+ result.removed.append(item.path)
204
+ result.freed_bytes += item.size_bytes
205
+ except OSError as exc:
206
+ result.failed.append((item.path, str(exc)))
207
+ return result