diff --git a/bot_bottle/cli/start.py b/bot_bottle/cli/start.py index 0d9a388..2966035 100644 --- a/bot_bottle/cli/start.py +++ b/bot_bottle/cli/start.py @@ -2,6 +2,11 @@ interactive claude-code session. The container is torn down when the session ends. +`--headless` selects a non-interactive launch (agent/bottles/label from +flags, no TUI selectors, no y/N prompt) for orchestrators (e.g. Paseo), +CI, and webhook dispatch. The agent still execs on the inherited +stdio/PTY, so an orchestrator that allocates the PTY drives the session. + The launch core is shared with `cli.py resume ` through the private orchestrator `_launch_bottle`. """ @@ -31,7 +36,7 @@ from ..bottle_state import ( is_preserved, mark_preserved, ) -from ..log import info +from ..log import info, die from ..manifest import Manifest, ManifestIndex from ._common import PROG, USER_CWD, read_tty_line from . import tui @@ -50,6 +55,34 @@ def cmd_start(argv: list[str]) -> int: "or host auto-selection). Overrides the env var when set." ), ) + parser.add_argument( + "--headless", + action="store_true", + help=( + "non-interactive launch: take agent/bottles/label from flags, " + "skip all prompts. For orchestrators (e.g. Paseo), CI, and webhooks." + ), + ) + parser.add_argument( + "--bottle", + action="append", + default=None, + metavar="NAME", + help=( + "bottle to compose, repeatable (order = merge order). In " + "--headless, defaults to the agent's own bottle when omitted." + ), + ) + parser.add_argument( + "--label", + default=None, + help="bottle label / terminal title (--headless default: agent name)", + ) + parser.add_argument( + "--color", + default=None, + help="bottle color, one of the 16 ANSI color names (--headless default: none)", + ) parser.add_argument( "name", nargs="?", @@ -61,6 +94,12 @@ def cmd_start(argv: list[str]) -> int: dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1" manifest = ManifestIndex.resolve(USER_CWD) + backend_name: str | None = args.backend + + if args.headless: + return _start_headless( + manifest, args, dry_run=dry_run, backend_name=backend_name + ) agent_name: str | None = args.name if agent_name is None: @@ -71,8 +110,6 @@ def cmd_start(argv: list[str]) -> int: if agent_name is None: return 0 - backend_name: str | None = args.backend - # Bottle multiselect: always show after agent selection so operators # can compose bottles at launch time without editing agent manifests. available_bottles = manifest.all_bottle_names @@ -109,6 +146,72 @@ def cmd_start(argv: list[str]) -> int: ) +# --- Headless launch ----------------------------------------------------- + + +def _start_headless( + manifest: ManifestIndex, + args: argparse.Namespace, + *, + dry_run: bool, + backend_name: str | None, +) -> int: + """Non-interactive launch path for orchestrators / CI / webhooks. + + Resolves agent, bottles, label, and color from flags + manifest + defaults instead of the TUI selectors, and auto-confirms the + preflight. Otherwise runs the same launch core as the interactive + path, so the agent still execs on the inherited stdio/PTY — an + orchestrator like Paseo allocates that PTY and relays it to its + desktop/mobile clients.""" + agent_name = args.name + if not agent_name: + die("--headless requires an agent name: ./cli.py start --headless") + manifest.require_agent(agent_name) # raises ManifestError if unknown + + if args.bottle: + bottle_names: tuple[str, ...] = tuple(args.bottle) + else: + default_bottle = _peek_agent_bottle(manifest, agent_name) + if not default_bottle: + die( + f"--headless: agent '{agent_name}' has no default bottle; " + f"pass one or more --bottle NAME" + ) + bottle_names = (default_bottle,) + + label = _uniquify_label_headless(args.label or agent_name) + + spec = BottleSpec( + manifest=manifest, + agent_name=agent_name, + copy_cwd=args.cwd, + user_cwd=USER_CWD, + label=label, + color=args.color or "", + bottle_names=bottle_names, + ) + return _launch_bottle( + spec, dry_run=dry_run, backend_name=backend_name, assume_yes=True + ) + + +def _uniquify_label_headless(label: str) -> str: + """Non-interactive analog of `_resolve_unique_label`: if the label's + slug collides with a running bottle, append -2, -3, … until free, + logging the chosen label. Orchestrators fire-and-forget many bottles, + so silently picking a free name beats erroring on every collision.""" + active_slugs = {a.slug for a in enumerate_active_agents()} + if docker_mod.slugify(label) not in active_slugs: + return label + n = 2 + while docker_mod.slugify(f"{label}-{n}") in active_slugs: + n += 1 + chosen = f"{label}-{n}" + info(f"label '{label}' already in use; using '{chosen}'") + return chosen + + # --- Launch helpers ------------------------------------------------------ @@ -376,10 +479,14 @@ def _launch_bottle( *, dry_run: bool, backend_name: str | None = None, + assume_yes: 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.""" + attaches claude, and prints the resume hint on session end. + + `assume_yes` skips the interactive y/N confirmation (headless / + orchestrator launches), where there is no human at the prompt.""" stage_dir = Path(tempfile.mkdtemp(prefix="bot-bottle-stage.")) identity = "" try: @@ -387,7 +494,7 @@ def _launch_bottle( spec, stage_dir=stage_dir, render_preflight=_text_render_preflight(), - prompt_yes=_text_prompt_yes, + prompt_yes=(lambda: True) if assume_yes else _text_prompt_yes, dry_run=dry_run, backend_name=backend_name, ) diff --git a/tests/unit/test_cli_start_headless.py b/tests/unit/test_cli_start_headless.py new file mode 100644 index 0000000..f0ec60a --- /dev/null +++ b/tests/unit/test_cli_start_headless.py @@ -0,0 +1,157 @@ +"""Unit: `cli.py start --headless` non-interactive launch path. + +Headless is the keystone for orchestrators (Paseo), CI, and webhook +dispatch: agent/bottles/label come from flags + manifest defaults, no +TUI selectors fire, and the preflight y/N is auto-confirmed +(`assume_yes=True`). All actual launch work is stubbed so no container +is created. +""" + +from __future__ import annotations + +import os +import unittest +from unittest.mock import MagicMock, patch + +import bot_bottle.cli.start as start_mod +import bot_bottle.cli.tui as tui_mod +from bot_bottle.backend import ActiveAgent +from bot_bottle.log import Die +from bot_bottle.manifest import ManifestError + + +def _make_manifest( + agent_names: list[str], + bottle_names: list[str] | None = None, + agent_bottle: str = "", +): + manifest = MagicMock() + manifest.agents = {name: MagicMock(bottle=agent_bottle) for name in agent_names} + manifest.all_agent_names = sorted(agent_names) + manifest.all_bottle_names = sorted(bottle_names or []) + manifest.home_md = None # eager mode so _peek_agent_bottle uses agents dict + manifest.require_agent = MagicMock(return_value=None) + return manifest + + +def _active_agent(slug: str) -> ActiveAgent: + return ActiveAgent( + backend_name="docker", + slug=slug, + agent_name="demo", + started_at="2026-01-01T00:00:00+00:00", + services=(), + ) + + +class TestCmdStartHeadless(unittest.TestCase): + """Drive `cmd_start --headless` with launch + TUI stubbed out.""" + + def setUp(self): + self._manifest = _make_manifest( + ["researcher", "implementer"], ["claude", "dev"], agent_bottle="claude" + ) + patch( + "bot_bottle.cli.start.ManifestIndex.resolve", + return_value=self._manifest, + ).start() + self._launch_mock = patch( + "bot_bottle.cli.start._launch_bottle", return_value=0 + ).start() + # No bottles running by default → no label collision. + patch( + "bot_bottle.cli.start.enumerate_active_agents", return_value=[] + ).start() + # If any TUI picker fires in headless mode, that's a bug. + self._agent_picker = patch.object(tui_mod, "filter_select").start() + self._bottle_picker = patch.object(tui_mod, "filter_multiselect").start() + self._modal = patch.object(tui_mod, "name_color_modal").start() + patch.dict(os.environ, {}, clear=False).start() + os.environ.pop("BOT_BOTTLE_BACKEND", None) + self.addCleanup(patch.stopall) + + def _spec(self): + self._launch_mock.assert_called_once() + return self._launch_mock.call_args[0][0] + + # -- no TUI in headless -------------------------------------------- + + def test_headless_fires_no_pickers(self): + rc = start_mod.cmd_start(["--headless", "researcher", "--bottle", "claude"]) + self.assertEqual(0, rc) + self._agent_picker.assert_not_called() + self._bottle_picker.assert_not_called() + self._modal.assert_not_called() + + def test_headless_assume_yes_forwarded(self): + start_mod.cmd_start(["--headless", "researcher", "--bottle", "claude"]) + self.assertTrue(self._launch_mock.call_args[1]["assume_yes"]) + + # -- bottle resolution --------------------------------------------- + + def test_explicit_bottles_forwarded_in_order(self): + start_mod.cmd_start( + ["--headless", "researcher", "--bottle", "dev", "--bottle", "claude"] + ) + self.assertEqual(("dev", "claude"), self._spec().bottle_names) + + def test_omitted_bottle_falls_back_to_agent_default(self): + start_mod.cmd_start(["--headless", "implementer"]) + self.assertEqual(("claude",), self._spec().bottle_names) + + def test_no_bottle_and_no_default_dies(self): + manifest = _make_manifest(["researcher"], ["claude"], agent_bottle="") + with patch( + "bot_bottle.cli.start.ManifestIndex.resolve", return_value=manifest + ): + with self.assertRaises(Die): + start_mod.cmd_start(["--headless", "researcher"]) + self._launch_mock.assert_not_called() + + # -- agent resolution ---------------------------------------------- + + def test_missing_agent_name_dies(self): + with self.assertRaises(Die): + start_mod.cmd_start(["--headless"]) + self._launch_mock.assert_not_called() + + def test_unknown_agent_raises_manifest_error(self): + self._manifest.require_agent.side_effect = ManifestError("agent 'x' not defined") + with self.assertRaises(ManifestError): + start_mod.cmd_start(["--headless", "x", "--bottle", "claude"]) + self._launch_mock.assert_not_called() + + # -- label / color ------------------------------------------------- + + def test_label_defaults_to_agent_name(self): + start_mod.cmd_start(["--headless", "researcher", "--bottle", "claude"]) + self.assertEqual("researcher", self._spec().label) + + def test_explicit_label_and_color_forwarded(self): + start_mod.cmd_start( + ["--headless", "researcher", "--bottle", "claude", + "--label", "nightly", "--color", "green"] + ) + spec = self._spec() + self.assertEqual("nightly", spec.label) + self.assertEqual("green", spec.color) + + def test_label_collision_uniquifies(self): + with patch( + "bot_bottle.cli.start.enumerate_active_agents", + return_value=[_active_agent("researcher")], + ): + start_mod.cmd_start(["--headless", "researcher", "--bottle", "claude"]) + self.assertEqual("researcher-2", self._spec().label) + + # -- backend wiring ------------------------------------------------ + + def test_backend_flag_forwarded(self): + start_mod.cmd_start( + ["--headless", "--backend=docker", "researcher", "--bottle", "claude"] + ) + self.assertEqual("docker", self._launch_mock.call_args[1]["backend_name"]) + + +if __name__ == "__main__": + unittest.main()