Commit Graph

20 Commits

Author SHA1 Message Date
didericis 18d29fc23f refactor(bottles): two-phase cleanup parallel to prepare/launch
test / run tests/run_tests.py (pull_request) Successful in 13s
cmd_cleanup used to only sweep running containers via `docker ps`,
missing stopped pipelock sidecars and orphaned networks entirely. On
my host the new version surfaced ~10 stranded networks left behind by
SIGKILLed sessions — the kind of thing the old command implied it was
handling.

New shape, symmetric with start:
- BottleCleanupPlan (abstract, in bottles/__init__.py) with `print` +
  `empty` abstract members.
- DockerBottleCleanupPlan (concrete, in bottles/docker.py) carrying
  the resolved tuples of containers and networks.
- BottlePlatform gains abstract prepare_cleanup() + cleanup(plan).
  DockerBottlePlatform implements both:
    - prepare_cleanup: docker ps -a + docker network ls, both
      filtered to ^claude-bottle-, sorted for stable output.
    - cleanup: docker rm -f containers first (they hold the network
      attachment), then docker network rm.
- cmd_cleanup is now ~25 lines: prepare → print → y/N → cleanup.
2026-05-10 23:14:54 -04:00
didericis 4a45c267f3 refactor(cli): remove redundant 'build' command
test / run tests/run_tests.py (pull_request) Successful in 13s
DockerBottlePlatform.launch already runs 'docker build' on every
start, and the layer cache makes no-change rebuilds nearly free.
Anything 'cli.py build' did, 'cli.py start' does for you.
2026-05-10 23:05:24 -04:00
didericis 5f82044403 refactor(bottles): move _run_agent_container and _provision_container onto the platform class
test / run tests/run_tests.py (pull_request) Successful in 14s
They were the only remaining module-level helpers in docker.py besides
runsc_available(); promote them to private methods on DockerBottlePlatform
so all the Docker orchestration logic lives in one place.
2026-05-10 23:02:21 -04:00
didericis 7ab35a5e2a refactor(bottles): absorb prepare/launch fns into DockerBottlePlatform
test / run tests/run_tests.py (pull_request) Successful in 14s
The module-level prepare_docker_bottle and launch_docker_bottle were
trivial delegations from the platform class. Move their bodies onto
DockerBottlePlatform.prepare and .launch directly. The launch method
keeps the @contextmanager decorator; isinstance narrows BottlePlan
to DockerBottlePlan at the entry point.

Internal helpers (_run_agent_container, _provision_container,
runsc_available) stay at module scope — they're stateless and don't
benefit from self.

No behavior change. Tests pass; dry-run output unchanged.
2026-05-10 23:00:07 -04:00
didericis e22a96e511 refactor(bottles): BottlePlatform becomes ABC; DockerBottlePlatform in docker.py
test / run tests/run_tests.py (pull_request) Successful in 18s
Mirror the BottlePlan -> DockerBottlePlan hierarchy at the platform
layer. BottlePlatform is now an abstract base with abstract `prepare`
and `launch` methods; DockerBottlePlatform lives alongside the rest of
the Docker code in bottles/docker.py and supplies the concrete impls.

The registry in bottles/__init__.py now holds an instance of each
concrete platform class. Future per-platform state (region, api
token, cleanup primitives) has a natural home on the subclass rather
than being stitched onto a dataclass struct.

No behavior change. Tests pass; dry-run output unchanged.
2026-05-10 22:56:47 -04:00
didericis 2827d9b899 refactor(bottles): introduce BottlePlan base + move print onto plan
test / run tests/run_tests.py (pull_request) Successful in 19s
- Add BottlePlan (frozen dataclass + ABC) with spec, stage_dir, and an
  abstract `print(*, remote_control)` method.
- DockerBottlePlan now inherits from BottlePlan; spec/stage_dir come
  from the base, Docker-specific fields stay on the subclass.
- Move BottleSpec from bottles/docker.py to bottles/__init__.py so the
  cross-platform types live together. docker.py pulls them via
  `from . import ...`.
- Move show_plan from cli/start.py to `DockerBottlePlan.print`. Caller
  becomes `plan.print(remote_control=...)`. The CLI no longer reads
  any Docker-specific fields.
- BottlePlatform.prepare is now typed `Callable[..., BottlePlan]`.

cmd_start drops ~46 more lines.
2026-05-10 22:49:57 -04:00
didericis 236c4fa50c refactor(bottles): rename DockerBottleSpec to BottleSpec
test / run tests/run_tests.py (pull_request) Successful in 13s
The spec is intent-only and platform-agnostic — only the plan carries
Docker-specific fields. Drop the 'Docker' prefix and re-export from
claude_bottle.bottles so callers see it as cross-platform.
2026-05-10 22:40:19 -04:00
didericis 4f16b3a9e1 refactor(bottles): split factory into prepare + launch phases
test / run tests/run_tests.py (pull_request) Successful in 15s
The Docker factory had absorbed live container ops but left the
host-side prep (image-name resolution, container-name collision
retry, pipelock yaml generation, env_resolve writes, host
validation) in cli/start.py. That kept ~half the Docker-specific
logic outside the abstraction.

Split the factory into two phases:

  prepare_docker_bottle(spec, stage_dir=...) -> DockerBottlePlan
      Resolves names, validates skills/SSH, writes scratch files.
      No Docker resources created yet.

  launch_docker_bottle(plan) -> ContextManager[Bottle]
      Builds image, creates networks, boots pipelock, runs the
      agent container, provisions files. Teardown on exit.

DockerBottleSpec shrinks to intent-only inputs (manifest, agent
name, --cwd flag, user_cwd, forward_oauth_token). The CLI no longer
references docker_mod, pipelock, skills, ssh, or env_resolve.

get_bottle_factory becomes get_bottle_platform returning a
BottlePlatform with .prepare and .launch — one selectable thing per
platform.

The Bottle handle now remembers the in-container prompt path and
adds --append-system-prompt-file to claude's argv when present, so
the CLI no longer needs to know the path.

cmd_start: ~148 lines down from 229. Tests pass; dry-run output
byte-identical.
2026-05-10 22:36:26 -04:00
didericis a284d85296 refactor(start): show_plan now takes DockerBottleSpec
test / run tests/run_tests.py (pull_request) Successful in 15s
2026-05-10 22:23:40 -04:00
didericis 7500ba230c refactor(start): extract show_plan from cmd_start
test / run tests/run_tests.py (pull_request) Successful in 15s
2026-05-10 22:20:33 -04:00
didericis d75cc9325f feat(bottles): implement bottle factory abstraction per PRD 0003
test / run tests/run_tests.py (pull_request) Successful in 16s
Introduce claude_bottle/bottles/ with a Bottle Protocol and a
get_bottle_factory() that dispatches on CLAUDE_BOTTLE_PLATFORM
(default "docker"). Move every Docker-specific subprocess.run call
from cli/start.py, plus the orchestration of build, networks, the
pipelock sidecar, container launch, and per-container provisioning
(prompt, skills, ssh, .git), into create_docker_bottle.

Drop bottles[].runtime from the manifest schema. Auto-detect whether
gVisor is registered with the daemon and pass --runtime=runsc when it
is; the preflight shows the resolved runtime so the choice is visible.
Manifests still carrying 'runtime' get a clear error pointing at the
auto-detect behavior, rather than silent ignore.

Out of scope: cli/cleanup.py and cli/list.py still call docker
directly. They enumerate active bottles across the host, which is a
separate concern from "create a bottle" and is left for a follow-up
that introduces a list_active/cleanup primitive on the factory.
2026-05-10 22:15:05 -04:00
didericis 9343f6f21d refactor(manifest): drop _json_type, use type(x).__name__ in error messages
test / run tests/run_tests.py (pull_request) Successful in 14s
The jq-style mapping (bool→"boolean", list→"array", None→"null", etc.)
existed only to match the original bash error wording. Not worth the
extra function; Python's native type names are clear enough.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 21:36:38 -04:00
didericis e9a3de49af fix(types): make manifest.py clean under pyright strict
test / run tests/run_tests.py (pull_request) Successful in 14s
- log.die() typed NoReturn so pyright knows it terminates control flow
  (was returning the unreachable Die instance type).
- manifest.py: raw inputs typed object (not Any) and narrowed via a new
  _as_json_object helper that validates str keys and returns
  dict[str, object]. Eliminates the Unknown cascade through .get()
  calls under strict.
- _from_dict classmethods renamed to from_dict so cross-class
  construction (Bottle.from_dict from Manifest.from_json_obj, etc.)
  doesn't trip reportPrivateUsage.
- _SUPPORTED_RUNTIMES typed tuple[Runtime, ...] so the membership
  check narrows runtime_raw to Literal["runc", "runsc"] and the
  # type: ignore[assignment] is no longer needed.
- Bottle.env uses a typed _empty_str_dict factory; bare dict resolves
  to dict[Unknown, Unknown] under strict.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 21:34:03 -04:00
didericis 1f36d53f7b refactor(manifest): convert TypedDict to frozen dataclasses
test / run tests/run_tests.py (pull_request) Successful in 14s
Replace the TypedDict + 14 manifest_* free functions with frozen
dataclasses (SshEntry, BottleEgress, Bottle, Agent, Manifest) carrying
their own validators and constructors. Call sites import Manifest and
chain attribute access; the manifest_* helpers and manifest_validate
are gone.

Behavior changes worth flagging:
- Agent.bottle is now required (was optional with a "(none)" fallback).
  Manifest.from_json_obj dies if any agent lacks a 'bottle' field or
  references an undefined bottle, where previously start.py raised the
  error lazily for the specific agent being launched.
- ssh.py now takes SshEntry instances; Host/IdentityFile shape checks
  moved upstream into Manifest construction, leaving only the IdentityFile
  filesystem-existence check in ssh_validate_entries.
- pipelock_bottle_allowlist's per-element string check is dropped — the
  Manifest validator enforces it at load.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 21:20:15 -04:00
didericis 36cb0c53bf refactor(manifest): add TypedDict schema and eager validation
test / run tests/run_tests.py (pull_request) Successful in 20s
Move schema checks out of per-access getters into a single
manifest_validate pass invoked by manifest_resolve. Getters can now
assume bottles/agents are well-typed dicts and every agent has a
defined bottle, so the .get(...) or {} chains collapse. Behavior
change: a bad runtime / shape error anywhere in the manifest now
fails at load instead of on the N-th read.

Intermediate step toward replacing TypedDict with a dataclass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 21:08:54 -04:00
didericis e3f5a5907a feat(bottle): opt-in gVisor runtime per bottle
test / run tests/run_tests.py (push) Successful in 19s
Bottles can now set "runtime": "runsc" to launch the agent container
under gVisor instead of runc, adding a userspace syscall barrier
between the agent and the host kernel. Default is runc (Docker
default). Pipelock stays on the default runtime per the research doc's
minimum-diff prescription.

The launcher verifies runsc is registered with the daemon before
launch, surfaces the runtime in the preflight plan, and dies with an
install pointer (and a macOS-not-supported note) when runsc is
requested but unavailable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 00:48:11 -04:00
didericis 4ebfcec2f7 fix(cli): make 'build --help' actually print help
test / run tests/run_tests.py (push) Successful in 15s
cmd_build was ignoring its argv, so 'cli.py build --help' fell through
and started a docker build instead of printing the subcommand's
argparse help. Wire up an empty parser so --help and unknown args are
handled the same way the other subcommands handle them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 00:16:17 -04:00
didericis f817847dff refactor(cli): split claude_bottle/cli.py into a package
test / run tests/run_tests.py (push) Successful in 20s
One file per subcommand under claude_bottle/cli/, with shared constants
and the tty helper in _common.py and dispatch in __init__.py. The
public import (from claude_bottle.cli import main) is unchanged, so
the root cli.py entrypoint and the test suite see no surface change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 00:15:16 -04:00
didericis 3f03d65e73 chore: tidy gitignore, Dockerfile indent, unused import
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:51:59 -04:00
didericis 399ed93dc8 refactor: convert project from bash to Python
Replaces cli.sh + lib/*.sh with a claude_bottle/ Python package and a
cli.py entry point. No external dependencies — uses only Python's
stdlib (json, subprocess, getpass, tempfile, argparse, re, etc.).

- claude_bottle/{log,docker,manifest,env_resolve,network,pipelock,
  skills,ssh,cli}.py mirror the previous lib/*.sh modules.
- Tests converted to unittest under tests/test_*.py with a stdlib
  runner at tests/run_tests.py (unit | integration | path).
- .githooks/commit-msg ported to Python; same Conventional Commits rules.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:26:58 +00:00