mirror of
https://github.com/akadeepesh/contextzip.git
synced 2026-10-20 08:36:20 +02:00
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:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user