feat(cli): add 'contextzip apply-zip' command

Wires applier.py into the CLI: auto-detects a zip from
.contextzip/inbox/ (or takes an explicit path), prints a plan summary
(new/modified/unchanged/drifted/untracked counts), and only prompts
for confirmation when something looks risky — drift, untracked paths,
or no manifest at all. Clean modifications and new files apply with
no prompt.

Flags: --dry-run/-n, --verbose/-v, --yes/-y, --manifest.
This commit is contained in:
akadeepesh
2026-08-24 08:59:44 +05:30
parent c6871db8bd
commit 00dba7ac8c
2 changed files with 237 additions and 0 deletions
+145
View File
@@ -44,6 +44,17 @@ from contextzip.cli_display import (
print_package_result,
print_zip_write_warnings,
print_clipboard_result,
print_apply_plan,
print_apply_result,
)
from contextzip.applier import (
ApplyError,
MultipleZipsFoundError,
build_plan,
discard_plan,
execute_plan,
find_latest_manifest,
find_zip_to_apply,
)
from contextzip.cli_ai import (
normalize_pattern,
@@ -292,6 +303,140 @@ def cmd_include(
)
# ---------------------------------------------------------------------------
# Subcommand: apply-zip
# ---------------------------------------------------------------------------
@main.command("apply-zip")
@click.argument("zip_path", required=False, metavar="[ZIP]")
@click.option(
"--manifest",
"manifest_path",
default=None,
metavar="PATH",
help=(
"Manifest to diff against, instead of auto-detecting the most "
"recently created one in .contextzip/output/."
),
)
@click.option(
"--dry-run",
"-n",
is_flag=True,
default=False,
help="Preview what would change without writing anything.",
)
@click.option(
"--verbose",
"-v",
is_flag=True,
default=False,
help="Show every file and its status, not just the summary.",
)
@click.option(
"--yes",
"-y",
is_flag=True,
default=False,
help="Skip the confirmation prompt, even for risky changes.",
)
def cmd_apply_zip(
zip_path: str | None,
manifest_path: str | None,
dry_run: bool,
verbose: bool,
yes: bool,
) -> None:
"""
Apply an AI-returned ZIP back into the project.
\b
EXAMPLES
contextzip apply-zip # auto-detect from .contextzip/inbox/
contextzip apply-zip fix.zip # explicit path overrides the inbox
contextzip apply-zip --dry-run --verbose
contextzip apply-zip -y # skip confirmation, even if risky
\b
HOW IT WORKS
Every zip contextzip creates gets a local manifest — a hash of each
included file — written next to it in .contextzip/output/. Never
inside the zip itself, so it's never uploaded and never visible to
whatever AI tool the zip is pasted into.
apply-zip diffs the returned zip against that manifest to classify
every file as new, modified, unchanged, or one needing a closer look
(edited locally since zipping, or with no baseline at all) before
writing anything. Only adds and modifies files — nothing is ever
deleted. Every overwritten file is backed up first, under
.contextzip/backups/<timestamp>/.
"""
project_dir = Path(os.getcwd()).resolve()
console.print()
console.print(
Panel.fit(
f"[bold cyan]contextzip[/] [dim]apply-zip[/]\n"
f"[dim]Project:[/] [white]{project_dir}[/]",
border_style="cyan",
padding=(0, 2),
)
)
console.print()
try:
resolved_zip = find_zip_to_apply(project_dir, zip_path)
except MultipleZipsFoundError as exc:
console.print(f"[red]{exc}[/]")
raise SystemExit(1)
except ApplyError as exc:
console.print(f"[red]{exc}[/]")
raise SystemExit(1)
manifest = find_latest_manifest(project_dir, manifest_path)
try:
plan = build_plan(resolved_zip, project_dir, manifest)
except ApplyError as exc:
console.print(f"[red]{exc}[/]")
raise SystemExit(1)
print_apply_plan(plan, project_dir, verbose=verbose)
if not plan.writable_entries:
console.print(
"[yellow]Nothing to apply.[/] Every file already matches the project."
)
discard_plan(plan)
return
if dry_run:
console.print(
Panel.fit(
"[yellow]Dry run — no files written.[/]\n"
"[dim]Remove --dry-run to apply these changes.[/]",
border_style="yellow",
padding=(0, 2),
)
)
discard_plan(plan)
return
if plan.is_risky and not yes:
proceed = click.confirm(
" Some files above need a closer look — apply anyway?", default=False
)
if not proceed:
console.print("[dim]Cancelled — no files written.[/]")
discard_plan(plan)
return
console.print()
result = execute_plan(plan, project_dir)
print_apply_result(result)
# ---------------------------------------------------------------------------
# Subcommand: watch
# ---------------------------------------------------------------------------
+92
View File
@@ -10,6 +10,7 @@ Imported by cli.py; nothing here imports from cli.py (no circular deps).
from __future__ import annotations
from collections import Counter
from pathlib import Path
from rich.console import Console
@@ -324,6 +325,97 @@ def print_clipboard_result(cb, *, con: Console = console) -> None:
)
# ---------------------------------------------------------------------------
# apply-zip
# ---------------------------------------------------------------------------
_APPLY_STATUS_STYLE = {
"new": ("green", "+"),
"modified": ("cyan", "~"),
"unchanged": ("dim", "="),
"drifted": ("yellow", "!"),
"untracked": ("yellow", "?"),
}
def print_apply_plan(
plan,
project_dir: Path,
*,
verbose: bool = False,
con: Console = console,
) -> None:
"""Render the apply-zip plan: which zip/manifest were used, and a status breakdown."""
con.print(f" [dim]Zip :[/] [cyan]{plan.zip_path}[/]")
if plan.has_manifest:
con.print(f" [dim]Manifest:[/] [cyan]{plan.manifest_path}[/]")
else:
con.print(
" [dim]Manifest:[/] [yellow]none found[/] "
"[dim](every existing path will be treated as untracked)[/]"
)
con.print()
counts = Counter(e.status.value for e in plan.entries)
table = Table(box=box.ROUNDED, show_header=False, padding=(0, 2))
table.add_column(style="dim")
table.add_column()
table.add_row("New", f"[green]{counts.get('new', 0)}[/]")
table.add_row("Modified", f"[cyan]{counts.get('modified', 0)}[/]")
table.add_row("Unchanged", f"[dim]{counts.get('unchanged', 0)}[/]")
if counts.get("drifted", 0):
table.add_row("Drifted (locally changed)", f"[yellow]{counts['drifted']}[/]")
if counts.get("untracked", 0):
table.add_row("Untracked (no baseline)", f"[yellow]{counts['untracked']}[/]")
con.print(
Panel(table, title="[bold]Apply Plan[/]", border_style="blue", padding=(0, 1))
)
con.print()
if verbose:
con.print("[bold]All files:[/]")
for e in plan.entries:
colour, sym = _APPLY_STATUS_STYLE[e.status.value]
con.print(f" [{colour}]{sym}[/] {e.rel_path} [dim]({e.status.value})[/]")
con.print()
elif plan.risky_entries:
con.print("[bold]Needs a closer look:[/]")
for e in plan.risky_entries[:10]:
colour, sym = _APPLY_STATUS_STYLE[e.status.value]
reason = (
"changed or removed locally since this zip was made"
if e.status.value == "drifted"
else "no baseline — wasn't part of the original zip"
)
con.print(f" [{colour}]{sym}[/] {e.rel_path} [dim]— {reason}[/]")
if len(plan.risky_entries) > 10:
con.print(f" [dim]… and {len(plan.risky_entries) - 10} more[/]")
con.print()
def print_apply_result(result, *, con: Console = console) -> None:
"""Render the apply-zip result: what was written, backed up, and archived."""
table = Table(box=box.ROUNDED, show_header=False, padding=(0, 2))
table.add_column(style="dim", min_width=16)
table.add_column()
table.add_row("Files written", f"[green]{len(result.written)}[/]")
table.add_row(
"Backup",
f"[cyan]{result.backup_dir}[/]" if result.backup_dir else "[dim]none needed[/]",
)
table.add_row("Zip archived to", f"[dim]{result.applied_zip_path}[/]")
con.print(
Panel(
table,
title="[bold green]✓ Applied[/]",
border_style="green",
padding=(0, 1),
)
)
# ---------------------------------------------------------------------------
# Utility
# ---------------------------------------------------------------------------