diff --git a/claude_bottle/cli/start.py b/claude_bottle/cli/start.py index 0a9901a..1647b18 100644 --- a/claude_bottle/cli/start.py +++ b/claude_bottle/cli/start.py @@ -2,8 +2,10 @@ interactive claude-code session. The container is torn down when the session ends. -The launch core is shared with `cli.py resume `: see -_launch_bottle below. +The launch core is shared with `cli.py resume ` and (PRD +0020 chunk 1+) the dashboard's in-process start flow: see the +public helpers `prepare_with_preflight`, `attach_claude`, and the +private orchestrator `_launch_bottle`. """ from __future__ import annotations @@ -14,8 +16,10 @@ import shutil import sys import tempfile from pathlib import Path +from typing import Callable -from ..backend import BottleSpec, get_bottle_backend +from ..backend import Bottle, BottleSpec, get_bottle_backend +from ..backend.docker.bottle_plan import DockerBottlePlan from ..backend.docker.bottle_state import ( cleanup_state, is_preserved, @@ -51,73 +55,67 @@ def cmd_start(argv: list[str]) -> int: ) -def _launch_bottle( +# --- Public helpers shared with the dashboard (PRD 0020) ----------------- + + +def prepare_with_preflight( spec: BottleSpec, *, - dry_run: bool, - remote_control: bool, + stage_dir: Path, + render_preflight: Callable[[DockerBottlePlan], None], + prompt_yes: Callable[[], bool], + dry_run: bool = False, +) -> tuple[DockerBottlePlan | None, str]: + """Run `backend.prepare`, render the preflight summary via the + injected callable, prompt y/N via the injected callable. The CLI + binds these to stderr/stdin; the dashboard binds them to a + curses modal. + + Returns `(plan, identity)`. `plan` is None on dry-run or + operator-N, but `identity` is set as soon as `backend.prepare` + returns so callers can reap the prepare-time state dir via + `settle_state(identity)` in their finally — exactly the existing + semantics.""" + backend = get_bottle_backend() + plan = backend.prepare(spec, stage_dir=stage_dir) + identity = _identity_from_plan(plan) + + render_preflight(plan) + + if dry_run: + info("dry-run requested; not starting container.") + return None, identity + if not prompt_yes(): + info("aborted by user") + return None, identity + return plan, identity + + +def attach_claude( + bottle: Bottle, *, remote_control: bool = False, ) -> int: - """Shared launch core for `start` and `resume`. Builds the plan, - prints / dry-runs / prompts as appropriate, brings the bottle up, - attaches claude, and prints the resume hint on session end.""" - stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage.")) - identity = "" - try: - backend = get_bottle_backend() - plan = backend.prepare(spec, stage_dir=stage_dir) - identity = _identity_from_plan(plan) + """Run claude inside `bottle` as an interactive session. Blocks + until the session ends; returns the claude process's exit code. - plan.print(remote_control=remote_control) - - if dry_run: - info("dry-run requested; not starting container.") - return 0 - - sys.stderr.write("claude-bottle: launch this agent? [y/N] ") - sys.stderr.flush() - reply = read_tty_line() - if reply not in ("y", "Y", "yes", "YES"): - info("aborted by user") - return 0 - - with backend.launch(plan) as bottle: - info( - "attaching interactive claude session " - "(Ctrl-D or 'exit' to leave; container will be removed)" - ) - claude_args = ["--dangerously-skip-permissions"] - if remote_control: - claude_args.append("--remote-control") - exit_code = bottle.exec_claude(claude_args, tty=True) - info( - f"session ended (exit {exit_code}); " - f"container {bottle.name} will be removed" - ) - # While the container is still alive: always snapshot the - # transcript and — if the agent exited non-zero — mark - # the state for preservation. Capability-block already - # did both before triggering teardown from the dashboard; - # this picks up crashes / Ctrl-Cs / OOM kills the same - # way. snapshot_transcript is best-effort so the - # capability-block path's prior snapshot isn't clobbered - # when the container is already gone. - _capture_session_state(identity, exit_code) - return 0 - finally: - # PRD 0018 chunk 2: prepare now writes the bottle's bind-mount - # sources under state//. If we never reached the - # launch context (dry-run, preflight-N, prepare exception), or - # we did but nothing requested preservation, reap them along - # with everything else. _settle_state subsumes the prior - # post-launch settlement and the new pre-launch cleanup. - _settle_state(identity) - shutil.rmtree(stage_dir, ignore_errors=True) + Used as the inner step of `./cli.py start` (one-shot) and by the + dashboard (PRD 0020), which calls it from inside a `curses.endwin + → … → stdscr.refresh()` handoff so the curses surface gets out + of the terminal's way while claude has it.""" + info( + "attaching interactive claude session " + "(Ctrl-D or 'exit' to leave; container will be removed)" + ) + claude_args = ["--dangerously-skip-permissions"] + if remote_control: + claude_args.append("--remote-control") + return bottle.exec_claude(claude_args, tty=True) -def _capture_session_state(identity: str, exit_code: int) -> None: +def capture_session_state(identity: str, exit_code: int) -> None: """Inside the launch context, while the container is still alive: snapshot the transcript and mark for preservation if - claude crashed. Pure-function-ish; tests stub the helpers.""" + claude crashed. Public for the dashboard's death-handling path + (PRD 0020 open question 3).""" if not identity: return snapshot_transcript(identity) @@ -125,7 +123,11 @@ def _capture_session_state(identity: str, exit_code: int) -> None: mark_preserved(identity) -def _settle_state(identity: str) -> None: +def settle_state(identity: str) -> None: + """Post-teardown housekeeping: print the resume hint if the + state was preserved, otherwise reap the per-bottle state dir. + Public so the dashboard's explicit-stop path calls the same + settlement the CLI uses on context exit.""" if not identity: return if is_preserved(identity): @@ -140,3 +142,68 @@ def _identity_from_plan(plan: object) -> str: identity attribute; for now we duck-type to keep this layer backend-agnostic.""" return getattr(plan, "slug", "") + + +def _text_prompt_yes() -> bool: + """Default `prompt_yes` for CLI use: reads y/N from the + controlling tty via stderr prompt + tty-line read.""" + sys.stderr.write("claude-bottle: launch this agent? [y/N] ") + sys.stderr.flush() + reply = read_tty_line() + return reply in ("y", "Y", "yes", "YES") + + +def _text_render_preflight(*, remote_control: bool): + def _render(plan: DockerBottlePlan) -> None: + plan.print(remote_control=remote_control) + return _render + + +def _launch_bottle( + spec: BottleSpec, + *, + dry_run: bool, + remote_control: bool, +) -> int: + """Shared launch core for `start` and `resume`. Builds the plan, + prints / dry-runs / prompts as appropriate, brings the bottle up, + attaches claude, and prints the resume hint on session end.""" + stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage.")) + identity = "" + try: + plan, identity = prepare_with_preflight( + spec, + stage_dir=stage_dir, + render_preflight=_text_render_preflight(remote_control=remote_control), + prompt_yes=_text_prompt_yes, + dry_run=dry_run, + ) + if plan is None: + return 0 + + backend = get_bottle_backend() + with backend.launch(plan) as bottle: + exit_code = attach_claude(bottle, remote_control=remote_control) + info( + f"session ended (exit {exit_code}); " + f"container {bottle.name} will be removed" + ) + # While the container is still alive: always snapshot the + # transcript and — if the agent exited non-zero — mark + # the state for preservation. Capability-block already + # did both before triggering teardown from the dashboard; + # this picks up crashes / Ctrl-Cs / OOM kills the same + # way. snapshot_transcript is best-effort so the + # capability-block path's prior snapshot isn't clobbered + # when the container is already gone. + capture_session_state(identity, exit_code) + return 0 + finally: + # PRD 0018 chunk 2: prepare now writes the bottle's bind-mount + # sources under state//. If we never reached the + # launch context (dry-run, preflight-N, prepare exception), or + # we did but nothing requested preservation, reap them along + # with everything else. `settle_state` subsumes the prior + # post-launch settlement and the new pre-launch cleanup. + settle_state(identity) + shutil.rmtree(stage_dir, ignore_errors=True) diff --git a/tests/unit/test_cli_start_settle.py b/tests/unit/test_cli_start_settle.py index f6fad89..8ae0d05 100644 --- a/tests/unit/test_cli_start_settle.py +++ b/tests/unit/test_cli_start_settle.py @@ -45,25 +45,25 @@ class TestCaptureSessionState(_FakeHomeMixin, unittest.TestCase): self._teardown_fake_home() def test_clean_exit_snapshots_but_does_not_mark(self): - start_mod._capture_session_state("dev-abc", exit_code=0) + start_mod.capture_session_state("dev-abc", exit_code=0) self.assertEqual(["dev-abc"], self._snap_calls) self.assertFalse(bottle_state.is_preserved("dev-abc")) def test_crash_snapshots_and_marks(self): - start_mod._capture_session_state("dev-abc", exit_code=137) + start_mod.capture_session_state("dev-abc", exit_code=137) self.assertEqual(["dev-abc"], self._snap_calls) self.assertTrue(bottle_state.is_preserved("dev-abc")) def test_ctrl_c_treated_as_crash(self): # SIGINT delivers exit 130; the operator may have Ctrl-C'd # because something went wrong, so we preserve. - start_mod._capture_session_state("dev-abc", exit_code=130) + start_mod.capture_session_state("dev-abc", exit_code=130) self.assertTrue(bottle_state.is_preserved("dev-abc")) def test_empty_identity_is_noop(self): # Backends without an identity field shouldn't crash this # path (the _identity_from_plan helper falls back to ""). - start_mod._capture_session_state("", exit_code=137) + start_mod.capture_session_state("", exit_code=137) self.assertEqual([], self._snap_calls) @@ -77,16 +77,16 @@ class TestSettleState(_FakeHomeMixin, unittest.TestCase): def test_preserved_state_survives(self): bottle_state.write_per_bottle_dockerfile("dev-abc", "FROM x\n") bottle_state.mark_preserved("dev-abc") - start_mod._settle_state("dev-abc") + start_mod.settle_state("dev-abc") self.assertTrue(bottle_state.bottle_state_dir("dev-abc").is_dir()) def test_unpreserved_state_is_cleaned(self): bottle_state.write_per_bottle_dockerfile("dev-abc", "FROM x\n") - start_mod._settle_state("dev-abc") + start_mod.settle_state("dev-abc") self.assertFalse(bottle_state.bottle_state_dir("dev-abc").exists()) def test_empty_identity_is_noop(self): - start_mod._settle_state("") # should not raise + start_mod.settle_state("") # should not raise if __name__ == "__main__":