292 lines
9.3 KiB
Python
Executable File
292 lines
9.3 KiB
Python
Executable File
#!/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())
|