diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index d519033..a5bb084 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -209,6 +209,15 @@ class AgentProvider(ABC): the supervise sidecar is reachable. No-op when `plan.supervise_plan is None`.""" + @abstractmethod + def headless_prompt(self, prompt: str) -> list[str]: + """Return the agent CLI args that deliver `prompt` as the + initial task in a non-interactive (headless) session. + + Called only when ``--prompt`` is passed to + ``./cli.py start --headless``; the returned args are appended + after the provider's ``bypass_args`` and ``startup_args``.""" + def provision_ca(self, bottle: "Bottle", plan: "BottlePlan") -> None: """Install the egress MITM CA into the agent's trust store. diff --git a/bot_bottle/cli/start.py b/bot_bottle/cli/start.py index 0d9a388..0fcc572 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, +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`. """ @@ -16,7 +21,7 @@ import tempfile from pathlib import Path from typing import Callable -from ..agent_provider import runtime_for +from ..agent_provider import get_provider, runtime_for from ..backend import ( Bottle, BottleSpec, @@ -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,39 @@ 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, 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( + "--prompt", + default=None, + help="initial task prompt delivered to the agent (required with --headless)", + ) parser.add_argument( "name", nargs="?", @@ -61,6 +99,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 +115,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 +151,83 @@ 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 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 + + prompt = args.prompt + if not prompt: + die( + "--headless requires --prompt: " + "./cli.py start --headless --prompt 'Do the thing'" + ) + + 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, + headless_prompt_text=prompt, + ) + + +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 +495,19 @@ def _launch_bottle( *, dry_run: bool, backend_name: str | None = None, + assume_yes: bool = False, + headless_prompt_text: str = "", ) -> 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. + + `headless_prompt_text` is passed to the provider's `headless_prompt` + method and the resulting args are appended to startup_args so the + agent receives the initial task without interactive input.""" stage_dir = Path(tempfile.mkdtemp(prefix="bot-bottle-stage.")) identity = "" try: @@ -387,7 +515,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, ) @@ -397,10 +525,17 @@ def _launch_bottle( backend = get_bottle_backend(backend_name) with backend.launch(plan) as bottle: agent_provider_template = getattr(plan, "agent_provider_template", "claude") + extra_args: tuple[str, ...] = () + if headless_prompt_text: + extra_args = tuple( + get_provider(agent_provider_template).headless_prompt( + headless_prompt_text + ) + ) exit_code = attach_agent( bottle, agent_provider_template=agent_provider_template, - startup_args=plan.agent_provision.startup_args, + startup_args=plan.agent_provision.startup_args + extra_args, ) info( f"session ended (exit {exit_code}); " diff --git a/bot_bottle/contrib/claude/agent_provider.py b/bot_bottle/contrib/claude/agent_provider.py index 8999868..204357b 100644 --- a/bot_bottle/contrib/claude/agent_provider.py +++ b/bot_bottle/contrib/claude/agent_provider.py @@ -313,6 +313,9 @@ class ClaudeAgentProvider(AgentProvider): f"claude mcp add --scope user --transport http supervise {supervise_url}" ) + def headless_prompt(self, prompt: str) -> list[str]: + return ["-p", prompt] + def _exec(bottle: "Bottle", script: str, error: str) -> None: result = bottle.exec(script, user="root") diff --git a/bot_bottle/contrib/codex/agent_provider.py b/bot_bottle/contrib/codex/agent_provider.py index 812b13d..b7afbc5 100644 --- a/bot_bottle/contrib/codex/agent_provider.py +++ b/bot_bottle/contrib/codex/agent_provider.py @@ -279,6 +279,9 @@ class CodexAgentProvider(AgentProvider): f"codex mcp add supervise --url {shlex.quote(supervise_url)}" ) + def headless_prompt(self, prompt: str) -> list[str]: + return [prompt] + def _exec(bottle: "Bottle", script: str, error: str) -> None: result = bottle.exec(script, user="root") diff --git a/bot_bottle/contrib/pi/agent_provider.py b/bot_bottle/contrib/pi/agent_provider.py index 9c03b32..14ed5a1 100644 --- a/bot_bottle/contrib/pi/agent_provider.py +++ b/bot_bottle/contrib/pi/agent_provider.py @@ -315,6 +315,9 @@ class PiAgentProvider(AgentProvider): ) -> None: del plan, bottle, supervise_url + def headless_prompt(self, prompt: str) -> list[str]: + return ["-p", prompt] + def _exec(bottle: "Bottle", script: str, error: str) -> None: result = bottle.exec(script, user="root") diff --git a/tests/unit/test_cli_start_headless.py b/tests/unit/test_cli_start_headless.py new file mode 100644 index 0000000..b956452 --- /dev/null +++ b/tests/unit/test_cli_start_headless.py @@ -0,0 +1,188 @@ +"""Unit: `cli.py start --headless` non-interactive launch path. + +Headless is the keystone for orchestrators, 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", "--prompt", "Do it"] + ) + 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", "--prompt", "Do it"] + ) + self.assertTrue(self._launch_mock.call_args[1]["assume_yes"]) + + # -- prompt -------------------------------------------------------- + + def test_headless_without_prompt_dies(self): + with self.assertRaises(Die): + start_mod.cmd_start(["--headless", "researcher", "--bottle", "claude"]) + self._launch_mock.assert_not_called() + + def test_headless_prompt_forwarded_to_launch(self): + start_mod.cmd_start( + ["--headless", "researcher", "--bottle", "claude", + "--prompt", "Implement issue #42"] + ) + self.assertEqual( + "Implement issue #42", + self._launch_mock.call_args[1]["headless_prompt_text"], + ) + + # -- bottle resolution --------------------------------------------- + + def test_explicit_bottles_forwarded_in_order(self): + start_mod.cmd_start( + ["--headless", "researcher", "--bottle", "dev", "--bottle", "claude", + "--prompt", "Do it"] + ) + self.assertEqual(("dev", "claude"), self._spec().bottle_names) + + def test_omitted_bottle_falls_back_to_agent_default(self): + start_mod.cmd_start(["--headless", "implementer", "--prompt", "Do it"]) + 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", "--prompt", "Do it"] + ) + 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", "--prompt", "Do it"] + ) + self._launch_mock.assert_not_called() + + # -- label / color ------------------------------------------------- + + def test_label_defaults_to_agent_name(self): + start_mod.cmd_start( + ["--headless", "researcher", "--bottle", "claude", "--prompt", "Do it"] + ) + 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", "--prompt", "Do it"] + ) + 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", "--prompt", "Do it"] + ) + 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", + "--prompt", "Do it"] + ) + self.assertEqual("docker", self._launch_mock.call_args[1]["backend_name"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_contrib_claude_provider.py b/tests/unit/test_contrib_claude_provider.py index 7d9c389..91bf73e 100644 --- a/tests/unit/test_contrib_claude_provider.py +++ b/tests/unit/test_contrib_claude_provider.py @@ -343,5 +343,14 @@ class TestClaudeSuperviseMcp(unittest.TestCase): ) +class TestClaudeHeadlessPrompt(unittest.TestCase): + def test_returns_p_flag_and_prompt(self): + self.assertEqual(["-p", "Do the task"], ClaudeAgentProvider().headless_prompt("Do the task")) + + def test_preserves_prompt_text_verbatim(self): + text = "Fix issue #42: the widget breaks on empty input" + self.assertEqual(["-p", text], ClaudeAgentProvider().headless_prompt(text)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_contrib_codex_provider.py b/tests/unit/test_contrib_codex_provider.py index 36fe3b6..3d3cf1c 100644 --- a/tests/unit/test_contrib_codex_provider.py +++ b/tests/unit/test_contrib_codex_provider.py @@ -314,5 +314,14 @@ class TestCodexSuperviseMcp(unittest.TestCase): ) +class TestCodexHeadlessPrompt(unittest.TestCase): + def test_returns_prompt_as_positional_arg(self): + self.assertEqual(["Do the task"], CodexAgentProvider().headless_prompt("Do the task")) + + def test_preserves_prompt_text_verbatim(self): + text = "Fix issue #42: the widget breaks on empty input" + self.assertEqual([text], CodexAgentProvider().headless_prompt(text)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_contrib_pi_provider.py b/tests/unit/test_contrib_pi_provider.py index 0abe959..5f3c0a1 100644 --- a/tests/unit/test_contrib_pi_provider.py +++ b/tests/unit/test_contrib_pi_provider.py @@ -223,5 +223,14 @@ class TestPiDockerfile(unittest.TestCase): self.assertIn("chmod 1777 /tmp /var/tmp", dockerfile) +class TestPiHeadlessPrompt(unittest.TestCase): + def test_returns_p_flag_and_prompt(self): + self.assertEqual(["-p", "Do the task"], PiAgentProvider().headless_prompt("Do the task")) + + def test_preserves_prompt_text_verbatim(self): + text = "Fix issue #42: the widget breaks on empty input" + self.assertEqual(["-p", text], PiAgentProvider().headless_prompt(text)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_docker_provision_git_user.py b/tests/unit/test_docker_provision_git_user.py index ac858de..081bfce 100644 --- a/tests/unit/test_docker_provision_git_user.py +++ b/tests/unit/test_docker_provision_git_user.py @@ -38,6 +38,7 @@ class _Provider(AgentProvider): def provision_prompt(self, plan, bottle): ... # type: ignore[override] def provision(self, plan, bottle): ... # type: ignore[override] def provision_supervise_mcp(self, plan, bottle, supervise_url): ... # type: ignore[override] + def headless_prompt(self, prompt): return [] # type: ignore[override] _PROVIDER = _Provider() diff --git a/tests/unit/test_smolmachines_provision.py b/tests/unit/test_smolmachines_provision.py index 8c26919..d6b28c5 100644 --- a/tests/unit/test_smolmachines_provision.py +++ b/tests/unit/test_smolmachines_provision.py @@ -49,6 +49,7 @@ class _Provider(AgentProvider): def provision_prompt(self, plan, bottle): ... # type: ignore[override] def provision(self, plan, bottle): ... # type: ignore[override] def provision_supervise_mcp(self, plan, bottle, supervise_url): ... # type: ignore[override] + def headless_prompt(self, prompt): return [] # type: ignore[override] _PROVIDER = _Provider()