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
+69 -16
View File
@@ -19,12 +19,14 @@ backend exposes five methods:
cleanup(plan) -> None
Actually removes everything described by the cleanup plan.
list_active() -> None
Print every currently-running bottle on this backend to stderr.
enumerate_active() -> Sequence[ActiveBottle]
Return every currently-running bottle on this backend, with
enough metadata for callers (CLI `list active`, dashboard
agents pane) to render a row.
Selection is driven by CLAUDE_BOTTLE_BACKEND (default "docker"). Per
PRD 0003 the manifest does not carry a backend field; the host
environment picks.
Selection is driven by `--backend` on `start` or
CLAUDE_BOTTLE_BACKEND (env var; default "docker"). Per PRD 0003 the
manifest does not carry a backend field; the host picks.
"""
from __future__ import annotations
@@ -103,6 +105,26 @@ class ExecResult:
stderr: str
@dataclass(frozen=True)
class ActiveBottle:
"""One currently-running bottle, as the CLI `list active` and
dashboard agents pane render it.
Fields are deliberately backend-neutral. `services` is the set
of sidecar daemons currently up for this bottle (`pipelock`,
`egress`, `git-gate`, `supervise`); the dashboard uses it to
gate edit verbs. `backend_name` is the matching key in
`_BACKENDS` (`docker` / `smolmachines`) — used by the active-
list rendering to disambiguate and by the dashboard's
re-attach path."""
backend_name: str
slug: str
agent_name: str # from metadata.json; "?" if missing
started_at: str # ISO 8601 from metadata.json; "" if missing
services: tuple[str, ...] # alphabetical
class Bottle(ABC):
"""Handle to a running bottle. Yielded by a backend's launch step.
@@ -280,9 +302,11 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""Remove everything described by the cleanup plan."""
@abstractmethod
def list_active(self) -> None:
"""Print every currently-running bottle on this backend to
stderr (name + status)."""
def enumerate_active(self) -> Sequence[ActiveBottle]:
"""Return every currently-running bottle on this backend.
Empty when none. Backend-specific: docker queries `docker
compose ls`; smolmachines queries `smolvm machine ls --json`
+ cross-references its bundle container."""
# Import concrete backend classes AFTER the base types are defined, so
@@ -302,23 +326,52 @@ _BACKENDS: dict[str, BottleBackend[Any, Any]] = {
}
def get_bottle_backend() -> BottleBackend[Any, Any]:
"""Resolve the bottle backend for the active environment. Dies with
a pointer at the known backends if CLAUDE_BOTTLE_BACKEND names an
unimplemented one."""
name = os.environ.get("CLAUDE_BOTTLE_BACKEND", "docker")
if name not in _BACKENDS:
def get_bottle_backend(
name: str | None = None,
) -> BottleBackend[Any, Any]:
"""Resolve the bottle backend.
`name` precedence:
1. explicit arg (CLI `--backend=<name>` passes through here)
2. CLAUDE_BOTTLE_BACKEND env var
3. default `docker`
Dies with a pointer at the known backends if the chosen name
isn't implemented."""
resolved = name or os.environ.get("CLAUDE_BOTTLE_BACKEND") or "docker"
if resolved not in _BACKENDS:
known = ", ".join(sorted(_BACKENDS))
die(f"unknown CLAUDE_BOTTLE_BACKEND={name!r}; known backends: {known}")
return _BACKENDS[name]
die(f"unknown backend {resolved!r}; known backends: {known}")
return _BACKENDS[resolved]
def known_backend_names() -> tuple[str, ...]:
"""Sorted tuple of all backend keys in `_BACKENDS`. Used by
argparse (`--backend` choices) and the dashboard's backend
picker."""
return tuple(sorted(_BACKENDS))
def enumerate_active_bottles() -> list[ActiveBottle]:
"""All currently-running bottles, across every backend. Used by
CLI `list active` and the dashboard's agents pane so neither
has to know which backends exist. Ordered by backend name,
then slug."""
out: list[ActiveBottle] = []
for name in known_backend_names():
out.extend(_BACKENDS[name].enumerate_active())
return out
__all__ = [
"ActiveBottle",
"Bottle",
"BottleBackend",
"BottleCleanupPlan",
"BottlePlan",
"BottleSpec",
"ExecResult",
"enumerate_active_bottles",
"get_bottle_backend",
"known_backend_names",
]