contextzip 0.4.2__tar.gz → 0.4.4__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.2 → contextzip-0.4.4}/PKG-INFO +2 -2
- {contextzip-0.4.2 → contextzip-0.4.4}/README.md +1 -1
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/__init__.py +1 -1
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/api.py +1 -6
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/applier.py +212 -55
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/cleanup.py +44 -25
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/cli.py +91 -5
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/cli_display.py +1 -3
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/packager.py +18 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/project_config.py +13 -12
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/report.py +17 -4
- contextzip-0.4.4/contextzip/updater.py +165 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/webui/assets.py +11 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip.egg-info/PKG-INFO +2 -2
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip.egg-info/SOURCES.txt +1 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/pyproject.toml +1 -1
- {contextzip-0.4.2 → contextzip-0.4.4}/LICENSE +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/ai/__init__.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/ai/gemini.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/ai/heuristic.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/ai/selector.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/cli_ai.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/cli_onboard.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/clipboard.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/config.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/detector.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/error_parser.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/filters.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/git.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/redact.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/__init__.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/base.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/errors/__init__.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/errors/node.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/errors/python.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/go.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/node.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/python.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/ruby.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/rust.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/watcher.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/webui/__init__.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/webui/persist.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/webui/server.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/webui/suggestions.py +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip.egg-info/dependency_links.txt +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip.egg-info/entry_points.txt +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip.egg-info/requires.txt +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/contextzip.egg-info/top_level.txt +0 -0
- {contextzip-0.4.2 → contextzip-0.4.4}/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.4
|
|
4
4
|
Summary: Intelligently package your codebase for AI tools
|
|
5
5
|
Author-email: Deepesh <akadeepesh@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -87,7 +87,7 @@ Full function reference: [contextzip.vercel.app/cli#python-api](https://contextz
|
|
|
87
87
|
- [Packaging & detection](https://contextzip.vercel.app/features) — frameworks, exclusion rules, safety
|
|
88
88
|
- [AI-powered file selection](https://contextzip.vercel.app/ai-selection) — `--prompt`, Gemini setup
|
|
89
89
|
- [Terminal error watcher](https://contextzip.vercel.app/watch) — `contextzip watch`
|
|
90
|
-
- [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`,
|
|
90
|
+
- [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`, manifest diffing, safety
|
|
91
91
|
- [Configuration](https://contextzip.vercel.app/configuration) — personal & project config, visual config UI
|
|
92
92
|
- [CLI & Python API reference](https://contextzip.vercel.app/cli)
|
|
93
93
|
|
|
@@ -58,7 +58,7 @@ Full function reference: [contextzip.vercel.app/cli#python-api](https://contextz
|
|
|
58
58
|
- [Packaging & detection](https://contextzip.vercel.app/features) — frameworks, exclusion rules, safety
|
|
59
59
|
- [AI-powered file selection](https://contextzip.vercel.app/ai-selection) — `--prompt`, Gemini setup
|
|
60
60
|
- [Terminal error watcher](https://contextzip.vercel.app/watch) — `contextzip watch`
|
|
61
|
-
- [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`,
|
|
61
|
+
- [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`, manifest diffing, safety
|
|
62
62
|
- [Configuration](https://contextzip.vercel.app/configuration) — personal & project config, visual config UI
|
|
63
63
|
- [CLI & Python API reference](https://contextzip.vercel.app/cli)
|
|
64
64
|
|
|
@@ -354,8 +354,6 @@ def apply_zip(
|
|
|
354
354
|
classify every file as new, modified, unchanged, or one with no safe
|
|
355
355
|
baseline (locally edited since zipping, or never part of the original
|
|
356
356
|
manifest). Only adds and modifies files — nothing is ever deleted.
|
|
357
|
-
Every overwritten file is backed up first, under
|
|
358
|
-
``.contextzip/backups/<timestamp>/``.
|
|
359
357
|
|
|
360
358
|
Unlike the CLI, this applies immediately and does not prompt — check
|
|
361
359
|
``contextzip.applier.build_plan()`` yourself first if you want to
|
|
@@ -375,8 +373,7 @@ def apply_zip(
|
|
|
375
373
|
Returns
|
|
376
374
|
-------
|
|
377
375
|
ApplyResult
|
|
378
|
-
``written`` (list of relative paths written)
|
|
379
|
-
(``Path`` or ``None`` if nothing needed backing up), and
|
|
376
|
+
``written`` (list of relative paths written) and
|
|
380
377
|
``applied_zip_path`` (where the consumed zip ended up).
|
|
381
378
|
|
|
382
379
|
Raises
|
|
@@ -393,8 +390,6 @@ def apply_zip(
|
|
|
393
390
|
|
|
394
391
|
result = apply_zip() # picks up .contextzip/inbox/*.zip
|
|
395
392
|
print(f"Wrote {len(result.written)} files")
|
|
396
|
-
if result.backup_dir:
|
|
397
|
-
print(f"Backup at {result.backup_dir}")
|
|
398
393
|
"""
|
|
399
394
|
from contextzip.applier import (
|
|
400
395
|
ApplyError,
|
|
@@ -48,8 +48,86 @@ from pathlib import Path
|
|
|
48
48
|
_MANIFEST_SUFFIX = ".manifest.json"
|
|
49
49
|
_INBOX_DIRNAME = "inbox"
|
|
50
50
|
_APPLIED_DIRNAME = "applied"
|
|
51
|
-
_BACKUPS_DIRNAME = "backups"
|
|
52
51
|
_OUTPUT_DIRNAME = "output" # must match packager._OUTPUT_DIRNAME
|
|
52
|
+
_INBOX_README_NAME = "README.txt"
|
|
53
|
+
|
|
54
|
+
_INBOX_README_CONTENTS = """\
|
|
55
|
+
Drop your zip here which you want to apply.
|
|
56
|
+
============================================
|
|
57
|
+
|
|
58
|
+
This folder is contextzip's inbox — the one place `apply-zip` looks for
|
|
59
|
+
a zip to bring back into your project.
|
|
60
|
+
|
|
61
|
+
HOW TO USE IT
|
|
62
|
+
1. Take the zip your AI tool (Claude, ChatGPT, etc.) gave you back,
|
|
63
|
+
after you sent it a codebase package contextzip made for you.
|
|
64
|
+
2. Drop that zip file directly into this folder — no need to rename
|
|
65
|
+
it or unzip it first.
|
|
66
|
+
3. Run: contextzip apply-zip (short alias: cz apply-zip)
|
|
67
|
+
4. contextzip finds it automatically, compares every file inside it
|
|
68
|
+
against your project's manifest, and shows you exactly what's
|
|
69
|
+
new, modified, or unchanged before writing anything to disk.
|
|
70
|
+
|
|
71
|
+
WHAT HAPPENS TO THE ZIP AFTERWARDS
|
|
72
|
+
Once applied, contextzip moves it into inbox/applied/ instead of
|
|
73
|
+
deleting it, so there's always a record of exactly what was applied
|
|
74
|
+
and when. Old entries there are pruned automatically over time — see
|
|
75
|
+
the `cleanup` and `applied_zip_retention` settings in
|
|
76
|
+
.contextzip/config.json if you'd like to keep more of them.
|
|
77
|
+
|
|
78
|
+
A FEW THINGS WORTH KNOWING
|
|
79
|
+
- Only .zip files placed directly in this folder are picked up —
|
|
80
|
+
not ones inside a subfolder.
|
|
81
|
+
- If more than one zip ends up here at once, contextzip will ask you
|
|
82
|
+
to say which one: contextzip apply-zip <name>.zip
|
|
83
|
+
- You can also point apply-zip at a zip anywhere else on disk instead
|
|
84
|
+
of using this folder: contextzip apply-zip /path/to/file.zip
|
|
85
|
+
- This whole .contextzip/ folder is local to your machine and is
|
|
86
|
+
never committed to git (see .contextzip/.gitignore) — nothing here
|
|
87
|
+
leaves your computer unless you explicitly share it yourself.
|
|
88
|
+
- This file is just documentation — it's fine to edit it, and it
|
|
89
|
+
won't affect anything if you do. If it ever goes missing, though,
|
|
90
|
+
contextzip will put a fresh copy back the next time it touches this
|
|
91
|
+
folder; it never touches an actual pending zip, only this file.
|
|
92
|
+
|
|
93
|
+
Full docs: https://contextzip.vercel.app/apply-zip
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def ensure_inbox_scaffolding(workspace: Path) -> None:
|
|
98
|
+
"""
|
|
99
|
+
Make sure <workspace>/inbox/ exists, with its README.txt explaining
|
|
100
|
+
what the folder is for.
|
|
101
|
+
|
|
102
|
+
Called at the same moment the rest of the .contextzip/ workspace gets
|
|
103
|
+
set up (see packager.py's `_workspace_output_path[_silent]`), so the
|
|
104
|
+
inbox is ready and self-explanatory from the very first run — rather
|
|
105
|
+
than a bare, unexplained folder someone only discovers by reading the
|
|
106
|
+
docs, or worse, one that doesn't exist yet the first time `apply-zip`
|
|
107
|
+
goes looking for it.
|
|
108
|
+
|
|
109
|
+
Idempotent, same rule as the workspace .gitignore (see
|
|
110
|
+
`_ensure_workspace_gitignore` in packager.py): leaves an
|
|
111
|
+
already-correct README.txt untouched, and (re)writes it whenever it's
|
|
112
|
+
missing or doesn't match, so it stays reliable documentation rather
|
|
113
|
+
than something that quietly rots or disappears. Never touches
|
|
114
|
+
anything else already in the inbox — pending zips, `applied/`, past
|
|
115
|
+
reports.
|
|
116
|
+
"""
|
|
117
|
+
inbox = workspace / _INBOX_DIRNAME
|
|
118
|
+
inbox.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
|
|
120
|
+
readme_path = inbox / _INBOX_README_NAME
|
|
121
|
+
if readme_path.is_file():
|
|
122
|
+
try:
|
|
123
|
+
if readme_path.read_text(encoding="utf-8", errors="replace") == _INBOX_README_CONTENTS:
|
|
124
|
+
return
|
|
125
|
+
except OSError:
|
|
126
|
+
return
|
|
127
|
+
try:
|
|
128
|
+
readme_path.write_text(_INBOX_README_CONTENTS, encoding="utf-8")
|
|
129
|
+
except OSError:
|
|
130
|
+
pass
|
|
53
131
|
|
|
54
132
|
# Files contextzip itself writes into outgoing ZIPs that should never be
|
|
55
133
|
# written back into the project even if an AI tool echoes them back.
|
|
@@ -146,7 +224,6 @@ class ApplyPlan:
|
|
|
146
224
|
@dataclass
|
|
147
225
|
class ApplyResult:
|
|
148
226
|
written: list[str]
|
|
149
|
-
backup_dir: Path | None
|
|
150
227
|
applied_zip_path: Path
|
|
151
228
|
|
|
152
229
|
|
|
@@ -266,10 +343,17 @@ def find_zip_to_apply(
|
|
|
266
343
|
|
|
267
344
|
inbox = inbox_dir(project_dir)
|
|
268
345
|
if not inbox.is_dir():
|
|
346
|
+
# Normally created alongside the rest of .contextzip/ the first
|
|
347
|
+
# time any contextzip command runs (see
|
|
348
|
+
# ensure_inbox_scaffolding), so this only fires if apply-zip is
|
|
349
|
+
# somehow the very first command run in a project. Create it now
|
|
350
|
+
# rather than just describing where it should be — so the folder
|
|
351
|
+
# (and its README) are actually there for the person to drop a
|
|
352
|
+
# zip into right after reading this message.
|
|
353
|
+
ensure_inbox_scaffolding(_workspace_dir(project_dir))
|
|
269
354
|
raise NoZipFoundError(
|
|
270
|
-
f"No zip found. Drop the AI-returned zip into {inbox} "
|
|
271
|
-
"
|
|
272
|
-
"directly: contextzip apply-zip <path>"
|
|
355
|
+
f"No zip found. Drop the AI-returned zip into {inbox}, "
|
|
356
|
+
"or pass a path directly: contextzip apply-zip <path>"
|
|
273
357
|
)
|
|
274
358
|
|
|
275
359
|
candidates = sorted(p for p in inbox.glob("*.zip") if p.is_file())
|
|
@@ -417,6 +501,31 @@ def _match_rate(names: list[str], manifest_files: dict) -> float:
|
|
|
417
501
|
# ---------------------------------------------------------------------------
|
|
418
502
|
|
|
419
503
|
|
|
504
|
+
def _top_level_dir_match_rate(names: list[str], project_dir: Path) -> float:
|
|
505
|
+
"""
|
|
506
|
+
Fraction of *names*' unique top-level path segments that exist as a
|
|
507
|
+
real file or directory directly under *project_dir* right now.
|
|
508
|
+
|
|
509
|
+
Used as a fallback signal for wrapper-stripping when the manifest
|
|
510
|
+
can't settle it — e.g. the freshest manifest on disk is a narrow,
|
|
511
|
+
git-diff-scoped one (`.contextzip/output/git-changes/...`) that only
|
|
512
|
+
ever listed a handful of touched files, so a zip full of brand-new
|
|
513
|
+
files matches it at ~0% whether or not the wrapper is stripped. The
|
|
514
|
+
manifest is silent either way, but the project's actual top-level
|
|
515
|
+
layout — `app/`, `components/`, `lib/`, and so on — usually isn't:
|
|
516
|
+
a genuine wrapper's contents line up with it once stripped, while
|
|
517
|
+
the raw, unstripped paths (starting with the wrapper's own name)
|
|
518
|
+
essentially never do.
|
|
519
|
+
"""
|
|
520
|
+
if not names:
|
|
521
|
+
return 0.0
|
|
522
|
+
tops = {n.split("/", 1)[0] for n in names}
|
|
523
|
+
if not tops:
|
|
524
|
+
return 0.0
|
|
525
|
+
existing = sum(1 for t in tops if t and (project_dir / t).exists())
|
|
526
|
+
return existing / len(tops)
|
|
527
|
+
|
|
528
|
+
|
|
420
529
|
def build_plan(
|
|
421
530
|
zip_path: Path,
|
|
422
531
|
project_dir: Path,
|
|
@@ -446,10 +555,28 @@ def build_plan(
|
|
|
446
555
|
strip_prefix: str | None = None
|
|
447
556
|
wrapper_note: str | None = None
|
|
448
557
|
candidate = _detect_common_wrapper(raw_names)
|
|
449
|
-
if candidate
|
|
558
|
+
if candidate:
|
|
559
|
+
stripped_names = [n[len(candidate) + 1:] for n in raw_names]
|
|
560
|
+
candidate_is_real_dir = (project_dir / candidate).is_dir()
|
|
561
|
+
|
|
450
562
|
if manifest_files:
|
|
563
|
+
# Decide using the manifest — actual ground truth about what
|
|
564
|
+
# paths this project has — rather than whether a directory of
|
|
565
|
+
# that name merely happens to exist on disk. That on-disk check
|
|
566
|
+
# used to gate this whole branch, which backfired on the most
|
|
567
|
+
# common real case: an AI tool wraps the whole project in a
|
|
568
|
+
# folder named after the project itself (e.g. "contextzip/"),
|
|
569
|
+
# and the project's own top-level package/dir happens to share
|
|
570
|
+
# that exact name — so the wrapper looked "legitimate" and was
|
|
571
|
+
# never stripped, dumping a nested copy of the project instead
|
|
572
|
+
# of updating the real files. Comparing manifest match-rates
|
|
573
|
+
# with vs without stripping gets both cases right: a genuine
|
|
574
|
+
# wrapper matches far better once stripped, while a zip whose
|
|
575
|
+
# top-level folder is an intentional part of the project (e.g.
|
|
576
|
+
# updates confined to an existing "docs/" folder) matches
|
|
577
|
+
# better *unstripped*, since those paths are already in the
|
|
578
|
+
# manifest as-is.
|
|
451
579
|
unstripped_rate = _match_rate(raw_names, manifest_files)
|
|
452
|
-
stripped_names = [n[len(candidate) + 1:] for n in raw_names]
|
|
453
580
|
stripped_rate = _match_rate(stripped_names, manifest_files)
|
|
454
581
|
# Only strip when it clearly helps — meaningfully better match
|
|
455
582
|
# against paths we know this project actually has, not just a
|
|
@@ -462,16 +589,53 @@ def build_plan(
|
|
|
462
589
|
f"project manifest after stripping vs {unstripped_rate:.0%} "
|
|
463
590
|
"before."
|
|
464
591
|
)
|
|
465
|
-
|
|
592
|
+
elif stripped_rate == unstripped_rate and not candidate_is_real_dir:
|
|
593
|
+
# The manifest didn't help decide either way — most often
|
|
594
|
+
# because the manifest in use is scoped to a small subset
|
|
595
|
+
# of the project (e.g. a git-diff manifest from
|
|
596
|
+
# output/git-changes/, which only ever tracked the files
|
|
597
|
+
# touched in one change) rather than the whole tree, so a
|
|
598
|
+
# zip of brand-new files matches it at 0% regardless of
|
|
599
|
+
# stripping. Fall back to checking whether the paths line
|
|
600
|
+
# up with the project's real, current top-level layout
|
|
601
|
+
# instead of giving up and leaving the wrapper in place.
|
|
602
|
+
dir_unstripped_rate = _top_level_dir_match_rate(raw_names, project_dir)
|
|
603
|
+
dir_stripped_rate = _top_level_dir_match_rate(stripped_names, project_dir)
|
|
604
|
+
if dir_stripped_rate > dir_unstripped_rate and dir_stripped_rate >= 0.5:
|
|
605
|
+
strip_prefix = candidate
|
|
606
|
+
wrapper_note = (
|
|
607
|
+
f"Removed wrapping folder '{candidate}/' present in "
|
|
608
|
+
"every zip entry — the project manifest in use didn't "
|
|
609
|
+
"cover enough of these paths to tell either way "
|
|
610
|
+
"(likely scoped to a specific change rather than the "
|
|
611
|
+
"whole project), but "
|
|
612
|
+
f"{dir_stripped_rate:.0%} of the stripped paths' "
|
|
613
|
+
"top-level folders match this project's real layout "
|
|
614
|
+
f"vs {dir_unstripped_rate:.0%} before — double-check "
|
|
615
|
+
"the result before trusting it."
|
|
616
|
+
)
|
|
617
|
+
elif not candidate_is_real_dir:
|
|
466
618
|
# No manifest to confirm against, so this is a softer call —
|
|
467
619
|
# still strip (matches how `tar` and GitHub's own zip downloads
|
|
468
|
-
# behave)
|
|
620
|
+
# behave) as long as the project doesn't already have a real
|
|
621
|
+
# folder of that name, but say so plainly since it's not
|
|
622
|
+
# manifest-verified. Where the on-disk layout backs it up, say
|
|
623
|
+
# that too rather than leaving the caveat maximally vague.
|
|
469
624
|
strip_prefix = candidate
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
625
|
+
dir_stripped_rate = _top_level_dir_match_rate(stripped_names, project_dir)
|
|
626
|
+
if dir_stripped_rate >= 0.5:
|
|
627
|
+
wrapper_note = (
|
|
628
|
+
f"Removed wrapping folder '{candidate}/' present in every "
|
|
629
|
+
f"zip entry — {dir_stripped_rate:.0%} of the stripped "
|
|
630
|
+
"paths' top-level folders match this project's real "
|
|
631
|
+
"layout (no manifest available to confirm further)."
|
|
632
|
+
)
|
|
633
|
+
else:
|
|
634
|
+
wrapper_note = (
|
|
635
|
+
f"Removed wrapping folder '{candidate}/' present in every zip "
|
|
636
|
+
"entry (no manifest available to confirm — double-check the "
|
|
637
|
+
"result before trusting it)."
|
|
638
|
+
)
|
|
475
639
|
|
|
476
640
|
extraction_dir = Path(tempfile.mkdtemp(prefix="contextzip-apply-"))
|
|
477
641
|
_safe_extract(zip_path, extraction_dir, strip_prefix=strip_prefix)
|
|
@@ -520,16 +684,31 @@ def build_plan(
|
|
|
520
684
|
known = sum(1 for e in entries if e.rel_path in manifest_files)
|
|
521
685
|
rate = known / len(entries)
|
|
522
686
|
if rate < 0.1:
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
687
|
+
if strip_prefix and "top-level folders match" in (wrapper_note or ""):
|
|
688
|
+
# The wrapper was already resolved via the on-disk layout
|
|
689
|
+
# fallback above, so don't re-cast that as if the wrapper
|
|
690
|
+
# check itself came up empty — it didn't, the manifest just
|
|
691
|
+
# doesn't happen to cover these particular files.
|
|
692
|
+
structure_warning = (
|
|
693
|
+
f"Only {known} of {len(entries)} files in this zip match "
|
|
694
|
+
"paths from the project manifest. The wrapper folder was "
|
|
695
|
+
"still resolved using the project's real directory "
|
|
696
|
+
"layout (see above), but none of these exact files were "
|
|
697
|
+
"in the manifest, so this may be new work the manifest "
|
|
698
|
+
"predates rather than a mismatch — worth a quick look "
|
|
699
|
+
"before applying."
|
|
700
|
+
)
|
|
701
|
+
else:
|
|
702
|
+
structure_warning = (
|
|
703
|
+
f"Only {known} of {len(entries)} files in this zip match paths "
|
|
704
|
+
"from the project manifest, even after checking for a wrapping "
|
|
705
|
+
"folder. That usually means the zip's internal structure "
|
|
706
|
+
"doesn't line up with this project — the wrong zip, or one "
|
|
707
|
+
"built with an unexpected layout. Applying it as-is will "
|
|
708
|
+
"likely create a pile of unrelated new files rather than "
|
|
709
|
+
"update the ones you meant to change. Double-check the zip "
|
|
710
|
+
"before proceeding."
|
|
711
|
+
)
|
|
533
712
|
|
|
534
713
|
return ApplyPlan(
|
|
535
714
|
zip_path=zip_path,
|
|
@@ -552,29 +731,6 @@ def discard_plan(plan: ApplyPlan) -> None:
|
|
|
552
731
|
# ---------------------------------------------------------------------------
|
|
553
732
|
|
|
554
733
|
|
|
555
|
-
def _backup_entries(
|
|
556
|
-
entries: list[ApplyEntry], project_dir: Path, workspace_root: Path
|
|
557
|
-
) -> Path | None:
|
|
558
|
-
"""
|
|
559
|
-
Copy the current, pre-apply version of every existing file about to be
|
|
560
|
-
touched into .contextzip/backups/<timestamp>/, preserving relative
|
|
561
|
-
paths. Files that don't exist yet locally (new files) have nothing to
|
|
562
|
-
back up. Returns the backup directory, or None if nothing needed one.
|
|
563
|
-
"""
|
|
564
|
-
to_backup = [e for e in entries if (project_dir / e.rel_path).is_file()]
|
|
565
|
-
if not to_backup:
|
|
566
|
-
return None
|
|
567
|
-
|
|
568
|
-
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
569
|
-
backup_dir = workspace_root / _BACKUPS_DIRNAME / stamp
|
|
570
|
-
for entry in to_backup:
|
|
571
|
-
src = project_dir / entry.rel_path
|
|
572
|
-
dst = backup_dir / entry.rel_path
|
|
573
|
-
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
574
|
-
shutil.copy2(src, dst)
|
|
575
|
-
return backup_dir
|
|
576
|
-
|
|
577
|
-
|
|
578
734
|
def _move_zip_to_applied(
|
|
579
735
|
zip_path: Path, project_dir: Path, workspace_root: Path, retain: int = 1
|
|
580
736
|
) -> Path:
|
|
@@ -621,9 +777,14 @@ def execute_plan(plan: ApplyPlan, project_dir: Path, retain: int = 1) -> ApplyRe
|
|
|
621
777
|
"""
|
|
622
778
|
Write every non-unchanged entry from *plan* into the project.
|
|
623
779
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
780
|
+
Assumes the caller has already decided to proceed — dry-run and
|
|
781
|
+
confirmation prompts are handled upstream (see cli.py's `apply-zip`
|
|
782
|
+
command). Files are overwritten directly, with no backup taken first:
|
|
783
|
+
`build_plan`'s manifest diff and the confirmation prompt it drives
|
|
784
|
+
(see `ApplyPlan.is_risky`) are the safety net here, and the zip you
|
|
785
|
+
just applied is itself always recoverable — it's archived to
|
|
786
|
+
`.contextzip/inbox/applied/` (or left wherever you pointed at it)
|
|
787
|
+
rather than deleted.
|
|
627
788
|
|
|
628
789
|
*retain* caps how many applied zips are kept in
|
|
629
790
|
.contextzip/inbox/applied/ — typically a project's
|
|
@@ -632,8 +793,6 @@ def execute_plan(plan: ApplyPlan, project_dir: Path, retain: int = 1) -> ApplyRe
|
|
|
632
793
|
workspace_root = _workspace_dir(project_dir)
|
|
633
794
|
to_write = plan.writable_entries
|
|
634
795
|
|
|
635
|
-
backup_dir = _backup_entries(to_write, project_dir, workspace_root)
|
|
636
|
-
|
|
637
796
|
written: list[str] = []
|
|
638
797
|
for entry in to_write:
|
|
639
798
|
dest = project_dir / entry.rel_path
|
|
@@ -646,6 +805,4 @@ def execute_plan(plan: ApplyPlan, project_dir: Path, retain: int = 1) -> ApplyRe
|
|
|
646
805
|
)
|
|
647
806
|
shutil.rmtree(plan.extraction_dir, ignore_errors=True)
|
|
648
807
|
|
|
649
|
-
return ApplyResult(
|
|
650
|
-
written=written, backup_dir=backup_dir, applied_zip_path=applied_zip_path
|
|
651
|
-
)
|
|
808
|
+
return ApplyResult(written=written, applied_zip_path=applied_zip_path)
|
|
@@ -2,21 +2,16 @@
|
|
|
2
2
|
cleanup.py — Automatic workspace housekeeping for contextzip.
|
|
3
3
|
|
|
4
4
|
.contextzip/ accumulates disposable artifacts over time: a zip+manifest+report
|
|
5
|
-
set every run,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
The heavier caution used elsewhere in contextzip (backing up before overwrite
|
|
16
|
-
in apply-zip, never inferring deletions) is reserved for the project's own
|
|
17
|
-
files, where losing something can mean losing real work. Nothing here ever
|
|
18
|
-
touches the project — only disposable metadata under .contextzip/ that costs
|
|
19
|
-
nothing to regenerate.
|
|
5
|
+
set every run, and (until pruned) old archived applied-zips and loose apply
|
|
6
|
+
reports. None of it is precious — every zip is trivially reproducible by just
|
|
7
|
+
re-running contextzip. So cleanup here is deliberately brutal rather than
|
|
8
|
+
cautious: it runs automatically after every successful command (see
|
|
9
|
+
`_auto_cleanup` in cli.py), with no prompt and no separate command to
|
|
10
|
+
remember, and keeps only the most recent `cleanup.keep_recent` item(s) in
|
|
11
|
+
each category — everything else is deleted immediately, every time.
|
|
12
|
+
|
|
13
|
+
Nothing here ever touches the project — only disposable metadata under
|
|
14
|
+
.contextzip/ that costs nothing to regenerate.
|
|
20
15
|
|
|
21
16
|
Never touched, under any settings:
|
|
22
17
|
- .contextzip/config.json (project preferences)
|
|
@@ -28,11 +23,22 @@ Never touched, under any settings:
|
|
|
28
23
|
Kept only-the-most-recent-N, everything older deleted immediately:
|
|
29
24
|
- zip/manifest/report sets under .contextzip/output/<mode>/, per mode
|
|
30
25
|
folder
|
|
31
|
-
- timestamped folders under .contextzip/backups/
|
|
32
26
|
- archived zips under .contextzip/inbox/applied/ (this is also governed
|
|
33
27
|
by `applied_zip_retention`, already enforced on every apply-zip run —
|
|
34
28
|
scanning it here too just catches the case where zips piled up before
|
|
35
29
|
that setting existed, or the setting was since lowered)
|
|
30
|
+
- loose `*.apply-report.txt` files sitting directly in .contextzip/inbox/
|
|
31
|
+
(as opposed to inbox/applied/) — these are written next to wherever
|
|
32
|
+
apply-zip resolved its zip from, decoupled from the zip's own
|
|
33
|
+
lifecycle (a zip gets archived into applied/ or left where the user
|
|
34
|
+
put it; its report always lands in inbox/ itself), so nothing else
|
|
35
|
+
here would ever catch them piling up run after run
|
|
36
|
+
|
|
37
|
+
Removed outright, no matter how recent:
|
|
38
|
+
- .contextzip/backups/ in its entirety. apply-zip no longer creates
|
|
39
|
+
per-run backups there at all — this only ever catches a leftover
|
|
40
|
+
folder from a project that used an older contextzip version, and it's
|
|
41
|
+
dead weight the moment that version is gone.
|
|
36
42
|
|
|
37
43
|
Scanning only ever touches .contextzip/ directory listings and file mtimes —
|
|
38
44
|
no hashing, no reading file contents — so this stays fast even called on
|
|
@@ -47,7 +53,7 @@ from pathlib import Path
|
|
|
47
53
|
|
|
48
54
|
|
|
49
55
|
_OUTPUT_DIRNAME = "output"
|
|
50
|
-
|
|
56
|
+
_LEGACY_BACKUPS_DIRNAME = "backups"
|
|
51
57
|
_INBOX_DIRNAME = "inbox"
|
|
52
58
|
_APPLIED_DIRNAME = "applied"
|
|
53
59
|
|
|
@@ -60,7 +66,7 @@ _APPLIED_DIRNAME = "applied"
|
|
|
60
66
|
@dataclass
|
|
61
67
|
class CleanupItem:
|
|
62
68
|
path: Path
|
|
63
|
-
kind: str # "zip-set" | "
|
|
69
|
+
kind: str # "zip-set" | "applied-zip" | "apply-report" | "legacy-backups"
|
|
64
70
|
size_bytes: int
|
|
65
71
|
|
|
66
72
|
|
|
@@ -160,14 +166,14 @@ def scan(
|
|
|
160
166
|
for f in _zip_set_files(zp):
|
|
161
167
|
items.append(CleanupItem(path=f, kind="zip-set", size_bytes=_size_of(f)))
|
|
162
168
|
|
|
163
|
-
# ── backups
|
|
164
|
-
|
|
169
|
+
# ── backups/ — the feature that created these is gone entirely; wipe
|
|
170
|
+
# the whole folder outright rather than pruning it, so upgrading
|
|
171
|
+
# from an older contextzip version doesn't leave it behind forever ──
|
|
172
|
+
backups_root = workspace / _LEGACY_BACKUPS_DIRNAME
|
|
165
173
|
if backups_root.is_dir():
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
CleanupItem(path=backup_dir, kind="backup", size_bytes=_size_of(backup_dir))
|
|
170
|
-
)
|
|
174
|
+
items.append(
|
|
175
|
+
CleanupItem(path=backups_root, kind="legacy-backups", size_bytes=_size_of(backups_root))
|
|
176
|
+
)
|
|
171
177
|
|
|
172
178
|
# ── inbox/applied/ ───────────────────────────────────────────────────
|
|
173
179
|
applied_root = workspace / _INBOX_DIRNAME / _APPLIED_DIRNAME
|
|
@@ -177,6 +183,19 @@ def scan(
|
|
|
177
183
|
for zp in applied_zips[retain:]:
|
|
178
184
|
items.append(CleanupItem(path=zp, kind="applied-zip", size_bytes=_size_of(zp)))
|
|
179
185
|
|
|
186
|
+
# ── inbox/*.apply-report.txt ─────────────────────────────────────────
|
|
187
|
+
# Loose report files written directly in the inbox root (see
|
|
188
|
+
# report.py's write_apply_report) — decoupled from any single zip's
|
|
189
|
+
# own path, so nothing above ever catches these. Every apply-zip run
|
|
190
|
+
# writes one, so left alone they'd accumulate forever.
|
|
191
|
+
inbox_root = workspace / _INBOX_DIRNAME
|
|
192
|
+
if inbox_root.is_dir():
|
|
193
|
+
reports = _newest_first(
|
|
194
|
+
[p for p in inbox_root.glob("*.apply-report.txt") if p.is_file()]
|
|
195
|
+
)
|
|
196
|
+
for rp in reports[keep:]:
|
|
197
|
+
items.append(CleanupItem(path=rp, kind="apply-report", size_bytes=_size_of(rp)))
|
|
198
|
+
|
|
180
199
|
return CleanupPlan(workspace=workspace, items=items)
|
|
181
200
|
|
|
182
201
|
|
|
@@ -74,6 +74,7 @@ from contextzip.cli_ai import (
|
|
|
74
74
|
)
|
|
75
75
|
from contextzip.cli_onboard import onboard_api_key
|
|
76
76
|
from contextzip import cleanup as cleanup_mod
|
|
77
|
+
from contextzip import updater as updater_mod
|
|
77
78
|
|
|
78
79
|
console = Console()
|
|
79
80
|
|
|
@@ -381,8 +382,7 @@ def cmd_apply_zip(
|
|
|
381
382
|
every file as new, modified, unchanged, or one needing a closer look
|
|
382
383
|
(edited locally since zipping, or with no baseline at all) before
|
|
383
384
|
writing anything. Only adds and modifies files — nothing is ever
|
|
384
|
-
deleted.
|
|
385
|
-
.contextzip/backups/<timestamp>/.
|
|
385
|
+
deleted.
|
|
386
386
|
"""
|
|
387
387
|
project_dir = Path(os.getcwd()).resolve()
|
|
388
388
|
project_cfg = load_project_config(project_dir)
|
|
@@ -688,6 +688,90 @@ def cmd_config(
|
|
|
688
688
|
console.print()
|
|
689
689
|
|
|
690
690
|
|
|
691
|
+
# ---------------------------------------------------------------------------
|
|
692
|
+
# Subcommand: update
|
|
693
|
+
# ---------------------------------------------------------------------------
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
@main.command("update")
|
|
697
|
+
@click.option(
|
|
698
|
+
"--check",
|
|
699
|
+
"check_only",
|
|
700
|
+
is_flag=True,
|
|
701
|
+
default=False,
|
|
702
|
+
help="Only check for a newer version — don't install anything.",
|
|
703
|
+
)
|
|
704
|
+
@click.option(
|
|
705
|
+
"--yes",
|
|
706
|
+
"-y",
|
|
707
|
+
is_flag=True,
|
|
708
|
+
default=False,
|
|
709
|
+
help="Skip the confirmation prompt and update immediately if available.",
|
|
710
|
+
)
|
|
711
|
+
def cmd_update(check_only: bool, yes: bool) -> None:
|
|
712
|
+
"""
|
|
713
|
+
Check PyPI for a newer contextzip release and update in place.
|
|
714
|
+
|
|
715
|
+
\b
|
|
716
|
+
EXAMPLES
|
|
717
|
+
contextzip update # check, then confirm before updating
|
|
718
|
+
contextzip update --yes # update immediately if one is available
|
|
719
|
+
contextzip update --check # only report the current/latest version
|
|
720
|
+
cz update # same command, short alias
|
|
721
|
+
|
|
722
|
+
\b
|
|
723
|
+
HOW IT WORKS
|
|
724
|
+
contextzip cuts frequent releases, so this beats remembering
|
|
725
|
+
`pip install --upgrade contextzip` by hand. It detects whether this
|
|
726
|
+
install came from pip, pipx, or uv tool and re-invokes the matching
|
|
727
|
+
one, upgrading the same interpreter that's currently running —
|
|
728
|
+
never a possibly-different "pip" found on PATH.
|
|
729
|
+
"""
|
|
730
|
+
console.print()
|
|
731
|
+
with console.status("[cyan]Checking for updates…[/]", spinner="dots"):
|
|
732
|
+
check = updater_mod.check_latest_version()
|
|
733
|
+
|
|
734
|
+
if check.latest is None:
|
|
735
|
+
err(f"Could not check for updates: {check.error}")
|
|
736
|
+
info(f"You're on {check.current}. Try again later, or update manually:")
|
|
737
|
+
info(" pip install --upgrade contextzip")
|
|
738
|
+
console.print()
|
|
739
|
+
raise SystemExit(1)
|
|
740
|
+
|
|
741
|
+
if not check.update_available:
|
|
742
|
+
ok(f"You're up to date — contextzip {check.current}")
|
|
743
|
+
console.print()
|
|
744
|
+
return
|
|
745
|
+
|
|
746
|
+
info(f"Update available: {check.current} → {check.latest}")
|
|
747
|
+
|
|
748
|
+
if check_only:
|
|
749
|
+
info("Run contextzip update to install it.")
|
|
750
|
+
console.print()
|
|
751
|
+
return
|
|
752
|
+
|
|
753
|
+
if not yes:
|
|
754
|
+
proceed = click.confirm(f" Update to {check.latest} now?", default=True)
|
|
755
|
+
if not proceed:
|
|
756
|
+
info("Not updating.")
|
|
757
|
+
console.print()
|
|
758
|
+
return
|
|
759
|
+
|
|
760
|
+
with console.status(f"[cyan]Updating to {check.latest}…[/]", spinner="dots"):
|
|
761
|
+
result = updater_mod.run_update()
|
|
762
|
+
|
|
763
|
+
console.print()
|
|
764
|
+
if result.ok:
|
|
765
|
+
ok(f"Updated to contextzip {check.latest}", f"via {result.method}")
|
|
766
|
+
info("Restart any running contextzip/watch sessions to pick it up.")
|
|
767
|
+
else:
|
|
768
|
+
err(f"Update via {result.method} failed")
|
|
769
|
+
if result.output:
|
|
770
|
+
console.print(f"[dim]{result.output}[/]")
|
|
771
|
+
info("You can also update manually: pip install --upgrade contextzip")
|
|
772
|
+
console.print()
|
|
773
|
+
|
|
774
|
+
|
|
691
775
|
# Core execution logic (shared by main command + all subcommands)
|
|
692
776
|
# ---------------------------------------------------------------------------
|
|
693
777
|
|
|
@@ -772,9 +856,11 @@ def _auto_cleanup(project_dir: Path, project_cfg) -> None:
|
|
|
772
856
|
to remember. Every zip is trivially reproducible by re-running
|
|
773
857
|
contextzip, so this is deliberately brutal rather than cautious: it
|
|
774
858
|
keeps only the `cleanup.keep_recent` most recent zip/manifest/report
|
|
775
|
-
set per mode folder, the most recent `cleanup.keep_recent`
|
|
776
|
-
|
|
777
|
-
|
|
859
|
+
set per mode folder, the most recent `cleanup.keep_recent` archived
|
|
860
|
+
applied-zip(s), and the most recent `cleanup.keep_recent` loose
|
|
861
|
+
apply-report(s) in inbox/ — everything else is deleted immediately,
|
|
862
|
+
every run. Also wipes .contextzip/backups/ outright if a leftover one
|
|
863
|
+
still exists from an older contextzip version.
|
|
778
864
|
|
|
779
865
|
Gated on `cleanup.enabled` (default True) — set to False in
|
|
780
866
|
.contextzip/config.json to turn this off entirely.
|
|
@@ -380,10 +380,8 @@ def print_apply_plan(
|
|
|
380
380
|
|
|
381
381
|
|
|
382
382
|
def print_apply_result(result, *, con: Console = console) -> None:
|
|
383
|
-
"""Two
|
|
383
|
+
"""Two lines: what was written and archived."""
|
|
384
384
|
ok(f"Applied {len(result.written)} files", con=con)
|
|
385
|
-
if result.backup_dir:
|
|
386
|
-
ok("Backed up to", str(result.backup_dir), con=con)
|
|
387
385
|
ok("Archived zip to", str(result.applied_zip_path), con=con)
|
|
388
386
|
|
|
389
387
|
|
|
@@ -663,6 +663,15 @@ def _workspace_output_path_silent(
|
|
|
663
663
|
except OSError:
|
|
664
664
|
return Path(tempfile.gettempdir()) / filename
|
|
665
665
|
|
|
666
|
+
# Lazy import: applier.py resolves the workspace dir via this module,
|
|
667
|
+
# so importing it at module load time would be circular.
|
|
668
|
+
from contextzip.applier import ensure_inbox_scaffolding
|
|
669
|
+
|
|
670
|
+
try:
|
|
671
|
+
ensure_inbox_scaffolding(workspace)
|
|
672
|
+
except OSError:
|
|
673
|
+
pass
|
|
674
|
+
|
|
666
675
|
if is_git_repo:
|
|
667
676
|
try:
|
|
668
677
|
_ensure_workspace_gitignore(workspace)
|
|
@@ -704,6 +713,15 @@ def _workspace_output_path(
|
|
|
704
713
|
)
|
|
705
714
|
return Path(tempfile.gettempdir()) / filename
|
|
706
715
|
|
|
716
|
+
# Lazy import: applier.py resolves the workspace dir via this module,
|
|
717
|
+
# so importing it at module load time would be circular.
|
|
718
|
+
from contextzip.applier import ensure_inbox_scaffolding
|
|
719
|
+
|
|
720
|
+
try:
|
|
721
|
+
ensure_inbox_scaffolding(workspace)
|
|
722
|
+
except OSError:
|
|
723
|
+
pass
|
|
724
|
+
|
|
707
725
|
# Handle .gitignore only when we're inside a git repo and the workspace
|
|
708
726
|
# actually lives inside it (a custom absolute path outside the repo has
|
|
709
727
|
# nothing for .gitignore to protect)
|
|
@@ -110,24 +110,25 @@ built-in default for the newer preference fields.
|
|
|
110
110
|
|
|
111
111
|
cleanup
|
|
112
112
|
Automatic housekeeping for `.contextzip/`, which otherwise only
|
|
113
|
-
grows over time (a zip+manifest+report set per run,
|
|
114
|
-
|
|
115
|
-
every successful contextzip command, the
|
|
116
|
-
to only the most recent `keep_recent`
|
|
117
|
-
confirmation, no separate command needed.
|
|
118
|
-
brutal rather than cautious: every zip is
|
|
119
|
-
by re-running contextzip, so there's little
|
|
120
|
-
pile up.
|
|
113
|
+
grows over time (a zip+manifest+report set per run, archived zips
|
|
114
|
+
under inbox/applied/, loose apply-report.txt files in inbox/). See
|
|
115
|
+
cleanup.py — after every successful contextzip command, the
|
|
116
|
+
workspace is pruned down to only the most recent `keep_recent`
|
|
117
|
+
item(s) per category, no confirmation, no separate command needed.
|
|
118
|
+
This is deliberately brutal rather than cautious: every zip is
|
|
119
|
+
trivially reproducible by re-running contextzip, so there's little
|
|
120
|
+
reason to let old ones pile up.
|
|
121
121
|
enabled — if true (default), auto-cleanup runs after every
|
|
122
122
|
successful command. Set to false to keep
|
|
123
123
|
everything contextzip has ever generated.
|
|
124
124
|
keep_recent — how many most-recent item(s) are kept per
|
|
125
125
|
category before the rest are deleted: zip+manifest
|
|
126
126
|
+report sets per mode folder (output/codebase/,
|
|
127
|
-
output/git-changes/, output/prompt/),
|
|
128
|
-
|
|
129
|
-
inbox
|
|
130
|
-
of each, delete everything
|
|
127
|
+
output/git-changes/, output/prompt/), archived
|
|
128
|
+
zips under inbox/applied/, and loose
|
|
129
|
+
apply-report.txt files in inbox/. Default 1 —
|
|
130
|
+
keep only the latest of each, delete everything
|
|
131
|
+
older immediately.
|
|
131
132
|
|
|
132
133
|
The schema is intentionally a flat, easily-extended dict so future
|
|
133
134
|
preferences can be added without another migration.
|
|
@@ -16,6 +16,7 @@ from pathlib import Path
|
|
|
16
16
|
|
|
17
17
|
from contextzip.filters import summarise_exclusions
|
|
18
18
|
from contextzip.cli_display import human_size
|
|
19
|
+
from contextzip.applier import inbox_dir
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
def report_path_for(zip_path: Path) -> Path:
|
|
@@ -140,8 +141,21 @@ def write_apply_report(
|
|
|
140
141
|
result=None,
|
|
141
142
|
) -> Path | None:
|
|
142
143
|
"""
|
|
143
|
-
Write the full apply-zip report (every file + status)
|
|
144
|
-
|
|
144
|
+
Write the full apply-zip report (every file + status) into
|
|
145
|
+
.contextzip/inbox/ — always there, regardless of where the zip that
|
|
146
|
+
was applied actually lives. Returns the report path, or None on
|
|
147
|
+
failure.
|
|
148
|
+
|
|
149
|
+
`apply-zip <path>` accepts an explicit path anywhere on disk (not
|
|
150
|
+
just the inbox), and that path is meant to be left alone afterwards
|
|
151
|
+
rather than moved — see `archive_applied_zip`. The report used to
|
|
152
|
+
follow the same "leave it where the zip is" rule, which meant
|
|
153
|
+
pointing apply-zip at a zip sitting in the project root (or anywhere
|
|
154
|
+
else outside .contextzip/inbox/) dropped a stray
|
|
155
|
+
`<name>.apply-report.txt` right there, cluttering the repo as an
|
|
156
|
+
untracked file. The report itself doesn't need to travel with the
|
|
157
|
+
zip the way an archived copy would, so it always goes to the one
|
|
158
|
+
place these reports are expected to live.
|
|
145
159
|
"""
|
|
146
160
|
lines: list[str] = []
|
|
147
161
|
w = lines.append
|
|
@@ -161,11 +175,10 @@ def write_apply_report(
|
|
|
161
175
|
if result is not None:
|
|
162
176
|
w("Result")
|
|
163
177
|
w(f" written : {len(result.written)}")
|
|
164
|
-
w(f" backup : {result.backup_dir or 'none needed'}")
|
|
165
178
|
w(f" archived: {result.applied_zip_path}")
|
|
166
179
|
w("")
|
|
167
180
|
|
|
168
|
-
report_path =
|
|
181
|
+
report_path = inbox_dir(project_dir) / (plan.zip_path.stem + ".apply-report.txt")
|
|
169
182
|
try:
|
|
170
183
|
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
171
184
|
report_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
@@ -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;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: contextzip
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Summary: Intelligently package your codebase for AI tools
|
|
5
5
|
Author-email: Deepesh <akadeepesh@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -87,7 +87,7 @@ Full function reference: [contextzip.vercel.app/cli#python-api](https://contextz
|
|
|
87
87
|
- [Packaging & detection](https://contextzip.vercel.app/features) — frameworks, exclusion rules, safety
|
|
88
88
|
- [AI-powered file selection](https://contextzip.vercel.app/ai-selection) — `--prompt`, Gemini setup
|
|
89
89
|
- [Terminal error watcher](https://contextzip.vercel.app/watch) — `contextzip watch`
|
|
90
|
-
- [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`,
|
|
90
|
+
- [Applying changes back](https://contextzip.vercel.app/apply-zip) — `apply-zip`, manifest diffing, safety
|
|
91
91
|
- [Configuration](https://contextzip.vercel.app/configuration) — personal & project config, visual config UI
|
|
92
92
|
- [CLI & Python API reference](https://contextzip.vercel.app/cli)
|
|
93
93
|
|
|
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
|