refactor: rename platform abstraction to backend
test / run tests/run_tests.py (pull_request) Successful in 21s

Across the package:
  - claude_bottle/platform/         -> claude_bottle/backend/
  - platform/docker/platform.py     -> backend/docker/backend.py
  - class BottlePlatform            -> BottleBackend
  - class DockerBottlePlatform      -> DockerBottleBackend
  - get_bottle_platform()           -> get_bottle_backend()
  - env var CLAUDE_BOTTLE_PLATFORM  -> CLAUDE_BOTTLE_BACKEND
  - dict _PLATFORMS                 -> _BACKENDS

"Backend" is shorter and more established as the term for a
pluggable strategy-pattern implementation. "Platform" was vague
(could mean OS, hardware, cloud) and mildly redundant — Docker is
itself a platform.

The previous PRD section claiming "the Backend protocol was
rejected" referred to a low-level run/exec/cp/network_connect
protocol; the name was never the reason. The PRD is updated to
describe that rejected design by shape rather than by name.

The bottle/agent concepts and the manifest schema are unchanged.
This commit is contained in:
2026-05-10 23:59:38 -04:00
parent c79966731c
commit 70a22fa210
13 changed files with 91 additions and 87 deletions
@@ -1,7 +1,7 @@
"""Per-platform bottle factories.
"""Per-backend bottle factories.
A bottle is a running, isolated environment with claude inside. Each
platform exposes four methods:
backend exposes five methods:
prepare(spec, stage_dir=...) -> BottlePlan
Resolves names, validates host-side prerequisites, and writes
@@ -19,8 +19,11 @@ platform exposes four methods:
cleanup(plan) -> None
Actually removes everything described by the cleanup plan.
Selection is driven by CLAUDE_BOTTLE_PLATFORM (default "docker"). Per
PRD 0003 the manifest does not carry a platform field; the host
list_active() -> None
Print every currently-running bottle on this backend to stderr.
Selection is driven by CLAUDE_BOTTLE_BACKEND (default "docker"). Per
PRD 0003 the manifest does not carry a backend field; the host
environment picks.
"""
@@ -38,8 +41,8 @@ from ..manifest import Manifest
@dataclass(frozen=True)
class BottleSpec:
"""CLI-supplied intent. Platform-agnostic — each platform's prepare
step consumes it and produces its own platform-specific plan.
"""CLI-supplied intent. Backend-agnostic — each backend's prepare
step consumes it and produces its own backend-specific plan.
Resolved values (image names, container name, scratch paths, runsc
availability) live on the plan, not the spec."""
@@ -52,8 +55,8 @@ class BottleSpec:
@dataclass(frozen=True)
class BottlePlan(ABC):
"""Base output of a platform's prepare step. Concrete subclasses
(e.g. DockerBottlePlan) add platform-specific resolved fields and
"""Base output of a backend's prepare step. Concrete subclasses
(e.g. DockerBottlePlan) add backend-specific resolved fields and
implement `print`."""
spec: BottleSpec
@@ -66,8 +69,8 @@ class BottlePlan(ABC):
@dataclass(frozen=True)
class BottleCleanupPlan(ABC):
"""Base output of a platform's prepare_cleanup step. Concrete
subclasses (e.g. DockerBottleCleanupPlan) carry platform-specific
"""Base output of a backend's prepare_cleanup step. Concrete
subclasses (e.g. DockerBottleCleanupPlan) carry backend-specific
lists of resources to be removed and implement `print` + `empty`."""
@abstractmethod
@@ -82,7 +85,7 @@ class BottleCleanupPlan(ABC):
class Bottle(ABC):
"""Handle to a running bottle. Yielded by a platform's launch step.
"""Handle to a running bottle. Yielded by a backend's launch step.
`exec_claude` runs `claude` inside the bottle and blocks until the
session ends. `cp_in` copies a host path into the bottle. `close`
@@ -101,9 +104,9 @@ class Bottle(ABC):
def close(self) -> None: ...
class BottlePlatform(ABC):
"""Abstract base for selectable bottle platforms. Concrete subclasses
(e.g. DockerBottlePlatform) own their own prepare/launch impls.
class BottleBackend(ABC):
"""Abstract base for selectable bottle backends. Concrete subclasses
(e.g. DockerBottleBackend) own their own prepare/launch impls.
Symmetric with the BottlePlan DockerBottlePlan hierarchy."""
name: str
@@ -128,37 +131,37 @@ class BottlePlatform(ABC):
@abstractmethod
def list_active(self) -> None:
"""Print every currently-running bottle on this platform to
"""Print every currently-running bottle on this backend to
stderr (name + status)."""
# Import concrete platform classes AFTER the base types are defined, so
# each platform module can pull BottleSpec / BottlePlan / BottlePlatform
# Import concrete backend classes AFTER the base types are defined, so
# each backend module can pull BottleSpec / BottlePlan / BottleBackend
# via `from . import ...` without hitting a partially-initialized module.
from .docker import DockerBottlePlatform # noqa: E402
from .docker import DockerBottleBackend # noqa: E402
_PLATFORMS: dict[str, BottlePlatform] = {
"docker": DockerBottlePlatform(),
_BACKENDS: dict[str, BottleBackend] = {
"docker": DockerBottleBackend(),
}
def get_bottle_platform() -> BottlePlatform:
"""Resolve the bottle platform for the active environment. Dies with
a pointer at the known platforms if CLAUDE_BOTTLE_PLATFORM names an
def get_bottle_backend() -> BottleBackend:
"""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_PLATFORM", "docker")
if name not in _PLATFORMS:
known = ", ".join(sorted(_PLATFORMS))
die(f"unknown CLAUDE_BOTTLE_PLATFORM={name!r}; known platforms: {known}")
return _PLATFORMS[name]
name = os.environ.get("CLAUDE_BOTTLE_BACKEND", "docker")
if name not in _BACKENDS:
known = ", ".join(sorted(_BACKENDS))
die(f"unknown CLAUDE_BOTTLE_BACKEND={name!r}; known backends: {known}")
return _BACKENDS[name]
__all__ = [
"Bottle",
"BottleBackend",
"BottleCleanupPlan",
"BottlePlan",
"BottlePlatform",
"BottleSpec",
"get_bottle_platform",
"get_bottle_backend",
]
@@ -1,28 +1,29 @@
"""Docker bottle platform.
"""Docker bottle backend.
The bulk of the implementation lives in sibling modules:
- util: thin Docker subprocess wrappers
- network: Docker network plumbing
- bottle_plan: DockerBottlePlan
- bottle_cleanup_plan: DockerBottleCleanupPlan
- bottle: DockerBottle handle
- platform: DockerBottlePlatform
- backend: DockerBottleBackend
This file only re-exports the public names so
`from claude_bottle.platform.docker import DockerBottlePlatform` keeps
`from claude_bottle.backend.docker import DockerBottleBackend` keeps
working.
"""
from __future__ import annotations
from .backend import DockerBottleBackend
from .bottle import DockerBottle
from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_plan import DockerBottlePlan
from .platform import DockerBottlePlatform
__all__ = [
"DockerBottle",
"DockerBottleBackend",
"DockerBottleCleanupPlan",
"DockerBottlePlan",
"DockerBottlePlatform",
]
@@ -1,4 +1,4 @@
"""DockerBottlePlatform — the Docker implementation of BottlePlatform.
"""DockerBottleBackend — the Docker implementation of BottleBackend.
Methods:
.prepare(spec, stage_dir=...) -> DockerBottlePlan
@@ -22,7 +22,7 @@ from ... import skills as skills_mod
from ... import ssh as ssh_mod
from ...env_resolve import env_resolve
from ...log import die, info
from .. import BottleCleanupPlan, BottlePlan, BottlePlatform, BottleSpec
from .. import BottleBackend, BottleCleanupPlan, BottlePlan, BottleSpec
from . import network as network_mod
from . import util as docker_mod
from .bottle import DockerBottle
@@ -34,8 +34,8 @@ from .bottle_plan import DockerBottlePlan
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
class DockerBottlePlatform(BottlePlatform):
"""Docker platform implementation. Selected by CLAUDE_BOTTLE_PLATFORM
class DockerBottleBackend(BottleBackend):
"""Docker backend implementation. Selected by CLAUDE_BOTTLE_BACKEND
(default)."""
name = "docker"
@@ -131,7 +131,7 @@ class DockerBottlePlatform(BottlePlatform):
def launch(self, plan: BottlePlan) -> Iterator[DockerBottle]:
"""Build, launch, and provision a Docker bottle. Teardown on exit."""
assert isinstance(plan, DockerBottlePlan), (
f"DockerBottlePlatform.launch expects DockerBottlePlan, "
f"DockerBottleBackend.launch expects DockerBottlePlan, "
f"got {type(plan).__name__}"
)
@@ -358,7 +358,7 @@ class DockerBottlePlatform(BottlePlatform):
Containers first; networks would refuse to delete while
containers are still attached."""
assert isinstance(plan, DockerBottleCleanupPlan), (
f"DockerBottlePlatform.cleanup expects DockerBottleCleanupPlan, "
f"DockerBottleBackend.cleanup expects DockerBottleCleanupPlan, "
f"got {type(plan).__name__}"
)
for name in plan.containers:
@@ -1,5 +1,5 @@
"""DockerBottle — concrete Bottle handle yielded by
DockerBottlePlatform.launch.
DockerBottleBackend.launch.
Holds the container name plus the in-container prompt path so
exec_claude can transparently add --append-system-prompt-file when a
@@ -1,7 +1,7 @@
"""DockerBottleCleanupPlan — concrete subclass of BottleCleanupPlan.
Holds the tuples of container and network names that
DockerBottlePlatform.cleanup will remove. The y/N preflight reads
DockerBottleBackend.cleanup will remove. The y/N preflight reads
these via `print`; the CLI short-circuits via `empty`.
"""
@@ -16,7 +16,7 @@ from .. import BottleCleanupPlan
@dataclass(frozen=True)
class DockerBottleCleanupPlan(BottleCleanupPlan):
"""Resources DockerBottlePlatform.cleanup will remove. Produced by
"""Resources DockerBottleBackend.cleanup will remove. Produced by
`prepare_cleanup` from a snapshot of `docker ps -a` + `docker
network ls`; sorted so the y/N output is stable."""
@@ -1,7 +1,7 @@
"""DockerBottlePlan — concrete subclass of BottlePlan.
Carries the Docker-specific resolved fields produced by
DockerBottlePlatform.prepare. The launch step consumes it without
DockerBottleBackend.prepare. The launch step consumes it without
further resolution; show_plan-style rendering is the `print` method.
"""
@@ -18,7 +18,7 @@ from .. import BottlePlan
@dataclass(frozen=True)
class DockerBottlePlan(BottlePlan):
"""Docker-specific resolved fields produced by
DockerBottlePlatform.prepare. Inherits `spec` and `stage_dir` from
DockerBottleBackend.prepare. Inherits `spec` and `stage_dir` from
BottlePlan."""
slug: str
@@ -1,4 +1,4 @@
"""Docker host-side primitives used by DockerBottlePlatform: probing
"""Docker host-side primitives used by DockerBottleBackend: probing
for docker on PATH, slugifying agent names, checking image/container
existence, and building images."""
+4 -4
View File
@@ -5,14 +5,14 @@ from __future__ import annotations
import sys
from ..platform import get_bottle_platform
from ..backend import get_bottle_backend
from ..log import info
from ._common import read_tty_line
def cmd_cleanup(_argv: list[str]) -> int:
platform = get_bottle_platform()
plan = platform.prepare_cleanup()
backend = get_bottle_backend()
plan = backend.prepare_cleanup()
if plan.empty:
info("no claude-bottle resources to clean up")
@@ -26,6 +26,6 @@ def cmd_cleanup(_argv: list[str]) -> int:
info("aborted")
return 0
platform.cleanup(plan)
backend.cleanup(plan)
info("done")
return 0
+2 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import argparse
from ..platform import get_bottle_platform
from ..backend import get_bottle_backend
from ..manifest import Manifest
from ._common import PROG, USER_CWD
@@ -20,5 +20,5 @@ def cmd_list(argv: list[str]) -> int:
print(name)
return 0
get_bottle_platform().list_active()
get_bottle_backend().list_active()
return 0
+4 -4
View File
@@ -11,7 +11,7 @@ import sys
import tempfile
from pathlib import Path
from ..platform import BottleSpec, get_bottle_platform
from ..backend import BottleSpec, get_bottle_backend
from ..log import info
from ..manifest import Manifest
from ._common import PROG, USER_CWD, read_tty_line
@@ -38,8 +38,8 @@ def cmd_start(argv: list[str]) -> int:
stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage."))
try:
platform = get_bottle_platform()
plan = platform.prepare(spec, stage_dir=stage_dir)
backend = get_bottle_backend()
plan = backend.prepare(spec, stage_dir=stage_dir)
plan.print(remote_control=args.remote_control)
if dry_run:
@@ -53,7 +53,7 @@ def cmd_start(argv: list[str]) -> int:
info("aborted by user")
return 0
with platform.launch(plan) as bottle:
with backend.launch(plan) as bottle:
info(
"attaching interactive claude session "
"(Ctrl-D or 'exit' to leave; container will be removed)"