feat(cli): add --format=json to start --dry-run for machine-readable plan

BottlePlan gains a to_dict method (abstract on the base, implemented
on DockerBottlePlan) returning a JSON-serializable view of the resolved
plan. `cli.py start --dry-run --format=json` prints it to stdout and
exits zero. --format=json without --dry-run is rejected — emitting JSON
during a real launch would race the y/N prompt.

The dry-run integration test now parses the JSON and asserts on
structured fields (agent, bottle, runtime, hosts sorted+deduped, etc.)
instead of regex-matching the human-readable preflight stdout. That
kills the magic-"8 hosts allowed" coupling — adding a new baked
default doesn't break the test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 16:23:24 -04:00
parent 30b4f12288
commit beb0c9d58f
4 changed files with 119 additions and 18 deletions
+7
View File
@@ -66,6 +66,13 @@ class BottlePlan(ABC):
def print(self, *, remote_control: bool) -> None: def print(self, *, remote_control: bool) -> None:
"""Render the y/N preflight summary to stderr.""" """Render the y/N preflight summary to stderr."""
@abstractmethod
def to_dict(self, *, remote_control: bool) -> dict[str, object]:
"""Return the plan as a JSON-serializable dict for machine
consumption (used by `start --dry-run --format=json`). The key
set is part of the CLI's user-facing contract — adding fields
is fine, renaming or removing is a breaking change."""
@dataclass(frozen=True) @dataclass(frozen=True)
class BottleCleanupPlan(ABC): class BottleCleanupPlan(ABC):
+34 -1
View File
@@ -12,7 +12,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from ...log import info from ...log import info
from ...pipelock import PipelockProxyPlan from ...pipelock import PipelockProxyPlan, pipelock_effective_allowlist
from .. import BottlePlan from .. import BottlePlan
@@ -75,3 +75,36 @@ class DockerBottlePlan(BottlePlan):
) )
info("remote-control : " + ("enabled" if remote_control else "disabled")) info("remote-control : " + ("enabled" if remote_control else "disabled"))
print(file=sys.stderr) print(file=sys.stderr)
def to_dict(self, *, remote_control: bool) -> dict[str, object]:
spec = self.spec
manifest = spec.manifest
agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
env_names = list(bottle.env.keys())
if spec.forward_oauth_token:
env_names.append("CLAUDE_CODE_OAUTH_TOKEN")
hosts = pipelock_effective_allowlist(bottle)
return {
"agent": spec.agent_name,
"bottle": agent.bottle,
"container_name": self.container_name,
"image": self.image,
"derived_image": self.derived_image,
"stage_dir": str(self.stage_dir),
"runtime": "runsc" if self.use_runsc else "runc",
"env_names": env_names,
"skills": list(agent.skills),
"ssh_hosts": [e.Host for e in bottle.ssh],
"egress": {
"host_count": len(hosts),
"hosts": hosts,
},
"prompt": {
"length": len(agent.prompt),
"first_line": agent.prompt.splitlines()[0] if agent.prompt else "",
},
"remote_control": remote_control,
}
+16 -1
View File
@@ -5,6 +5,7 @@ session ends."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json
import os import os
import shutil import shutil
import sys import sys
@@ -12,7 +13,7 @@ import tempfile
from pathlib import Path from pathlib import Path
from ..backend import BottleSpec, get_bottle_backend from ..backend import BottleSpec, get_bottle_backend
from ..log import info from ..log import die, info
from ..manifest import Manifest from ..manifest import Manifest
from ._common import PROG, USER_CWD, read_tty_line from ._common import PROG, USER_CWD, read_tty_line
@@ -22,10 +23,18 @@ def cmd_start(argv: list[str]) -> int:
parser.add_argument("--dry-run", action="store_true") parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--cwd", action="store_true", help="copy host cwd into a derived image") parser.add_argument("--cwd", action="store_true", help="copy host cwd into a derived image")
parser.add_argument("--remote-control", action="store_true") parser.add_argument("--remote-control", action="store_true")
parser.add_argument(
"--format",
choices=("text", "json"),
default="text",
help="preflight output format; --format=json requires --dry-run",
)
parser.add_argument("name", help="agent name defined in claude-bottle.json") parser.add_argument("name", help="agent name defined in claude-bottle.json")
args = parser.parse_args(argv) args = parser.parse_args(argv)
dry_run = args.dry_run or os.environ.get("CLAUDE_BOTTLE_DRY_RUN") == "1" dry_run = args.dry_run or os.environ.get("CLAUDE_BOTTLE_DRY_RUN") == "1"
if args.format == "json" and not dry_run:
die("--format=json requires --dry-run")
manifest = Manifest.resolve(USER_CWD) manifest = Manifest.resolve(USER_CWD)
spec = BottleSpec( spec = BottleSpec(
@@ -40,6 +49,12 @@ def cmd_start(argv: list[str]) -> int:
try: try:
backend = get_bottle_backend() backend = get_bottle_backend()
plan = backend.prepare(spec, stage_dir=stage_dir) plan = backend.prepare(spec, stage_dir=stage_dir)
if args.format == "json":
json.dump(plan.to_dict(remote_control=args.remote_control), sys.stdout, indent=2)
sys.stdout.write("\n")
return 0
plan.print(remote_control=args.remote_control) plan.print(remote_control=args.remote_control)
if dry_run: if dry_run:
+62 -16
View File
@@ -1,10 +1,9 @@
"""Integration: cli.py start --dry-run renders the planned shape and """Integration: cli.py start --dry-run --format=json renders a stable
does not create any docker resources. Confirms the preflight contract machine-readable plan and creates zero Docker resources. The shape of
from PRD 0001 (allowlist line in the plan, no docker side effects).""" the JSON document is part of the CLI's user-facing contract."""
import json import json
import os import os
import re
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -13,12 +12,12 @@ from pathlib import Path
from tests._docker import skip_unless_docker from tests._docker import skip_unless_docker
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent.parent
@skip_unless_docker() @skip_unless_docker()
class TestDryRunPlan(unittest.TestCase): class TestDryRunPlan(unittest.TestCase):
def test_dry_run(self): def test_dry_run_emits_structured_plan(self):
work_dir = Path(tempfile.mkdtemp()) work_dir = Path(tempfile.mkdtemp())
try: try:
manifest = work_dir / "claude-bottle.json" manifest = work_dir / "claude-bottle.json"
@@ -34,24 +33,43 @@ class TestDryRunPlan(unittest.TestCase):
env = os.environ.copy() env = os.environ.copy()
env["HOME"] = str(work_dir) env["HOME"] = str(work_dir)
env["CLAUDE_BOTTLE_DRY_RUN"] = "1" env.pop("CLAUDE_BOTTLE_DRY_RUN", None)
result = subprocess.run( result = subprocess.run(
[sys.executable, str(REPO_ROOT / "cli.py"), "start", "demo"], [
sys.executable, str(REPO_ROOT / "cli.py"),
"start", "--dry-run", "--format", "json", "demo",
],
cwd=work_dir, cwd=work_dir,
env=env, env=env,
capture_output=True, capture_output=True,
text=True, text=True,
) )
out = result.stdout + result.stderr self.assertEqual(
0, result.returncode,
f"start --dry-run failed: stderr={result.stderr}",
)
self.assertIn("egress", out, "preflight: egress line present") plan = json.loads(result.stdout)
# 7 baked defaults + 1 bottle entry = 8.
self.assertRegex(out, r"8 hosts allowed", "preflight: bottle entry counted")
self.assertIn("api.anthropic.com", out, "preflight: baked default shown")
self.assertRegex(out, r"runtime\s*:\s*runc", "preflight: default runtime shown")
self.assertIn("dry-run requested", out, "dry-run banner present")
self.assertNotIn("/dev/tty", out, "dry-run exited before tty prompt")
self.assertEqual("demo", plan["agent"])
self.assertEqual("dev", plan["bottle"])
self.assertEqual("runc", plan["runtime"],
"runsc isn't available on the CI runner")
self.assertEqual([], plan["skills"])
self.assertEqual([], plan["ssh_hosts"])
self.assertEqual(False, plan["remote_control"])
self.assertEqual(0, plan["prompt"]["length"])
# User-declared host + a baked default both present; the union
# is sorted and deduplicated.
hosts = plan["egress"]["hosts"]
self.assertIn("example.org", hosts)
self.assertIn("api.anthropic.com", hosts)
self.assertEqual(plan["egress"]["host_count"], len(hosts))
self.assertEqual(sorted(set(hosts)), hosts,
"hosts must be sorted and deduplicated")
# No Docker side effects.
self.assertEqual(nets_before, self._count_claude_bottle_networks(), self.assertEqual(nets_before, self._count_claude_bottle_networks(),
"no networks created") "no networks created")
self.assertEqual(ctrs_before, self._count_claude_bottle_containers(), self.assertEqual(ctrs_before, self._count_claude_bottle_containers(),
@@ -60,6 +78,34 @@ class TestDryRunPlan(unittest.TestCase):
import shutil import shutil
shutil.rmtree(work_dir, ignore_errors=True) shutil.rmtree(work_dir, ignore_errors=True)
def test_json_format_requires_dry_run(self):
"""The CLI rejects --format=json without --dry-run; emitting JSON
in a real run would race the y/N prompt."""
work_dir = Path(tempfile.mkdtemp())
try:
manifest = work_dir / "claude-bottle.json"
manifest.write_text(json.dumps({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}))
env = os.environ.copy()
env["HOME"] = str(work_dir)
env.pop("CLAUDE_BOTTLE_DRY_RUN", None)
result = subprocess.run(
[
sys.executable, str(REPO_ROOT / "cli.py"),
"start", "--format", "json", "demo",
],
cwd=work_dir,
env=env,
capture_output=True,
text=True,
)
self.assertNotEqual(0, result.returncode)
finally:
import shutil
shutil.rmtree(work_dir, ignore_errors=True)
def _count_claude_bottle_networks(self) -> int: def _count_claude_bottle_networks(self) -> int:
result = subprocess.run( result = subprocess.run(
["docker", "network", "ls", "--format", "{{.Name}}"], ["docker", "network", "ls", "--format", "{{.Name}}"],