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.
Files changed (50) hide show
  1. {contextzip-0.4.2 → contextzip-0.4.4}/PKG-INFO +2 -2
  2. {contextzip-0.4.2 → contextzip-0.4.4}/README.md +1 -1
  3. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/__init__.py +1 -1
  4. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/api.py +1 -6
  5. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/applier.py +212 -55
  6. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/cleanup.py +44 -25
  7. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/cli.py +91 -5
  8. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/cli_display.py +1 -3
  9. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/packager.py +18 -0
  10. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/project_config.py +13 -12
  11. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/report.py +17 -4
  12. contextzip-0.4.4/contextzip/updater.py +165 -0
  13. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/webui/assets.py +11 -0
  14. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip.egg-info/PKG-INFO +2 -2
  15. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip.egg-info/SOURCES.txt +1 -0
  16. {contextzip-0.4.2 → contextzip-0.4.4}/pyproject.toml +1 -1
  17. {contextzip-0.4.2 → contextzip-0.4.4}/LICENSE +0 -0
  18. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/ai/__init__.py +0 -0
  19. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/ai/gemini.py +0 -0
  20. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/ai/heuristic.py +0 -0
  21. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/ai/selector.py +0 -0
  22. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/cli_ai.py +0 -0
  23. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/cli_onboard.py +0 -0
  24. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/clipboard.py +0 -0
  25. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/config.py +0 -0
  26. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/detector.py +0 -0
  27. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/error_parser.py +0 -0
  28. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/filters.py +0 -0
  29. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/git.py +0 -0
  30. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/redact.py +0 -0
  31. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/__init__.py +0 -0
  32. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/base.py +0 -0
  33. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/errors/__init__.py +0 -0
  34. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/errors/node.py +0 -0
  35. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/errors/python.py +0 -0
  36. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/go.py +0 -0
  37. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/node.py +0 -0
  38. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/python.py +0 -0
  39. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/ruby.py +0 -0
  40. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/rules/rust.py +0 -0
  41. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/watcher.py +0 -0
  42. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/webui/__init__.py +0 -0
  43. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/webui/persist.py +0 -0
  44. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/webui/server.py +0 -0
  45. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip/webui/suggestions.py +0 -0
  46. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip.egg-info/dependency_links.txt +0 -0
  47. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip.egg-info/entry_points.txt +0 -0
  48. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip.egg-info/requires.txt +0 -0
  49. {contextzip-0.4.2 → contextzip-0.4.4}/contextzip.egg-info/top_level.txt +0 -0
  50. {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.2
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`, backups, safety
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`, backups, safety
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
 
@@ -1,6 +1,6 @@
1
1
  """contextzip — intelligent codebase packager for AI tools."""
2
2
 
3
- __version__ = "0.4.2"
3
+ __version__ = "0.4.4"
4
4
 
5
5
  from contextzip.api import (
6
6
  FileCollection,
@@ -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), ``backup_dir``
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
- "(created the first time you run contextzip), or pass a path "
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 and not (project_dir / candidate).is_dir():
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
- else:
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), but say so plainly since it's not manifest-verified.
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
- wrapper_note = (
471
- f"Removed wrapping folder '{candidate}/' present in every zip "
472
- "entry (no manifest available to confirm — double-check the "
473
- "result before trusting it)."
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
- structure_warning = (
524
- f"Only {known} of {len(entries)} files in this zip match paths "
525
- "from the project manifest, even after checking for a wrapping "
526
- "folder. That usually means the zip's internal structure "
527
- "doesn't line up with this project — the wrong zip, or one "
528
- "built with an unexpected layout. Applying it as-is will "
529
- "likely create a pile of unrelated new files rather than "
530
- "update the ones you meant to change. Double-check the zip "
531
- "before proceeding."
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
- Backs up whatever's about to be overwritten first. Assumes the caller
625
- has already decided to proceed — dry-run and confirmation prompts are
626
- handled upstream (see cli.py's `apply-zip` command).
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, a timestamped backup folder every risky `apply-zip`, and (until
6
- pruned) old archived applied-zips. None of it is precious — every zip is
7
- trivially reproducible by just re-running contextzip, and a backup only
8
- matters until the apply that made it is confirmed good. So cleanup here is
9
- deliberately brutal rather than cautious: it runs automatically after every
10
- successful command (see `_auto_cleanup` in cli.py), with no prompt and no
11
- separate command to remember, and keeps only the most recent
12
- `cleanup.keep_recent` item(s) in each category — everything else is deleted
13
- immediately, every time.
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
- _BACKUPS_DIRNAME = "backups"
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" | "backup" | "applied-zip"
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/<timestamp>/ ─────────────────────────────────────────────
164
- backups_root = workspace / _BACKUPS_DIRNAME
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
- backups = _newest_first([p for p in backups_root.iterdir() if p.is_dir()])
167
- for backup_dir in backups[keep:]:
168
- items.append(
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. Every overwritten file is backed up first, under
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` backup
776
- folder(s), and the most recent `cleanup.keep_recent` archived
777
- applied-zip(s) — everything else is deleted immediately, every run.
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 or three lines: what was written, backed up, and archived."""
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, a timestamped
114
- folder under backups/ per risky apply-zip). See cleanup.py — after
115
- every successful contextzip command, the workspace is pruned down
116
- to only the most recent `keep_recent` item(s) per category, no
117
- confirmation, no separate command needed. This is deliberately
118
- brutal rather than cautious: every zip is trivially reproducible
119
- by re-running contextzip, so there's little reason to let old ones
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/), timestamped
128
- folders under backups/, and archived zips under
129
- inbox/applied/. Default 1 — keep only the latest
130
- of each, delete everything older immediately.
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) next to the
144
- zip that was applied. Returns the report path, or None on failure.
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 = plan.zip_path.with_name(plan.zip_path.stem + ".apply-report.txt")
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.2
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`, backups, safety
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
 
@@ -19,6 +19,7 @@ contextzip/packager.py
19
19
  contextzip/project_config.py
20
20
  contextzip/redact.py
21
21
  contextzip/report.py
22
+ contextzip/updater.py
22
23
  contextzip/watcher.py
23
24
  contextzip.egg-info/PKG-INFO
24
25
  contextzip.egg-info/SOURCES.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "contextzip"
7
- version = "0.4.2"
7
+ version = "0.4.4"
8
8
  description = "Intelligently package your codebase for AI tools"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes
File without changes
File without changes