contextzip 0.4.1__tar.gz → 0.4.2__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 (49) hide show
  1. {contextzip-0.4.1 → contextzip-0.4.2}/PKG-INFO +3 -1
  2. {contextzip-0.4.1 → contextzip-0.4.2}/README.md +2 -0
  3. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/__init__.py +1 -1
  4. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/cli.py +9 -0
  5. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/cli_display.py +17 -0
  6. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/config.py +3 -1
  7. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/packager.py +120 -29
  8. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/project_config.py +22 -11
  9. contextzip-0.4.2/contextzip/redact.py +175 -0
  10. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/report.py +7 -0
  11. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/webui/persist.py +2 -2
  12. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip.egg-info/PKG-INFO +3 -1
  13. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip.egg-info/SOURCES.txt +1 -0
  14. {contextzip-0.4.1 → contextzip-0.4.2}/pyproject.toml +1 -1
  15. {contextzip-0.4.1 → contextzip-0.4.2}/LICENSE +0 -0
  16. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/ai/__init__.py +0 -0
  17. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/ai/gemini.py +0 -0
  18. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/ai/heuristic.py +0 -0
  19. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/ai/selector.py +0 -0
  20. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/api.py +0 -0
  21. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/applier.py +0 -0
  22. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/cleanup.py +0 -0
  23. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/cli_ai.py +0 -0
  24. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/cli_onboard.py +0 -0
  25. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/clipboard.py +0 -0
  26. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/detector.py +0 -0
  27. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/error_parser.py +0 -0
  28. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/filters.py +0 -0
  29. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/git.py +0 -0
  30. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/__init__.py +0 -0
  31. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/base.py +0 -0
  32. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/errors/__init__.py +0 -0
  33. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/errors/node.py +0 -0
  34. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/errors/python.py +0 -0
  35. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/go.py +0 -0
  36. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/node.py +0 -0
  37. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/python.py +0 -0
  38. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/ruby.py +0 -0
  39. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/rust.py +0 -0
  40. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/watcher.py +0 -0
  41. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/webui/__init__.py +0 -0
  42. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/webui/assets.py +0 -0
  43. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/webui/server.py +0 -0
  44. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/webui/suggestions.py +0 -0
  45. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip.egg-info/dependency_links.txt +0 -0
  46. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip.egg-info/entry_points.txt +0 -0
  47. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip.egg-info/requires.txt +0 -0
  48. {contextzip-0.4.1 → contextzip-0.4.2}/contextzip.egg-info/top_level.txt +0 -0
  49. {contextzip-0.4.1 → contextzip-0.4.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contextzip
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Intelligently package your codebase for AI tools
5
5
  Author-email: Deepesh <akadeepesh@gmail.com>
6
6
  License-Expression: MIT
@@ -27,6 +27,8 @@ Requires-Dist: pathspec>=0.12
27
27
  Requires-Dist: httpx>=0.27
28
28
  Dynamic: license-file
29
29
 
30
+ <img width="1536" height="1024" alt="cz" src="https://github.com/user-attachments/assets/a930b49c-34a0-447b-8f3a-1909eae977c8" />
31
+
30
32
  # contextzip
31
33
 
32
34
  > Package exactly the right parts of your codebase and paste it into any AI tool — in one command.
@@ -1,3 +1,5 @@
1
+ <img width="1536" height="1024" alt="cz" src="https://github.com/user-attachments/assets/a930b49c-34a0-447b-8f3a-1909eae977c8" />
2
+
1
3
  # contextzip
2
4
 
3
5
  > Package exactly the right parts of your codebase and paste it into any AI tool — in one command.
@@ -1,6 +1,6 @@
1
1
  """contextzip — intelligent codebase packager for AI tools."""
2
2
 
3
- __version__ = "0.4.1"
3
+ __version__ = "0.4.2"
4
4
 
5
5
  from contextzip.api import (
6
6
  FileCollection,
@@ -55,6 +55,7 @@ from contextzip.cli_display import (
55
55
  print_apply_plan,
56
56
  print_apply_result,
57
57
  print_auto_cleanup,
58
+ print_redaction_summary,
58
59
  )
59
60
  from contextzip.report import write_scan_report, write_apply_report
60
61
  from contextzip.applier import (
@@ -673,6 +674,11 @@ def cmd_config(
673
674
  info(
674
675
  f"ai: enabled={ai.enabled} provider={ai.provider} max_files={ai.max_files}"
675
676
  )
677
+ limits = project_cfg.limits
678
+ info(
679
+ f"limits: max_file_size_mb={limits.max_file_size_mb} "
680
+ f"redact_secrets={limits.redact_secrets}"
681
+ )
676
682
  cleanup_cfg = project_cfg.cleanup
677
683
  info(
678
684
  f"cleanup: enabled={cleanup_cfg.enabled} "
@@ -984,6 +990,7 @@ def _run(
984
990
  console=console,
985
991
  mode=run_mode,
986
992
  prompt_txt=prompt_txt,
993
+ redact_secrets_enabled=project_cfg.limits.redact_secrets,
987
994
  )
988
995
  except Exception as exc:
989
996
  err(f"Failed to create ZIP: {exc}")
@@ -994,6 +1001,7 @@ def _run(
994
1001
  resolved, project_dir, large_file_warn_bytes=large_file_warn_bytes
995
1002
  )
996
1003
  print_zip_write_warnings(result)
1004
+ print_redaction_summary(result)
997
1005
  print_package_result(result)
998
1006
 
999
1007
  # ── Report ───────────────────────────────────────────────────────────────
@@ -1006,6 +1014,7 @@ def _run(
1006
1014
  ai_prompt=prompt,
1007
1015
  ai_selected=resolved.included if prompt else None,
1008
1016
  large_file_warn_bytes=large_file_warn_bytes,
1017
+ redacted=result.redacted,
1009
1018
  )
1010
1019
  print_report_hint(report_path)
1011
1020
 
@@ -238,6 +238,23 @@ def print_zip_write_warnings(result, *, con: Console = console) -> None:
238
238
  )
239
239
 
240
240
 
241
+ def print_redaction_summary(result, *, con: Console = console) -> None:
242
+ """
243
+ A one-line heads-up when limits.redact_secrets caught something —
244
+ same "surface it, don't hide it" treatment as large-file warnings.
245
+ Silent when nothing was redacted, including when the setting is off.
246
+ """
247
+ if not result.redacted:
248
+ return
249
+ n = len(result.redacted)
250
+ total_hits = sum(len(names) for _, names in result.redacted)
251
+ warn(
252
+ f"Redacted {total_hits} secret-shaped value(s) in {n} file{'s' if n != 1 else ''} "
253
+ "before zipping — see report",
254
+ con=con,
255
+ )
256
+
257
+
241
258
  def print_report_hint(report_path, *, con: Console = console) -> None:
242
259
  """Dim pointer to the full report, printed once at the end of a run."""
243
260
  if report_path:
@@ -147,7 +147,9 @@ def config_path() -> Path:
147
147
  # now just output zips) gets created, when nothing more specific overrides
148
148
  # it. This is a *personal*, per-machine preference — for a setting the whole
149
149
  # team should share, use the project-level config instead (project_config.py,
150
- # a .contextzip/config.json file meant to be committed to the repo).
150
+ # a .contextzip/config.json file — gitignored like the rest of the workspace
151
+ # by default, but explicitly force-addable with `git add -f` for teams that
152
+ # want to share it).
151
153
  #
152
154
  # Resolution order (highest wins), enforced by packager.py, not here:
153
155
  # CLI flag > CONTEXTZIP_WORKSPACE_LOCATION env var > project config
@@ -20,11 +20,12 @@ Phase 7 changes:
20
20
  instead of directly in .contextzip/, leaving room for config.json
21
21
  alongside them.
22
22
  - Ignoring is now handled by a self-contained .contextzip/.gitignore
23
- (ignore everything except config.json) instead of a blanket
24
- ".contextzip/" entry in the project's top-level .gitignore — this keeps
25
- config.json trackable/shareable while output/ stays untracked, and it
26
- keeps working even when the workspace is relocated outside the default
27
- git-root anchor (see project_config.py / _resolve_workspace_location).
23
+ (ignore everything, no exceptions) instead of a blanket ".contextzip/"
24
+ entry in the project's top-level .gitignore — this keeps working even
25
+ when the workspace is relocated outside the default git-root anchor
26
+ (see project_config.py / _resolve_workspace_location). config.json is
27
+ local by default like everything else in the workspace; sharing it
28
+ with a team is a deliberate, manual `git add -f`, never automatic.
28
29
 
29
30
  Phase 8 changes:
30
31
  - Every ZIP now gets a sidecar manifest written next to it (e.g.
@@ -45,6 +46,15 @@ Phase 9 changes:
45
46
  to remember which run you just did. See `output_subdir_for_mode` /
46
47
  `zip_filename_for_mode`, which are the single source of truth other
47
48
  modules (watcher.py, cleanup.py) build on so the mapping never drifts.
49
+
50
+ Phase 10 changes:
51
+ - Implements `limits.redact_secrets`, previously persisted by config.py /
52
+ the config UI but never actually enforced (flagged as a known
53
+ limitation as of 0.4.0). Text files that are already going into the
54
+ archive get scanned for secret-shaped values (API keys, tokens,
55
+ private-key blocks, etc. — see redact.py) and the matched values are
56
+ replaced with "[REDACTED]" before writing. Binary and oversized files
57
+ are never scanned — see `_should_scan_for_secrets`.
48
58
  """
49
59
 
50
60
  from __future__ import annotations
@@ -67,6 +77,7 @@ from rich.progress import (
67
77
  )
68
78
 
69
79
  from contextzip.filters import ResolveResult
80
+ from contextzip.redact import redact_secrets
70
81
 
71
82
  # Subdirectory of the workspace where generated ZIPs are written.
72
83
  _OUTPUT_DIRNAME = "output"
@@ -113,22 +124,25 @@ def zip_filename_for_mode(mode: str) -> str:
113
124
  """The deterministic zip filename written for *mode* (see module docstring)."""
114
125
  return _MODE_ZIP_FILENAMES.get(mode, _MODE_ZIP_FILENAMES[_DEFAULT_MODE])
115
126
 
116
- # Contents of .contextzip/.gitignore — ignore everything in the workspace
117
- # except the team-shareable project config and this file itself.
127
+ # Contents of .contextzip/.gitignore — ignore absolutely everything in the
128
+ # workspace, no exceptions. .contextzip/ is a local, per-machine scratch
129
+ # space; nothing in it is ever pushed by default. Anyone who genuinely
130
+ # wants to share something from it (e.g. config.json, for team-wide
131
+ # settings) can still do so explicitly with `git add -f`, but contextzip
132
+ # itself never carves out an exception — see project_config.py.
118
133
  _WORKSPACE_GITIGNORE_CONTENTS = (
119
134
  "# contextzip workspace\n"
120
- "# Everything here is a local, per-machine artifact except config.json,\n"
121
- "# which holds project-level contextzip preferences meant to be shared\n"
122
- "# with your team via Git.\n"
135
+ "# Everything here is a local, per-machine artifact. Nothing in this\n"
136
+ "# directory is ever pushed by default. To share something from it\n"
137
+ "# (e.g. config.json) with your team anyway, use `git add -f`.\n"
123
138
  "*\n"
124
- "!.gitignore\n"
125
- "!config.json\n"
126
139
  )
127
140
 
128
141
  # A previous version of contextzip added this block to the project's
129
142
  # top-level .gitignore. Now that .contextzip/.gitignore handles ignoring
130
- # on its own (and does so in a way that keeps config.json trackable), we
131
- # clean up that older entry the first time we touch a project — see
143
+ # on its own (and works correctly regardless of where the workspace is
144
+ # relocated), we clean up that older entry the first time we touch a
145
+ # project — see
132
146
  # _migrate_legacy_root_gitignore_entry.
133
147
  _LEGACY_GITIGNORE_BLOCK = "# contextzip workspace\n.contextzip/\n"
134
148
 
@@ -145,6 +159,10 @@ class PackageResult:
145
159
  uncompressed_bytes: int
146
160
  compressed_bytes: int
147
161
  skipped_in_zip: list[tuple[Path, str]] = field(default_factory=list)
162
+ # Files where at least one secret-shaped value was redacted before
163
+ # writing — (path relative to project root, pattern names matched).
164
+ # Always empty unless limits.redact_secrets is enabled.
165
+ redacted: list[tuple[Path, list[str]]] = field(default_factory=list)
148
166
 
149
167
  @property
150
168
  def compression_ratio(self) -> float:
@@ -161,6 +179,49 @@ class PackageResult:
161
179
  return self.compressed_bytes > self.uncompressed_bytes
162
180
 
163
181
 
182
+ # ---------------------------------------------------------------------------
183
+ # Shared per-file write logic (redaction happens here, in exactly one place)
184
+ # ---------------------------------------------------------------------------
185
+
186
+
187
+ def _write_member(
188
+ zf: zipfile.ZipFile,
189
+ abs_path: Path,
190
+ rel: Path,
191
+ redact_enabled: bool,
192
+ binary_paths: frozenset[Path],
193
+ large_paths: frozenset[Path],
194
+ ) -> list[str]:
195
+ """
196
+ Write one file into *zf*.
197
+
198
+ When *redact_enabled* is True and *abs_path* isn't already known to be
199
+ binary or oversized (per resolve_files()'s existing classification —
200
+ never re-detected here), reads it as UTF-8 text and redacts any
201
+ secret-shaped values before writing. Binary/oversized files, or text
202
+ that fails to decode as UTF-8, are written unmodified via the same
203
+ fast zf.write() path used when redaction is off entirely.
204
+
205
+ Returns the list of pattern names redacted (empty if none matched, or
206
+ if this file wasn't eligible for scanning). May raise OSError, exactly
207
+ as the old plain zf.write() call did — callers already handle that.
208
+ """
209
+ if not redact_enabled or abs_path in binary_paths or abs_path in large_paths:
210
+ zf.write(abs_path, arcname=rel.as_posix())
211
+ return []
212
+
213
+ original_bytes = abs_path.read_bytes()
214
+ try:
215
+ text = original_bytes.decode("utf-8")
216
+ except UnicodeDecodeError:
217
+ zf.writestr(rel.as_posix(), original_bytes)
218
+ return []
219
+
220
+ redacted_text, matched = redact_secrets(text)
221
+ zf.writestr(rel.as_posix(), redacted_text.encode("utf-8") if matched else original_bytes)
222
+ return matched
223
+
224
+
164
225
  # ---------------------------------------------------------------------------
165
226
  # Public API
166
227
  # ---------------------------------------------------------------------------
@@ -172,6 +233,7 @@ def create_zip_silent(
172
233
  output_path: Path | None,
173
234
  mode: str = _DEFAULT_MODE,
174
235
  prompt_txt: str | None = None,
236
+ redact_secrets_enabled: bool = False,
175
237
  ) -> PackageResult:
176
238
  """
177
239
  Write the included files from *resolve_result* into a ZIP archive
@@ -184,6 +246,9 @@ def create_zip_silent(
184
246
  *mode* selects which .contextzip/output/<mode>/ subfolder and
185
247
  deterministic filename to use — see `output_subdir_for_mode` /
186
248
  `zip_filename_for_mode`. Ignored when *output_path* is given.
249
+
250
+ *redact_secrets_enabled* mirrors the project's `limits.redact_secrets`
251
+ setting — see redact.py.
187
252
  """
188
253
  if output_path is not None:
189
254
  zip_path = output_path
@@ -193,7 +258,10 @@ def create_zip_silent(
193
258
  zip_path.parent.mkdir(parents=True, exist_ok=True)
194
259
 
195
260
  included: list[Path] = resolve_result.included
261
+ binary_paths = frozenset(resolve_result.binary_files)
262
+ large_paths = frozenset(p for p, _ in resolve_result.large_files)
196
263
  skipped_in_zip: list[tuple[Path, str]] = []
264
+ redacted: list[tuple[Path, list[str]]] = []
197
265
  uncompressed = 0
198
266
  file_count = 0
199
267
 
@@ -223,7 +291,11 @@ def create_zip_silent(
223
291
  continue
224
292
 
225
293
  try:
226
- zf.write(abs_path, arcname=rel.as_posix())
294
+ matched = _write_member(
295
+ zf, abs_path, rel, redact_secrets_enabled, binary_paths, large_paths
296
+ )
297
+ if matched:
298
+ redacted.append((rel, matched))
227
299
  uncompressed += file_size
228
300
  file_count += 1
229
301
  except PermissionError:
@@ -241,6 +313,7 @@ def create_zip_silent(
241
313
  uncompressed_bytes=uncompressed,
242
314
  compressed_bytes=compressed,
243
315
  skipped_in_zip=skipped_in_zip,
316
+ redacted=redacted,
244
317
  )
245
318
 
246
319
 
@@ -251,6 +324,7 @@ def create_zip(
251
324
  console: Console,
252
325
  mode: str = _DEFAULT_MODE,
253
326
  prompt_txt: str | None = None,
327
+ redact_secrets_enabled: bool = False,
254
328
  ) -> PackageResult:
255
329
  """
256
330
  Write the included files from *resolve_result* into a ZIP archive.
@@ -268,6 +342,11 @@ def create_zip(
268
342
  file is written as the first entry in the ZIP. Any AI tool that receives
269
343
  the ZIP will immediately see the task description and selected file list.
270
344
 
345
+ *redact_secrets_enabled* mirrors the project's `limits.redact_secrets`
346
+ setting — see redact.py. Only ever scans files already known (via
347
+ *resolve_result*) to be text and within `limits.max_file_size_mb`;
348
+ binary/oversized files are always written unmodified.
349
+
271
350
  Returns a :class:`PackageResult` with compression stats and any
272
351
  files that had to be skipped during writing (e.g. permission denied).
273
352
  """
@@ -280,7 +359,10 @@ def create_zip(
280
359
  zip_path.parent.mkdir(parents=True, exist_ok=True)
281
360
 
282
361
  included: list[Path] = resolve_result.included
362
+ binary_paths = frozenset(resolve_result.binary_files)
363
+ large_paths = frozenset(p for p, _ in resolve_result.large_files)
283
364
  skipped_in_zip: list[tuple[Path, str]] = []
365
+ redacted: list[tuple[Path, list[str]]] = []
284
366
  uncompressed = 0
285
367
  file_count = 0
286
368
 
@@ -325,7 +407,11 @@ def create_zip(
325
407
  continue
326
408
 
327
409
  try:
328
- zf.write(abs_path, arcname=rel.as_posix())
410
+ matched = _write_member(
411
+ zf, abs_path, rel, redact_secrets_enabled, binary_paths, large_paths
412
+ )
413
+ if matched:
414
+ redacted.append((rel, matched))
329
415
  uncompressed += file_size
330
416
  file_count += 1
331
417
  except PermissionError:
@@ -345,6 +431,7 @@ def create_zip(
345
431
  uncompressed_bytes=uncompressed,
346
432
  compressed_bytes=compressed,
347
433
  skipped_in_zip=skipped_in_zip,
434
+ redacted=redacted,
348
435
  )
349
436
 
350
437
 
@@ -406,20 +493,20 @@ def _find_git_root(start: Path) -> Path | None:
406
493
  def _ensure_workspace_gitignore(workspace: Path) -> None:
407
494
  """
408
495
  Ensure <workspace>/.gitignore exists with the standard contents that
409
- ignore everything in the workspace except config.json (and the
410
- .gitignore file itself).
496
+ ignore the entire workspace, no exceptions — .contextzip/ is a local,
497
+ per-machine scratch space and nothing in it is tracked by default,
498
+ including config.json and this .gitignore file itself.
411
499
 
412
500
  This is self-contained: it works no matter where *workspace* ends up
413
501
  living (git-root default, cwd, or a custom relocated path), since a
414
502
  nested .gitignore applies to its own directory regardless of where
415
503
  that directory sits in the tree — unlike a single blanket entry in a
416
504
  distant top-level .gitignore, which can't be relied on to reach a
417
- relocated workspace and (if it ignores the whole directory rather than
418
- its contents) would prevent config.json from ever being trackable.
505
+ relocated workspace.
419
506
 
420
507
  Idempotent — leaves an existing, already-correct file untouched, and
421
508
  only rewrites files that don't yet match (e.g. hand-edited or from an
422
- older contextzip version).
509
+ older contextzip version that carved out an exception for config.json).
423
510
  """
424
511
  gitignore_path = workspace / ".gitignore"
425
512
 
@@ -441,11 +528,14 @@ def _migrate_legacy_root_gitignore_entry(git_root: Path) -> None:
441
528
  .gitignore, if present.
442
529
 
443
530
  That blanket directory-level ignore predates .contextzip/.gitignore and
444
- would otherwise stop config.json from ever becoming trackable (git does
445
- not descend into an ignored directory to apply nested un-ignore rules).
446
- Only ever removes the exact block contextzip itself wrote — never
447
- touches unrelated .gitignore content, and is a no-op if the block isn't
448
- present (e.g. it was already removed, or never added).
531
+ is redundant now that the nested .gitignore ignores the whole workspace
532
+ on its own — leaving the old root-level entry around is harmless but
533
+ unnecessary clutter, and it would have stopped git from ever descending
534
+ into .contextzip/ to notice a force-added file (`git add -f`) if the
535
+ entry ignores the directory itself rather than its contents. Only ever
536
+ removes the exact block contextzip itself wrote — never touches
537
+ unrelated .gitignore content, and is a no-op if the block isn't present
538
+ (e.g. it was already removed, or never added).
449
539
  """
450
540
  gitignore_path = git_root / ".gitignore"
451
541
  if not gitignore_path.is_file():
@@ -490,9 +580,10 @@ def _resolve_workspace_location(project_dir: Path) -> tuple[str, str]:
490
580
 
491
581
  Precedence, highest wins:
492
582
  1. CONTEXTZIP_WORKSPACE_LOCATION env var
493
- 2. Project config (.contextzip/config.json at git root — team-shared,
494
- committed; falls back to the deprecated .contextzip.json if that's
495
- all a project has)
583
+ 2. Project config (.contextzip/config.json at git root — local by
584
+ default like the rest of the workspace, force-added with
585
+ `git add -f` if a team wants to share it; falls back to the
586
+ deprecated .contextzip.json if that's all a project has)
496
587
  3. Personal config (~/.config/contextzip/config.json — per-machine)
497
588
  4. Built-in default: "git-root"
498
589
 
@@ -1,15 +1,17 @@
1
1
  """
2
- project_config.py — Project-level, team-shareable contextzip settings.
2
+ project_config.py — Project-level contextzip settings.
3
3
 
4
4
  Loaded from `.contextzip/config.json`, anchored at the project's git root
5
5
  (or the project directory itself outside a git repo) — the same anchor
6
6
  `.contextzip/` itself resolves to by default. Unlike config.py's personal,
7
7
  per-machine store (~/.config/contextzip/config.json, holds API keys, never
8
- committed), this file is meant to be committed to the repo so every
9
- contributor gets the same project defaults automatically — no per-machine
10
- setup required. `.contextzip/` is gitignored by default (see packager.py's
11
- _ensure_workspace_gitignore), but config.json is deliberately carved out of
12
- that ignore rule so it stays trackable.
8
+ committed), this file holds settings a whole team could plausibly want to
9
+ share — but contextzip never presumes that on your behalf. `.contextzip/`
10
+ is gitignored in full by default (see packager.py's
11
+ _ensure_workspace_gitignore), config.json included; a team that wants to
12
+ share these settings has to explicitly `git add -f .contextzip/config.json`
13
+ to commit it. This is a deliberate design choice, not an oversight — see
14
+ "Never presumes what belongs in git" below.
13
15
 
14
16
  Currently supports:
15
17
 
@@ -78,11 +80,20 @@ built-in default for the newer preference fields.
78
80
  "large" before packaging (still included,
79
81
  just surfaced) instead of the fixed 1 MB
80
82
  default. Fractional values are allowed.
81
- redact_secrets — reserved for a future best-effort scrub of
82
- secret-shaped values (API keys, tokens) inside
83
- otherwise-included files, on top of the
84
- always-excluded credential file patterns.
85
- Currently persisted but not yet enforced.
83
+ redact_secrets — when true, files that are already going into the
84
+ archive are scanned for secret-shaped values
85
+ (API keys, tokens, private-key blocks — see
86
+ redact.py) and matched values are replaced
87
+ with "[REDACTED]" before writing. This is a
88
+ second line of defense on top of, not a
89
+ replacement for, the always-excluded whole-file
90
+ credential patterns (§5-equivalent baseline in
91
+ packager.py) — those still keep obviously-secret
92
+ files out entirely regardless of this setting.
93
+ Only ever scans files already known to be text
94
+ and within max_file_size_mb; binary and
95
+ oversized files are never scanned. Off by
96
+ default.
86
97
 
87
98
  applied_zip_retention
88
99
  How many past `apply-zip` archives to keep in
@@ -0,0 +1,175 @@
1
+ """
2
+ redact.py — Best-effort in-content secret redaction.
3
+
4
+ Implements the `limits.redact_secrets` project setting (see project_config.py),
5
+ which until now was persisted and editable in the config UI but never actually
6
+ enforced by the packaging logic — see the 0.4.0 changelog's "known
7
+ limitation."
8
+
9
+ This is deliberately a *second line of defense*, not a replacement for the
10
+ hard secrets/credentials exclusion list in packager.py's universal baseline.
11
+ That list keeps whole files that are almost certainly secrets (private keys,
12
+ credential files, .env) out of the archive entirely, unconditionally, and
13
+ that behavior is unchanged. This module instead handles the narrower case
14
+ those file-level rules can't catch: an ordinary source or config file that's
15
+ fine to include, but happens to have a secret-shaped value hardcoded
16
+ somewhere inside it (e.g. an AWS key pasted into a settings.py for local
17
+ testing).
18
+
19
+ Design choices, deliberately conservative to match the rest of the project:
20
+ - A curated list of high-precision patterns (known key/token formats,
21
+ private-key blocks, JWTs, and a narrow generic key=value assignment
22
+ form) rather than an entropy-based scan — same "curated list, not a
23
+ heuristic" approach as the file-level secrets baseline in packager.py.
24
+ - Only the matched *value* is replaced with "[REDACTED...]", not the
25
+ whole line — a redacted file should stay useful context for whatever
26
+ AI tool receives the archive.
27
+ - Obvious placeholders ("changeme", "xxxxxxxx", "<your-key-here>") are
28
+ left alone rather than redacted, so example/template files don't get
29
+ needlessly mangled.
30
+
31
+ Performance: this only ever runs on files the caller has already confirmed
32
+ are text and within `limits.max_file_size_mb` (see packager.py, which reuses
33
+ the binary/large-file classification `resolve_files()` already computes) —
34
+ never on binaries or oversized files. A handful of compiled regexes over a
35
+ small text file costs low-single-digit milliseconds; across an entire
36
+ project it's negligible next to the I/O of zipping in the first place.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import re
42
+ from dataclasses import dataclass
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class _SecretPattern:
47
+ name: str
48
+ regex: re.Pattern[str]
49
+ # Which regex group holds the actual secret value to redact.
50
+ # 0 means "redact the whole match" (used for freestanding key formats
51
+ # with no surrounding key=value context to preserve).
52
+ value_group: int = 0
53
+
54
+
55
+ _PLACEHOLDER_VALUES = {
56
+ "changeme",
57
+ "change_me",
58
+ "your_api_key_here",
59
+ "your-api-key-here",
60
+ "your_key_here",
61
+ "example",
62
+ "placeholder",
63
+ "insert_key_here",
64
+ "todo",
65
+ "fixme",
66
+ "xxx",
67
+ "n/a",
68
+ "none",
69
+ "null",
70
+ "test",
71
+ "fake",
72
+ "dummy",
73
+ }
74
+
75
+
76
+ def _looks_like_placeholder(value: str) -> bool:
77
+ v = value.strip().strip("'\"").lower()
78
+ if not v:
79
+ return True
80
+ if v in _PLACEHOLDER_VALUES:
81
+ return True
82
+ if set(v) <= {"x"}: # xxxxxxxx, XXXXXXXX
83
+ return True
84
+ if v.startswith("<") and v.endswith(">"): # <your-key-here>
85
+ return True
86
+ if v.startswith("$") or v.startswith("{{") or v.startswith("%"):
87
+ # env-var interpolation / templating placeholders, e.g. ${API_KEY},
88
+ # {{ secrets.API_KEY }}, %API_KEY% — a reference, not a live secret.
89
+ return True
90
+ return False
91
+
92
+
93
+ # Freestanding, self-identifying formats — redact the whole match, no
94
+ # surrounding key=value context needed.
95
+ _PATTERNS: list[_SecretPattern] = [
96
+ _SecretPattern("AWS Access Key ID", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
97
+ _SecretPattern("Google API Key", re.compile(r"\bAIza[0-9A-Za-z\-_]{35}\b")),
98
+ _SecretPattern("GitHub Token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b")),
99
+ _SecretPattern("Slack Token", re.compile(r"\bxox[baprs]-[0-9A-Za-z-]{10,48}\b")),
100
+ _SecretPattern("Stripe Key", re.compile(r"\bsk_(?:live|test)_[0-9A-Za-z]{24,}\b")),
101
+ _SecretPattern("Anthropic API Key", re.compile(r"\bsk-ant-[A-Za-z0-9\-_]{20,}\b")),
102
+ _SecretPattern("OpenAI API Key", re.compile(r"\bsk-proj-[A-Za-z0-9\-_]{20,}\b")),
103
+ _SecretPattern(
104
+ "JWT",
105
+ re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"),
106
+ ),
107
+ _SecretPattern(
108
+ "Private Key Block",
109
+ re.compile(
110
+ r"-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----"
111
+ r"[\s\S]+?"
112
+ r"-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----"
113
+ ),
114
+ ),
115
+ # AWS secret access keys have no distinguishing prefix, so this one
116
+ # needs the "aws_secret_access_key = ..." context to be high-precision.
117
+ _SecretPattern(
118
+ "AWS Secret Access Key",
119
+ re.compile(
120
+ r"(?i)aws_secret_access_key\s*[:=]\s*['\"]?([A-Za-z0-9/+=]{40})['\"]?"
121
+ ),
122
+ value_group=1,
123
+ ),
124
+ # Narrow generic assignment: fires when a key/secret/token/password-shaped
125
+ # word appears in the assigned name (standalone or as part of a
126
+ # compound identifier like DB_PASSWORD, STRIPE_SECRET_KEY), assigned a
127
+ # quoted value of at least 10 chars — conservative on purpose to keep
128
+ # false positives low, though a false positive here only means an
129
+ # unnecessary "[REDACTED]" in the zip copy, not any change to the
130
+ # user's actual file.
131
+ _SecretPattern(
132
+ "Generic Secret Assignment",
133
+ re.compile(
134
+ r"(?i)\b[A-Za-z0-9_]*?"
135
+ r"(?:api[_-]?key|secret[_-]?key|access[_-]?token|"
136
+ r"auth[_-]?token|client[_-]?secret|password|passwd)"
137
+ r"[A-Za-z0-9_]*\s*[:=]\s*['\"]([^'\"\s]{10,})['\"]"
138
+ ),
139
+ value_group=1,
140
+ ),
141
+ ]
142
+
143
+
144
+ def redact_secrets(text: str) -> tuple[str, list[str]]:
145
+ """
146
+ Scan *text* for secret-shaped values and replace each one with
147
+ "[REDACTED]", leaving everything else — including the surrounding
148
+ key name, quotes, and punctuation — untouched.
149
+
150
+ Returns (possibly-modified text, list of pattern names that matched at
151
+ least once — e.g. ["AWS Access Key ID", "Generic Secret Assignment"]).
152
+ An empty list means nothing matched and *text* is returned unchanged
153
+ (the same string object, so callers can cheaply check `if matched:`
154
+ to skip rewriting a file that didn't need it).
155
+ """
156
+ matched_names: list[str] = []
157
+
158
+ for pattern in _PATTERNS:
159
+
160
+ def _sub(m: re.Match[str], pattern: _SecretPattern = pattern) -> str:
161
+ value = m.group(pattern.value_group)
162
+ if _looks_like_placeholder(value):
163
+ return m.group(0)
164
+ matched_names.append(pattern.name)
165
+ if pattern.value_group == 0:
166
+ return "[REDACTED]"
167
+ full = m.group(0)
168
+ g_start, g_end = m.span(pattern.value_group)
169
+ rel_start = g_start - m.start()
170
+ rel_end = g_end - m.start()
171
+ return full[:rel_start] + "[REDACTED]" + full[rel_end:]
172
+
173
+ text = pattern.regex.sub(_sub, text)
174
+
175
+ return text, matched_names
@@ -33,6 +33,7 @@ def write_scan_report(
33
33
  ai_prompt: str | None = None,
34
34
  ai_selected: list[Path] | None = None,
35
35
  large_file_warn_bytes: int,
36
+ redacted: list[tuple[Path, list[str]]] | None = None,
36
37
  ) -> Path:
37
38
  """
38
39
  Write the full scan/package report next to *zip_path*.
@@ -111,6 +112,12 @@ def write_scan_report(
111
112
  w(f" {rel}")
112
113
  w("")
113
114
 
115
+ if redacted:
116
+ w("Redacted secrets (limits.redact_secrets)")
117
+ for rel, names in redacted:
118
+ w(f" {rel.as_posix():<60} {', '.join(names)}")
119
+ w("")
120
+
114
121
  if resolved.skipped:
115
122
  w("Skipped files (unreadable or dangling symlink)")
116
123
  for p, reason in resolved.skipped:
@@ -3,8 +3,8 @@ webui/persist.py — Writes the local config UI's choices to
3
3
  .contextzip/config.json.
4
4
 
5
5
  Reuses packager._ensure_workspace_gitignore so a UI-driven save produces
6
- exactly the same on-disk workspace layout (config.json trackable,
7
- output/ ignored) that a normal contextzip run would create.
6
+ exactly the same on-disk workspace layout (entire .contextzip/ ignored,
7
+ config.json included) that a normal contextzip run would create.
8
8
  """
9
9
 
10
10
  from __future__ import annotations
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contextzip
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Intelligently package your codebase for AI tools
5
5
  Author-email: Deepesh <akadeepesh@gmail.com>
6
6
  License-Expression: MIT
@@ -27,6 +27,8 @@ Requires-Dist: pathspec>=0.12
27
27
  Requires-Dist: httpx>=0.27
28
28
  Dynamic: license-file
29
29
 
30
+ <img width="1536" height="1024" alt="cz" src="https://github.com/user-attachments/assets/a930b49c-34a0-447b-8f3a-1909eae977c8" />
31
+
30
32
  # contextzip
31
33
 
32
34
  > Package exactly the right parts of your codebase and paste it into any AI tool — in one command.
@@ -17,6 +17,7 @@ contextzip/filters.py
17
17
  contextzip/git.py
18
18
  contextzip/packager.py
19
19
  contextzip/project_config.py
20
+ contextzip/redact.py
20
21
  contextzip/report.py
21
22
  contextzip/watcher.py
22
23
  contextzip.egg-info/PKG-INFO
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "contextzip"
7
- version = "0.4.1"
7
+ version = "0.4.2"
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
File without changes