feat: unify bootstrap export and apply cli

This commit is contained in:
2026-07-02 16:02:54 +00:00
parent 5f9302abc5
commit 6c5498085d
6 changed files with 851 additions and 123 deletions
+291
View File
@@ -0,0 +1,291 @@
#!/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())