contextzip 0.3.8__tar.gz → 0.4.0__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 (50) hide show
  1. contextzip-0.4.0/PKG-INFO +98 -0
  2. contextzip-0.4.0/README.md +69 -0
  3. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/__init__.py +1 -1
  4. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/ai/selector.py +33 -11
  5. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/api.py +1 -1
  6. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/applier.py +164 -11
  7. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/cli.py +19 -4
  8. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/cli_ai.py +5 -0
  9. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/cli_display.py +27 -3
  10. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/config.py +24 -16
  11. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/filters.py +10 -2
  12. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/project_config.py +137 -9
  13. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/base.py +31 -0
  14. contextzip-0.4.0/contextzip/webui/assets.py +1185 -0
  15. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/webui/persist.py +32 -4
  16. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/webui/server.py +156 -16
  17. contextzip-0.4.0/contextzip.egg-info/PKG-INFO +98 -0
  18. {contextzip-0.3.8 → contextzip-0.4.0}/pyproject.toml +1 -1
  19. contextzip-0.3.8/PKG-INFO +0 -390
  20. contextzip-0.3.8/README.md +0 -361
  21. contextzip-0.3.8/contextzip/webui/assets.py +0 -1012
  22. contextzip-0.3.8/contextzip.egg-info/PKG-INFO +0 -390
  23. {contextzip-0.3.8 → contextzip-0.4.0}/LICENSE +0 -0
  24. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/ai/__init__.py +0 -0
  25. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/ai/gemini.py +0 -0
  26. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/ai/heuristic.py +0 -0
  27. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/cli_onboard.py +0 -0
  28. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/clipboard.py +0 -0
  29. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/detector.py +0 -0
  30. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/error_parser.py +0 -0
  31. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/git.py +0 -0
  32. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/packager.py +0 -0
  33. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/__init__.py +0 -0
  34. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/errors/__init__.py +0 -0
  35. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/errors/node.py +0 -0
  36. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/errors/python.py +0 -0
  37. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/go.py +0 -0
  38. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/node.py +0 -0
  39. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/python.py +0 -0
  40. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/ruby.py +0 -0
  41. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/rust.py +0 -0
  42. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/watcher.py +0 -0
  43. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/webui/__init__.py +0 -0
  44. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/webui/suggestions.py +0 -0
  45. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip.egg-info/SOURCES.txt +0 -0
  46. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip.egg-info/dependency_links.txt +0 -0
  47. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip.egg-info/entry_points.txt +0 -0
  48. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip.egg-info/requires.txt +0 -0
  49. {contextzip-0.3.8 → contextzip-0.4.0}/contextzip.egg-info/top_level.txt +0 -0
  50. {contextzip-0.3.8 → contextzip-0.4.0}/setup.cfg +0 -0
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: contextzip
3
+ Version: 0.4.0
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.8"
3
+ __version__ = "0.4.0"
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}:")
@@ -47,7 +47,7 @@ from contextzip.filters import (
47
47
  resolve_files,
48
48
  resolve_files_from_git,
49
49
  )
50
- from contextzip.git import GitChanges, GitError, GitErrorKind, get_changed_files
50
+ from contextzip.git import GitError, GitErrorKind, get_changed_files
51
51
  from contextzip.packager import PackageResult, create_zip_silent
52
52
 
53
53
 
@@ -112,6 +112,9 @@ class ApplyPlan:
112
112
  manifest_path: Path | None
113
113
  entries: list[ApplyEntry] = field(default_factory=list)
114
114
  extraction_dir: Path = None # type: ignore[assignment]
115
+ wrapper_stripped: str | None = None
116
+ wrapper_note: str | None = None
117
+ structure_warning: str | None = None
115
118
 
116
119
  @property
117
120
  def has_manifest(self) -> bool:
@@ -133,7 +136,11 @@ class ApplyPlan:
133
136
  @property
134
137
  def is_risky(self) -> bool:
135
138
  """True if anything here warrants a confirmation prompt before writing."""
136
- return bool(self.risky_entries) or not self.has_manifest
139
+ return (
140
+ bool(self.risky_entries)
141
+ or not self.has_manifest
142
+ or bool(self.structure_warning)
143
+ )
137
144
 
138
145
 
139
146
  @dataclass
@@ -316,20 +323,36 @@ def _is_within(path: Path, parent: Path) -> bool:
316
323
  return False
317
324
 
318
325
 
319
- def _safe_extract(zip_path: Path, dest: Path) -> None:
326
+ def _safe_extract(zip_path: Path, dest: Path, strip_prefix: str | None = None) -> None:
320
327
  """
321
328
  Extract *zip_path* into *dest*, refusing to write anything if any entry
322
329
  would resolve outside *dest* (zip-slip protection). Validated in a full
323
330
  first pass before any file is written, so a malicious entry never
324
331
  causes a partial extraction.
332
+
333
+ If *strip_prefix* is given (see `_detect_common_wrapper`), that leading
334
+ path component is removed from every entry before it's resolved against
335
+ *dest* — e.g. `codebase/contextzip/config.py` extracts to
336
+ `contextzip/config.py` instead of recreating a `codebase/` folder.
325
337
  """
326
338
  dest = dest.resolve()
339
+ strip = f"{strip_prefix}/" if strip_prefix else None
327
340
  with zipfile.ZipFile(zip_path) as zf:
328
341
  targets: dict[str, Path] = {}
329
342
  for info in zf.infolist():
330
343
  if info.is_dir():
331
344
  continue
332
- target = (dest / info.filename).resolve()
345
+ name = info.filename
346
+ if strip:
347
+ if not name.startswith(strip):
348
+ # Shouldn't happen given how strip_prefix is detected
349
+ # (every entry shares it), but never silently misplace
350
+ # a file if it somehow doesn't.
351
+ continue
352
+ name = name[len(strip):]
353
+ if not name:
354
+ continue
355
+ target = (dest / name).resolve()
333
356
  if not _is_within(target, dest):
334
357
  raise UnsafeZipEntryError(
335
358
  f"Refusing to apply: entry '{info.filename}' resolves "
@@ -340,12 +363,53 @@ def _safe_extract(zip_path: Path, dest: Path) -> None:
340
363
  for info in zf.infolist():
341
364
  if info.is_dir():
342
365
  continue
343
- target = targets[info.filename]
366
+ target = targets.get(info.filename)
367
+ if target is None:
368
+ continue
344
369
  target.parent.mkdir(parents=True, exist_ok=True)
345
370
  with zf.open(info) as src, target.open("wb") as out:
346
371
  shutil.copyfileobj(src, out)
347
372
 
348
373
 
374
+ # ---------------------------------------------------------------------------
375
+ # Wrapper-folder detection ((1) in the apply-zip structure fix)
376
+ # ---------------------------------------------------------------------------
377
+
378
+
379
+ def _detect_common_wrapper(names: list[str]) -> str | None:
380
+ """
381
+ If every entry in *names* is nested one level under the exact same
382
+ single top-level directory, return that directory's name — otherwise
383
+ None. This is the shape produced by `zip -r out.zip myfolder`, GitHub's
384
+ "Download ZIP" (`repo-branch/...`), and similar: an incidental wrapper
385
+ around the real, root-relative paths, rather than an intentional part
386
+ of the project's structure.
387
+
388
+ Deliberately conservative: a single root-level file mixed in with the
389
+ rest (no wrapper actually applies), or more than one top-level
390
+ directory, both return None rather than guessing.
391
+ """
392
+ if not names:
393
+ return None
394
+ tops: set[str] = set()
395
+ for n in names:
396
+ if "/" not in n:
397
+ return None
398
+ tops.add(n.split("/", 1)[0])
399
+ if len(tops) != 1:
400
+ return None
401
+ candidate = next(iter(tops))
402
+ return candidate or None
403
+
404
+
405
+ def _match_rate(names: list[str], manifest_files: dict) -> float:
406
+ """Fraction of *names* that appear as a path in *manifest_files*."""
407
+ if not names:
408
+ return 0.0
409
+ known = sum(1 for n in names if n in manifest_files)
410
+ return known / len(names)
411
+
412
+
349
413
  # ---------------------------------------------------------------------------
350
414
  # Building the plan
351
415
  # ---------------------------------------------------------------------------
@@ -360,12 +424,55 @@ def build_plan(
360
424
  Extract *zip_path* to a temp directory and classify every file against
361
425
  *manifest_path* (may be None). Caller is responsible for eventually
362
426
  calling `execute_plan` or `discard_plan` to clean up the temp dir.
427
+
428
+ Before extracting, checks whether every entry in the zip shares a
429
+ single wrapping top-level directory (e.g. `codebase/contextzip/...`)
430
+ that isn't actually part of the project — see `_detect_common_wrapper`.
431
+ If so, and stripping it would line paths up with the manifest better
432
+ than leaving them alone, it's stripped automatically. Separately, if a
433
+ manifest exists and almost none of the resulting paths match it,
434
+ `structure_warning` is set so the caller can make sure the person
435
+ actually looks before applying — a zip whose files come out looking
436
+ all-new is exactly what a silently-mismatched structure produces.
363
437
  """
364
438
  manifest = load_manifest(manifest_path) if manifest_path else {}
365
439
  manifest_files: dict = manifest.get("files", {}) if manifest else {}
366
440
 
441
+ with zipfile.ZipFile(zip_path) as zf:
442
+ raw_names = sorted(i.filename for i in zf.infolist() if not i.is_dir())
443
+
444
+ strip_prefix: str | None = None
445
+ wrapper_note: str | None = None
446
+ candidate = _detect_common_wrapper(raw_names)
447
+ if candidate and not (project_dir / candidate).is_dir():
448
+ if manifest_files:
449
+ unstripped_rate = _match_rate(raw_names, manifest_files)
450
+ stripped_names = [n[len(candidate) + 1:] for n in raw_names]
451
+ stripped_rate = _match_rate(stripped_names, manifest_files)
452
+ # Only strip when it clearly helps — meaningfully better match
453
+ # against paths we know this project actually has, not just a
454
+ # coincidental improvement on a tiny zip.
455
+ if stripped_rate > unstripped_rate and stripped_rate >= 0.5:
456
+ strip_prefix = candidate
457
+ wrapper_note = (
458
+ f"Removed wrapping folder '{candidate}/' present in every "
459
+ f"zip entry — {stripped_rate:.0%} of paths matched the "
460
+ f"project manifest after stripping vs {unstripped_rate:.0%} "
461
+ "before."
462
+ )
463
+ else:
464
+ # No manifest to confirm against, so this is a softer call —
465
+ # still strip (matches how `tar` and GitHub's own zip downloads
466
+ # behave), but say so plainly since it's not manifest-verified.
467
+ strip_prefix = candidate
468
+ wrapper_note = (
469
+ f"Removed wrapping folder '{candidate}/' present in every zip "
470
+ "entry (no manifest available to confirm — double-check the "
471
+ "result before trusting it)."
472
+ )
473
+
367
474
  extraction_dir = Path(tempfile.mkdtemp(prefix="contextzip-apply-"))
368
- _safe_extract(zip_path, extraction_dir)
475
+ _safe_extract(zip_path, extraction_dir, strip_prefix=strip_prefix)
369
476
 
370
477
  entries: list[ApplyEntry] = []
371
478
  for extracted in sorted(extraction_dir.rglob("*")):
@@ -406,11 +513,30 @@ def build_plan(
406
513
  ApplyEntry(rel_path=rel, status=status, size=size, extracted_path=extracted)
407
514
  )
408
515
 
516
+ structure_warning: str | None = None
517
+ if manifest_files and len(entries) >= 3:
518
+ known = sum(1 for e in entries if e.rel_path in manifest_files)
519
+ rate = known / len(entries)
520
+ if rate < 0.1:
521
+ structure_warning = (
522
+ f"Only {known} of {len(entries)} files in this zip match paths "
523
+ "from the project manifest, even after checking for a wrapping "
524
+ "folder. That usually means the zip's internal structure "
525
+ "doesn't line up with this project — the wrong zip, or one "
526
+ "built with an unexpected layout. Applying it as-is will "
527
+ "likely create a pile of unrelated new files rather than "
528
+ "update the ones you meant to change. Double-check the zip "
529
+ "before proceeding."
530
+ )
531
+
409
532
  return ApplyPlan(
410
533
  zip_path=zip_path,
411
534
  manifest_path=manifest_path,
412
535
  entries=entries,
413
536
  extraction_dir=extraction_dir,
537
+ wrapper_stripped=strip_prefix,
538
+ wrapper_note=wrapper_note,
539
+ structure_warning=structure_warning,
414
540
  )
415
541
 
416
542
 
@@ -447,18 +573,39 @@ def _backup_entries(
447
573
  return backup_dir
448
574
 
449
575
 
450
- def _move_zip_to_applied(zip_path: Path, project_dir: Path, workspace_root: Path) -> Path:
576
+ def _move_zip_to_applied(
577
+ zip_path: Path, project_dir: Path, workspace_root: Path, retain: int = 1
578
+ ) -> Path:
451
579
  """
452
580
  Move a consumed inbox zip into .contextzip/inbox/applied/, timestamped,
453
- so it can't be accidentally re-applied and stays around as an audit
454
- trail. Zips passed by an explicit path outside the inbox are left where
455
- the user put them rather than being moved unexpectedly.
581
+ so it can't be accidentally re-applied. Zips passed by an explicit path
582
+ outside the inbox are left where the user put them rather than being
583
+ moved unexpectedly.
584
+
585
+ Keeps at most *retain* applied zips (default 1 — only the most recent).
586
+ Filenames are timestamp-prefixed, so a lexicographic sort is also a
587
+ chronological one; the oldest are pruned first, before this run's zip
588
+ is moved in, so the folder never holds more than *retain* at once.
589
+ Typically a project's `applied_zip_retention` preference
590
+ (.contextzip/config.json) — raise it to keep a longer audit trail.
456
591
  """
457
592
  if zip_path.parent.resolve() != inbox_dir(project_dir).resolve():
458
593
  return zip_path
459
594
 
460
595
  applied_dir = workspace_root / _INBOX_DIRNAME / _APPLIED_DIRNAME
461
596
  applied_dir.mkdir(parents=True, exist_ok=True)
597
+
598
+ retain = max(1, retain)
599
+ existing = sorted(applied_dir.glob("*.zip"))
600
+ # Keep room for the zip we're about to add: prune down to retain - 1
601
+ # existing entries before moving the new one in.
602
+ overflow = len(existing) - (retain - 1)
603
+ for old in existing[: max(0, overflow)]:
604
+ try:
605
+ old.unlink()
606
+ except OSError:
607
+ pass # best-effort — a leftover old zip isn't worth failing the apply over
608
+
462
609
  stamp = time.strftime("%Y%m%d-%H%M%S")
463
610
  dest = applied_dir / f"{stamp}-{zip_path.name}"
464
611
  try:
@@ -468,13 +615,17 @@ def _move_zip_to_applied(zip_path: Path, project_dir: Path, workspace_root: Path
468
615
  return dest
469
616
 
470
617
 
471
- def execute_plan(plan: ApplyPlan, project_dir: Path) -> ApplyResult:
618
+ def execute_plan(plan: ApplyPlan, project_dir: Path, retain: int = 1) -> ApplyResult:
472
619
  """
473
620
  Write every non-unchanged entry from *plan* into the project.
474
621
 
475
622
  Backs up whatever's about to be overwritten first. Assumes the caller
476
623
  has already decided to proceed — dry-run and confirmation prompts are
477
624
  handled upstream (see cli.py's `apply-zip` command).
625
+
626
+ *retain* caps how many applied zips are kept in
627
+ .contextzip/inbox/applied/ — typically a project's
628
+ `applied_zip_retention` preference (.contextzip/config.json).
478
629
  """
479
630
  workspace_root = _workspace_dir(project_dir)
480
631
  to_write = plan.writable_entries
@@ -488,7 +639,9 @@ def execute_plan(plan: ApplyPlan, project_dir: Path) -> ApplyResult:
488
639
  shutil.copy2(entry.extracted_path, dest)
489
640
  written.append(entry.rel_path)
490
641
 
491
- applied_zip_path = _move_zip_to_applied(plan.zip_path, project_dir, workspace_root)
642
+ applied_zip_path = _move_zip_to_applied(
643
+ plan.zip_path, project_dir, workspace_root, retain=retain
644
+ )
492
645
  shutil.rmtree(plan.extraction_dir, ignore_errors=True)
493
646
 
494
647
  return ApplyResult(
@@ -373,6 +373,7 @@ def cmd_apply_zip(
373
373
  .contextzip/backups/<timestamp>/.
374
374
  """
375
375
  project_dir = Path(os.getcwd()).resolve()
376
+ project_cfg = load_project_config(project_dir)
376
377
 
377
378
  console.print()
378
379
  console.print(
@@ -424,16 +425,19 @@ def cmd_apply_zip(
424
425
  return
425
426
 
426
427
  if plan.is_risky and not yes:
427
- proceed = click.confirm(
428
- " Some files above need a closer look — apply anyway?", default=False
428
+ prompt = (
429
+ " This zip's structure doesn't look right — apply anyway?"
430
+ if plan.structure_warning
431
+ else " Some files above need a closer look — apply anyway?"
429
432
  )
433
+ proceed = click.confirm(prompt, default=False)
430
434
  if not proceed:
431
435
  console.print("[dim]Cancelled — no files written.[/]")
432
436
  discard_plan(plan)
433
437
  return
434
438
  console.print()
435
439
 
436
- result = execute_plan(plan, project_dir)
440
+ result = execute_plan(plan, project_dir, retain=project_cfg.applied_zip_retention)
437
441
  print_apply_result(result)
438
442
 
439
443
 
@@ -919,6 +923,9 @@ def _run(
919
923
  resolved = resolve_files_from_git(
920
924
  git_files=git_result.files,
921
925
  project_dir=project_dir,
926
+ large_file_warn_bytes=int(
927
+ project_cfg.limits.max_file_size_mb * 1024 * 1024
928
+ ),
922
929
  )
923
930
 
924
931
  else:
@@ -957,11 +964,18 @@ def _run(
957
964
  spec=spec,
958
965
  include_only=include_only if include_only else None,
959
966
  force_include=force_include,
967
+ large_file_warn_bytes=int(
968
+ project_cfg.limits.max_file_size_mb * 1024 * 1024
969
+ ),
960
970
  )
961
971
 
962
972
  # ── File scan summary + warnings ─────────────────────────────────────────
963
973
  print_scan_summary(resolved, project_dir, verbose, git_mode=git_changes)
964
- print_file_warnings(resolved, project_dir)
974
+ print_file_warnings(
975
+ resolved,
976
+ project_dir,
977
+ large_file_warn_bytes=int(project_cfg.limits.max_file_size_mb * 1024 * 1024),
978
+ )
965
979
 
966
980
  # ── Dry run ──────────────────────────────────────────────────────────────
967
981
  if dry_run:
@@ -1023,6 +1037,7 @@ def _run(
1023
1037
  ecosystem=detection.display_name,
1024
1038
  api_key=api_key,
1025
1039
  max_files=project_cfg.ai.max_files,
1040
+ prompt_template=project_cfg.ai.prompt_template,
1026
1041
  )
1027
1042
 
1028
1043
  if not selected_paths:
@@ -61,6 +61,7 @@ def run_ai_selection(
61
61
  ecosystem: str,
62
62
  api_key: str,
63
63
  max_files: int | None = None,
64
+ prompt_template: str = "",
64
65
  con: Console = console,
65
66
  ) -> tuple[list[Path], str]:
66
67
  """
@@ -70,6 +71,9 @@ def run_ai_selection(
70
71
  project's `ai.max_files` preference (.contextzip/config.json). None
71
72
  falls back to each backend's own built-in default.
72
73
 
74
+ *prompt_template*, if non-empty, is a project's `ai.prompt_template`
75
+ preference, prepended to the generated prompt.txt.
76
+
73
77
  Handles progress display, heuristic-fallback warning, and Gemini
74
78
  errors. Raises SystemExit(1) on hard failure.
75
79
  """
@@ -86,6 +90,7 @@ def run_ai_selection(
86
90
  ecosystem=ecosystem,
87
91
  api_key=api_key,
88
92
  max_files=max_files,
93
+ prompt_template=prompt_template,
89
94
  )
90
95
  except GeminiError as exc:
91
96
  con.print(