contextzip 0.4.1__tar.gz → 0.4.3__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.3}/PKG-INFO +3 -1
- {contextzip-0.4.1 → contextzip-0.4.3}/README.md +2 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/__init__.py +1 -1
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/applier.py +21 -3
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/cli.py +94 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/cli_display.py +17 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/config.py +3 -1
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/packager.py +120 -29
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/project_config.py +22 -11
- contextzip-0.4.3/contextzip/redact.py +175 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/report.py +7 -0
- contextzip-0.4.3/contextzip/updater.py +165 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/webui/assets.py +11 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/webui/persist.py +2 -2
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip.egg-info/PKG-INFO +3 -1
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip.egg-info/SOURCES.txt +2 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/pyproject.toml +1 -1
- {contextzip-0.4.1 → contextzip-0.4.3}/LICENSE +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/ai/__init__.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/ai/gemini.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/ai/heuristic.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/ai/selector.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/api.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/cleanup.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/cli_ai.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/cli_onboard.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/clipboard.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/detector.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/error_parser.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/filters.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/git.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/rules/__init__.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/rules/base.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/rules/errors/__init__.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/rules/errors/node.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/rules/errors/python.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/rules/go.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/rules/node.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/rules/python.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/rules/ruby.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/rules/rust.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/watcher.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/webui/__init__.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/webui/server.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip/webui/suggestions.py +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip.egg-info/dependency_links.txt +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip.egg-info/entry_points.txt +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip.egg-info/requires.txt +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/contextzip.egg-info/top_level.txt +0 -0
- {contextzip-0.4.1 → contextzip-0.4.3}/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.3
|
|
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.
|
|
@@ -446,8 +446,24 @@ def build_plan(
|
|
|
446
446
|
strip_prefix: str | None = None
|
|
447
447
|
wrapper_note: str | None = None
|
|
448
448
|
candidate = _detect_common_wrapper(raw_names)
|
|
449
|
-
if candidate
|
|
449
|
+
if candidate:
|
|
450
450
|
if manifest_files:
|
|
451
|
+
# Decide using the manifest — actual ground truth about what
|
|
452
|
+
# paths this project has — rather than whether a directory of
|
|
453
|
+
# that name merely happens to exist on disk. That on-disk check
|
|
454
|
+
# used to gate this whole branch, which backfired on the most
|
|
455
|
+
# common real case: an AI tool wraps the whole project in a
|
|
456
|
+
# folder named after the project itself (e.g. "contextzip/"),
|
|
457
|
+
# and the project's own top-level package/dir happens to share
|
|
458
|
+
# that exact name — so the wrapper looked "legitimate" and was
|
|
459
|
+
# never stripped, dumping a nested copy of the project instead
|
|
460
|
+
# of updating the real files. Comparing manifest match-rates
|
|
461
|
+
# with vs without stripping gets both cases right: a genuine
|
|
462
|
+
# wrapper matches far better once stripped, while a zip whose
|
|
463
|
+
# top-level folder is an intentional part of the project (e.g.
|
|
464
|
+
# updates confined to an existing "docs/" folder) matches
|
|
465
|
+
# better *unstripped*, since those paths are already in the
|
|
466
|
+
# manifest as-is.
|
|
451
467
|
unstripped_rate = _match_rate(raw_names, manifest_files)
|
|
452
468
|
stripped_names = [n[len(candidate) + 1:] for n in raw_names]
|
|
453
469
|
stripped_rate = _match_rate(stripped_names, manifest_files)
|
|
@@ -462,10 +478,12 @@ def build_plan(
|
|
|
462
478
|
f"project manifest after stripping vs {unstripped_rate:.0%} "
|
|
463
479
|
"before."
|
|
464
480
|
)
|
|
465
|
-
|
|
481
|
+
elif not (project_dir / candidate).is_dir():
|
|
466
482
|
# No manifest to confirm against, so this is a softer call —
|
|
467
483
|
# still strip (matches how `tar` and GitHub's own zip downloads
|
|
468
|
-
# behave)
|
|
484
|
+
# behave) as long as the project doesn't already have a real
|
|
485
|
+
# folder of that name, but say so plainly since it's not
|
|
486
|
+
# manifest-verified.
|
|
469
487
|
strip_prefix = candidate
|
|
470
488
|
wrapper_note = (
|
|
471
489
|
f"Removed wrapping folder '{candidate}/' present in every zip "
|
|
@@ -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 (
|
|
@@ -73,6 +74,7 @@ from contextzip.cli_ai import (
|
|
|
73
74
|
)
|
|
74
75
|
from contextzip.cli_onboard import onboard_api_key
|
|
75
76
|
from contextzip import cleanup as cleanup_mod
|
|
77
|
+
from contextzip import updater as updater_mod
|
|
76
78
|
|
|
77
79
|
console = Console()
|
|
78
80
|
|
|
@@ -673,6 +675,11 @@ def cmd_config(
|
|
|
673
675
|
info(
|
|
674
676
|
f"ai: enabled={ai.enabled} provider={ai.provider} max_files={ai.max_files}"
|
|
675
677
|
)
|
|
678
|
+
limits = project_cfg.limits
|
|
679
|
+
info(
|
|
680
|
+
f"limits: max_file_size_mb={limits.max_file_size_mb} "
|
|
681
|
+
f"redact_secrets={limits.redact_secrets}"
|
|
682
|
+
)
|
|
676
683
|
cleanup_cfg = project_cfg.cleanup
|
|
677
684
|
info(
|
|
678
685
|
f"cleanup: enabled={cleanup_cfg.enabled} "
|
|
@@ -682,6 +689,90 @@ def cmd_config(
|
|
|
682
689
|
console.print()
|
|
683
690
|
|
|
684
691
|
|
|
692
|
+
# ---------------------------------------------------------------------------
|
|
693
|
+
# Subcommand: update
|
|
694
|
+
# ---------------------------------------------------------------------------
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
@main.command("update")
|
|
698
|
+
@click.option(
|
|
699
|
+
"--check",
|
|
700
|
+
"check_only",
|
|
701
|
+
is_flag=True,
|
|
702
|
+
default=False,
|
|
703
|
+
help="Only check for a newer version — don't install anything.",
|
|
704
|
+
)
|
|
705
|
+
@click.option(
|
|
706
|
+
"--yes",
|
|
707
|
+
"-y",
|
|
708
|
+
is_flag=True,
|
|
709
|
+
default=False,
|
|
710
|
+
help="Skip the confirmation prompt and update immediately if available.",
|
|
711
|
+
)
|
|
712
|
+
def cmd_update(check_only: bool, yes: bool) -> None:
|
|
713
|
+
"""
|
|
714
|
+
Check PyPI for a newer contextzip release and update in place.
|
|
715
|
+
|
|
716
|
+
\b
|
|
717
|
+
EXAMPLES
|
|
718
|
+
contextzip update # check, then confirm before updating
|
|
719
|
+
contextzip update --yes # update immediately if one is available
|
|
720
|
+
contextzip update --check # only report the current/latest version
|
|
721
|
+
cz update # same command, short alias
|
|
722
|
+
|
|
723
|
+
\b
|
|
724
|
+
HOW IT WORKS
|
|
725
|
+
contextzip cuts frequent releases, so this beats remembering
|
|
726
|
+
`pip install --upgrade contextzip` by hand. It detects whether this
|
|
727
|
+
install came from pip, pipx, or uv tool and re-invokes the matching
|
|
728
|
+
one, upgrading the same interpreter that's currently running —
|
|
729
|
+
never a possibly-different "pip" found on PATH.
|
|
730
|
+
"""
|
|
731
|
+
console.print()
|
|
732
|
+
with console.status("[cyan]Checking for updates…[/]", spinner="dots"):
|
|
733
|
+
check = updater_mod.check_latest_version()
|
|
734
|
+
|
|
735
|
+
if check.latest is None:
|
|
736
|
+
err(f"Could not check for updates: {check.error}")
|
|
737
|
+
info(f"You're on {check.current}. Try again later, or update manually:")
|
|
738
|
+
info(" pip install --upgrade contextzip")
|
|
739
|
+
console.print()
|
|
740
|
+
raise SystemExit(1)
|
|
741
|
+
|
|
742
|
+
if not check.update_available:
|
|
743
|
+
ok(f"You're up to date — contextzip {check.current}")
|
|
744
|
+
console.print()
|
|
745
|
+
return
|
|
746
|
+
|
|
747
|
+
info(f"Update available: {check.current} → {check.latest}")
|
|
748
|
+
|
|
749
|
+
if check_only:
|
|
750
|
+
info("Run contextzip update to install it.")
|
|
751
|
+
console.print()
|
|
752
|
+
return
|
|
753
|
+
|
|
754
|
+
if not yes:
|
|
755
|
+
proceed = click.confirm(f" Update to {check.latest} now?", default=True)
|
|
756
|
+
if not proceed:
|
|
757
|
+
info("Not updating.")
|
|
758
|
+
console.print()
|
|
759
|
+
return
|
|
760
|
+
|
|
761
|
+
with console.status(f"[cyan]Updating to {check.latest}…[/]", spinner="dots"):
|
|
762
|
+
result = updater_mod.run_update()
|
|
763
|
+
|
|
764
|
+
console.print()
|
|
765
|
+
if result.ok:
|
|
766
|
+
ok(f"Updated to contextzip {check.latest}", f"via {result.method}")
|
|
767
|
+
info("Restart any running contextzip/watch sessions to pick it up.")
|
|
768
|
+
else:
|
|
769
|
+
err(f"Update via {result.method} failed")
|
|
770
|
+
if result.output:
|
|
771
|
+
console.print(f"[dim]{result.output}[/]")
|
|
772
|
+
info("You can also update manually: pip install --upgrade contextzip")
|
|
773
|
+
console.print()
|
|
774
|
+
|
|
775
|
+
|
|
685
776
|
# Core execution logic (shared by main command + all subcommands)
|
|
686
777
|
# ---------------------------------------------------------------------------
|
|
687
778
|
|
|
@@ -984,6 +1075,7 @@ def _run(
|
|
|
984
1075
|
console=console,
|
|
985
1076
|
mode=run_mode,
|
|
986
1077
|
prompt_txt=prompt_txt,
|
|
1078
|
+
redact_secrets_enabled=project_cfg.limits.redact_secrets,
|
|
987
1079
|
)
|
|
988
1080
|
except Exception as exc:
|
|
989
1081
|
err(f"Failed to create ZIP: {exc}")
|
|
@@ -994,6 +1086,7 @@ def _run(
|
|
|
994
1086
|
resolved, project_dir, large_file_warn_bytes=large_file_warn_bytes
|
|
995
1087
|
)
|
|
996
1088
|
print_zip_write_warnings(result)
|
|
1089
|
+
print_redaction_summary(result)
|
|
997
1090
|
print_package_result(result)
|
|
998
1091
|
|
|
999
1092
|
# ── Report ───────────────────────────────────────────────────────────────
|
|
@@ -1006,6 +1099,7 @@ def _run(
|
|
|
1006
1099
|
ai_prompt=prompt,
|
|
1007
1100
|
ai_selected=resolved.included if prompt else None,
|
|
1008
1101
|
large_file_warn_bytes=large_file_warn_bytes,
|
|
1102
|
+
redacted=result.redacted,
|
|
1009
1103
|
)
|
|
1010
1104
|
print_report_hint(report_path)
|
|
1011
1105
|
|
|
@@ -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:
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
updater.py — Self-update support for `contextzip update` / `cz update`.
|
|
3
|
+
|
|
4
|
+
contextzip cuts frequent releases, so re-running
|
|
5
|
+
`pip install --upgrade contextzip` by hand every time gets old fast.
|
|
6
|
+
This module:
|
|
7
|
+
|
|
8
|
+
1. Asks PyPI's JSON API for the latest published version.
|
|
9
|
+
2. Compares it against the version actually running (contextzip.__version__).
|
|
10
|
+
3. If newer, re-invokes pip (or pipx, if that's how contextzip was
|
|
11
|
+
installed) to upgrade in place.
|
|
12
|
+
|
|
13
|
+
Never raises on network failure — a broken connection or PyPI hiccup
|
|
14
|
+
should degrade to "couldn't check right now", not crash the CLI.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
from contextzip import __version__ as installed_version
|
|
25
|
+
|
|
26
|
+
PYPI_URL = "https://pypi.org/pypi/contextzip/json"
|
|
27
|
+
_TIMEOUT_SECS = 5
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Version comparison (no `packaging` dependency)
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_version(v: str) -> tuple[int, ...]:
|
|
36
|
+
"""
|
|
37
|
+
Turn "1.2.3" into (1, 2, 3) for comparison. Ignores any pre-release /
|
|
38
|
+
build suffix after the numeric core (e.g. "1.2.3rc1" -> (1, 2, 3)) —
|
|
39
|
+
good enough for "is there a newer release" without pulling in the
|
|
40
|
+
`packaging` library just for this.
|
|
41
|
+
"""
|
|
42
|
+
core = v.strip().lstrip("v")
|
|
43
|
+
parts: list[int] = []
|
|
44
|
+
for chunk in core.split("."):
|
|
45
|
+
digits = ""
|
|
46
|
+
for ch in chunk:
|
|
47
|
+
if ch.isdigit():
|
|
48
|
+
digits += ch
|
|
49
|
+
else:
|
|
50
|
+
break
|
|
51
|
+
parts.append(int(digits) if digits else 0)
|
|
52
|
+
return tuple(parts) if parts else (0,)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_newer(latest: str, current: str) -> bool:
|
|
56
|
+
"""True if *latest* is a strictly newer version than *current*."""
|
|
57
|
+
a, b = _parse_version(latest), _parse_version(current)
|
|
58
|
+
length = max(len(a), len(b))
|
|
59
|
+
a = a + (0,) * (length - len(a))
|
|
60
|
+
b = b + (0,) * (length - len(b))
|
|
61
|
+
return a > b
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# PyPI lookup
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class VersionCheck:
|
|
71
|
+
current: str
|
|
72
|
+
latest: str | None # None if the lookup failed
|
|
73
|
+
error: str | None = None
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def update_available(self) -> bool:
|
|
77
|
+
return bool(self.latest) and is_newer(self.latest, self.current)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def check_latest_version(timeout: float = _TIMEOUT_SECS) -> VersionCheck:
|
|
81
|
+
"""
|
|
82
|
+
Query PyPI for the latest published contextzip version.
|
|
83
|
+
|
|
84
|
+
Uses httpx (already a hard dependency) but never lets a network
|
|
85
|
+
problem escape as an exception — callers always get a VersionCheck
|
|
86
|
+
back, with `.error` set on failure.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
import httpx
|
|
90
|
+
|
|
91
|
+
resp = httpx.get(PYPI_URL, timeout=timeout)
|
|
92
|
+
resp.raise_for_status()
|
|
93
|
+
data = resp.json()
|
|
94
|
+
latest = data.get("info", {}).get("version")
|
|
95
|
+
if not latest:
|
|
96
|
+
return VersionCheck(
|
|
97
|
+
current=installed_version, latest=None, error="Malformed PyPI response"
|
|
98
|
+
)
|
|
99
|
+
return VersionCheck(current=installed_version, latest=latest)
|
|
100
|
+
except Exception as exc: # noqa: BLE001 — any network/parsing failure
|
|
101
|
+
return VersionCheck(
|
|
102
|
+
current=installed_version, latest=None, error=f"{type(exc).__name__}: {exc}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Install-method detection
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _installed_via_pipx() -> bool:
|
|
112
|
+
"""
|
|
113
|
+
Best-effort guess at whether this contextzip was installed with pipx
|
|
114
|
+
rather than plain pip — pipx installs live under a `pipx/venvs/...`
|
|
115
|
+
path, which shows up in the interpreter's own executable path.
|
|
116
|
+
"""
|
|
117
|
+
exe = sys.executable.replace("\\", "/").lower()
|
|
118
|
+
return "pipx" in exe
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _installed_via_uv_tool() -> bool:
|
|
122
|
+
exe = sys.executable.replace("\\", "/").lower()
|
|
123
|
+
return "/uv/tools/" in exe or "\\uv\\tools\\" in sys.executable.lower()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class UpdateResult:
|
|
128
|
+
ok: bool
|
|
129
|
+
method: str
|
|
130
|
+
output: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def run_update(timeout: float = 120) -> UpdateResult:
|
|
134
|
+
"""
|
|
135
|
+
Upgrade the installed contextzip package to the latest PyPI release.
|
|
136
|
+
|
|
137
|
+
Picks the tool that matches how contextzip was installed:
|
|
138
|
+
- pipx, if this interpreter lives inside a pipx venv and the pipx
|
|
139
|
+
executable is on PATH
|
|
140
|
+
- uv tool, if this interpreter lives inside a uv tool install
|
|
141
|
+
- otherwise `python -m pip install --upgrade contextzip`, using
|
|
142
|
+
the *currently running* interpreter's pip — never a bare "pip"
|
|
143
|
+
off PATH, which could silently target the wrong Python.
|
|
144
|
+
"""
|
|
145
|
+
if _installed_via_pipx() and shutil.which("pipx"):
|
|
146
|
+
cmd = ["pipx", "upgrade", "contextzip"]
|
|
147
|
+
method = "pipx"
|
|
148
|
+
elif _installed_via_uv_tool() and shutil.which("uv"):
|
|
149
|
+
cmd = ["uv", "tool", "upgrade", "contextzip"]
|
|
150
|
+
method = "uv tool"
|
|
151
|
+
else:
|
|
152
|
+
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "contextzip"]
|
|
153
|
+
method = "pip"
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
proc = subprocess.run(
|
|
157
|
+
cmd,
|
|
158
|
+
capture_output=True,
|
|
159
|
+
text=True,
|
|
160
|
+
timeout=timeout,
|
|
161
|
+
)
|
|
162
|
+
output = (proc.stdout or "") + (proc.stderr or "")
|
|
163
|
+
return UpdateResult(ok=proc.returncode == 0, method=method, output=output.strip())
|
|
164
|
+
except Exception as exc: # noqa: BLE001
|
|
165
|
+
return UpdateResult(ok=False, method=method, output=f"{type(exc).__name__}: {exc}")
|
|
@@ -1148,6 +1148,17 @@ INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1148
1148
|
document.getElementById("overlay").classList.add("show");
|
|
1149
1149
|
setTimeout(function () {
|
|
1150
1150
|
api("/api/shutdown", { method: "GET" }).catch(function () {});
|
|
1151
|
+
// Try to close the tab automatically and hop back to the terminal.
|
|
1152
|
+
// Browsers only allow script-initiated close on windows/tabs that
|
|
1153
|
+
// were opened by a script, or ones whose entire session history is
|
|
1154
|
+
// just this one page — which this config-UI tab always is, since
|
|
1155
|
+
// it's a single page with no navigation. If the browser still
|
|
1156
|
+
// blocks it (some do, regardless), window.close() is a silent
|
|
1157
|
+
// no-op and the overlay's "you can close this tab" text — already
|
|
1158
|
+
// on screen — is the fallback the user follows manually.
|
|
1159
|
+
setTimeout(function () {
|
|
1160
|
+
try { window.close(); } catch (e) { /* ignored — fallback is the overlay text */ }
|
|
1161
|
+
}, 300);
|
|
1151
1162
|
}, 900);
|
|
1152
1163
|
}).catch(function () {
|
|
1153
1164
|
btn.disabled = false;
|
|
@@ -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.3
|
|
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,7 +17,9 @@ 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
|
|
22
|
+
contextzip/updater.py
|
|
21
23
|
contextzip/watcher.py
|
|
22
24
|
contextzip.egg-info/PKG-INFO
|
|
23
25
|
contextzip.egg-info/SOURCES.txt
|
|
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
|