feat: unify bootstrap export and apply cli
This commit is contained in:
Executable
+291
@@ -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())
|
||||
Reference in New Issue
Block a user