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.
Files changed (46) hide show
  1. {contextzip-0.3.8 → contextzip-0.3.9}/PKG-INFO +20 -60
  2. {contextzip-0.3.8 → contextzip-0.3.9}/README.md +19 -59
  3. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/__init__.py +1 -1
  4. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/api.py +1 -1
  5. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/applier.py +147 -8
  6. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/cli.py +5 -2
  7. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/cli_display.py +13 -0
  8. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/config.py +24 -16
  9. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/base.py +31 -0
  10. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/webui/server.py +32 -10
  11. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip.egg-info/PKG-INFO +20 -60
  12. {contextzip-0.3.8 → contextzip-0.3.9}/pyproject.toml +1 -1
  13. {contextzip-0.3.8 → contextzip-0.3.9}/LICENSE +0 -0
  14. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/ai/__init__.py +0 -0
  15. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/ai/gemini.py +0 -0
  16. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/ai/heuristic.py +0 -0
  17. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/ai/selector.py +0 -0
  18. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/cli_ai.py +0 -0
  19. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/cli_onboard.py +0 -0
  20. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/clipboard.py +0 -0
  21. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/detector.py +0 -0
  22. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/error_parser.py +0 -0
  23. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/filters.py +0 -0
  24. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/git.py +0 -0
  25. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/packager.py +0 -0
  26. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/project_config.py +0 -0
  27. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/__init__.py +0 -0
  28. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/errors/__init__.py +0 -0
  29. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/errors/node.py +0 -0
  30. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/errors/python.py +0 -0
  31. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/go.py +0 -0
  32. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/node.py +0 -0
  33. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/python.py +0 -0
  34. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/ruby.py +0 -0
  35. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/rules/rust.py +0 -0
  36. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/watcher.py +0 -0
  37. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/webui/__init__.py +0 -0
  38. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/webui/assets.py +0 -0
  39. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/webui/persist.py +0 -0
  40. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip/webui/suggestions.py +0 -0
  41. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip.egg-info/SOURCES.txt +0 -0
  42. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip.egg-info/dependency_links.txt +0 -0
  43. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip.egg-info/entry_points.txt +0 -0
  44. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip.egg-info/requires.txt +0 -0
  45. {contextzip-0.3.8 → contextzip-0.3.9}/contextzip.egg-info/top_level.txt +0 -0
  46. {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.8
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`, `apply-zip`, `watch`, `config` — run `contextzip --help` for full details.
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, apply_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`, `ZipNotFoundError`, etc.) rather than exiting.
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/output/debug-context.zip`. Your server keeps running — no restart, no interruption.
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`, `apply-zip`, `watch`, `config` — run `contextzip --help` for full details.
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, apply_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`, `ZipNotFoundError`, etc.) rather than exiting.
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/output/debug-context.zip`. Your server keeps running — no restart, no interruption.
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
 
@@ -1,6 +1,6 @@
1
1
  """contextzip — intelligent codebase packager for AI tools."""
2
2
 
3
- __version__ = "0.3.8"
3
+ __version__ = "0.3.9"
4
4
 
5
5
  from contextzip.api import (
6
6
  FileCollection,
@@ -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
 
@@ -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 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.
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
- proceed = click.confirm(
428
- " Some files above need a closer look — apply anyway?", default=False
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
- _CONFIG_FILE.write_text(
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
- _CONFIG_FILE.write_text(
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
- _CONFIG_FILE.write_text(
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
- _CONFIG_FILE.write_text(
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(self.project_dir, self.all_files, spec, force_include)
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
- return self._token_from_request() == state.token
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(self, always_include: list[str], always_exclude: list[str]) -> dict:
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(state.always_include, state.always_exclude)
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 = [p for p in body.get("always_include", []) if isinstance(p, str)]
328
- always_exclude = [p for p in body.get("always_exclude", []) if isinstance(p, str)]
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() for p in body.get("always_include", []) if isinstance(p, str) and 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() for p in body.get("always_exclude", []) if isinstance(p, str) and 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("[cyan]Waiting for you to finish in the browser…[/]", spinner="dots"):
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(f" [green]✓[/] Saved to [dim]{project_config_path(project_dir)}[/]\n")
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.8
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`, `apply-zip`, `watch`, `config` — run `contextzip --help` for full details.
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, apply_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`, `ZipNotFoundError`, etc.) rather than exiting.
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/output/debug-context.zip`. Your server keeps running — no restart, no interruption.
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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "contextzip"
7
- version = "0.3.8"
7
+ version = "0.3.9"
8
8
  description = "Intelligently package your codebase for AI tools"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes
File without changes
File without changes