From 50a67c04bd3fa3018413af72f31c458a59475f8b Mon Sep 17 00:00:00 2001 From: didericis Date: Fri, 24 Jul 2026 17:25:25 -0400 Subject: [PATCH] refactor(cli): group subcommand handlers under cli/commands/ 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 --- bot_bottle/cli/__init__.py | 22 +++++----- bot_bottle/cli/commands/__init__.py | 6 +++ bot_bottle/cli/{ => commands}/backend.py | 4 +- bot_bottle/cli/{ => commands}/cleanup.py | 6 +-- bot_bottle/cli/{ => commands}/commit.py | 12 ++--- bot_bottle/cli/{ => commands}/edit.py | 4 +- bot_bottle/cli/{ => commands}/info.py | 6 +-- bot_bottle/cli/{ => commands}/init.py | 4 +- bot_bottle/cli/{ => commands}/list.py | 6 +-- bot_bottle/cli/{ => commands}/login.py | 2 +- bot_bottle/cli/{ => commands}/resume.py | 10 ++--- bot_bottle/cli/{ => commands}/start.py | 26 +++++------ bot_bottle/cli/{ => commands}/supervise.py | 12 ++--- tests/unit/test_cli_backend.py | 2 +- tests/unit/test_cli_cleanup_cross_backend.py | 2 +- tests/unit/test_cli_commit.py | 20 ++++----- tests/unit/test_cli_login.py | 44 +++++++++---------- tests/unit/test_cli_start_headless.py | 24 +++++----- tests/unit/test_cli_start_selector.py | 26 +++++------ tests/unit/test_cli_start_settle.py | 2 +- tests/unit/test_cli_start_stale.py | 10 ++--- tests/unit/test_supervise_cli.py | 2 +- .../unit/test_supervise_cli_crash_logging.py | 2 +- 23 files changed, 130 insertions(+), 124 deletions(-) create mode 100644 bot_bottle/cli/commands/__init__.py rename bot_bottle/cli/{ => commands}/backend.py (94%) rename bot_bottle/cli/{ => commands}/cleanup.py (93%) rename bot_bottle/cli/{ => commands}/commit.py (86%) rename bot_bottle/cli/{ => commands}/edit.py (95%) rename bot_bottle/cli/{ => commands}/info.py (93%) rename bot_bottle/cli/{ => commands}/init.py (98%) rename bot_bottle/cli/{ => commands}/list.py (93%) rename bot_bottle/cli/{ => commands}/login.py (99%) rename bot_bottle/cli/{ => commands}/resume.py (90%) rename bot_bottle/cli/{ => commands}/start.py (97%) rename bot_bottle/cli/{ => commands}/supervise.py (98%) diff --git a/bot_bottle/cli/__init__.py b/bot_bottle/cli/__init__.py index d1d550a..41ffa7d 100644 --- a/bot_bottle/cli/__init__.py +++ b/bot_bottle/cli/__init__.py @@ -12,17 +12,17 @@ from ..log import Die, die, error from ..manifest import ManifestError from ..orchestrator.store.store_manager import StoreManager from ._common import PROG -from . import list as _list_mod -from .backend import cmd_backend -from .cleanup import cmd_cleanup -from .commit import cmd_commit -from .edit import cmd_edit -from .info import cmd_info -from .init import cmd_init -from .login import cmd_login -from .resume import cmd_resume -from .start import cmd_start -from .supervise import cmd_supervise +from .commands import list as _list_mod +from .commands.backend import cmd_backend +from .commands.cleanup import cmd_cleanup +from .commands.commit import cmd_commit +from .commands.edit import cmd_edit +from .commands.info import cmd_info +from .commands.init import cmd_init +from .commands.login import cmd_login +from .commands.resume import cmd_resume +from .commands.start import cmd_start +from .commands.supervise import cmd_supervise cmd_list = _list_mod.cmd_list diff --git a/bot_bottle/cli/commands/__init__.py b/bot_bottle/cli/commands/__init__.py new file mode 100644 index 0000000..0daac76 --- /dev/null +++ b/bot_bottle/cli/commands/__init__.py @@ -0,0 +1,6 @@ +"""CLI subcommand handlers — one module per `bot-bottle `. + +Each module exposes a `cmd_(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. +""" diff --git a/bot_bottle/cli/backend.py b/bot_bottle/cli/commands/backend.py similarity index 94% rename from bot_bottle/cli/backend.py rename to bot_bottle/cli/commands/backend.py index ed6808f..2997db3 100644 --- a/bot_bottle/cli/backend.py +++ b/bot_bottle/cli/commands/backend.py @@ -15,8 +15,8 @@ from __future__ import annotations import argparse -from ..backend import get_bottle_backend, known_backend_names -from ._common import PROG +from ...backend import get_bottle_backend, known_backend_names +from .._common import PROG def cmd_backend(args: list[str]) -> int: diff --git a/bot_bottle/cli/cleanup.py b/bot_bottle/cli/commands/cleanup.py similarity index 93% rename from bot_bottle/cli/cleanup.py rename to bot_bottle/cli/commands/cleanup.py index 34d6de9..685f6bc 100644 --- a/bot_bottle/cli/cleanup.py +++ b/bot_bottle/cli/commands/cleanup.py @@ -21,9 +21,9 @@ from __future__ import annotations import sys -from ..backend import get_bottle_backend, has_backend, known_backend_names -from ..log import info -from ._common import read_tty_line +from ...backend import get_bottle_backend, has_backend, known_backend_names +from ...log import info +from .._common import read_tty_line def cmd_cleanup(_argv: list[str]) -> int: diff --git a/bot_bottle/cli/commit.py b/bot_bottle/cli/commands/commit.py similarity index 86% rename from bot_bottle/cli/commit.py rename to bot_bottle/cli/commands/commit.py index 51d94d9..502ae69 100644 --- a/bot_bottle/cli/commit.py +++ b/bot_bottle/cli/commands/commit.py @@ -12,12 +12,12 @@ from __future__ import annotations import argparse -from ..backend import enumerate_active_agents -from ..backend.freeze import CommitCancelled, get_freezer -from ..bottle_state import read_metadata -from ..log import die -from ._common import PROG -from . import tui +from ...backend import enumerate_active_agents +from ...backend.freeze import CommitCancelled, get_freezer +from ...bottle_state import read_metadata +from ...log import die +from .._common import PROG +from .. import tui def cmd_commit(argv: list[str]) -> int: diff --git a/bot_bottle/cli/edit.py b/bot_bottle/cli/commands/edit.py similarity index 95% rename from bot_bottle/cli/edit.py rename to bot_bottle/cli/commands/edit.py index 8c412d9..82ca91b 100644 --- a/bot_bottle/cli/edit.py +++ b/bot_bottle/cli/commands/edit.py @@ -7,8 +7,8 @@ import json import os from pathlib import Path -from ..log import die -from ._common import PROG, USER_CWD +from ...log import die +from .._common import PROG, USER_CWD def cmd_edit(argv: list[str]) -> int: diff --git a/bot_bottle/cli/info.py b/bot_bottle/cli/commands/info.py similarity index 93% rename from bot_bottle/cli/info.py rename to bot_bottle/cli/commands/info.py index 61b8945..d15b561 100644 --- a/bot_bottle/cli/info.py +++ b/bot_bottle/cli/commands/info.py @@ -4,9 +4,9 @@ from __future__ import annotations import argparse -from ..log import info -from ..manifest import ManifestIndex -from ._common import PROG, USER_CWD +from ...log import info +from ...manifest import ManifestIndex +from .._common import PROG, USER_CWD def cmd_info(argv: list[str]) -> int: diff --git a/bot_bottle/cli/init.py b/bot_bottle/cli/commands/init.py similarity index 98% rename from bot_bottle/cli/init.py rename to bot_bottle/cli/commands/init.py index 21b62a0..ca169f1 100644 --- a/bot_bottle/cli/init.py +++ b/bot_bottle/cli/commands/init.py @@ -10,8 +10,8 @@ import sys from pathlib import Path from typing import Any -from ..log import die, info, warn -from ._common import PROG, USER_CWD, read_tty_line +from ...log import die, info, warn +from .._common import PROG, USER_CWD, read_tty_line def cmd_init(argv: list[str]) -> int: diff --git a/bot_bottle/cli/list.py b/bot_bottle/cli/commands/list.py similarity index 93% rename from bot_bottle/cli/list.py rename to bot_bottle/cli/commands/list.py index c727428..5c0caf3 100644 --- a/bot_bottle/cli/list.py +++ b/bot_bottle/cli/commands/list.py @@ -6,9 +6,9 @@ import argparse import os import sys -from ..backend import enumerate_active_agents -from ..manifest import ManifestIndex -from ._common import PROG, USER_CWD +from ...backend import enumerate_active_agents +from ...manifest import ManifestIndex +from .._common import PROG, USER_CWD _ANSI_COLOR_CODES: dict[str, str] = { "red": "\033[91m", diff --git a/bot_bottle/cli/login.py b/bot_bottle/cli/commands/login.py similarity index 99% rename from bot_bottle/cli/login.py rename to bot_bottle/cli/commands/login.py index 3d00f17..55e04bf 100644 --- a/bot_bottle/cli/login.py +++ b/bot_bottle/cli/commands/login.py @@ -25,7 +25,7 @@ import urllib.request from pathlib import Path from typing import Any -from ..paths import bot_bottle_root +from ...paths import bot_bottle_root _CONSOLE_URL_ENV = "BB_CONSOLE_URL" _POLL_SLEEP = 2 # seconds between polls; matches console's poll_interval default diff --git a/bot_bottle/cli/resume.py b/bot_bottle/cli/commands/resume.py similarity index 90% rename from bot_bottle/cli/resume.py rename to bot_bottle/cli/commands/resume.py index 5dd421b..6d8224a 100644 --- a/bot_bottle/cli/resume.py +++ b/bot_bottle/cli/commands/resume.py @@ -16,11 +16,11 @@ from __future__ import annotations import argparse -from ..backend import BottleSpec -from ..bottle_state import read_metadata -from ..log import die -from ..manifest import ManifestIndex -from ._common import PROG, USER_CWD +from ...backend import BottleSpec +from ...bottle_state import read_metadata +from ...log import die +from ...manifest import ManifestIndex +from .._common import PROG, USER_CWD from .start import _launch_bottle diff --git a/bot_bottle/cli/start.py b/bot_bottle/cli/commands/start.py similarity index 97% rename from bot_bottle/cli/start.py rename to bot_bottle/cli/commands/start.py index b587216..24c2e9c 100644 --- a/bot_bottle/cli/start.py +++ b/bot_bottle/cli/commands/start.py @@ -22,25 +22,25 @@ import tempfile from pathlib import Path from typing import Callable -from ..agent_provider import get_provider, runtime_for -from ..backend import ( +from ...agent_provider import get_provider, runtime_for +from ...backend import ( Bottle, BottleSpec, enumerate_active_agents, get_bottle_backend, ) -from ..backend.docker import util as docker_mod -from ..backend.docker.bottle_plan import DockerBottlePlan -from ..bottle_state import ( +from ...backend.docker import util as docker_mod +from ...backend.docker.bottle_plan import DockerBottlePlan +from ...bottle_state import ( cleanup_state, is_preserved, mark_preserved, ) -from ..image_cache import StaleImageError -from ..log import info, die -from ..manifest import Manifest, ManifestIndex -from ._common import PROG, USER_CWD, read_tty_line -from . import tui +from ...image_cache import StaleImageError +from ...log import info, die +from ...manifest import Manifest, ManifestIndex +from .._common import PROG, USER_CWD, read_tty_line +from .. import tui 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 "" - from ..manifest.loader import scan_agent_names - from ..yaml_subset import YamlSubsetError, parse_frontmatter + from ...manifest.loader import scan_agent_names + from ...yaml_subset import YamlSubsetError, parse_frontmatter home_agents = scan_agent_names(manifest.home_md / "agents") cwd_agents: dict[str, Path] = {} @@ -449,7 +449,7 @@ def _bottle_lineage(manifest: ManifestIndex) -> dict[str, str]: if not bottles_dir.is_dir(): return {} - from ..yaml_subset import YamlSubsetError, parse_frontmatter + from ...yaml_subset import YamlSubsetError, parse_frontmatter extends_of: dict[str, str] = {} for path in bottles_dir.glob("*.md"): diff --git a/bot_bottle/cli/supervise.py b/bot_bottle/cli/commands/supervise.py similarity index 98% rename from bot_bottle/cli/supervise.py rename to bot_bottle/cli/commands/supervise.py index abf7d70..c2beb03 100644 --- a/bot_bottle/cli/supervise.py +++ b/bot_bottle/cli/commands/supervise.py @@ -19,22 +19,22 @@ from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -from ..paths import bot_bottle_root -from ..log import Die, error, info -from ..orchestrator.client import ( +from ...paths import bot_bottle_root +from ...log import Die, error, info +from ...orchestrator.client import ( OrchestratorClient, OrchestratorClientError, discover_orchestrator_url, ) -from ..supervisor.types import ( +from ...supervisor.types import ( Proposal, TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK, TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_ALLOW, ) -from ._common import PROG +from .._common import PROG _REFRESH_INTERVAL_MS = 1000 @@ -81,7 +81,7 @@ def _resolve_orchestrator_url() -> str: try: return discover_orchestrator_url() except OrchestratorClientError: - from ..backend import get_bottle_backend + from ...backend import get_bottle_backend backend = get_bottle_backend() info(f"no orchestrator control plane running; starting one ({backend.name})…") return backend.ensure_orchestrator() diff --git a/tests/unit/test_cli_backend.py b/tests/unit/test_cli_backend.py index 16eea2c..40f2890 100644 --- a/tests/unit/test_cli_backend.py +++ b/tests/unit/test_cli_backend.py @@ -12,7 +12,7 @@ import subprocess import unittest 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 diff --git a/tests/unit/test_cli_cleanup_cross_backend.py b/tests/unit/test_cli_cleanup_cross_backend.py index e1f4be8..47ab133 100644 --- a/tests/unit/test_cli_cleanup_cross_backend.py +++ b/tests/unit/test_cli_cleanup_cross_backend.py @@ -11,7 +11,7 @@ from __future__ import annotations import unittest 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): diff --git a/tests/unit/test_cli_commit.py b/tests/unit/test_cli_commit.py index 6a7e7ff..9eee542 100644 --- a/tests/unit/test_cli_commit.py +++ b/tests/unit/test_cli_commit.py @@ -7,7 +7,7 @@ import unittest from pathlib import Path 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 bot_bottle import bottle_state from bot_bottle.backend.freeze import CommitCancelled @@ -42,7 +42,7 @@ class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase): slug = "dev-abc12" 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_gf.return_value = mock_freezer rc = cmd_commit([slug]) @@ -56,7 +56,7 @@ class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase): slug = "dev-abc12" 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_gf.return_value = mock_freezer rc = cmd_commit([slug]) @@ -68,7 +68,7 @@ class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase): slug = "dev-abc12" 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_gf.return_value = mock_freezer rc = cmd_commit([slug]) @@ -81,7 +81,7 @@ class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase): slug = "dev-abc12" 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_gf.return_value = mock_freezer rc = cmd_commit([slug]) @@ -93,7 +93,7 @@ class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase): slug = "dev-abc12" 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.commit_slug.side_effect = CommitCancelled 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): with patch( - "bot_bottle.cli.commit.enumerate_active_agents", return_value=[], + "bot_bottle.cli.commands.commit.enumerate_active_agents", return_value=[], ), patch( - "bot_bottle.cli.commit.die", side_effect=SystemExit("die"), + "bot_bottle.cli.commands.commit.die", side_effect=SystemExit("die"), ) as mock_die: with self.assertRaises(SystemExit): cmd_commit([]) @@ -124,9 +124,9 @@ class TestCmdCommitNoActiveBottles(_FakeHomeMixin, unittest.TestCase): active = MagicMock() active.slug = "dev-abc12" with patch( - "bot_bottle.cli.commit.enumerate_active_agents", return_value=[active], + "bot_bottle.cli.commands.commit.enumerate_active_agents", return_value=[active], ), 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([]) diff --git a/tests/unit/test_cli_login.py b/tests/unit/test_cli_login.py index 0d4c13c..67c00e0 100644 --- a/tests/unit/test_cli_login.py +++ b/tests/unit/test_cli_login.py @@ -14,27 +14,27 @@ from unittest.mock import MagicMock, patch class TestFlagParsing(unittest.TestCase): 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") 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( _flag(["--console-url=http://x"], "--console-url"), "http://x" ) 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") 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")) class TestHttpHelpers(unittest.TestCase): 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.__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") 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.__enter__.return_value.status = 200 @@ -59,7 +59,7 @@ class TestHttpHelpers(unittest.TestCase): ) 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( "http://console/status", 410, "gone", Message(), None @@ -70,7 +70,7 @@ class TestHttpHelpers(unittest.TestCase): class TestSaveCredentials(unittest.TestCase): 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 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: """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 tmp_perms_at_replace: list[int] = [] @@ -107,7 +107,7 @@ class TestSaveCredentials(unittest.TestCase): def test_cleanup_on_write_failure(self) -> None: """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 patch.dict(os.environ, {"BOT_BOTTLE_ROOT": tmp}): @@ -120,12 +120,12 @@ class TestSaveCredentials(unittest.TestCase): class TestCmdLoginMissingUrl(unittest.TestCase): 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) 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): os.environ.pop("BB_CONSOLE_URL", None) result = cmd_login([]) @@ -133,7 +133,7 @@ class TestCmdLoginMissingUrl(unittest.TestCase): def test_reads_env_var(self) -> None: """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]: raise OSError("connection refused") @@ -146,7 +146,7 @@ class TestCmdLoginMissingUrl(unittest.TestCase): "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([]) self.assertEqual(result, 1) @@ -155,7 +155,7 @@ class TestCmdLoginFlow(unittest.TestCase): def _run_with_mocks( self, poll_responses: list[dict[str, Any]], tmp: str ) -> int: - from bot_bottle.cli.login import cmd_login + from bot_bottle.cli.commands.login import cmd_login start_resp = { "device_code": "dc123", @@ -175,8 +175,8 @@ class TestCmdLoginFlow(unittest.TestCase): return 200, next(poll_iter, {"status": "pending"}) 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.login._get", side_effect=_fake_get): + with patch("bot_bottle.cli.commands.login._post", side_effect=_fake_post): + with patch("bot_bottle.cli.commands.login._get", side_effect=_fake_get): with patch("time.sleep"): return cmd_login(["--console-url", "http://console"]) @@ -202,7 +202,7 @@ class TestCmdLoginFlow(unittest.TestCase): self.assertEqual(result, 1) 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 = { "device_code": "dc", @@ -213,13 +213,13 @@ class TestCmdLoginFlow(unittest.TestCase): with tempfile.TemporaryDirectory() as 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"]) self.assertEqual(result, 1) def test_poll_interval_from_server_is_used(self) -> None: """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 start_resp = { @@ -241,9 +241,9 @@ class TestCmdLoginFlow(unittest.TestCase): with tempfile.TemporaryDirectory() as 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( - "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: result = cmd_login(["--console-url", "http://console"]) diff --git a/tests/unit/test_cli_start_headless.py b/tests/unit/test_cli_start_headless.py index 092b26c..e259acb 100644 --- a/tests/unit/test_cli_start_headless.py +++ b/tests/unit/test_cli_start_headless.py @@ -14,7 +14,7 @@ import os import unittest 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 from bot_bottle.backend import ActiveAgent from bot_bottle.log import Die @@ -53,15 +53,15 @@ class TestCmdStartHeadless(unittest.TestCase): ["researcher", "implementer"], ["claude", "dev"], agent_bottle="claude" ) patch( - "bot_bottle.cli.start.ManifestIndex.resolve", + "bot_bottle.cli.commands.start.ManifestIndex.resolve", return_value=self._manifest, ).start() self._launch_mock = patch( - "bot_bottle.cli.start._launch_bottle", return_value=0 + "bot_bottle.cli.commands.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=[] + "bot_bottle.cli.commands.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() @@ -71,7 +71,7 @@ class TestCmdStartHeadless(unittest.TestCase): os.environ.pop("BOT_BOTTLE_BACKEND", None) # PTY check uses os.isatty(sys.stdin.fileno()); stub both so # 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) def _spec(self): @@ -128,7 +128,7 @@ class TestCmdStartHeadless(unittest.TestCase): 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 + "bot_bottle.cli.commands.start.ManifestIndex.resolve", return_value=manifest ): with self.assertRaises(Die): start_mod.cmd_start( @@ -170,7 +170,7 @@ class TestCmdStartHeadless(unittest.TestCase): def test_label_collision_uniquifies(self): with patch( - "bot_bottle.cli.start.enumerate_active_agents", + "bot_bottle.cli.commands.start.enumerate_active_agents", return_value=[_active_agent("researcher")], ): start_mod.cmd_start( @@ -203,9 +203,9 @@ class TestPrepareWithPreflight(unittest.TestCase): mock_backend.name = "test-backend" render = MagicMock() - with patch("bot_bottle.cli.start.get_bottle_backend", return_value=mock_backend), \ - patch("bot_bottle.cli.start._identity_from_plan", return_value="id"), \ - patch("bot_bottle.cli.start.info"): + with patch("bot_bottle.cli.commands.start.get_bottle_backend", return_value=mock_backend), \ + patch("bot_bottle.cli.commands.start._identity_from_plan", return_value="id"), \ + patch("bot_bottle.cli.commands.start.info"): start_mod.prepare_with_preflight( MagicMock(), stage_dir=Path("/tmp"), @@ -223,7 +223,7 @@ class TestNoAgentsDefined(unittest.TestCase): def test_no_agents_defined_returns_1(self): manifest = _make_manifest([], []) 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: rc = start_mod.cmd_start([]) self.assertEqual(1, rc) @@ -236,7 +236,7 @@ class TestTextRenderPreflight(unittest.TestCase): def test_backend_name_in_output(self): render = start_mod._text_render_preflight() 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: render(plan, "my-backend") self.assertIn("my-backend", err.getvalue()) diff --git a/tests/unit/test_cli_start_selector.py b/tests/unit/test_cli_start_selector.py index 4f41f07..5819dac 100644 --- a/tests/unit/test_cli_start_selector.py +++ b/tests/unit/test_cli_start_selector.py @@ -14,7 +14,7 @@ import unittest from collections.abc import Mapping, Sequence 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 from bot_bottle.backend import ActiveAgent @@ -38,13 +38,13 @@ class TestCmdStartSelector(unittest.TestCase): def setUp(self): self._manifest = _make_manifest(["researcher", "implementer"], ["claude", "dev"]) self._resolve_patch = patch( - "bot_bottle.cli.start.ManifestIndex.resolve", + "bot_bottle.cli.commands.start.ManifestIndex.resolve", return_value=self._manifest, ) self._resolve_patch.start() self._launch_patch = patch( - "bot_bottle.cli.start._launch_bottle", + "bot_bottle.cli.commands.start._launch_bottle", return_value=0, ) self._launch_mock = self._launch_patch.start() @@ -66,7 +66,7 @@ class TestCmdStartSelector(unittest.TestCase): self._modal_patch.start() self._image_policy_patch = patch( - "bot_bottle.cli.start._select_image_policy", + "bot_bottle.cli.commands.start._select_image_policy", return_value="fresh", ) self._image_policy_patch.start() @@ -141,14 +141,14 @@ class TestCmdStartSelector(unittest.TestCase): self.assertEqual(("claude", "dev"), spec.bottle_names) 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"]) self._launch_mock.assert_called_once() spec = self._launch_mock.call_args[0][0] self.assertEqual("cached", spec.image_policy) 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"]) self.assertEqual(0, rc) self._launch_mock.assert_not_called() @@ -169,7 +169,7 @@ class TestCmdStartSelector(unittest.TestCase): ["implementer"], ["claude", "dev"], agent_bottle="claude" ) 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"]) call_kwargs = self._bottle_picker_mock.call_args @@ -178,7 +178,7 @@ class TestCmdStartSelector(unittest.TestCase): def test_no_agent_bottle_empty_initial(self): manifest = _make_manifest(["researcher"], ["claude", "dev"], agent_bottle="") 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"]) call_kwargs = self._bottle_picker_mock.call_args @@ -228,19 +228,19 @@ class TestCmdStartLabelCollision(unittest.TestCase): def setUp(self): 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( - "bot_bottle.cli.start._launch_bottle", return_value=0, + "bot_bottle.cli.commands.start._launch_bottle", return_value=0, ).start() # Stub the bottle picker to always return a selection. 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) def test_no_collision_proceeds_without_reprompt(self): with ( 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"]) self.assertEqual(0, rc) @@ -261,7 +261,7 @@ class TestCmdStartLabelCollision(unittest.TestCase): with ( patch.object(tui_mod, "name_color_modal", side_effect=_modal) as modal, patch( - "bot_bottle.cli.start.enumerate_active_agents", + "bot_bottle.cli.commands.start.enumerate_active_agents", side_effect=[[collision_agent], []], ), ): diff --git a/tests/unit/test_cli_start_settle.py b/tests/unit/test_cli_start_settle.py index 4b1c405..55666b8 100644 --- a/tests/unit/test_cli_start_settle.py +++ b/tests/unit/test_cli_start_settle.py @@ -10,7 +10,7 @@ from pathlib import Path from tests.unit import use_bottle_root 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: diff --git a/tests/unit/test_cli_start_stale.py b/tests/unit/test_cli_start_stale.py index 87625ae..97651a1 100644 --- a/tests/unit/test_cli_start_stale.py +++ b/tests/unit/test_cli_start_stale.py @@ -56,7 +56,7 @@ class TestLaunchBottleStaleHandling(unittest.TestCase): ) 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() with patch.object(start_mod, "prepare_with_preflight", return_value=(_fake_plan(), "dev-abc")), \ @@ -71,7 +71,7 @@ class TestLaunchBottleStaleHandling(unittest.TestCase): def test_headless_stale_calls_die(self) -> None: """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.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: """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.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: """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.name = "dev-abc" @@ -122,7 +122,7 @@ class TestLaunchBottleStaleHandling(unittest.TestCase): def test_interactive_yes_uppercase_also_accepted(self) -> None: """'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.name = "dev-abc" diff --git a/tests/unit/test_supervise_cli.py b/tests/unit/test_supervise_cli.py index f3f4477..1d6679b 100644 --- a/tests/unit/test_supervise_cli.py +++ b/tests/unit/test_supervise_cli.py @@ -12,7 +12,7 @@ import unittest from datetime import datetime, timezone 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 ( Proposal, TOOL_EGRESS_ALLOW, diff --git a/tests/unit/test_supervise_cli_crash_logging.py b/tests/unit/test_supervise_cli_crash_logging.py index 0f07f08..e36c0c3 100644 --- a/tests/unit/test_supervise_cli_crash_logging.py +++ b/tests/unit/test_supervise_cli_crash_logging.py @@ -18,7 +18,7 @@ from pathlib import Path from unittest import mock 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