feat(cli): cross-backend list active + --backend flag + dashboard picker (issue #77)
test / unit (pull_request) Successful in 29s
test / integration (pull_request) Successful in 41s

CLI and dashboard now share one cross-backend abstraction for
listing + launching bottles, so adding a backend (docker /
smolmachines) lights up in both places without separate wiring.

Backend abstraction:
- New `ActiveBottle` dataclass (`backend_name`, `slug`,
  `agent_name`, `started_at`, `services`) replaces the
  docker-specific `ActiveAgent`. Same field surface for the
  existing dashboard consumers; `ActiveAgent` becomes a typed
  alias for source-compat.
- New `BottleBackend.enumerate_active() -> Sequence[ActiveBottle]`
  replaces the old `list_active() -> None` (which printed and
  returned nothing). Docker implements it via the existing
  compose query; smolmachines implements it via `smolvm machine
  ls --json` cross-referenced with each bundle container's
  `CLAUDE_BOTTLE_SIDECAR_DAEMONS` env (`backend/smolmachines/
  enumerate.py`).
- New `enumerate_active_bottles()` and `known_backend_names()`
  module-level helpers fold every backend into one call.
- `get_bottle_backend(name=None)` takes an optional explicit
  name (precedence: arg > $CLAUDE_BOTTLE_BACKEND > "docker").

CLI:
- `./cli.py list active` enumerates every backend, prints
  tab-separated `<backend>\t<slug>\t<agent>\t<services>`. The
  smolmachines bottle the user was looking for now shows up.
- `./cli.py start` grows `--backend=<docker|smolmachines>`
  (choices pulled live from `known_backend_names()`). Threaded
  through `prepare_with_preflight(backend_name=...)` so the
  resume path picks up the flag too.

Dashboard:
- Active agents pane lists both backends (the row formatter now
  prefixes `[docker]` / `[smolmachines]`).
- New-agent flow inserts a backend picker modal between agent
  pick and preflight (`_backend_picker_modal`). Short-circuits
  when only one backend is registered.
- `discover_active_agents()` collapses to
  `enumerate_active_bottles()`; `_parse_services_by_project` and
  `_query_services_by_project` move to
  `backend/docker/cleanup.py` where the docker enumerator owns
  them.

Tests: parser + enumerate-active tests relocated to
`test_docker_enumerate_active.py`. New
`test_backend_selection.py` covers `get_bottle_backend`,
`known_backend_names`, `enumerate_active_bottles`. New
`test_cli_start_backend_flag.py` covers `--backend`'s argparse
shape + the explicit-over-env precedence.

605 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 18:27:12 -04:00
parent 1e82aed54b
commit adff1263d8
12 changed files with 761 additions and 282 deletions
+70 -21
View File
@@ -29,10 +29,16 @@ import subprocess
from ... import supervise as _supervise
from ...log import info, warn
from .. import ActiveBottle
from . import util as docker_mod
from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_state import bottle_state_dir, is_preserved
from .compose import COMPOSE_PROJECT_PREFIX, list_compose_projects
from .bottle_state import bottle_state_dir, is_preserved, read_metadata
from .compose import (
COMPOSE_PROJECT_PREFIX,
compose_project_name,
list_active_slugs,
list_compose_projects,
)
def _list_prefixed_containers() -> list[str]:
@@ -161,24 +167,67 @@ def cleanup(plan: DockerBottleCleanupPlan) -> None:
warn(f"failed to remove {path}: {e}")
def list_active() -> None:
"""Print every active claude-bottle compose project + its
services. Empty banner when there are none."""
docker_mod.require_docker()
active = list_compose_projects(include_stopped=False)
if not active:
info("no active claude-bottle compose projects")
return
print()
for project in active:
info(f"compose project: {project}")
ps = subprocess.run(
["docker", "compose", "-p", project, "ps", "--format",
"{{.Service}}\t{{.Name}}\t{{.Status}}"],
def enumerate_active() -> list[ActiveBottle]:
"""All currently-running docker-backed bottles as
`ActiveBottle` records. Backend-agnostic shape — the CLI
`list active` command and the dashboard agents pane both
consume this. Empty list when docker is unreachable or
nothing's running."""
# docker on PATH? Defensive — `list active` shouldn't die
# just because the docker backend isn't usable on this host.
if shutil.which("docker") is None:
return []
slugs = list_active_slugs(include_stopped=False)
if not slugs:
return []
services_by_project = _query_services_by_project()
out: list[ActiveBottle] = []
for slug in slugs:
project = compose_project_name(slug)
services = services_by_project.get(project, set())
metadata = read_metadata(slug)
out.append(ActiveBottle(
backend_name="docker",
slug=slug,
agent_name=metadata.agent_name if metadata else "?",
started_at=metadata.started_at if metadata else "",
services=tuple(sorted(services)),
))
return out
def _parse_services_by_project(stdout: str) -> dict[str, set[str]]:
"""Parse `docker ps` output formatted as
`<project-label>\\t<service-label>` (one line per container)
into a `{project: {service, ...}}` mapping. Pure function for
testing — the docker invocation is in `_query_services_by_project`."""
out: dict[str, set[str]] = {}
for line in stdout.splitlines():
project, _, service = line.partition("\t")
if not project or not service:
continue
out.setdefault(project, set()).add(service)
return out
def _query_services_by_project() -> dict[str, set[str]]:
"""One `docker ps` call → `{project: {service, ...}}`. Moved
here from the dashboard so the same query backs the CLI's
`list active` and the dashboard's agents pane."""
try:
r = subprocess.run(
[
"docker", "ps",
"--filter", "label=com.docker.compose.project",
"--format",
'{{.Label "com.docker.compose.project"}}'
"\t"
'{{.Label "com.docker.compose.service"}}',
],
capture_output=True, text=True, check=False,
)
for line in (ps.stdout or "").splitlines():
service, _, rest = line.partition("\t")
name, _, status = rest.partition("\t")
info(f" {service:12s} {name} ({status})")
print()
except FileNotFoundError:
return {}
if r.returncode != 0:
return {}
return _parse_services_by_project(r.stdout or "")