fb2b5844c4
`cli.py cleanup` already enumerated orphan containers + networks and asked for confirmation before nuking them. Per-bottle state under ~/.claude-bottle/state/ wasn't touched — accumulated forever, including orphans from old code paths. Add state to the cleanup flow with its own prompt: the trade-off is different from containers (which are pure debris) because a state dir may carry a resumable bottle (capability-block rebuild + transcript snapshot) the operator still wants. Output shows the resumable / orphan / rebuilt-Dockerfile / transcript / preserve-marker flags for each state dir so the operator sees what they'd lose. Both sections are skippable independently — answering "n" to containers doesn't skip the state prompt. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
98 lines
3.0 KiB
Python
98 lines
3.0 KiB
Python
"""cleanup: stop and remove all orphaned claude-bottle resources
|
|
(containers + networks) left behind by previous bottles, plus
|
|
optionally the per-bottle state dirs under ~/.claude-bottle/state/.
|
|
|
|
State cleanup is prompted separately from container cleanup because
|
|
the trade-off is different: containers + networks are pure debris,
|
|
but a state dir may carry a resumable bottle (capability-block
|
|
rebuild + transcript snapshot) the operator still wants."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from .. import supervise as _supervise
|
|
from ..backend import get_bottle_backend
|
|
from ..log import info
|
|
from ._common import read_tty_line
|
|
|
|
|
|
def cmd_cleanup(_argv: list[str]) -> int:
|
|
backend = get_bottle_backend()
|
|
plan = backend.prepare_cleanup()
|
|
state_dirs = _enumerate_state_dirs()
|
|
|
|
if plan.empty and not state_dirs:
|
|
info("no claude-bottle resources to clean up")
|
|
return 0
|
|
|
|
if not plan.empty:
|
|
plan.print()
|
|
if _prompt_yes("remove all of the above?"):
|
|
backend.cleanup(plan)
|
|
info("containers + networks: cleaned")
|
|
else:
|
|
info("containers + networks: skipped")
|
|
|
|
if state_dirs:
|
|
_print_state(state_dirs)
|
|
if _prompt_yes(
|
|
"remove per-bottle state? (loses resumable bottles)",
|
|
):
|
|
for d in state_dirs:
|
|
shutil.rmtree(d, ignore_errors=True)
|
|
info(f"state: removed {len(state_dirs)} dir(s)")
|
|
else:
|
|
info("state: skipped")
|
|
|
|
return 0
|
|
|
|
|
|
# --- State enumeration + display ------------------------------------------
|
|
|
|
|
|
def _enumerate_state_dirs() -> list[Path]:
|
|
"""All per-bottle state dirs under ~/.claude-bottle/state/.
|
|
Sorted for stable preflight output."""
|
|
state_root = _supervise.claude_bottle_root() / "state"
|
|
if not state_root.is_dir():
|
|
return []
|
|
return sorted(p for p in state_root.iterdir() if p.is_dir())
|
|
|
|
|
|
def _state_summary(path: Path) -> str:
|
|
"""One-line description suitable for the cleanup prompt. Calls
|
|
out resumability so the operator can decide whether removing it
|
|
loses anything they care about."""
|
|
flags: list[str] = []
|
|
if (path / "metadata.json").is_file():
|
|
flags.append("resumable")
|
|
else:
|
|
flags.append("no metadata.json (orphan)")
|
|
if (path / "Dockerfile").is_file():
|
|
flags.append("rebuilt Dockerfile")
|
|
if (path / "transcript").is_dir():
|
|
flags.append("transcript snapshot")
|
|
if (path / ".preserve").is_file():
|
|
flags.append("preserve marker")
|
|
return f"state: {path.name} ({', '.join(flags)})"
|
|
|
|
|
|
def _print_state(dirs: list[Path]) -> None:
|
|
print(file=sys.stderr)
|
|
for d in dirs:
|
|
info(_state_summary(d))
|
|
print(file=sys.stderr)
|
|
|
|
|
|
# --- Prompt ----------------------------------------------------------------
|
|
|
|
|
|
def _prompt_yes(message: str) -> bool:
|
|
sys.stderr.write(f"claude-bottle: {message} [y/N] ")
|
|
sys.stderr.flush()
|
|
reply = read_tty_line()
|
|
return reply in ("y", "Y", "yes", "YES")
|