feat(cli): cross-backend list active + --backend flag + dashboard picker (issue #77) #78

Merged
didericis merged 3 commits from cli-backend-aware-list-and-flag into main 2026-05-27 19:18:52 -04:00
Collaborator

Summary

Addresses #77. The CLI and dashboard now share one cross-backend abstraction for listing + launching bottles — adding a backend 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 existing dashboard consumers; ActiveAgent becomes a typed alias for source-compat.
  • New BottleBackend.enumerate_active() -> Sequence[ActiveBottle] replaces the old list_active() -> None. Docker uses its existing compose query; smolmachines uses smolvm machine ls --json cross-referenced with each bundle container's CLAUDE_BOTTLE_SIDECAR_DAEMONS env (backend/smolmachines/enumerate.py).
  • New module-level enumerate_active_bottles() + known_backend_names() helpers.
  • get_bottle_backend(name=None) takes an explicit name (precedence: arg > env > "docker").

CLI

  • ./cli.py list active now enumerates every backend → tab-separated <backend>\t<slug>\t<agent>\t<services>. Smolmachines bottles show up.
  • ./cli.py start --backend=<docker|smolmachines> (choices pulled live from known_backend_names()).

Dashboard

  • Active agents pane lists both backends; row formatter prefixes [docker] / [smolmachines].
  • New-agent flow inserts a backend picker modal between agent pick and preflight. Short-circuits if only one backend is registered.
  • discover_active_agents() collapses to enumerate_active_bottles(); the parser/query helpers move into backend/docker/cleanup.py where the enumerator that owns them now lives.

Drift concern from the issue

Ensure the same core methods are being used for both, and that we have an abstraction which allows us to change the behavior in one place.

  • Listing: both go through enumerate_active_bottles().
  • Starting: both go through prepare_with_preflight() + attach_claude() (shared by CLI and dashboard; the dashboard now passes its modal-picked backend_name).
  • The --backend flag and the modal both feed into the same get_bottle_backend(name) resolver, so changing precedence changes both behaviors at once.

Tests

  • Parser + enumerate-active tests relocated to test_docker_enumerate_active.py (was test_dashboard_active_agents.py).
  • New test_backend_selection.py: covers get_bottle_backend, known_backend_names, enumerate_active_bottles.
  • New test_cli_start_backend_flag.py: argparse shape + explicit-over-env precedence.
  • 605 unit tests pass.

Not in this PR

The dashboard's re-attach + capability-block flows still synthesize a DockerBottle directly from the slug. Re-attaching a smolmachines bottle from the dashboard would require a backend-side "reconstitute from slug" path that doesn't exist yet — out of scope; tracked separately if it becomes blocking.

Closes #77.

## Summary Addresses [#77](https://gitea.dideric.is/didericis/claude-bottle/issues/77). The CLI and dashboard now share one cross-backend abstraction for listing + launching bottles — adding a backend 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 existing dashboard consumers; `ActiveAgent` becomes a typed alias for source-compat. - New `BottleBackend.enumerate_active() -> Sequence[ActiveBottle]` replaces the old `list_active() -> None`. Docker uses its existing compose query; smolmachines uses `smolvm machine ls --json` cross-referenced with each bundle container's `CLAUDE_BOTTLE_SIDECAR_DAEMONS` env (`backend/smolmachines/enumerate.py`). - New module-level `enumerate_active_bottles()` + `known_backend_names()` helpers. - `get_bottle_backend(name=None)` takes an explicit name (precedence: arg > env > "docker"). ### CLI - `./cli.py list active` now enumerates every backend → tab-separated `<backend>\t<slug>\t<agent>\t<services>`. Smolmachines bottles show up. - `./cli.py start --backend=<docker|smolmachines>` (choices pulled live from `known_backend_names()`). ### Dashboard - Active agents pane lists both backends; row formatter prefixes `[docker]` / `[smolmachines]`. - New-agent flow inserts a backend picker modal between agent pick and preflight. Short-circuits if only one backend is registered. - `discover_active_agents()` collapses to `enumerate_active_bottles()`; the parser/query helpers move into `backend/docker/cleanup.py` where the enumerator that owns them now lives. ### Drift concern from the issue > Ensure the same core methods are being used for both, and that we have an abstraction which allows us to change the behavior in one place. - Listing: both go through `enumerate_active_bottles()`. - Starting: both go through `prepare_with_preflight()` + `attach_claude()` (shared by CLI and dashboard; the dashboard now passes its modal-picked `backend_name`). - The `--backend` flag and the modal both feed into the same `get_bottle_backend(name)` resolver, so changing precedence changes both behaviors at once. ### Tests - Parser + enumerate-active tests relocated to `test_docker_enumerate_active.py` (was `test_dashboard_active_agents.py`). - New `test_backend_selection.py`: covers `get_bottle_backend`, `known_backend_names`, `enumerate_active_bottles`. - New `test_cli_start_backend_flag.py`: argparse shape + explicit-over-env precedence. - 605 unit tests pass. ### Not in this PR The dashboard's re-attach + capability-block flows still synthesize a `DockerBottle` directly from the slug. Re-attaching a smolmachines bottle from the dashboard would require a backend-side "reconstitute from slug" path that doesn't exist yet — out of scope; tracked separately if it becomes blocking. Closes #77.
didericis-claude added 1 commit 2026-05-27 18:27:34 -04:00
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
adff1263d8
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>
didericis reviewed 2026-05-27 18:45:28 -04:00
@@ -178,0 +175,4 @@
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:
Owner

create a "has_backend('docker')" function instead which sits outside this so we can determine whether a backend exists on a system. Use that instead of this one off

create a "has_backend('docker')" function instead which sits outside this so we can determine whether a backend exists on a system. Use that instead of this one off
didericis reviewed 2026-05-27 18:47:13 -04:00
@@ -175,3 +170,1 @@
ps = subprocess.run(
["docker", "compose", "-p", project, "ps", "--format",
"{{.Service}}\t{{.Name}}\t{{.Status}}"],
def enumerate_active() -> list[ActiveBottle]:
Owner

should probably go into an "enumerate" file to mirror the smolmachines backend structure

should probably go into an "enumerate" file to mirror the smolmachines backend structure
didericis reviewed 2026-05-27 18:48:27 -04:00
@@ -0,0 +70,4 @@
Smolmachines bundles all run the PRD-0024 image with the
same daemon set declared via env, so one inspect per bundle
gets us the picture without exec'ing into the container."""
if shutil.which("docker") is None:
Owner

same note RE one off/detecting backend availability

same note RE one off/detecting backend availability
didericis reviewed 2026-05-27 18:50:01 -04:00
@@ -109,0 +103,4 @@
# Field surface stays compatible (slug / agent_name / started_at
# / services) plus a new `backend_name` so dashboard rows can
# show which backend a bottle came from.
ActiveAgent = ActiveBottle
Owner

Don't bother with backwards compatibility/use the updated one.

But it should be "Agent"... the thing running (bottle+agent) is always referred to as an "agent", not a "bottle".

Don't bother with backwards compatibility/use the updated one. But it should be "Agent"... the thing running (bottle+agent) is always referred to as an "agent", not a "bottle".
didericis added 1 commit 2026-05-27 19:03:20 -04:00
refactor(backend): has_backend() helper + docker/enumerate split + ActiveAgent rename
test / unit (pull_request) Successful in 28s
test / integration (pull_request) Successful in 42s
3b418580a9
Addresses PR #78 review feedback:

- New `has_backend(name)` on the backend package + abstract
  `BottleBackend.is_available()` on each concrete subclass.
  Replaces inline `shutil.which("docker") is None` checks in
  docker/cleanup.py:178 and smolmachines/enumerate.py:73.
  Docker → `shutil.which("docker") is not None`; smolmachines
  → `smolvm.is_available()`. Cross-backend `enumerate_active_
  agents()` skips backends whose `is_available()` is False so a
  docker-only host doesn't fail when iterating past
  smolmachines (and vice versa).
- Move docker `enumerate_active` + parser helpers out of
  cleanup.py into a new `backend/docker/enumerate.py`, mirroring
  the smolmachines/enumerate.py layout. cleanup.py is now
  purely about prepare_cleanup / cleanup; the active-listing
  concern owns its own file.
- Drop the `ActiveAgent = ActiveBottle` alias in dashboard.py.
  The canonical name is `ActiveAgent` (the thing running inside
  a bottle is always called "agent" in this codebase; the bottle
  is the container). Renamed `enumerate_active_bottles` →
  `enumerate_active_agents` to match.

Tests:
- `test_backend_selection.TestEnumerateActiveAgents
  .test_skips_unavailable_backends` locks down the
  `is_available()`-gated iteration.
- New `TestHasBackend` covers `has_backend("docker")` consulting
  the backend's `is_available`, and unknown-name → False.
- Existing tests follow the rename; the docker-availability-
  side-effect test in `test_docker_enumerate_active` moves up
  to the cross-backend layer (where the gate lives now).

607 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
didericis-claude reviewed 2026-05-27 19:04:31 -04:00
didericis-claude left a comment
Author
Collaborator

Review feedback addressed in 3b41858.

Review feedback addressed in 3b41858.
@@ -166,5 +166,5 @@
except OSError as e:
warn(f"failed to remove {path}: {e}")
def enumerate_active() -> list[ActiveBottle]:
Author
Collaborator

Done in 3b41858 — moved enumerate_active + parser + query helpers into a new backend/docker/enumerate.py, mirroring backend/smolmachines/enumerate.py. cleanup.py is now purely prepare/cleanup.

Done in 3b41858 — moved `enumerate_active` + parser + query helpers into a new `backend/docker/enumerate.py`, mirroring `backend/smolmachines/enumerate.py`. `cleanup.py` is now purely prepare/cleanup.
@@ -174,5 +174,5 @@
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:
Author
Collaborator

Done in 3b41858 — added abstract BottleBackend.is_available() (docker → shutil.which('docker'), smolmachines → smolvm.is_available()) and a top-level has_backend(name) on the backend package. Cross-backend enumerate_active_agents() gates on it, so neither inline shutil.which check exists anymore.

Done in 3b41858 — added abstract `BottleBackend.is_available()` (docker → `shutil.which('docker')`, smolmachines → `smolvm.is_available()`) and a top-level `has_backend(name)` on the backend package. Cross-backend `enumerate_active_agents()` gates on it, so neither inline `shutil.which` check exists anymore.
@@ -0,0 +70,4 @@
def _query_bundle_services() -> dict[str, tuple[str, ...]]:
"""`{slug: ('egress', 'pipelock', ...)}` from each running
bundle container's `CLAUDE_BOTTLE_SIDECAR_DAEMONS` env var.
Author
Collaborator

Done in 3b41858 — same has_backend('docker') gate replaces the inline shutil.which. (Late-imported inside _query_bundle_services() to sidestep the cycle, since backend/__init__.py imports this module transitively.)

Done in 3b41858 — same `has_backend('docker')` gate replaces the inline `shutil.which`. (Late-imported inside `_query_bundle_services()` to sidestep the cycle, since `backend/__init__.py` imports this module transitively.)
@@ -169,0 +103,4 @@
backend is reachable. Backed by the shared
`enumerate_active_agents` helper so the CLI's
`./cli.py list active` and this dashboard show the same data."""
return enumerate_active_agents()
Author
Collaborator

Done in 3b41858ActiveBottle renamed to ActiveAgent everywhere (backend package, both backends, CLI, dashboard, tests). The ActiveAgent = ActiveBottle alias is gone; enumerate_active_bottles is now enumerate_active_agents.

Done in 3b41858 — `ActiveBottle` renamed to `ActiveAgent` everywhere (backend package, both backends, CLI, dashboard, tests). The `ActiveAgent = ActiveBottle` alias is gone; `enumerate_active_bottles` is now `enumerate_active_agents`.
didericis reviewed 2026-05-27 19:11:35 -04:00
@@ -184,1 +164,3 @@
print()
# `enumerate_active` moved to `backend/docker/enumerate.py` to
# mirror the smolmachines layout. Cleanup keeps the orphan
# enumeration; enumeration of live agents is its own concern.
Owner

remove this comment

remove this comment
didericis reviewed 2026-05-27 19:14:44 -04:00
@@ -13,3 +3,1 @@
The actual `docker ps` invocation is exercised by manual probing
during development and the (real-docker) integration tests; here
we lock down the shape contract so a regression surfaces in unit CI.
The active-bottle enumeration tests moved to
Owner

no need to leave this comment

no need to leave this comment
didericis reviewed 2026-05-27 19:15:25 -04:00
@@ -0,0 +174,4 @@
)
# `noop when docker missing` lives at the cross-backend gate
# now (`enumerate_active_agents` skips backends whose
Owner

also remove this

also remove this
didericis added 1 commit 2026-05-27 19:16:31 -04:00
style(backend): drop stale "moved/removed" pointer comments
test / unit (pull_request) Successful in 25s
test / integration (pull_request) Successful in 41s
test / unit (push) Successful in 27s
test / integration (push) Successful in 41s
5d740a6948
PR #78 review comments 580, 582, 584. Each was a comment
describing what the previous refactor removed or relocated —
information that's in git history, not load-bearing for a
reader of the file as-is.

- claude_bottle/backend/docker/cleanup.py: drop trailing
  "enumerate_active moved to enumerate.py" note.
- tests/unit/test_dashboard_active_agents.py: drop module
  docstring paragraph about which tests moved where.
- tests/unit/test_docker_enumerate_active.py: drop
  "noop-when-docker-missing lives at the cross-backend gate
  now" trailing comment.

607 tests still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
didericis-claude reviewed 2026-05-27 19:16:41 -04:00
didericis-claude left a comment
Author
Collaborator

Stale pointer comments removed in 5d740a6.

Stale pointer comments removed in 5d740a6.
@@ -162,5 +162,5 @@
# `enumerate_active` moved to `backend/docker/enumerate.py` to
# mirror the smolmachines layout. Cleanup keeps the orphan
# enumeration; enumeration of live agents is its own concern.
Author
Collaborator

Removed in 5d740a6.

Removed in 5d740a6.
@@ -16,3 +1,3 @@
"""
"""Unit: dashboard's row-formatting + selection helpers (PRD 0019)."""
from __future__ import annotations
Author
Collaborator

Removed in 5d740a6.

Removed in 5d740a6.
@@ -0,0 +174,4 @@
)
if __name__ == "__main__":
Author
Collaborator

Removed in 5d740a6.

Removed in 5d740a6.
didericis approved these changes 2026-05-27 19:18:41 -04:00
didericis merged commit 5d740a6948 into main 2026-05-27 19:18:52 -04:00
didericis deleted branch cli-backend-aware-list-and-flag 2026-05-27 19:18:53 -04:00
Sign in to join this conversation.