refactor(cli): group subcommand handlers under cli/commands/
test / integration-docker (pull_request) Successful in 18s
tracker-policy-pr / check-pr (pull_request) Successful in 17s
test / unit (pull_request) Failing after 41s
lint / lint (push) Failing after 57s
test / integration-firecracker (pull_request) Successful in 3m26s
test / coverage (pull_request) Has been skipped
test / publish-infra (pull_request) Has been skipped

Move the eleven per-command modules (backend, cleanup, commit, edit,
info, init, list, login, resume, start, supervise) into a new
bot_bottle/cli/commands/ package, leaving the dispatcher (__init__),
entrypoint (__main__), and shared helpers (_common, tui) at the cli
root. Makes the command surface obvious at a glance and separates
handlers from the plumbing that registers them.

Updated the dispatcher's COMMANDS imports to .commands.*, bumped the
moved files' relative-import depths (.._common, .. import tui, sibling
.start unchanged), and repointed test references to
bot_bottle.cli.commands.*. Full unit suite green (2243); CLI dispatch
verified via `python -m bot_bottle.cli --help`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 17:25:25 -04:00
parent 82d02d2c4b
commit 50a67c04bd
23 changed files with 130 additions and 124 deletions
+11 -11
View File
@@ -12,17 +12,17 @@ from ..log import Die, die, error
from ..manifest import ManifestError from ..manifest import ManifestError
from ..orchestrator.store.store_manager import StoreManager from ..orchestrator.store.store_manager import StoreManager
from ._common import PROG from ._common import PROG
from . import list as _list_mod from .commands import list as _list_mod
from .backend import cmd_backend from .commands.backend import cmd_backend
from .cleanup import cmd_cleanup from .commands.cleanup import cmd_cleanup
from .commit import cmd_commit from .commands.commit import cmd_commit
from .edit import cmd_edit from .commands.edit import cmd_edit
from .info import cmd_info from .commands.info import cmd_info
from .init import cmd_init from .commands.init import cmd_init
from .login import cmd_login from .commands.login import cmd_login
from .resume import cmd_resume from .commands.resume import cmd_resume
from .start import cmd_start from .commands.start import cmd_start
from .supervise import cmd_supervise from .commands.supervise import cmd_supervise
cmd_list = _list_mod.cmd_list cmd_list = _list_mod.cmd_list
+6
View File
@@ -0,0 +1,6 @@
"""CLI subcommand handlers — one module per `bot-bottle <command>`.
Each module exposes a `cmd_<name>(argv)` handler that the dispatcher
(`bot_bottle.cli`) registers in its COMMANDS table. Shared CLI helpers
(`_common`, `tui`) stay one level up in the `cli` package.
"""
@@ -15,8 +15,8 @@ from __future__ import annotations
import argparse import argparse
from ..backend import get_bottle_backend, known_backend_names from ...backend import get_bottle_backend, known_backend_names
from ._common import PROG from .._common import PROG
def cmd_backend(args: list[str]) -> int: def cmd_backend(args: list[str]) -> int:
@@ -21,9 +21,9 @@ from __future__ import annotations
import sys import sys
from ..backend import get_bottle_backend, has_backend, known_backend_names from ...backend import get_bottle_backend, has_backend, known_backend_names
from ..log import info from ...log import info
from ._common import read_tty_line from .._common import read_tty_line
def cmd_cleanup(_argv: list[str]) -> int: def cmd_cleanup(_argv: list[str]) -> int:
@@ -12,12 +12,12 @@ from __future__ import annotations
import argparse import argparse
from ..backend import enumerate_active_agents from ...backend import enumerate_active_agents
from ..backend.freeze import CommitCancelled, get_freezer from ...backend.freeze import CommitCancelled, get_freezer
from ..bottle_state import read_metadata from ...bottle_state import read_metadata
from ..log import die from ...log import die
from ._common import PROG from .._common import PROG
from . import tui from .. import tui
def cmd_commit(argv: list[str]) -> int: def cmd_commit(argv: list[str]) -> int:
@@ -7,8 +7,8 @@ import json
import os import os
from pathlib import Path from pathlib import Path
from ..log import die from ...log import die
from ._common import PROG, USER_CWD from .._common import PROG, USER_CWD
def cmd_edit(argv: list[str]) -> int: def cmd_edit(argv: list[str]) -> int:
@@ -4,9 +4,9 @@ from __future__ import annotations
import argparse import argparse
from ..log import info from ...log import info
from ..manifest import ManifestIndex from ...manifest import ManifestIndex
from ._common import PROG, USER_CWD from .._common import PROG, USER_CWD
def cmd_info(argv: list[str]) -> int: def cmd_info(argv: list[str]) -> int:
@@ -10,8 +10,8 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from ..log import die, info, warn from ...log import die, info, warn
from ._common import PROG, USER_CWD, read_tty_line from .._common import PROG, USER_CWD, read_tty_line
def cmd_init(argv: list[str]) -> int: def cmd_init(argv: list[str]) -> int:
@@ -6,9 +6,9 @@ import argparse
import os import os
import sys import sys
from ..backend import enumerate_active_agents from ...backend import enumerate_active_agents
from ..manifest import ManifestIndex from ...manifest import ManifestIndex
from ._common import PROG, USER_CWD from .._common import PROG, USER_CWD
_ANSI_COLOR_CODES: dict[str, str] = { _ANSI_COLOR_CODES: dict[str, str] = {
"red": "\033[91m", "red": "\033[91m",
@@ -25,7 +25,7 @@ import urllib.request
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from ..paths import bot_bottle_root from ...paths import bot_bottle_root
_CONSOLE_URL_ENV = "BB_CONSOLE_URL" _CONSOLE_URL_ENV = "BB_CONSOLE_URL"
_POLL_SLEEP = 2 # seconds between polls; matches console's poll_interval default _POLL_SLEEP = 2 # seconds between polls; matches console's poll_interval default
@@ -16,11 +16,11 @@ from __future__ import annotations
import argparse import argparse
from ..backend import BottleSpec from ...backend import BottleSpec
from ..bottle_state import read_metadata from ...bottle_state import read_metadata
from ..log import die from ...log import die
from ..manifest import ManifestIndex from ...manifest import ManifestIndex
from ._common import PROG, USER_CWD from .._common import PROG, USER_CWD
from .start import _launch_bottle from .start import _launch_bottle
@@ -22,25 +22,25 @@ import tempfile
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
from ..agent_provider import get_provider, runtime_for from ...agent_provider import get_provider, runtime_for
from ..backend import ( from ...backend import (
Bottle, Bottle,
BottleSpec, BottleSpec,
enumerate_active_agents, enumerate_active_agents,
get_bottle_backend, get_bottle_backend,
) )
from ..backend.docker import util as docker_mod from ...backend.docker import util as docker_mod
from ..backend.docker.bottle_plan import DockerBottlePlan from ...backend.docker.bottle_plan import DockerBottlePlan
from ..bottle_state import ( from ...bottle_state import (
cleanup_state, cleanup_state,
is_preserved, is_preserved,
mark_preserved, mark_preserved,
) )
from ..image_cache import StaleImageError from ...image_cache import StaleImageError
from ..log import info, die from ...log import info, die
from ..manifest import Manifest, ManifestIndex from ...manifest import Manifest, ManifestIndex
from ._common import PROG, USER_CWD, read_tty_line from .._common import PROG, USER_CWD, read_tty_line
from . import tui from .. import tui
def cmd_start(argv: list[str]) -> int: def cmd_start(argv: list[str]) -> int:
@@ -380,8 +380,8 @@ def _peek_agent_bottle(manifest: ManifestIndex, agent_name: str) -> str:
return manifest.agents[agent_name].bottle return manifest.agents[agent_name].bottle
return "" return ""
from ..manifest.loader import scan_agent_names from ...manifest.loader import scan_agent_names
from ..yaml_subset import YamlSubsetError, parse_frontmatter from ...yaml_subset import YamlSubsetError, parse_frontmatter
home_agents = scan_agent_names(manifest.home_md / "agents") home_agents = scan_agent_names(manifest.home_md / "agents")
cwd_agents: dict[str, Path] = {} cwd_agents: dict[str, Path] = {}
@@ -449,7 +449,7 @@ def _bottle_lineage(manifest: ManifestIndex) -> dict[str, str]:
if not bottles_dir.is_dir(): if not bottles_dir.is_dir():
return {} return {}
from ..yaml_subset import YamlSubsetError, parse_frontmatter from ...yaml_subset import YamlSubsetError, parse_frontmatter
extends_of: dict[str, str] = {} extends_of: dict[str, str] = {}
for path in bottles_dir.glob("*.md"): for path in bottles_dir.glob("*.md"):
@@ -19,22 +19,22 @@ from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from ..paths import bot_bottle_root from ...paths import bot_bottle_root
from ..log import Die, error, info from ...log import Die, error, info
from ..orchestrator.client import ( from ...orchestrator.client import (
OrchestratorClient, OrchestratorClient,
OrchestratorClientError, OrchestratorClientError,
discover_orchestrator_url, discover_orchestrator_url,
) )
from ..supervisor.types import ( from ...supervisor.types import (
Proposal, Proposal,
TOOL_EGRESS_ALLOW, TOOL_EGRESS_ALLOW,
TOOL_EGRESS_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_GITLEAKS_ALLOW, TOOL_GITLEAKS_ALLOW,
TOOL_EGRESS_TOKEN_ALLOW, TOOL_EGRESS_TOKEN_ALLOW,
) )
from ._common import PROG from .._common import PROG
_REFRESH_INTERVAL_MS = 1000 _REFRESH_INTERVAL_MS = 1000
@@ -81,7 +81,7 @@ def _resolve_orchestrator_url() -> str:
try: try:
return discover_orchestrator_url() return discover_orchestrator_url()
except OrchestratorClientError: except OrchestratorClientError:
from ..backend import get_bottle_backend from ...backend import get_bottle_backend
backend = get_bottle_backend() backend = get_bottle_backend()
info(f"no orchestrator control plane running; starting one ({backend.name})…") info(f"no orchestrator control plane running; starting one ({backend.name})…")
return backend.ensure_orchestrator() return backend.ensure_orchestrator()
+1 -1
View File
@@ -12,7 +12,7 @@ import subprocess
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from bot_bottle.cli import backend as cmd from bot_bottle.cli.commands import backend as cmd
from bot_bottle.backend.docker import setup as docker_setup from bot_bottle.backend.docker import setup as docker_setup
+1 -1
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import unittest import unittest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from bot_bottle.cli import cleanup as cmd from bot_bottle.cli.commands import cleanup as cmd
def _make_backend(empty: bool = True): def _make_backend(empty: bool = True):
+10 -10
View File
@@ -7,7 +7,7 @@ import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from bot_bottle.cli.commit import cmd_commit from bot_bottle.cli.commands.commit import cmd_commit
from tests.unit import use_bottle_root from tests.unit import use_bottle_root
from bot_bottle import bottle_state from bot_bottle import bottle_state
from bot_bottle.backend.freeze import CommitCancelled from bot_bottle.backend.freeze import CommitCancelled
@@ -42,7 +42,7 @@ class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
slug = "dev-abc12" slug = "dev-abc12"
self._write_meta(slug, "docker") self._write_meta(slug, "docker")
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf: with patch("bot_bottle.cli.commands.commit.get_freezer") as mock_gf:
mock_freezer = MagicMock() mock_freezer = MagicMock()
mock_gf.return_value = mock_freezer mock_gf.return_value = mock_freezer
rc = cmd_commit([slug]) rc = cmd_commit([slug])
@@ -56,7 +56,7 @@ class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
slug = "dev-abc12" slug = "dev-abc12"
self._write_meta(slug, "") self._write_meta(slug, "")
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf: with patch("bot_bottle.cli.commands.commit.get_freezer") as mock_gf:
mock_freezer = MagicMock() mock_freezer = MagicMock()
mock_gf.return_value = mock_freezer mock_gf.return_value = mock_freezer
rc = cmd_commit([slug]) rc = cmd_commit([slug])
@@ -68,7 +68,7 @@ class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
slug = "dev-abc12" slug = "dev-abc12"
self._write_meta(slug, "macos-container") self._write_meta(slug, "macos-container")
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf: with patch("bot_bottle.cli.commands.commit.get_freezer") as mock_gf:
mock_freezer = MagicMock() mock_freezer = MagicMock()
mock_gf.return_value = mock_freezer mock_gf.return_value = mock_freezer
rc = cmd_commit([slug]) rc = cmd_commit([slug])
@@ -81,7 +81,7 @@ class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
slug = "dev-abc12" slug = "dev-abc12"
self._write_meta(slug, "firecracker") self._write_meta(slug, "firecracker")
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf: with patch("bot_bottle.cli.commands.commit.get_freezer") as mock_gf:
mock_freezer = MagicMock() mock_freezer = MagicMock()
mock_gf.return_value = mock_freezer mock_gf.return_value = mock_freezer
rc = cmd_commit([slug]) rc = cmd_commit([slug])
@@ -93,7 +93,7 @@ class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
slug = "dev-abc12" slug = "dev-abc12"
self._write_meta(slug, "macos-container") self._write_meta(slug, "macos-container")
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf: with patch("bot_bottle.cli.commands.commit.get_freezer") as mock_gf:
mock_freezer = MagicMock() mock_freezer = MagicMock()
mock_freezer.commit_slug.side_effect = CommitCancelled mock_freezer.commit_slug.side_effect = CommitCancelled
mock_gf.return_value = mock_freezer mock_gf.return_value = mock_freezer
@@ -111,9 +111,9 @@ class TestCmdCommitNoActiveBottles(_FakeHomeMixin, unittest.TestCase):
def test_dies_when_no_active_bottles_and_no_slug(self): def test_dies_when_no_active_bottles_and_no_slug(self):
with patch( with patch(
"bot_bottle.cli.commit.enumerate_active_agents", return_value=[], "bot_bottle.cli.commands.commit.enumerate_active_agents", return_value=[],
), patch( ), patch(
"bot_bottle.cli.commit.die", side_effect=SystemExit("die"), "bot_bottle.cli.commands.commit.die", side_effect=SystemExit("die"),
) as mock_die: ) as mock_die:
with self.assertRaises(SystemExit): with self.assertRaises(SystemExit):
cmd_commit([]) cmd_commit([])
@@ -124,9 +124,9 @@ class TestCmdCommitNoActiveBottles(_FakeHomeMixin, unittest.TestCase):
active = MagicMock() active = MagicMock()
active.slug = "dev-abc12" active.slug = "dev-abc12"
with patch( with patch(
"bot_bottle.cli.commit.enumerate_active_agents", return_value=[active], "bot_bottle.cli.commands.commit.enumerate_active_agents", return_value=[active],
), patch( ), patch(
"bot_bottle.cli.commit.tui.filter_select", return_value=None, "bot_bottle.cli.commands.commit.tui.filter_select", return_value=None,
): ):
rc = cmd_commit([]) rc = cmd_commit([])
+22 -22
View File
@@ -14,27 +14,27 @@ from unittest.mock import MagicMock, patch
class TestFlagParsing(unittest.TestCase): class TestFlagParsing(unittest.TestCase):
def test_console_url_flag(self) -> None: def test_console_url_flag(self) -> None:
from bot_bottle.cli.login import _flag from bot_bottle.cli.commands.login import _flag
self.assertEqual(_flag(["--console-url", "http://x"], "--console-url"), "http://x") self.assertEqual(_flag(["--console-url", "http://x"], "--console-url"), "http://x")
def test_console_url_equals_form(self) -> None: def test_console_url_equals_form(self) -> None:
from bot_bottle.cli.login import _flag from bot_bottle.cli.commands.login import _flag
self.assertEqual( self.assertEqual(
_flag(["--console-url=http://x"], "--console-url"), "http://x" _flag(["--console-url=http://x"], "--console-url"), "http://x"
) )
def test_label_flag(self) -> None: def test_label_flag(self) -> None:
from bot_bottle.cli.login import _flag from bot_bottle.cli.commands.login import _flag
self.assertEqual(_flag(["--label", "my-mac"], "--label"), "my-mac") self.assertEqual(_flag(["--label", "my-mac"], "--label"), "my-mac")
def test_missing_flag_returns_none(self) -> None: def test_missing_flag_returns_none(self) -> None:
from bot_bottle.cli.login import _flag from bot_bottle.cli.commands.login import _flag
self.assertIsNone(_flag([], "--console-url")) self.assertIsNone(_flag([], "--console-url"))
class TestHttpHelpers(unittest.TestCase): class TestHttpHelpers(unittest.TestCase):
def test_post_sends_json_and_decodes_response(self) -> None: def test_post_sends_json_and_decodes_response(self) -> None:
from bot_bottle.cli.login import _post from bot_bottle.cli.commands.login import _post
response = MagicMock() response = MagicMock()
response.__enter__.return_value.read.return_value = b'{"ok": true}' response.__enter__.return_value.read.return_value = b'{"ok": true}'
@@ -48,7 +48,7 @@ class TestHttpHelpers(unittest.TestCase):
self.assertEqual(request.get_header("Content-type"), "application/json") self.assertEqual(request.get_header("Content-type"), "application/json")
def test_get_decodes_success_response(self) -> None: def test_get_decodes_success_response(self) -> None:
from bot_bottle.cli.login import _get from bot_bottle.cli.commands.login import _get
response = MagicMock() response = MagicMock()
response.__enter__.return_value.status = 200 response.__enter__.return_value.status = 200
@@ -59,7 +59,7 @@ class TestHttpHelpers(unittest.TestCase):
) )
def test_get_returns_http_error_status(self) -> None: def test_get_returns_http_error_status(self) -> None:
from bot_bottle.cli.login import _get from bot_bottle.cli.commands.login import _get
error = urllib.error.HTTPError( error = urllib.error.HTTPError(
"http://console/status", 410, "gone", Message(), None "http://console/status", 410, "gone", Message(), None
@@ -70,7 +70,7 @@ class TestHttpHelpers(unittest.TestCase):
class TestSaveCredentials(unittest.TestCase): class TestSaveCredentials(unittest.TestCase):
def test_writes_json_and_sets_perms(self) -> None: def test_writes_json_and_sets_perms(self) -> None:
from bot_bottle.cli.login import _save_credentials from bot_bottle.cli.commands.login import _save_credentials
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}): with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}):
@@ -85,7 +85,7 @@ class TestSaveCredentials(unittest.TestCase):
def test_temp_file_is_private_before_replace(self) -> None: def test_temp_file_is_private_before_replace(self) -> None:
"""Temp file must be 0600 at the moment os.replace is called.""" """Temp file must be 0600 at the moment os.replace is called."""
from bot_bottle.cli.login import _save_credentials from bot_bottle.cli.commands.login import _save_credentials
from pathlib import Path as _Path from pathlib import Path as _Path
tmp_perms_at_replace: list[int] = [] tmp_perms_at_replace: list[int] = []
@@ -107,7 +107,7 @@ class TestSaveCredentials(unittest.TestCase):
def test_cleanup_on_write_failure(self) -> None: def test_cleanup_on_write_failure(self) -> None:
"""Temp file is removed and no credentials remain if replace fails.""" """Temp file is removed and no credentials remain if replace fails."""
from bot_bottle.cli.login import _save_credentials from bot_bottle.cli.commands.login import _save_credentials
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}): with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}):
@@ -120,12 +120,12 @@ class TestSaveCredentials(unittest.TestCase):
class TestCmdLoginMissingUrl(unittest.TestCase): class TestCmdLoginMissingUrl(unittest.TestCase):
def test_help_returns_0(self) -> None: def test_help_returns_0(self) -> None:
from bot_bottle.cli.login import cmd_login from bot_bottle.cli.commands.login import cmd_login
self.assertEqual(cmd_login(["--help"]), 0) self.assertEqual(cmd_login(["--help"]), 0)
def test_returns_1_without_url(self) -> None: def test_returns_1_without_url(self) -> None:
from bot_bottle.cli.login import cmd_login from bot_bottle.cli.commands.login import cmd_login
with patch.dict(os.environ, {}, clear=True): with patch.dict(os.environ, {}, clear=True):
os.environ.pop("BB_CONSOLE_URL", None) os.environ.pop("BB_CONSOLE_URL", None)
result = cmd_login([]) result = cmd_login([])
@@ -133,7 +133,7 @@ class TestCmdLoginMissingUrl(unittest.TestCase):
def test_reads_env_var(self) -> None: def test_reads_env_var(self) -> None:
"""Exits 1 (network error) not because of missing URL when env var is set.""" """Exits 1 (network error) not because of missing URL when env var is set."""
from bot_bottle.cli.login import cmd_login from bot_bottle.cli.commands.login import cmd_login
def _fail_post(_url: str, _payload: dict[str, Any]) -> dict[str, Any]: def _fail_post(_url: str, _payload: dict[str, Any]) -> dict[str, Any]:
raise OSError("connection refused") raise OSError("connection refused")
@@ -146,7 +146,7 @@ class TestCmdLoginMissingUrl(unittest.TestCase):
"BOT_BOTTLE_ROOT": tmp, "BOT_BOTTLE_ROOT": tmp,
}, },
): ):
with patch("bot_bottle.cli.login._post", side_effect=_fail_post): with patch("bot_bottle.cli.commands.login._post", side_effect=_fail_post):
result = cmd_login([]) result = cmd_login([])
self.assertEqual(result, 1) self.assertEqual(result, 1)
@@ -155,7 +155,7 @@ class TestCmdLoginFlow(unittest.TestCase):
def _run_with_mocks( def _run_with_mocks(
self, poll_responses: list[dict[str, Any]], tmp: str self, poll_responses: list[dict[str, Any]], tmp: str
) -> int: ) -> int:
from bot_bottle.cli.login import cmd_login from bot_bottle.cli.commands.login import cmd_login
start_resp = { start_resp = {
"device_code": "dc123", "device_code": "dc123",
@@ -175,8 +175,8 @@ class TestCmdLoginFlow(unittest.TestCase):
return 200, next(poll_iter, {"status": "pending"}) return 200, next(poll_iter, {"status": "pending"})
with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}): with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}):
with patch("bot_bottle.cli.login._post", side_effect=_fake_post): with patch("bot_bottle.cli.commands.login._post", side_effect=_fake_post):
with patch("bot_bottle.cli.login._get", side_effect=_fake_get): with patch("bot_bottle.cli.commands.login._get", side_effect=_fake_get):
with patch("time.sleep"): with patch("time.sleep"):
return cmd_login(["--console-url", "http://console"]) return cmd_login(["--console-url", "http://console"])
@@ -202,7 +202,7 @@ class TestCmdLoginFlow(unittest.TestCase):
self.assertEqual(result, 1) self.assertEqual(result, 1)
def test_timeout_returns_1(self) -> None: def test_timeout_returns_1(self) -> None:
from bot_bottle.cli.login import cmd_login from bot_bottle.cli.commands.login import cmd_login
start_resp = { start_resp = {
"device_code": "dc", "device_code": "dc",
@@ -213,13 +213,13 @@ class TestCmdLoginFlow(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}): with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}):
with patch("bot_bottle.cli.login._post", return_value=start_resp): with patch("bot_bottle.cli.commands.login._post", return_value=start_resp):
result = cmd_login(["--console-url", "http://console"]) result = cmd_login(["--console-url", "http://console"])
self.assertEqual(result, 1) self.assertEqual(result, 1)
def test_poll_interval_from_server_is_used(self) -> None: def test_poll_interval_from_server_is_used(self) -> None:
"""time.sleep must be called with the server-provided poll_interval.""" """time.sleep must be called with the server-provided poll_interval."""
from bot_bottle.cli.login import cmd_login from bot_bottle.cli.commands.login import cmd_login
server_interval = 7 server_interval = 7
start_resp = { start_resp = {
@@ -241,9 +241,9 @@ class TestCmdLoginFlow(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}): with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}):
with patch("bot_bottle.cli.login._post", return_value=start_resp): with patch("bot_bottle.cli.commands.login._post", return_value=start_resp):
with patch( with patch(
"bot_bottle.cli.login._get", side_effect=_fake_get "bot_bottle.cli.commands.login._get", side_effect=_fake_get
): ):
with patch("time.sleep") as mock_sleep: with patch("time.sleep") as mock_sleep:
result = cmd_login(["--console-url", "http://console"]) result = cmd_login(["--console-url", "http://console"])
+12 -12
View File
@@ -14,7 +14,7 @@ import os
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import bot_bottle.cli.start as start_mod import bot_bottle.cli.commands.start as start_mod
import bot_bottle.cli.tui as tui_mod import bot_bottle.cli.tui as tui_mod
from bot_bottle.backend import ActiveAgent from bot_bottle.backend import ActiveAgent
from bot_bottle.log import Die from bot_bottle.log import Die
@@ -53,15 +53,15 @@ class TestCmdStartHeadless(unittest.TestCase):
["researcher", "implementer"], ["claude", "dev"], agent_bottle="claude" ["researcher", "implementer"], ["claude", "dev"], agent_bottle="claude"
) )
patch( patch(
"bot_bottle.cli.start.ManifestIndex.resolve", "bot_bottle.cli.commands.start.ManifestIndex.resolve",
return_value=self._manifest, return_value=self._manifest,
).start() ).start()
self._launch_mock = patch( self._launch_mock = patch(
"bot_bottle.cli.start._launch_bottle", return_value=0 "bot_bottle.cli.commands.start._launch_bottle", return_value=0
).start() ).start()
# No bottles running by default → no label collision. # No bottles running by default → no label collision.
patch( patch(
"bot_bottle.cli.start.enumerate_active_agents", return_value=[] "bot_bottle.cli.commands.start.enumerate_active_agents", return_value=[]
).start() ).start()
# If any TUI picker fires in headless mode, that's a bug. # If any TUI picker fires in headless mode, that's a bug.
self._agent_picker = patch.object(tui_mod, "filter_select").start() self._agent_picker = patch.object(tui_mod, "filter_select").start()
@@ -71,7 +71,7 @@ class TestCmdStartHeadless(unittest.TestCase):
os.environ.pop("BOT_BOTTLE_BACKEND", None) os.environ.pop("BOT_BOTTLE_BACKEND", None)
# PTY check uses os.isatty(sys.stdin.fileno()); stub both so # PTY check uses os.isatty(sys.stdin.fileno()); stub both so
# headless unit tests aren't blocked on a real TTY. # headless unit tests aren't blocked on a real TTY.
patch("bot_bottle.cli.start.os.isatty", return_value=True).start() patch("bot_bottle.cli.commands.start.os.isatty", return_value=True).start()
self.addCleanup(patch.stopall) self.addCleanup(patch.stopall)
def _spec(self): def _spec(self):
@@ -128,7 +128,7 @@ class TestCmdStartHeadless(unittest.TestCase):
def test_no_bottle_and_no_default_dies(self): def test_no_bottle_and_no_default_dies(self):
manifest = _make_manifest(["researcher"], ["claude"], agent_bottle="") manifest = _make_manifest(["researcher"], ["claude"], agent_bottle="")
with patch( with patch(
"bot_bottle.cli.start.ManifestIndex.resolve", return_value=manifest "bot_bottle.cli.commands.start.ManifestIndex.resolve", return_value=manifest
): ):
with self.assertRaises(Die): with self.assertRaises(Die):
start_mod.cmd_start( start_mod.cmd_start(
@@ -170,7 +170,7 @@ class TestCmdStartHeadless(unittest.TestCase):
def test_label_collision_uniquifies(self): def test_label_collision_uniquifies(self):
with patch( with patch(
"bot_bottle.cli.start.enumerate_active_agents", "bot_bottle.cli.commands.start.enumerate_active_agents",
return_value=[_active_agent("researcher")], return_value=[_active_agent("researcher")],
): ):
start_mod.cmd_start( start_mod.cmd_start(
@@ -203,9 +203,9 @@ class TestPrepareWithPreflight(unittest.TestCase):
mock_backend.name = "test-backend" mock_backend.name = "test-backend"
render = MagicMock() render = MagicMock()
with patch("bot_bottle.cli.start.get_bottle_backend", return_value=mock_backend), \ with patch("bot_bottle.cli.commands.start.get_bottle_backend", return_value=mock_backend), \
patch("bot_bottle.cli.start._identity_from_plan", return_value="id"), \ patch("bot_bottle.cli.commands.start._identity_from_plan", return_value="id"), \
patch("bot_bottle.cli.start.info"): patch("bot_bottle.cli.commands.start.info"):
start_mod.prepare_with_preflight( start_mod.prepare_with_preflight(
MagicMock(), MagicMock(),
stage_dir=Path("/tmp"), stage_dir=Path("/tmp"),
@@ -223,7 +223,7 @@ class TestNoAgentsDefined(unittest.TestCase):
def test_no_agents_defined_returns_1(self): def test_no_agents_defined_returns_1(self):
manifest = _make_manifest([], []) manifest = _make_manifest([], [])
with patch( with patch(
"bot_bottle.cli.start.ManifestIndex.resolve", return_value=manifest "bot_bottle.cli.commands.start.ManifestIndex.resolve", return_value=manifest
), patch("sys.stderr", io.StringIO()) as err: ), patch("sys.stderr", io.StringIO()) as err:
rc = start_mod.cmd_start([]) rc = start_mod.cmd_start([])
self.assertEqual(1, rc) self.assertEqual(1, rc)
@@ -236,7 +236,7 @@ class TestTextRenderPreflight(unittest.TestCase):
def test_backend_name_in_output(self): def test_backend_name_in_output(self):
render = start_mod._text_render_preflight() render = start_mod._text_render_preflight()
plan = MagicMock() plan = MagicMock()
with patch("bot_bottle.cli.start._manifest_to_yaml", return_value=""), \ with patch("bot_bottle.cli.commands.start._manifest_to_yaml", return_value=""), \
patch("sys.stderr", io.StringIO()) as err: patch("sys.stderr", io.StringIO()) as err:
render(plan, "my-backend") render(plan, "my-backend")
self.assertIn("my-backend", err.getvalue()) self.assertIn("my-backend", err.getvalue())
+13 -13
View File
@@ -14,7 +14,7 @@ import unittest
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import bot_bottle.cli.start as start_mod import bot_bottle.cli.commands.start as start_mod
import bot_bottle.cli.tui as tui_mod import bot_bottle.cli.tui as tui_mod
from bot_bottle.backend import ActiveAgent from bot_bottle.backend import ActiveAgent
@@ -38,13 +38,13 @@ class TestCmdStartSelector(unittest.TestCase):
def setUp(self): def setUp(self):
self._manifest = _make_manifest(["researcher", "implementer"], ["claude", "dev"]) self._manifest = _make_manifest(["researcher", "implementer"], ["claude", "dev"])
self._resolve_patch = patch( self._resolve_patch = patch(
"bot_bottle.cli.start.ManifestIndex.resolve", "bot_bottle.cli.commands.start.ManifestIndex.resolve",
return_value=self._manifest, return_value=self._manifest,
) )
self._resolve_patch.start() self._resolve_patch.start()
self._launch_patch = patch( self._launch_patch = patch(
"bot_bottle.cli.start._launch_bottle", "bot_bottle.cli.commands.start._launch_bottle",
return_value=0, return_value=0,
) )
self._launch_mock = self._launch_patch.start() self._launch_mock = self._launch_patch.start()
@@ -66,7 +66,7 @@ class TestCmdStartSelector(unittest.TestCase):
self._modal_patch.start() self._modal_patch.start()
self._image_policy_patch = patch( self._image_policy_patch = patch(
"bot_bottle.cli.start._select_image_policy", "bot_bottle.cli.commands.start._select_image_policy",
return_value="fresh", return_value="fresh",
) )
self._image_policy_patch.start() self._image_policy_patch.start()
@@ -141,14 +141,14 @@ class TestCmdStartSelector(unittest.TestCase):
self.assertEqual(("claude", "dev"), spec.bottle_names) self.assertEqual(("claude", "dev"), spec.bottle_names)
def test_image_policy_forwarded_to_spec(self): def test_image_policy_forwarded_to_spec(self):
with patch("bot_bottle.cli.start._select_image_policy", return_value="cached"): with patch("bot_bottle.cli.commands.start._select_image_policy", return_value="cached"):
start_mod.cmd_start(["researcher"]) start_mod.cmd_start(["researcher"])
self._launch_mock.assert_called_once() self._launch_mock.assert_called_once()
spec = self._launch_mock.call_args[0][0] spec = self._launch_mock.call_args[0][0]
self.assertEqual("cached", spec.image_policy) self.assertEqual("cached", spec.image_policy)
def test_image_policy_cancel_returns_0(self): def test_image_policy_cancel_returns_0(self):
with patch("bot_bottle.cli.start._select_image_policy", return_value=None): with patch("bot_bottle.cli.commands.start._select_image_policy", return_value=None):
rc = start_mod.cmd_start(["researcher"]) rc = start_mod.cmd_start(["researcher"])
self.assertEqual(0, rc) self.assertEqual(0, rc)
self._launch_mock.assert_not_called() self._launch_mock.assert_not_called()
@@ -169,7 +169,7 @@ class TestCmdStartSelector(unittest.TestCase):
["implementer"], ["claude", "dev"], agent_bottle="claude" ["implementer"], ["claude", "dev"], agent_bottle="claude"
) )
with patch( with patch(
"bot_bottle.cli.start.ManifestIndex.resolve", return_value=manifest "bot_bottle.cli.commands.start.ManifestIndex.resolve", return_value=manifest
): ):
start_mod.cmd_start(["implementer"]) start_mod.cmd_start(["implementer"])
call_kwargs = self._bottle_picker_mock.call_args call_kwargs = self._bottle_picker_mock.call_args
@@ -178,7 +178,7 @@ class TestCmdStartSelector(unittest.TestCase):
def test_no_agent_bottle_empty_initial(self): def test_no_agent_bottle_empty_initial(self):
manifest = _make_manifest(["researcher"], ["claude", "dev"], agent_bottle="") manifest = _make_manifest(["researcher"], ["claude", "dev"], agent_bottle="")
with patch( with patch(
"bot_bottle.cli.start.ManifestIndex.resolve", return_value=manifest "bot_bottle.cli.commands.start.ManifestIndex.resolve", return_value=manifest
): ):
start_mod.cmd_start(["researcher"]) start_mod.cmd_start(["researcher"])
call_kwargs = self._bottle_picker_mock.call_args call_kwargs = self._bottle_picker_mock.call_args
@@ -228,19 +228,19 @@ class TestCmdStartLabelCollision(unittest.TestCase):
def setUp(self): def setUp(self):
self._manifest = _make_manifest(["researcher"], ["claude"]) self._manifest = _make_manifest(["researcher"], ["claude"])
patch("bot_bottle.cli.start.ManifestIndex.resolve", return_value=self._manifest).start() patch("bot_bottle.cli.commands.start.ManifestIndex.resolve", return_value=self._manifest).start()
self._launch_mock = patch( self._launch_mock = patch(
"bot_bottle.cli.start._launch_bottle", return_value=0, "bot_bottle.cli.commands.start._launch_bottle", return_value=0,
).start() ).start()
# Stub the bottle picker to always return a selection. # Stub the bottle picker to always return a selection.
patch.object(tui_mod, "filter_multiselect", return_value=["claude"]).start() patch.object(tui_mod, "filter_multiselect", return_value=["claude"]).start()
patch("bot_bottle.cli.start._select_image_policy", return_value="fresh").start() patch("bot_bottle.cli.commands.start._select_image_policy", return_value="fresh").start()
self.addCleanup(patch.stopall) self.addCleanup(patch.stopall)
def test_no_collision_proceeds_without_reprompt(self): def test_no_collision_proceeds_without_reprompt(self):
with ( with (
patch.object(tui_mod, "name_color_modal", return_value=("researcher", "")) as modal, patch.object(tui_mod, "name_color_modal", return_value=("researcher", "")) as modal,
patch("bot_bottle.cli.start.enumerate_active_agents", return_value=[]), patch("bot_bottle.cli.commands.start.enumerate_active_agents", return_value=[]),
): ):
rc = start_mod.cmd_start(["researcher"]) rc = start_mod.cmd_start(["researcher"])
self.assertEqual(0, rc) self.assertEqual(0, rc)
@@ -261,7 +261,7 @@ class TestCmdStartLabelCollision(unittest.TestCase):
with ( with (
patch.object(tui_mod, "name_color_modal", side_effect=_modal) as modal, patch.object(tui_mod, "name_color_modal", side_effect=_modal) as modal,
patch( patch(
"bot_bottle.cli.start.enumerate_active_agents", "bot_bottle.cli.commands.start.enumerate_active_agents",
side_effect=[[collision_agent], []], side_effect=[[collision_agent], []],
), ),
): ):
+1 -1
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from tests.unit import use_bottle_root from tests.unit import use_bottle_root
from bot_bottle import bottle_state from bot_bottle import bottle_state
from bot_bottle.cli import start as start_mod from bot_bottle.cli.commands import start as start_mod
class _FakeHomeMixin: class _FakeHomeMixin:
+5 -5
View File
@@ -56,7 +56,7 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
) )
def _run_launch(self, **patch_kwargs: Any) -> int: def _run_launch(self, **patch_kwargs: Any) -> int:
import bot_bottle.cli.start as start_mod import bot_bottle.cli.commands.start as start_mod
spec = self._spec() spec = self._spec()
with patch.object(start_mod, "prepare_with_preflight", with patch.object(start_mod, "prepare_with_preflight",
return_value=(_fake_plan(), "dev-abc")), \ return_value=(_fake_plan(), "dev-abc")), \
@@ -71,7 +71,7 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
def test_headless_stale_calls_die(self) -> None: def test_headless_stale_calls_die(self) -> None:
"""In headless mode (assume_yes=True), a StaleImageError from prelaunch_checks must call die().""" """In headless mode (assume_yes=True), a StaleImageError from prelaunch_checks must call die()."""
import bot_bottle.cli.start as start_mod import bot_bottle.cli.commands.start as start_mod
backend_mock = MagicMock() backend_mock = MagicMock()
backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old") backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old")
@@ -85,7 +85,7 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
def test_interactive_user_declines_stops_before_launch(self) -> None: def test_interactive_user_declines_stops_before_launch(self) -> None:
"""Interactive user answering 'n' → launch is never called.""" """Interactive user answering 'n' → launch is never called."""
import bot_bottle.cli.start as start_mod import bot_bottle.cli.commands.start as start_mod
backend_mock = MagicMock() backend_mock = MagicMock()
backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old") backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old")
@@ -100,7 +100,7 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
def test_interactive_user_confirms_launches_once(self) -> None: def test_interactive_user_confirms_launches_once(self) -> None:
"""Interactive user answering 'y' → prelaunch stale error is bypassed; launch called once.""" """Interactive user answering 'y' → prelaunch stale error is bypassed; launch called once."""
import bot_bottle.cli.start as start_mod import bot_bottle.cli.commands.start as start_mod
bottle_mock = MagicMock() bottle_mock = MagicMock()
bottle_mock.name = "dev-abc" bottle_mock.name = "dev-abc"
@@ -122,7 +122,7 @@ class TestLaunchBottleStaleHandling(unittest.TestCase):
def test_interactive_yes_uppercase_also_accepted(self) -> None: def test_interactive_yes_uppercase_also_accepted(self) -> None:
"""'Y' or 'YES' should also be accepted as confirmation.""" """'Y' or 'YES' should also be accepted as confirmation."""
import bot_bottle.cli.start as start_mod import bot_bottle.cli.commands.start as start_mod
bottle_mock = MagicMock() bottle_mock = MagicMock()
bottle_mock.name = "dev-abc" bottle_mock.name = "dev-abc"
+1 -1
View File
@@ -12,7 +12,7 @@ import unittest
from datetime import datetime, timezone from datetime import datetime, timezone
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from bot_bottle.cli import supervise as supervise_cli from bot_bottle.cli.commands import supervise as supervise_cli
from bot_bottle.orchestrator.supervisor import ( from bot_bottle.orchestrator.supervisor import (
Proposal, Proposal,
TOOL_EGRESS_ALLOW, TOOL_EGRESS_ALLOW,
@@ -18,7 +18,7 @@ from pathlib import Path
from unittest import mock from unittest import mock
from tests.unit import use_bottle_root from tests.unit import use_bottle_root
from bot_bottle.cli import supervise as supervise_cli from bot_bottle.cli.commands import supervise as supervise_cli
from bot_bottle.log import Die, die from bot_bottle.log import Die, die