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.
- contextzip-0.4.0/PKG-INFO +98 -0
- contextzip-0.4.0/README.md +69 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/__init__.py +1 -1
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/ai/selector.py +33 -11
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/api.py +1 -1
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/applier.py +164 -11
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/cli.py +19 -4
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/cli_ai.py +5 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/cli_display.py +27 -3
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/config.py +24 -16
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/filters.py +10 -2
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/project_config.py +137 -9
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/base.py +31 -0
- contextzip-0.4.0/contextzip/webui/assets.py +1185 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/webui/persist.py +32 -4
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/webui/server.py +156 -16
- contextzip-0.4.0/contextzip.egg-info/PKG-INFO +98 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/pyproject.toml +1 -1
- contextzip-0.3.8/PKG-INFO +0 -390
- contextzip-0.3.8/README.md +0 -361
- contextzip-0.3.8/contextzip/webui/assets.py +0 -1012
- contextzip-0.3.8/contextzip.egg-info/PKG-INFO +0 -390
- {contextzip-0.3.8 → contextzip-0.4.0}/LICENSE +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/ai/__init__.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/ai/gemini.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/ai/heuristic.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/cli_onboard.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/clipboard.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/detector.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/error_parser.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/git.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/packager.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/__init__.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/errors/__init__.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/errors/node.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/errors/python.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/go.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/node.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/python.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/ruby.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/rules/rust.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/watcher.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/webui/__init__.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip/webui/suggestions.py +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip.egg-info/SOURCES.txt +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip.egg-info/dependency_links.txt +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip.egg-info/entry_points.txt +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip.egg-info/requires.txt +0 -0
- {contextzip-0.3.8 → contextzip-0.4.0}/contextzip.egg-info/top_level.txt +0 -0
- {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.
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
|
133
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
454
|
-
|
|
455
|
-
|
|
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(
|
|
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
|
-
|
|
428
|
-
"
|
|
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(
|
|
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(
|