#!/usr/bin/env python3 """Unified Agent OS bootstrap CLI.""" from __future__ import annotations import argparse import json import shutil import subprocess import tempfile from pathlib import Path from bootstrap_lib import ( BOOTSTRAP_ROOT, DEFAULT_OUTPUT, STATE_FILE, apply_bundle, build_bundle, count_files, ensure_safe_output, export_bundle, load_manifest, verify_export, ) EXPECTED_EXPORT_FILES = { ".gitignore", "CLAUDE.md", "README.md", "brain.md", "bundle-manifest.json", "context/.gitkeep", "export-rules.json", "identity.md", "logs/.gitkeep", "memory/active-projects.md", "memory/constraints.md", "memory/notes-from-last-run.md", "memory/persistent.md", "memory/recent-decisions.md", "skills/_template/context/.gitkeep", "skills/_template/context/handoff.md", "skills/_template/eval.json", "skills/_template/learnings.md", "skills/_template/skill.md", } EXPECTED_PROJECT_FILES = EXPECTED_EXPORT_FILES | {STATE_FILE} def run_command(*args: str) -> subprocess.CompletedProcess[str]: return subprocess.run( args, cwd=BOOTSTRAP_ROOT.parent, text=True, capture_output=True, check=False, ) def cmd_export(args: argparse.Namespace) -> int: output = ensure_safe_output(args.output) manifest = load_manifest() export_bundle(output, manifest) verify_export(output) print(f"OK {manifest['bundle']['id']} -> {output}") print(f"OK verified 18 required paths") print(f"OK exported {count_files(output)} files") return 0 def cmd_verify(args: argparse.Namespace) -> int: output = ensure_safe_output(args.output) manifest = load_manifest() verify_export(output) print(f"OK {manifest['bundle']['id']} -> {output}") print("OK verified 18 required paths") print(f"OK exported {count_files(output)} files") return 0 def cmd_apply(args: argparse.Namespace) -> int: target_root = args.target.resolve() target_root.mkdir(parents=True, exist_ok=True) if args.bundle is not None: bundle_root = args.bundle.resolve() verify_export(bundle_root) result = apply_bundle(bundle_root, target_root, conflict_policy=args.on_conflict) print(json.dumps(result, indent=2)) return 0 with tempfile.TemporaryDirectory(prefix="agent-os-bootstrap-init.") as temp_dir: bundle_root = build_bundle(Path(temp_dir)) result = apply_bundle(bundle_root, target_root, conflict_policy=args.on_conflict) print(json.dumps(result, indent=2)) return 0 def cmd_smoke_test(args: argparse.Namespace) -> int: temp_dir = Path(tempfile.mkdtemp(prefix="agent-os-bootstrap-test.")) output_dir = temp_dir / "bootstrap" / "exports" / "generic" try: cold_verify = run_command( "python3", str(BOOTSTRAP_ROOT / "cli.py"), "verify", "--output", str(output_dir), ) if cold_verify.returncode == 0: raise SystemExit("expected verify to fail before generation") generate = run_command( "python3", str(BOOTSTRAP_ROOT / "cli.py"), "export", "--output", str(output_dir), ) if generate.returncode != 0: raise SystemExit(generate.stderr or generate.stdout) verify = run_command( "python3", str(BOOTSTRAP_ROOT / "cli.py"), "verify", "--output", str(output_dir), ) if verify.returncode != 0: raise SystemExit(verify.stderr or verify.stdout) exported_files = { path.relative_to(output_dir).as_posix() for path in output_dir.rglob("*") if path.is_file() } if exported_files != EXPECTED_EXPORT_FILES: missing = sorted(EXPECTED_EXPORT_FILES - exported_files) extra = sorted(exported_files - EXPECTED_EXPORT_FILES) raise SystemExit( "exported file set mismatch:\n" f"missing={missing}\n" f"extra={extra}" ) project_root = temp_dir / "project" init = run_command( "python3", str(BOOTSTRAP_ROOT / "cli.py"), "apply", str(project_root), ) if init.returncode != 0: raise SystemExit(init.stderr or init.stdout) project_files = { path.relative_to(project_root).as_posix() for path in project_root.rglob("*") if path.is_file() } if project_files != EXPECTED_PROJECT_FILES: missing = sorted(EXPECTED_PROJECT_FILES - project_files) extra = sorted(project_files - EXPECTED_PROJECT_FILES) raise SystemExit( "initialized project file set mismatch:\n" f"missing={missing}\n" f"extra={extra}" ) readme = project_root / "README.md" original_readme = readme.read_text(encoding="utf-8") readme.write_text(original_readme + "\nLocal changes.\n", encoding="utf-8") conflict_without_policy = run_command( "python3", str(BOOTSTRAP_ROOT / "cli.py"), "apply", str(project_root), ) if conflict_without_policy.returncode == 0: raise SystemExit( "expected unattended conflicted apply to require --on-conflict" ) rerun = run_command( "python3", str(BOOTSTRAP_ROOT / "cli.py"), "apply", str(project_root), "--on-conflict", "write-new", ) if rerun.returncode != 0: raise SystemExit(rerun.stderr or rerun.stdout) replacement = project_root / "README.bootstrap-new.md" if not replacement.is_file(): raise SystemExit("expected write-new to create README.bootstrap-new.md") if readme.read_text(encoding="utf-8") != original_readme + "\nLocal changes.\n": raise SystemExit("write-new should not overwrite the existing README.md") state = json.loads((project_root / STATE_FILE).read_text(encoding="utf-8")) if "README.md" not in state["managed_files"]: raise SystemExit("expected bootstrap state to track managed README.md") if "README.bootstrap-new.md" not in state["managed_files"]: raise SystemExit("expected bootstrap state to track write-new output") unattended = run_command( "python3", str(BOOTSTRAP_ROOT / "cli.py"), "apply", str(project_root), "--on-conflict", "keep", ) if unattended.returncode != 0: raise SystemExit(unattended.stderr or unattended.stdout) print(f"OK fresh temp dir: {temp_dir}") print(f"OK exported files: {len(exported_files)}") print("OK cold verify fails before generation") print("OK export + verify succeed after generation") print("OK apply creates a root-level project tree") print("OK unattended conflicted apply requires explicit --on-conflict") print("OK managed file conflicts support write-new with explicit policy") print("OK unattended runs require explicit conflict behavior") if args.keep: print(f"KEPT {temp_dir}") finally: if not args.keep: shutil.rmtree(temp_dir, ignore_errors=True) return 0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers(dest="command", required=True) export_parser = subparsers.add_parser("export", help="Generate a bootstrap export bundle.") export_parser.add_argument( "--output", type=Path, default=DEFAULT_OUTPUT, help="Export directory. Must end in bootstrap/exports/generic.", ) export_parser.set_defaults(func=cmd_export) verify_parser = subparsers.add_parser("verify", help="Verify an existing bootstrap export bundle.") verify_parser.add_argument( "--output", type=Path, default=DEFAULT_OUTPUT, help="Export directory. Must end in bootstrap/exports/generic.", ) verify_parser.set_defaults(func=cmd_verify) apply_parser = subparsers.add_parser("apply", help="Apply the bootstrap bundle to a project root.") apply_parser.add_argument("target", type=Path, help="Project root to initialize or update.") apply_parser.add_argument( "--bundle", type=Path, help="Existing bootstrap bundle to apply. Defaults to a generated temp bundle.", ) apply_parser.add_argument( "--on-conflict", choices=["keep", "replace", "write-new"], help="Required for unattended conflict handling. Interactive mode remains the default.", ) apply_parser.set_defaults(func=cmd_apply) smoke_parser = subparsers.add_parser("smoke-test", help="Run the bootstrap smoke test.") smoke_parser.add_argument( "--keep", action="store_true", help="Keep the temp directory instead of deleting it.", ) smoke_parser.set_defaults(func=cmd_smoke_test) return parser def main() -> int: parser = build_parser() args = parser.parse_args() return args.func(args) if __name__ == "__main__": raise SystemExit(main())