From fb2b5844c4c6d8a0e6e468687a5537be2463338c Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 25 May 2026 06:56:04 -0400 Subject: [PATCH] feat(cleanup): prompt to remove per-bottle state, separately from containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- claude_bottle/cli/cleanup.py | 88 ++++++++++++++++++++++++---- tests/unit/test_cli_cleanup.py | 102 +++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 11 deletions(-) create mode 100644 tests/unit/test_cli_cleanup.py diff --git a/claude_bottle/cli/cleanup.py b/claude_bottle/cli/cleanup.py index cfaafa1..52b861b 100644 --- a/claude_bottle/cli/cleanup.py +++ b/claude_bottle/cli/cleanup.py @@ -1,10 +1,19 @@ """cleanup: stop and remove all orphaned claude-bottle resources -(containers + networks) left behind by previous bottles.""" +(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 @@ -13,19 +22,76 @@ 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: + if plan.empty and not state_dirs: info("no claude-bottle resources to clean up") return 0 - plan.print() - sys.stderr.write("claude-bottle: remove all of the above? [y/N] ") + 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() - if reply not in ("y", "Y", "yes", "YES"): - info("aborted") - return 0 - - backend.cleanup(plan) - info("done") - return 0 + return reply in ("y", "Y", "yes", "YES") diff --git a/tests/unit/test_cli_cleanup.py b/tests/unit/test_cli_cleanup.py new file mode 100644 index 0000000..97c04ac --- /dev/null +++ b/tests/unit/test_cli_cleanup.py @@ -0,0 +1,102 @@ +"""Unit: cli/cleanup.py state-dir enumeration + summary. + +The end-to-end cleanup-with-prompt flow is exercised manually; +here we cover the state-dir display logic so a regression in the +resumable / orphan / rebuild flags surfaces in unit CI.""" + +import tempfile +import unittest +from pathlib import Path + +from claude_bottle import supervise +from claude_bottle.backend.docker import bottle_state +from claude_bottle.cli.cleanup import _enumerate_state_dirs, _state_summary + + +class _FakeHomeMixin: + def _setup_fake_home(self): + self._tmp = tempfile.TemporaryDirectory(prefix="cli-cleanup-test.") + original = supervise.claude_bottle_root + + def fake_root() -> Path: + return Path(self._tmp.name) / ".claude-bottle" + + supervise.claude_bottle_root = fake_root # type: ignore[assignment] + self._restore = lambda: setattr(supervise, "claude_bottle_root", original) + + def _teardown_fake_home(self): + self._restore() + self._tmp.cleanup() + + +class TestEnumerateStateDirs(_FakeHomeMixin, unittest.TestCase): + def setUp(self): + self._setup_fake_home() + + def tearDown(self): + self._teardown_fake_home() + + def test_empty_when_no_state_root(self): + self.assertEqual([], _enumerate_state_dirs()) + + def test_lists_each_identity_dir(self): + bottle_state.write_per_bottle_dockerfile("dev-aaa", "FROM x\n") + bottle_state.write_per_bottle_dockerfile("api-bbb", "FROM y\n") + dirs = _enumerate_state_dirs() + self.assertEqual( + ["api-bbb", "dev-aaa"], + [p.name for p in dirs], + ) + + def test_sorted_for_stable_preflight(self): + for name in ("z", "a", "m"): + bottle_state.write_per_bottle_dockerfile(name, "FROM x\n") + names = [p.name for p in _enumerate_state_dirs()] + self.assertEqual(["a", "m", "z"], names) + + +class TestStateSummary(_FakeHomeMixin, unittest.TestCase): + def setUp(self): + self._setup_fake_home() + + def tearDown(self): + self._teardown_fake_home() + + def _path(self, name: str) -> Path: + return bottle_state.bottle_state_dir(name) + + def test_orphan_state_dir(self): + # Only a Dockerfile, no metadata.json — the "api / dev" shape + # that comes from pre-identity-fix code. + bottle_state.write_per_bottle_dockerfile("orphan", "FROM old\n") + s = _state_summary(self._path("orphan")) + self.assertIn("orphan", s) + self.assertIn("no metadata.json", s) + self.assertIn("rebuilt Dockerfile", s) + + def test_resumable_state_dir(self): + bottle_state.write_metadata(bottle_state.BottleMetadata( + identity="dev-aaa", agent_name="dev", + cwd="/proj/A", copy_cwd=True, started_at="t", + )) + s = _state_summary(self._path("dev-aaa")) + self.assertIn("resumable", s) + self.assertNotIn("rebuilt Dockerfile", s) + + def test_resumable_with_capability_rebuild_and_preserve_marker(self): + bottle_state.write_metadata(bottle_state.BottleMetadata( + identity="dev-bbb", agent_name="dev", + cwd="", copy_cwd=False, started_at="t", + )) + bottle_state.write_per_bottle_dockerfile("dev-bbb", "FROM rebuilt\n") + bottle_state.transcript_snapshot_dir("dev-bbb").mkdir(parents=True) + bottle_state.mark_preserved("dev-bbb") + s = _state_summary(self._path("dev-bbb")) + self.assertIn("resumable", s) + self.assertIn("rebuilt Dockerfile", s) + self.assertIn("transcript snapshot", s) + self.assertIn("preserve marker", s) + + +if __name__ == "__main__": + unittest.main()