diff --git a/claude_bottle/backend/__init__.py b/claude_bottle/backend/__init__.py index cb0600a..d44633b 100644 --- a/claude_bottle/backend/__init__.py +++ b/claude_bottle/backend/__init__.py @@ -66,6 +66,13 @@ class BottlePlan(ABC): def print(self, *, remote_control: bool) -> None: """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) class BottleCleanupPlan(ABC): diff --git a/claude_bottle/backend/docker/bottle_plan.py b/claude_bottle/backend/docker/bottle_plan.py index 63651e5..2e98f1e 100644 --- a/claude_bottle/backend/docker/bottle_plan.py +++ b/claude_bottle/backend/docker/bottle_plan.py @@ -12,7 +12,7 @@ from dataclasses import dataclass from pathlib import Path from ...log import info -from ...pipelock import PipelockProxyPlan +from ...pipelock import PipelockProxyPlan, pipelock_effective_allowlist from .. import BottlePlan @@ -75,3 +75,36 @@ class DockerBottlePlan(BottlePlan): ) info("remote-control : " + ("enabled" if remote_control else "disabled")) 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, + } diff --git a/claude_bottle/cli/start.py b/claude_bottle/cli/start.py index 81b2c5e..585bd75 100644 --- a/claude_bottle/cli/start.py +++ b/claude_bottle/cli/start.py @@ -5,6 +5,7 @@ session ends.""" from __future__ import annotations import argparse +import json import os import shutil import sys @@ -12,7 +13,7 @@ import tempfile from pathlib import Path from ..backend import BottleSpec, get_bottle_backend -from ..log import info +from ..log import die, info from ..manifest import Manifest 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("--cwd", action="store_true", help="copy host cwd into a derived image") 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") args = parser.parse_args(argv) 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) spec = BottleSpec( @@ -40,6 +49,12 @@ def cmd_start(argv: list[str]) -> int: try: backend = get_bottle_backend() 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) if dry_run: diff --git a/tests/integration/test_dry_run_plan.py b/tests/integration/test_dry_run_plan.py index 1cf00a2..7850afe 100644 --- a/tests/integration/test_dry_run_plan.py +++ b/tests/integration/test_dry_run_plan.py @@ -1,10 +1,9 @@ -"""Integration: cli.py start --dry-run renders the planned shape and -does not create any docker resources. Confirms the preflight contract -from PRD 0001 (allowlist line in the plan, no docker side effects).""" +"""Integration: cli.py start --dry-run --format=json renders a stable +machine-readable plan and creates zero Docker resources. The shape of +the JSON document is part of the CLI's user-facing contract.""" import json import os -import re import subprocess import sys import tempfile @@ -13,12 +12,12 @@ from pathlib import Path 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() class TestDryRunPlan(unittest.TestCase): - def test_dry_run(self): + def test_dry_run_emits_structured_plan(self): work_dir = Path(tempfile.mkdtemp()) try: manifest = work_dir / "claude-bottle.json" @@ -34,24 +33,43 @@ class TestDryRunPlan(unittest.TestCase): env = os.environ.copy() env["HOME"] = str(work_dir) - env["CLAUDE_BOTTLE_DRY_RUN"] = "1" + env.pop("CLAUDE_BOTTLE_DRY_RUN", None) 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, env=env, capture_output=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") - # 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") + plan = json.loads(result.stdout) + 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(), "no networks created") self.assertEqual(ctrs_before, self._count_claude_bottle_containers(), @@ -60,6 +78,34 @@ class TestDryRunPlan(unittest.TestCase): import shutil 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: result = subprocess.run( ["docker", "network", "ls", "--format", "{{.Name}}"],