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.
- {contextzip-0.4.1 → contextzip-0.4.2}/PKG-INFO +3 -1
- {contextzip-0.4.1 → contextzip-0.4.2}/README.md +2 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/__init__.py +1 -1
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/cli.py +9 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/cli_display.py +17 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/config.py +3 -1
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/packager.py +120 -29
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/project_config.py +22 -11
- contextzip-0.4.2/contextzip/redact.py +175 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/report.py +7 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/webui/persist.py +2 -2
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip.egg-info/PKG-INFO +3 -1
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip.egg-info/SOURCES.txt +1 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/pyproject.toml +1 -1
- {contextzip-0.4.1 → contextzip-0.4.2}/LICENSE +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/ai/__init__.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/ai/gemini.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/ai/heuristic.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/ai/selector.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/api.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/applier.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/cleanup.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/cli_ai.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/cli_onboard.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/clipboard.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/detector.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/error_parser.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/filters.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/git.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/__init__.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/base.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/errors/__init__.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/errors/node.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/errors/python.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/go.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/node.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/python.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/ruby.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/rules/rust.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/watcher.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/webui/__init__.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/webui/assets.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/webui/server.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip/webui/suggestions.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip.egg-info/dependency_links.txt +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip.egg-info/entry_points.txt +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip.egg-info/requires.txt +0 -0
- {contextzip-0.4.1 → contextzip-0.4.2}/contextzip.egg-info/top_level.txt +0 -0
- {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.
|
|
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.
|
|
@@ -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
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
117
|
-
#
|
|
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
|
|
121
|
-
"#
|
|
122
|
-
"# with your team
|
|
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
|
|
131
|
-
# clean up that older entry the first time we touch a
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
410
|
-
|
|
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
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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 —
|
|
494
|
-
|
|
495
|
-
|
|
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
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
_ensure_workspace_gitignore),
|
|
12
|
-
|
|
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 —
|
|
82
|
-
secret-shaped values
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 (
|
|
7
|
-
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|