contextzip 0.3.8__tar.gz → 0.3.9__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.3.8 → contextzip-0.3.9}/PKG-INFO +20 -60
- {contextzip-0.3.8 → contextzip-0.3.9}/README.md +19 -59
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/__init__.py +1 -1
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/api.py +1 -1
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/applier.py +147 -8
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/cli.py +5 -2
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/cli_display.py +13 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/config.py +24 -16
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/base.py +31 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/webui/server.py +32 -10
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip.egg-info/PKG-INFO +20 -60
- {contextzip-0.3.8 → contextzip-0.3.9}/pyproject.toml +1 -1
- {contextzip-0.3.8 → contextzip-0.3.9}/LICENSE +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/ai/__init__.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/ai/gemini.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/ai/heuristic.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/ai/selector.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/cli_ai.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/cli_onboard.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/clipboard.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/detector.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/error_parser.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/filters.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/git.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/packager.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/project_config.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/__init__.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/errors/__init__.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/errors/node.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/errors/python.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/go.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/node.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/python.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/ruby.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/rust.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/watcher.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/webui/__init__.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/webui/assets.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/webui/persist.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/webui/suggestions.py +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip.egg-info/SOURCES.txt +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip.egg-info/dependency_links.txt +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip.egg-info/entry_points.txt +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip.egg-info/requires.txt +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/contextzip.egg-info/top_level.txt +0 -0
- {contextzip-0.3.8 → contextzip-0.3.9}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: contextzip
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.9
|
|
4
4
|
Summary: Intelligently package your codebase for AI tools
|
|
5
5
|
Author-email: Deepesh <akadeepesh@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -58,6 +58,7 @@ And when the AI tool hands you a ZIP back with the changes, `contextzip apply-zi
|
|
|
58
58
|
- **Configurable workspace location** — `.contextzip/` lives at the git root by default, but you can pin it elsewhere per-machine (`contextzip config --set-workspace`) or for the whole team via a committed `.contextzip.json`
|
|
59
59
|
- **Persistent workspace** — all generated ZIPs land in `.contextzip/`, discoverable, reusable, and git-ignored automatically
|
|
60
60
|
- **Warns before it's a problem** — flags large (≥ 1 MB) and binary files that AI tools can't read, before you waste an upload
|
|
61
|
+
- **Never packages secrets** — SSH keys, cloud credentials, keystores, Terraform state, and other credential files are always excluded, on top of your `.gitignore` (see [What gets excluded](#what-gets-excluded))
|
|
61
62
|
- **Handles edge cases** — dangling symlinks, unreadable files, and paths outside the project tree are caught and reported, never silently dropped
|
|
62
63
|
- **Full CLI control** — `--include`, `--exclude`, `--dry-run`, `--output`, all composable
|
|
63
64
|
|
|
@@ -121,7 +122,7 @@ contextzip [OPTIONS]
|
|
|
121
122
|
| `--no-clipboard` | Skip the clipboard / folder-open step |
|
|
122
123
|
| `--no-gitignore` | Ignore the project's `.gitignore` |
|
|
123
124
|
|
|
124
|
-
**Subcommands:** `exclude`, `include`, `
|
|
125
|
+
**Subcommands:** `exclude`, `include`, `watch`, `config` — run `contextzip --help` for full details.
|
|
125
126
|
|
|
126
127
|
---
|
|
127
128
|
|
|
@@ -160,7 +161,7 @@ contextzip apply-zip
|
|
|
160
161
|
contextzip is also usable as a library. All CLI capabilities are available as plain Python functions — no Click, no Rich output, no `SystemExit`.
|
|
161
162
|
|
|
162
163
|
```python
|
|
163
|
-
from contextzip import get_git_changes, get_files, create_zip
|
|
164
|
+
from contextzip import get_git_changes, get_files, create_zip
|
|
164
165
|
|
|
165
166
|
# Get changed files and use them directly
|
|
166
167
|
collection = get_git_changes()
|
|
@@ -190,7 +191,7 @@ print(f"Wrote {len(result.written)} files, backup at {result.backup_dir}")
|
|
|
190
191
|
| `apply_zip(zip_path?, project_dir?, manifest?)` | Apply an AI-returned ZIP back into the project |
|
|
191
192
|
| `detect_ecosystem(path?)` | Detect framework and confidence level |
|
|
192
193
|
|
|
193
|
-
All functions default `path` to `Path.cwd()`. Errors raise typed exceptions (`NotARepositoryError`, `GitNotFoundError`, `NoFilesError`,
|
|
194
|
+
All functions default `path` to `Path.cwd()`. Errors raise typed exceptions (`NotARepositoryError`, `GitNotFoundError`, `NoFilesError`, etc.) rather than exiting.
|
|
194
195
|
|
|
195
196
|
---
|
|
196
197
|
|
|
@@ -218,60 +219,7 @@ contextzip config # show current key status
|
|
|
218
219
|
contextzip config --reset-key # clear and re-run setup
|
|
219
220
|
```
|
|
220
221
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
## Applying AI-returned changes
|
|
224
|
-
|
|
225
|
-
Once an AI tool has made its edits and handed you back a ZIP, `contextzip apply-zip` writes those changes into your project — safely.
|
|
226
|
-
|
|
227
|
-
```bash
|
|
228
|
-
# Drop the AI's returned zip into .contextzip/inbox/, then:
|
|
229
|
-
contextzip apply-zip
|
|
230
|
-
|
|
231
|
-
# Or point at it directly, wherever it landed:
|
|
232
|
-
contextzip apply-zip ~/Downloads/fixed-project.zip
|
|
233
|
-
|
|
234
|
-
# Preview first, with full per-file detail:
|
|
235
|
-
contextzip apply-zip --dry-run --verbose
|
|
236
|
-
|
|
237
|
-
# Skip the confirmation prompt (e.g. in a script):
|
|
238
|
-
contextzip apply-zip --yes
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
### How it knows what's safe to apply
|
|
242
|
-
|
|
243
|
-
Every ZIP `contextzip` creates gets a small manifest written next to it in `.contextzip/output/` — a hash of each included file, taken at zip-time. This manifest is **never added to the ZIP itself**. It stays local, so it's never uploaded and never visible to whatever AI tool the ZIP is pasted into — nothing for a model, or a curious teammate, to notice or ask about.
|
|
244
|
-
|
|
245
|
-
When you run `apply-zip`, it diffs the returned ZIP against that manifest and classifies every file:
|
|
246
|
-
|
|
247
|
-
| Status | Meaning | Applied automatically? |
|
|
248
|
-
|---|---|---|
|
|
249
|
-
| **New** | Wasn't part of the original ZIP | Yes |
|
|
250
|
-
| **Modified** | Was sent, content changed, and your local copy hasn't moved since | Yes |
|
|
251
|
-
| **Unchanged** | Identical to what's already on disk | Skipped — nothing to do |
|
|
252
|
-
| **Drifted** | Your local file changed (or was deleted) since you zipped it | No — flagged, asks first |
|
|
253
|
-
| **Untracked** | In the returned ZIP but has no baseline to compare against | No — flagged, asks first |
|
|
254
|
-
|
|
255
|
-
The common case — you zip some files, the AI edits them, nothing else touched your project meanwhile — applies straight through with just a summary printed, no prompt. Anything that could clobber your own work, or introduce a path you didn't expect, stops and asks before writing.
|
|
256
|
-
|
|
257
|
-
If a ZIP arrives with no matching manifest at all (e.g. it wasn't produced by a `contextzip` round trip), every already-existing path is treated as untracked and you'll be asked to confirm the whole batch.
|
|
258
|
-
|
|
259
|
-
### What it does and doesn't do
|
|
260
|
-
|
|
261
|
-
- **Only adds and modifies files.** Deletions are never inferred from a ZIP's contents — a file that's simply missing from the returned ZIP is left alone.
|
|
262
|
-
- **Backs up before overwriting.** Every file about to be touched is copied into `.contextzip/backups/<timestamp>/` first, preserving its relative path.
|
|
263
|
-
- **Rejects unsafe paths outright.** Any ZIP entry that would resolve outside your project (`../../etc/passwd`-style path traversal) is refused before anything is written — no override flag, no exceptions.
|
|
264
|
-
- **Archives what it consumes.** Once applied, the ZIP is moved to `.contextzip/inbox/applied/<timestamp>-name.zip` — it won't be picked up again by accident, and stays around as an audit trail. ZIPs applied via an explicit path outside the inbox are left where you put them.
|
|
265
|
-
|
|
266
|
-
### Finding the right ZIP and manifest
|
|
267
|
-
|
|
268
|
-
```bash
|
|
269
|
-
contextzip apply-zip # exactly one *.zip in .contextzip/inbox/ → uses it
|
|
270
|
-
contextzip apply-zip path/to/fix.zip # explicit path always overrides the inbox
|
|
271
|
-
contextzip apply-zip --manifest path/to/codebase.manifest.json # override auto-detection
|
|
272
|
-
```
|
|
273
|
-
|
|
274
|
-
If `.contextzip/inbox/` has more than one ZIP and you don't specify which, `apply-zip` lists them and asks you to be explicit rather than guessing. Manifest auto-detection picks the most recently created `*.manifest.json` under `.contextzip/output/` — correct for the normal one-round-trip-at-a-time flow; pass `--manifest` explicitly if you've generated several ZIPs before getting a response back.
|
|
222
|
+
Saved keys live in your OS user-config directory (e.g. `~/.config/contextzip/config.json` on Linux/macOS), and that file's permissions are locked down to your user only (`0600`) on every save.
|
|
275
223
|
|
|
276
224
|
---
|
|
277
225
|
|
|
@@ -292,7 +240,7 @@ contextzip starts your process normally. You see output exactly as you would wit
|
|
|
292
240
|
╰───────────────────────────────────────────────────╯
|
|
293
241
|
```
|
|
294
242
|
|
|
295
|
-
Press **D** and contextzip immediately writes `.contextzip/
|
|
243
|
+
Press **D** and contextzip immediately writes `.contextzip/debug-context.zip`. Your server keeps running — no restart, no interruption.
|
|
296
244
|
|
|
297
245
|
**What's in the ZIP:**
|
|
298
246
|
|
|
@@ -314,7 +262,19 @@ Press **D** and contextzip immediately writes `.contextzip/output/debug-context.
|
|
|
314
262
|
|
|
315
263
|
contextzip stacks exclusion rules based on your detected stack, on top of your `.gitignore`.
|
|
316
264
|
|
|
317
|
-
**Always excluded:** `.git/`, `.env` files, logs, caches, editor config (`.vscode/`, `.idea/`), OS files (`.DS_Store`, `Thumbs.db`), common binary formats, and contextzip's own `.contextzip/` working folder.
|
|
265
|
+
**Always excluded:** `.git/`, `.env` files, logs, caches, editor config (`.vscode/`, `.idea/`), OS files (`.DS_Store`, `Thumbs.db`), common binary formats, secrets and credential files (see below), and contextzip's own `.contextzip/` working folder.
|
|
266
|
+
|
|
267
|
+
**Secrets & credentials — always excluded, regardless of framework:**
|
|
268
|
+
|
|
269
|
+
| Category | Examples |
|
|
270
|
+
|---|---|
|
|
271
|
+
| SSH private keys | `id_rsa`, `id_dsa`, `id_ecdsa`, `id_ed25519` (public counterparts like `id_rsa.pub` are kept) |
|
|
272
|
+
| Keystores & certs | `*.p12`, `*.pfx`, `*.pkcs12`, `*.jks`, `*.keystore`, `*.ppk`, `*.key` |
|
|
273
|
+
| CLI / package manager credentials | `.npmrc`, `.netrc`, `.pypirc`, `.pgpass`, `.dockercfg`, Docker `config.json` |
|
|
274
|
+
| Cloud provider credentials | `.aws/credentials`, `.aws/config`, `*serviceaccount*.json`, `*credentials*.json`, `kubeconfig` |
|
|
275
|
+
| Infra-as-code state | `*.tfstate`, `*.tfstate.*`, `.terraform/` (Terraform state routinely contains plaintext secrets, even for "just infra" resources) |
|
|
276
|
+
|
|
277
|
+
These patterns are applied on top of your `.gitignore` and can't be re-included, so a stray credential file lying around your project never accidentally ends up in a ZIP you paste into an AI tool.
|
|
318
278
|
|
|
319
279
|
**By framework:**
|
|
320
280
|
|
|
@@ -29,6 +29,7 @@ And when the AI tool hands you a ZIP back with the changes, `contextzip apply-zi
|
|
|
29
29
|
- **Configurable workspace location** — `.contextzip/` lives at the git root by default, but you can pin it elsewhere per-machine (`contextzip config --set-workspace`) or for the whole team via a committed `.contextzip.json`
|
|
30
30
|
- **Persistent workspace** — all generated ZIPs land in `.contextzip/`, discoverable, reusable, and git-ignored automatically
|
|
31
31
|
- **Warns before it's a problem** — flags large (≥ 1 MB) and binary files that AI tools can't read, before you waste an upload
|
|
32
|
+
- **Never packages secrets** — SSH keys, cloud credentials, keystores, Terraform state, and other credential files are always excluded, on top of your `.gitignore` (see [What gets excluded](#what-gets-excluded))
|
|
32
33
|
- **Handles edge cases** — dangling symlinks, unreadable files, and paths outside the project tree are caught and reported, never silently dropped
|
|
33
34
|
- **Full CLI control** — `--include`, `--exclude`, `--dry-run`, `--output`, all composable
|
|
34
35
|
|
|
@@ -92,7 +93,7 @@ contextzip [OPTIONS]
|
|
|
92
93
|
| `--no-clipboard` | Skip the clipboard / folder-open step |
|
|
93
94
|
| `--no-gitignore` | Ignore the project's `.gitignore` |
|
|
94
95
|
|
|
95
|
-
**Subcommands:** `exclude`, `include`, `
|
|
96
|
+
**Subcommands:** `exclude`, `include`, `watch`, `config` — run `contextzip --help` for full details.
|
|
96
97
|
|
|
97
98
|
---
|
|
98
99
|
|
|
@@ -131,7 +132,7 @@ contextzip apply-zip
|
|
|
131
132
|
contextzip is also usable as a library. All CLI capabilities are available as plain Python functions — no Click, no Rich output, no `SystemExit`.
|
|
132
133
|
|
|
133
134
|
```python
|
|
134
|
-
from contextzip import get_git_changes, get_files, create_zip
|
|
135
|
+
from contextzip import get_git_changes, get_files, create_zip
|
|
135
136
|
|
|
136
137
|
# Get changed files and use them directly
|
|
137
138
|
collection = get_git_changes()
|
|
@@ -161,7 +162,7 @@ print(f"Wrote {len(result.written)} files, backup at {result.backup_dir}")
|
|
|
161
162
|
| `apply_zip(zip_path?, project_dir?, manifest?)` | Apply an AI-returned ZIP back into the project |
|
|
162
163
|
| `detect_ecosystem(path?)` | Detect framework and confidence level |
|
|
163
164
|
|
|
164
|
-
All functions default `path` to `Path.cwd()`. Errors raise typed exceptions (`NotARepositoryError`, `GitNotFoundError`, `NoFilesError`,
|
|
165
|
+
All functions default `path` to `Path.cwd()`. Errors raise typed exceptions (`NotARepositoryError`, `GitNotFoundError`, `NoFilesError`, etc.) rather than exiting.
|
|
165
166
|
|
|
166
167
|
---
|
|
167
168
|
|
|
@@ -189,60 +190,7 @@ contextzip config # show current key status
|
|
|
189
190
|
contextzip config --reset-key # clear and re-run setup
|
|
190
191
|
```
|
|
191
192
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
## Applying AI-returned changes
|
|
195
|
-
|
|
196
|
-
Once an AI tool has made its edits and handed you back a ZIP, `contextzip apply-zip` writes those changes into your project — safely.
|
|
197
|
-
|
|
198
|
-
```bash
|
|
199
|
-
# Drop the AI's returned zip into .contextzip/inbox/, then:
|
|
200
|
-
contextzip apply-zip
|
|
201
|
-
|
|
202
|
-
# Or point at it directly, wherever it landed:
|
|
203
|
-
contextzip apply-zip ~/Downloads/fixed-project.zip
|
|
204
|
-
|
|
205
|
-
# Preview first, with full per-file detail:
|
|
206
|
-
contextzip apply-zip --dry-run --verbose
|
|
207
|
-
|
|
208
|
-
# Skip the confirmation prompt (e.g. in a script):
|
|
209
|
-
contextzip apply-zip --yes
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
### How it knows what's safe to apply
|
|
213
|
-
|
|
214
|
-
Every ZIP `contextzip` creates gets a small manifest written next to it in `.contextzip/output/` — a hash of each included file, taken at zip-time. This manifest is **never added to the ZIP itself**. It stays local, so it's never uploaded and never visible to whatever AI tool the ZIP is pasted into — nothing for a model, or a curious teammate, to notice or ask about.
|
|
215
|
-
|
|
216
|
-
When you run `apply-zip`, it diffs the returned ZIP against that manifest and classifies every file:
|
|
217
|
-
|
|
218
|
-
| Status | Meaning | Applied automatically? |
|
|
219
|
-
|---|---|---|
|
|
220
|
-
| **New** | Wasn't part of the original ZIP | Yes |
|
|
221
|
-
| **Modified** | Was sent, content changed, and your local copy hasn't moved since | Yes |
|
|
222
|
-
| **Unchanged** | Identical to what's already on disk | Skipped — nothing to do |
|
|
223
|
-
| **Drifted** | Your local file changed (or was deleted) since you zipped it | No — flagged, asks first |
|
|
224
|
-
| **Untracked** | In the returned ZIP but has no baseline to compare against | No — flagged, asks first |
|
|
225
|
-
|
|
226
|
-
The common case — you zip some files, the AI edits them, nothing else touched your project meanwhile — applies straight through with just a summary printed, no prompt. Anything that could clobber your own work, or introduce a path you didn't expect, stops and asks before writing.
|
|
227
|
-
|
|
228
|
-
If a ZIP arrives with no matching manifest at all (e.g. it wasn't produced by a `contextzip` round trip), every already-existing path is treated as untracked and you'll be asked to confirm the whole batch.
|
|
229
|
-
|
|
230
|
-
### What it does and doesn't do
|
|
231
|
-
|
|
232
|
-
- **Only adds and modifies files.** Deletions are never inferred from a ZIP's contents — a file that's simply missing from the returned ZIP is left alone.
|
|
233
|
-
- **Backs up before overwriting.** Every file about to be touched is copied into `.contextzip/backups/<timestamp>/` first, preserving its relative path.
|
|
234
|
-
- **Rejects unsafe paths outright.** Any ZIP entry that would resolve outside your project (`../../etc/passwd`-style path traversal) is refused before anything is written — no override flag, no exceptions.
|
|
235
|
-
- **Archives what it consumes.** Once applied, the ZIP is moved to `.contextzip/inbox/applied/<timestamp>-name.zip` — it won't be picked up again by accident, and stays around as an audit trail. ZIPs applied via an explicit path outside the inbox are left where you put them.
|
|
236
|
-
|
|
237
|
-
### Finding the right ZIP and manifest
|
|
238
|
-
|
|
239
|
-
```bash
|
|
240
|
-
contextzip apply-zip # exactly one *.zip in .contextzip/inbox/ → uses it
|
|
241
|
-
contextzip apply-zip path/to/fix.zip # explicit path always overrides the inbox
|
|
242
|
-
contextzip apply-zip --manifest path/to/codebase.manifest.json # override auto-detection
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
If `.contextzip/inbox/` has more than one ZIP and you don't specify which, `apply-zip` lists them and asks you to be explicit rather than guessing. Manifest auto-detection picks the most recently created `*.manifest.json` under `.contextzip/output/` — correct for the normal one-round-trip-at-a-time flow; pass `--manifest` explicitly if you've generated several ZIPs before getting a response back.
|
|
193
|
+
Saved keys live in your OS user-config directory (e.g. `~/.config/contextzip/config.json` on Linux/macOS), and that file's permissions are locked down to your user only (`0600`) on every save.
|
|
246
194
|
|
|
247
195
|
---
|
|
248
196
|
|
|
@@ -263,7 +211,7 @@ contextzip starts your process normally. You see output exactly as you would wit
|
|
|
263
211
|
╰───────────────────────────────────────────────────╯
|
|
264
212
|
```
|
|
265
213
|
|
|
266
|
-
Press **D** and contextzip immediately writes `.contextzip/
|
|
214
|
+
Press **D** and contextzip immediately writes `.contextzip/debug-context.zip`. Your server keeps running — no restart, no interruption.
|
|
267
215
|
|
|
268
216
|
**What's in the ZIP:**
|
|
269
217
|
|
|
@@ -285,7 +233,19 @@ Press **D** and contextzip immediately writes `.contextzip/output/debug-context.
|
|
|
285
233
|
|
|
286
234
|
contextzip stacks exclusion rules based on your detected stack, on top of your `.gitignore`.
|
|
287
235
|
|
|
288
|
-
**Always excluded:** `.git/`, `.env` files, logs, caches, editor config (`.vscode/`, `.idea/`), OS files (`.DS_Store`, `Thumbs.db`), common binary formats, and contextzip's own `.contextzip/` working folder.
|
|
236
|
+
**Always excluded:** `.git/`, `.env` files, logs, caches, editor config (`.vscode/`, `.idea/`), OS files (`.DS_Store`, `Thumbs.db`), common binary formats, secrets and credential files (see below), and contextzip's own `.contextzip/` working folder.
|
|
237
|
+
|
|
238
|
+
**Secrets & credentials — always excluded, regardless of framework:**
|
|
239
|
+
|
|
240
|
+
| Category | Examples |
|
|
241
|
+
|---|---|
|
|
242
|
+
| SSH private keys | `id_rsa`, `id_dsa`, `id_ecdsa`, `id_ed25519` (public counterparts like `id_rsa.pub` are kept) |
|
|
243
|
+
| Keystores & certs | `*.p12`, `*.pfx`, `*.pkcs12`, `*.jks`, `*.keystore`, `*.ppk`, `*.key` |
|
|
244
|
+
| CLI / package manager credentials | `.npmrc`, `.netrc`, `.pypirc`, `.pgpass`, `.dockercfg`, Docker `config.json` |
|
|
245
|
+
| Cloud provider credentials | `.aws/credentials`, `.aws/config`, `*serviceaccount*.json`, `*credentials*.json`, `kubeconfig` |
|
|
246
|
+
| Infra-as-code state | `*.tfstate`, `*.tfstate.*`, `.terraform/` (Terraform state routinely contains plaintext secrets, even for "just infra" resources) |
|
|
247
|
+
|
|
248
|
+
These patterns are applied on top of your `.gitignore` and can't be re-included, so a stray credential file lying around your project never accidentally ends up in a ZIP you paste into an AI tool.
|
|
289
249
|
|
|
290
250
|
**By framework:**
|
|
291
251
|
|
|
@@ -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
|
|
|
@@ -450,15 +576,28 @@ def _backup_entries(
|
|
|
450
576
|
def _move_zip_to_applied(zip_path: Path, project_dir: Path, workspace_root: Path) -> Path:
|
|
451
577
|
"""
|
|
452
578
|
Move a consumed inbox zip into .contextzip/inbox/applied/, timestamped,
|
|
453
|
-
so it can't be accidentally re-applied
|
|
454
|
-
|
|
455
|
-
|
|
579
|
+
so it can't be accidentally re-applied. Zips passed by an explicit path
|
|
580
|
+
outside the inbox are left where the user put them rather than being
|
|
581
|
+
moved unexpectedly.
|
|
582
|
+
|
|
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.
|
|
456
588
|
"""
|
|
457
589
|
if zip_path.parent.resolve() != inbox_dir(project_dir).resolve():
|
|
458
590
|
return zip_path
|
|
459
591
|
|
|
460
592
|
applied_dir = workspace_root / _INBOX_DIRNAME / _APPLIED_DIRNAME
|
|
461
593
|
applied_dir.mkdir(parents=True, exist_ok=True)
|
|
594
|
+
|
|
595
|
+
for old in applied_dir.glob("*.zip"):
|
|
596
|
+
try:
|
|
597
|
+
old.unlink()
|
|
598
|
+
except OSError:
|
|
599
|
+
pass # best-effort — a leftover old zip isn't worth failing the apply over
|
|
600
|
+
|
|
462
601
|
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
463
602
|
dest = applied_dir / f"{stamp}-{zip_path.name}"
|
|
464
603
|
try:
|
|
@@ -424,9 +424,12 @@ def cmd_apply_zip(
|
|
|
424
424
|
return
|
|
425
425
|
|
|
426
426
|
if plan.is_risky and not yes:
|
|
427
|
-
|
|
428
|
-
"
|
|
427
|
+
prompt = (
|
|
428
|
+
" This zip's structure doesn't look right — apply anyway?"
|
|
429
|
+
if plan.structure_warning
|
|
430
|
+
else " Some files above need a closer look — apply anyway?"
|
|
429
431
|
)
|
|
432
|
+
proceed = click.confirm(prompt, default=False)
|
|
430
433
|
if not proceed:
|
|
431
434
|
console.print("[dim]Cancelled — no files written.[/]")
|
|
432
435
|
discard_plan(plan)
|
|
@@ -354,8 +354,21 @@ def print_apply_plan(
|
|
|
354
354
|
" [dim]Manifest:[/] [yellow]none found[/] "
|
|
355
355
|
"[dim](every existing path will be treated as untracked)[/]"
|
|
356
356
|
)
|
|
357
|
+
if plan.wrapper_note:
|
|
358
|
+
con.print(f" [dim]Note :[/] [cyan]{plan.wrapper_note}[/]")
|
|
357
359
|
con.print()
|
|
358
360
|
|
|
361
|
+
if plan.structure_warning:
|
|
362
|
+
con.print(
|
|
363
|
+
Panel(
|
|
364
|
+
f"[bold yellow]{plan.structure_warning}[/]",
|
|
365
|
+
title="[bold yellow]⚠ Structure mismatch[/]",
|
|
366
|
+
border_style="yellow",
|
|
367
|
+
padding=(0, 1),
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
con.print()
|
|
371
|
+
|
|
359
372
|
counts = Counter(e.status.value for e in plan.entries)
|
|
360
373
|
table = Table(box=box.ROUNDED, show_header=False, padding=(0, 2))
|
|
361
374
|
table.add_column(style="dim")
|
|
@@ -87,10 +87,7 @@ def save_api_key(key: str) -> None:
|
|
|
87
87
|
_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
88
88
|
data = _read_config()
|
|
89
89
|
data["gemini_api_key"] = key.strip()
|
|
90
|
-
|
|
91
|
-
json.dumps(data, indent=2) + "\n",
|
|
92
|
-
encoding="utf-8",
|
|
93
|
-
)
|
|
90
|
+
_write_config(data)
|
|
94
91
|
|
|
95
92
|
|
|
96
93
|
def delete_api_key() -> bool:
|
|
@@ -105,10 +102,7 @@ def delete_api_key() -> bool:
|
|
|
105
102
|
if "gemini_api_key" not in data:
|
|
106
103
|
return False
|
|
107
104
|
del data["gemini_api_key"]
|
|
108
|
-
|
|
109
|
-
json.dumps(data, indent=2) + "\n",
|
|
110
|
-
encoding="utf-8",
|
|
111
|
-
)
|
|
105
|
+
_write_config(data)
|
|
112
106
|
return True
|
|
113
107
|
except Exception:
|
|
114
108
|
return False
|
|
@@ -179,10 +173,7 @@ def save_workspace_location(value: str) -> None:
|
|
|
179
173
|
_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
180
174
|
data = _read_config()
|
|
181
175
|
data["workspace_location"] = value.strip()
|
|
182
|
-
|
|
183
|
-
json.dumps(data, indent=2) + "\n",
|
|
184
|
-
encoding="utf-8",
|
|
185
|
-
)
|
|
176
|
+
_write_config(data)
|
|
186
177
|
|
|
187
178
|
|
|
188
179
|
def delete_workspace_location() -> bool:
|
|
@@ -192,10 +183,7 @@ def delete_workspace_location() -> bool:
|
|
|
192
183
|
if "workspace_location" not in data:
|
|
193
184
|
return False
|
|
194
185
|
del data["workspace_location"]
|
|
195
|
-
|
|
196
|
-
json.dumps(data, indent=2) + "\n",
|
|
197
|
-
encoding="utf-8",
|
|
198
|
-
)
|
|
186
|
+
_write_config(data)
|
|
199
187
|
return True
|
|
200
188
|
except Exception:
|
|
201
189
|
return False
|
|
@@ -225,10 +213,30 @@ def save_config_ui_dismissed() -> None:
|
|
|
225
213
|
_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
226
214
|
data = _read_config()
|
|
227
215
|
data["config_ui_prompt_dismissed"] = True
|
|
216
|
+
_write_config(data)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _write_config(data: dict) -> None:
|
|
220
|
+
"""
|
|
221
|
+
Write *data* to the config file and lock its permissions down to the
|
|
222
|
+
owner only (0600) on POSIX systems.
|
|
223
|
+
|
|
224
|
+
The file may hold a Gemini API key in plaintext, so it shouldn't be
|
|
225
|
+
left group/world-readable — other local accounts on a shared machine
|
|
226
|
+
could otherwise read it straight off disk. Applied on every write
|
|
227
|
+
(not just creation) since some platforms reset perms on rewrite, and
|
|
228
|
+
a pre-existing file from an older contextzip version may still have
|
|
229
|
+
the old default (umask-dependent) permissions.
|
|
230
|
+
"""
|
|
228
231
|
_CONFIG_FILE.write_text(
|
|
229
232
|
json.dumps(data, indent=2) + "\n",
|
|
230
233
|
encoding="utf-8",
|
|
231
234
|
)
|
|
235
|
+
if os.name != "nt":
|
|
236
|
+
try:
|
|
237
|
+
os.chmod(_CONFIG_FILE, 0o600)
|
|
238
|
+
except OSError:
|
|
239
|
+
pass # best-effort — never block a save over a chmod failure
|
|
232
240
|
|
|
233
241
|
|
|
234
242
|
def _read_config() -> dict:
|
|
@@ -36,6 +36,37 @@ PATTERNS = [
|
|
|
36
36
|
"*.key",
|
|
37
37
|
"*.p12",
|
|
38
38
|
"*.pfx",
|
|
39
|
+
"*.pkcs12",
|
|
40
|
+
"*.jks",
|
|
41
|
+
"*.keystore",
|
|
42
|
+
"*.ppk",
|
|
43
|
+
# SSH private keys — no extension, so matched by exact basename.
|
|
44
|
+
# Public counterparts (id_rsa.pub etc.) are intentionally NOT excluded.
|
|
45
|
+
"id_rsa",
|
|
46
|
+
"id_dsa",
|
|
47
|
+
"id_ecdsa",
|
|
48
|
+
"id_ed25519",
|
|
49
|
+
# Credential files for common CLIs/package managers (auth tokens)
|
|
50
|
+
".npmrc",
|
|
51
|
+
".netrc",
|
|
52
|
+
".pypirc",
|
|
53
|
+
".pgpass",
|
|
54
|
+
".dockercfg",
|
|
55
|
+
"docker/config.json",
|
|
56
|
+
".docker/config.json",
|
|
57
|
+
# Cloud provider credential/config files
|
|
58
|
+
".aws/credentials",
|
|
59
|
+
".aws/config",
|
|
60
|
+
"*serviceaccount*.json",
|
|
61
|
+
"*service-account*.json",
|
|
62
|
+
"*credentials*.json",
|
|
63
|
+
"kubeconfig",
|
|
64
|
+
"*.kubeconfig",
|
|
65
|
+
# Infra-as-code state (Terraform state routinely contains plaintext
|
|
66
|
+
# secrets — resource passwords, keys — even for "just infra" resources)
|
|
67
|
+
"*.tfstate",
|
|
68
|
+
"*.tfstate.*",
|
|
69
|
+
".terraform/",
|
|
39
70
|
# Binaries & media (rarely useful for AI context)
|
|
40
71
|
"*.exe",
|
|
41
72
|
"*.dll",
|
|
@@ -24,6 +24,7 @@ preview feeling instant even on larger projects.
|
|
|
24
24
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
|
|
27
|
+
import hmac
|
|
27
28
|
import json
|
|
28
29
|
import secrets
|
|
29
30
|
import threading
|
|
@@ -90,7 +91,9 @@ class _ConfigUIState:
|
|
|
90
91
|
gitignore_path=self.gitignore_path,
|
|
91
92
|
)
|
|
92
93
|
force_include = build_force_include_spec(always_include or None)
|
|
93
|
-
return classify_scanned_files(
|
|
94
|
+
return classify_scanned_files(
|
|
95
|
+
self.project_dir, self.all_files, spec, force_include
|
|
96
|
+
)
|
|
94
97
|
|
|
95
98
|
|
|
96
99
|
def _make_handler(state: _ConfigUIState):
|
|
@@ -112,7 +115,10 @@ def _make_handler(state: _ConfigUIState):
|
|
|
112
115
|
return self.headers.get("X-Contextzip-Token")
|
|
113
116
|
|
|
114
117
|
def _authorized(self) -> bool:
|
|
115
|
-
|
|
118
|
+
token = self._token_from_request()
|
|
119
|
+
if not token:
|
|
120
|
+
return False
|
|
121
|
+
return hmac.compare_digest(token, state.token)
|
|
116
122
|
|
|
117
123
|
def _send_json(self, status: int, payload: dict) -> None:
|
|
118
124
|
body = json.dumps(payload).encode("utf-8")
|
|
@@ -158,7 +164,9 @@ def _make_handler(state: _ConfigUIState):
|
|
|
158
164
|
except ValueError:
|
|
159
165
|
return None
|
|
160
166
|
|
|
161
|
-
def _build_payload(
|
|
167
|
+
def _build_payload(
|
|
168
|
+
self, always_include: list[str], always_exclude: list[str]
|
|
169
|
+
) -> dict:
|
|
162
170
|
classified = state.classify(always_include, always_exclude)
|
|
163
171
|
|
|
164
172
|
root: dict = {"name": "", "path": "", "type": "dir", "children": {}}
|
|
@@ -279,7 +287,9 @@ def _make_handler(state: _ConfigUIState):
|
|
|
279
287
|
if not self._authorized():
|
|
280
288
|
self._send_json(403, {"error": "invalid token"})
|
|
281
289
|
return
|
|
282
|
-
payload = self._build_payload(
|
|
290
|
+
payload = self._build_payload(
|
|
291
|
+
state.always_include, state.always_exclude
|
|
292
|
+
)
|
|
283
293
|
payload["project"] = {
|
|
284
294
|
"name": state.project_dir.name or str(state.project_dir),
|
|
285
295
|
"path": str(state.project_dir),
|
|
@@ -324,8 +334,12 @@ def _make_handler(state: _ConfigUIState):
|
|
|
324
334
|
self._send_json(403, {"error": "invalid token"})
|
|
325
335
|
return
|
|
326
336
|
body = self._read_json_body()
|
|
327
|
-
always_include = [
|
|
328
|
-
|
|
337
|
+
always_include = [
|
|
338
|
+
p for p in body.get("always_include", []) if isinstance(p, str)
|
|
339
|
+
]
|
|
340
|
+
always_exclude = [
|
|
341
|
+
p for p in body.get("always_exclude", []) if isinstance(p, str)
|
|
342
|
+
]
|
|
329
343
|
payload = self._build_payload(always_include, always_exclude)
|
|
330
344
|
self._send_json(200, payload)
|
|
331
345
|
return
|
|
@@ -336,10 +350,14 @@ def _make_handler(state: _ConfigUIState):
|
|
|
336
350
|
return
|
|
337
351
|
body = self._read_json_body()
|
|
338
352
|
always_include = [
|
|
339
|
-
p.strip()
|
|
353
|
+
p.strip()
|
|
354
|
+
for p in body.get("always_include", [])
|
|
355
|
+
if isinstance(p, str) and p.strip()
|
|
340
356
|
]
|
|
341
357
|
always_exclude = [
|
|
342
|
-
p.strip()
|
|
358
|
+
p.strip()
|
|
359
|
+
for p in body.get("always_exclude", [])
|
|
360
|
+
if isinstance(p, str) and p.strip()
|
|
343
361
|
]
|
|
344
362
|
with state.lock:
|
|
345
363
|
try:
|
|
@@ -404,7 +422,9 @@ def launch_config_ui(project_dir: Path, detection, *, con: Console = None) -> bo
|
|
|
404
422
|
|
|
405
423
|
start = time.monotonic()
|
|
406
424
|
try:
|
|
407
|
-
with con.status(
|
|
425
|
+
with con.status(
|
|
426
|
+
"[cyan]Waiting for you to finish in the browser…[/]", spinner="dots"
|
|
427
|
+
):
|
|
408
428
|
while not state.should_stop.is_set() and not state.saved:
|
|
409
429
|
now = time.monotonic()
|
|
410
430
|
if now - state.last_activity > _IDLE_TIMEOUT_SECONDS:
|
|
@@ -419,7 +439,9 @@ def launch_config_ui(project_dir: Path, detection, *, con: Console = None) -> bo
|
|
|
419
439
|
httpd.server_close()
|
|
420
440
|
|
|
421
441
|
if state.saved:
|
|
422
|
-
con.print(
|
|
442
|
+
con.print(
|
|
443
|
+
f" [green]✓[/] Saved to [dim]{project_config_path(project_dir)}[/]\n"
|
|
444
|
+
)
|
|
423
445
|
else:
|
|
424
446
|
con.print(" [dim]Config UI closed without saving.[/]\n")
|
|
425
447
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: contextzip
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.9
|
|
4
4
|
Summary: Intelligently package your codebase for AI tools
|
|
5
5
|
Author-email: Deepesh <akadeepesh@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -58,6 +58,7 @@ And when the AI tool hands you a ZIP back with the changes, `contextzip apply-zi
|
|
|
58
58
|
- **Configurable workspace location** — `.contextzip/` lives at the git root by default, but you can pin it elsewhere per-machine (`contextzip config --set-workspace`) or for the whole team via a committed `.contextzip.json`
|
|
59
59
|
- **Persistent workspace** — all generated ZIPs land in `.contextzip/`, discoverable, reusable, and git-ignored automatically
|
|
60
60
|
- **Warns before it's a problem** — flags large (≥ 1 MB) and binary files that AI tools can't read, before you waste an upload
|
|
61
|
+
- **Never packages secrets** — SSH keys, cloud credentials, keystores, Terraform state, and other credential files are always excluded, on top of your `.gitignore` (see [What gets excluded](#what-gets-excluded))
|
|
61
62
|
- **Handles edge cases** — dangling symlinks, unreadable files, and paths outside the project tree are caught and reported, never silently dropped
|
|
62
63
|
- **Full CLI control** — `--include`, `--exclude`, `--dry-run`, `--output`, all composable
|
|
63
64
|
|
|
@@ -121,7 +122,7 @@ contextzip [OPTIONS]
|
|
|
121
122
|
| `--no-clipboard` | Skip the clipboard / folder-open step |
|
|
122
123
|
| `--no-gitignore` | Ignore the project's `.gitignore` |
|
|
123
124
|
|
|
124
|
-
**Subcommands:** `exclude`, `include`, `
|
|
125
|
+
**Subcommands:** `exclude`, `include`, `watch`, `config` — run `contextzip --help` for full details.
|
|
125
126
|
|
|
126
127
|
---
|
|
127
128
|
|
|
@@ -160,7 +161,7 @@ contextzip apply-zip
|
|
|
160
161
|
contextzip is also usable as a library. All CLI capabilities are available as plain Python functions — no Click, no Rich output, no `SystemExit`.
|
|
161
162
|
|
|
162
163
|
```python
|
|
163
|
-
from contextzip import get_git_changes, get_files, create_zip
|
|
164
|
+
from contextzip import get_git_changes, get_files, create_zip
|
|
164
165
|
|
|
165
166
|
# Get changed files and use them directly
|
|
166
167
|
collection = get_git_changes()
|
|
@@ -190,7 +191,7 @@ print(f"Wrote {len(result.written)} files, backup at {result.backup_dir}")
|
|
|
190
191
|
| `apply_zip(zip_path?, project_dir?, manifest?)` | Apply an AI-returned ZIP back into the project |
|
|
191
192
|
| `detect_ecosystem(path?)` | Detect framework and confidence level |
|
|
192
193
|
|
|
193
|
-
All functions default `path` to `Path.cwd()`. Errors raise typed exceptions (`NotARepositoryError`, `GitNotFoundError`, `NoFilesError`,
|
|
194
|
+
All functions default `path` to `Path.cwd()`. Errors raise typed exceptions (`NotARepositoryError`, `GitNotFoundError`, `NoFilesError`, etc.) rather than exiting.
|
|
194
195
|
|
|
195
196
|
---
|
|
196
197
|
|
|
@@ -218,60 +219,7 @@ contextzip config # show current key status
|
|
|
218
219
|
contextzip config --reset-key # clear and re-run setup
|
|
219
220
|
```
|
|
220
221
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
## Applying AI-returned changes
|
|
224
|
-
|
|
225
|
-
Once an AI tool has made its edits and handed you back a ZIP, `contextzip apply-zip` writes those changes into your project — safely.
|
|
226
|
-
|
|
227
|
-
```bash
|
|
228
|
-
# Drop the AI's returned zip into .contextzip/inbox/, then:
|
|
229
|
-
contextzip apply-zip
|
|
230
|
-
|
|
231
|
-
# Or point at it directly, wherever it landed:
|
|
232
|
-
contextzip apply-zip ~/Downloads/fixed-project.zip
|
|
233
|
-
|
|
234
|
-
# Preview first, with full per-file detail:
|
|
235
|
-
contextzip apply-zip --dry-run --verbose
|
|
236
|
-
|
|
237
|
-
# Skip the confirmation prompt (e.g. in a script):
|
|
238
|
-
contextzip apply-zip --yes
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
### How it knows what's safe to apply
|
|
242
|
-
|
|
243
|
-
Every ZIP `contextzip` creates gets a small manifest written next to it in `.contextzip/output/` — a hash of each included file, taken at zip-time. This manifest is **never added to the ZIP itself**. It stays local, so it's never uploaded and never visible to whatever AI tool the ZIP is pasted into — nothing for a model, or a curious teammate, to notice or ask about.
|
|
244
|
-
|
|
245
|
-
When you run `apply-zip`, it diffs the returned ZIP against that manifest and classifies every file:
|
|
246
|
-
|
|
247
|
-
| Status | Meaning | Applied automatically? |
|
|
248
|
-
|---|---|---|
|
|
249
|
-
| **New** | Wasn't part of the original ZIP | Yes |
|
|
250
|
-
| **Modified** | Was sent, content changed, and your local copy hasn't moved since | Yes |
|
|
251
|
-
| **Unchanged** | Identical to what's already on disk | Skipped — nothing to do |
|
|
252
|
-
| **Drifted** | Your local file changed (or was deleted) since you zipped it | No — flagged, asks first |
|
|
253
|
-
| **Untracked** | In the returned ZIP but has no baseline to compare against | No — flagged, asks first |
|
|
254
|
-
|
|
255
|
-
The common case — you zip some files, the AI edits them, nothing else touched your project meanwhile — applies straight through with just a summary printed, no prompt. Anything that could clobber your own work, or introduce a path you didn't expect, stops and asks before writing.
|
|
256
|
-
|
|
257
|
-
If a ZIP arrives with no matching manifest at all (e.g. it wasn't produced by a `contextzip` round trip), every already-existing path is treated as untracked and you'll be asked to confirm the whole batch.
|
|
258
|
-
|
|
259
|
-
### What it does and doesn't do
|
|
260
|
-
|
|
261
|
-
- **Only adds and modifies files.** Deletions are never inferred from a ZIP's contents — a file that's simply missing from the returned ZIP is left alone.
|
|
262
|
-
- **Backs up before overwriting.** Every file about to be touched is copied into `.contextzip/backups/<timestamp>/` first, preserving its relative path.
|
|
263
|
-
- **Rejects unsafe paths outright.** Any ZIP entry that would resolve outside your project (`../../etc/passwd`-style path traversal) is refused before anything is written — no override flag, no exceptions.
|
|
264
|
-
- **Archives what it consumes.** Once applied, the ZIP is moved to `.contextzip/inbox/applied/<timestamp>-name.zip` — it won't be picked up again by accident, and stays around as an audit trail. ZIPs applied via an explicit path outside the inbox are left where you put them.
|
|
265
|
-
|
|
266
|
-
### Finding the right ZIP and manifest
|
|
267
|
-
|
|
268
|
-
```bash
|
|
269
|
-
contextzip apply-zip # exactly one *.zip in .contextzip/inbox/ → uses it
|
|
270
|
-
contextzip apply-zip path/to/fix.zip # explicit path always overrides the inbox
|
|
271
|
-
contextzip apply-zip --manifest path/to/codebase.manifest.json # override auto-detection
|
|
272
|
-
```
|
|
273
|
-
|
|
274
|
-
If `.contextzip/inbox/` has more than one ZIP and you don't specify which, `apply-zip` lists them and asks you to be explicit rather than guessing. Manifest auto-detection picks the most recently created `*.manifest.json` under `.contextzip/output/` — correct for the normal one-round-trip-at-a-time flow; pass `--manifest` explicitly if you've generated several ZIPs before getting a response back.
|
|
222
|
+
Saved keys live in your OS user-config directory (e.g. `~/.config/contextzip/config.json` on Linux/macOS), and that file's permissions are locked down to your user only (`0600`) on every save.
|
|
275
223
|
|
|
276
224
|
---
|
|
277
225
|
|
|
@@ -292,7 +240,7 @@ contextzip starts your process normally. You see output exactly as you would wit
|
|
|
292
240
|
╰───────────────────────────────────────────────────╯
|
|
293
241
|
```
|
|
294
242
|
|
|
295
|
-
Press **D** and contextzip immediately writes `.contextzip/
|
|
243
|
+
Press **D** and contextzip immediately writes `.contextzip/debug-context.zip`. Your server keeps running — no restart, no interruption.
|
|
296
244
|
|
|
297
245
|
**What's in the ZIP:**
|
|
298
246
|
|
|
@@ -314,7 +262,19 @@ Press **D** and contextzip immediately writes `.contextzip/output/debug-context.
|
|
|
314
262
|
|
|
315
263
|
contextzip stacks exclusion rules based on your detected stack, on top of your `.gitignore`.
|
|
316
264
|
|
|
317
|
-
**Always excluded:** `.git/`, `.env` files, logs, caches, editor config (`.vscode/`, `.idea/`), OS files (`.DS_Store`, `Thumbs.db`), common binary formats, and contextzip's own `.contextzip/` working folder.
|
|
265
|
+
**Always excluded:** `.git/`, `.env` files, logs, caches, editor config (`.vscode/`, `.idea/`), OS files (`.DS_Store`, `Thumbs.db`), common binary formats, secrets and credential files (see below), and contextzip's own `.contextzip/` working folder.
|
|
266
|
+
|
|
267
|
+
**Secrets & credentials — always excluded, regardless of framework:**
|
|
268
|
+
|
|
269
|
+
| Category | Examples |
|
|
270
|
+
|---|---|
|
|
271
|
+
| SSH private keys | `id_rsa`, `id_dsa`, `id_ecdsa`, `id_ed25519` (public counterparts like `id_rsa.pub` are kept) |
|
|
272
|
+
| Keystores & certs | `*.p12`, `*.pfx`, `*.pkcs12`, `*.jks`, `*.keystore`, `*.ppk`, `*.key` |
|
|
273
|
+
| CLI / package manager credentials | `.npmrc`, `.netrc`, `.pypirc`, `.pgpass`, `.dockercfg`, Docker `config.json` |
|
|
274
|
+
| Cloud provider credentials | `.aws/credentials`, `.aws/config`, `*serviceaccount*.json`, `*credentials*.json`, `kubeconfig` |
|
|
275
|
+
| Infra-as-code state | `*.tfstate`, `*.tfstate.*`, `.terraform/` (Terraform state routinely contains plaintext secrets, even for "just infra" resources) |
|
|
276
|
+
|
|
277
|
+
These patterns are applied on top of your `.gitignore` and can't be re-included, so a stray credential file lying around your project never accidentally ends up in a ZIP you paste into an AI tool.
|
|
318
278
|
|
|
319
279
|
**By framework:**
|
|
320
280
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|