#!/usr/bin/env python3 """Deploy platform adapter templates outside the repository checkout.""" from __future__ import annotations import argparse import os import shutil import sys from dataclasses import dataclass, field from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) from scripts.runtime.skills import SkillRegistry CODEX_TEMPLATE = REPO_ROOT / "codex_adapter_templates" / "codex" ANTIGRAVITY_RULES_DIR = REPO_ROOT / ".agents" / "rules" ANTIGRAVITY_WORKFLOWS_DIR = REPO_ROOT / ".agents" / "workflows" ANTIGRAVITY_AGENTS_FILE = REPO_ROOT / ".agents" / "agents.md" @dataclass class DeployResult: platform: str target: Path written: list[Path] = field(default_factory=list) skipped: list[Path] = field(default_factory=list) planned: list[Path] = field(default_factory=list) backups: list[Path] = field(default_factory=list) def default_codex_home( *, env: dict[str, str] | None = None, user_home: Path | None = None, ) -> Path: values = os.environ if env is None else env if values.get("CODEX_HOME"): return Path(values["CODEX_HOME"]).expanduser() home = Path.home() if user_home is None else user_home return home / ".codex" def copy_tree_contents( src: Path, dst: Path, *, force: bool, dry_run: bool = False, backup_existing: bool = True, exclude: set[Path] | None = None, ) -> DeployResult: if not src.exists(): raise FileNotFoundError(f"adapter template source not found: {src}") result = DeployResult(platform="copy", target=dst) excluded = exclude or set() for item in sorted(src.rglob("*")): rel = item.relative_to(src) if rel in excluded: continue target = dst / rel if item.is_dir(): if not dry_run: target.mkdir(parents=True, exist_ok=True) continue if target.exists() and not force: result.skipped.append(target) continue result.planned.append(target) if dry_run: continue target.parent.mkdir(parents=True, exist_ok=True) if target.exists() and force and backup_existing: backup = target.with_name(f"{target.name}.bak") shutil.copy2(target, backup) result.backups.append(backup) shutil.copy2(item, target) result.written.append(target) return result def _merge_results(platform: str, target: Path, parts: list[DeployResult]) -> DeployResult: merged = DeployResult(platform=platform, target=target) for part in parts: merged.written.extend(part.written) merged.skipped.extend(part.skipped) merged.planned.extend(part.planned) merged.backups.extend(part.backups) return merged def copy_registered_skills(dst: Path, *, force: bool, dry_run: bool = False) -> DeployResult: result = DeployResult(platform="skills", target=dst) for skill in SkillRegistry().list(): part = copy_tree_contents(skill.path.parent, dst / skill.name, force=force, dry_run=dry_run) result.written.extend(part.written) result.skipped.extend(part.skipped) result.planned.extend(part.planned) result.backups.extend(part.backups) return result def copy_antigravity_rules(dst: Path, *, force: bool, dry_run: bool = False) -> DeployResult: result = DeployResult(platform="rules", target=dst) if not ANTIGRAVITY_RULES_DIR.exists(): return result part = copy_tree_contents(ANTIGRAVITY_RULES_DIR, dst, force=force, dry_run=dry_run) result.written.extend(part.written) result.skipped.extend(part.skipped) result.planned.extend(part.planned) result.backups.extend(part.backups) return result def copy_antigravity_workflows(dst: Path, *, force: bool, dry_run: bool = False) -> DeployResult: result = DeployResult(platform="workflows", target=dst) if not ANTIGRAVITY_WORKFLOWS_DIR.exists(): return result part = copy_tree_contents(ANTIGRAVITY_WORKFLOWS_DIR, dst, force=force, dry_run=dry_run) result.written.extend(part.written) result.skipped.extend(part.skipped) result.planned.extend(part.planned) result.backups.extend(part.backups) return result def copy_antigravity_agents_file(dst: Path, *, force: bool, dry_run: bool = False) -> DeployResult: result = DeployResult(platform="agents", target=dst) if not ANTIGRAVITY_AGENTS_FILE.exists(): return result target = dst / "agents.md" if target.exists() and not force: result.skipped.append(target) return result result.planned.append(target) if dry_run: return result target.parent.mkdir(parents=True, exist_ok=True) if target.exists() and force: backup = target.with_name("agents.md.bak") shutil.copy2(target, backup) result.backups.append(backup) shutil.copy2(ANTIGRAVITY_AGENTS_FILE, target) result.written.append(target) return result def deploy_codex( *, target: Path | None = None, force: bool = False, skip_skills: bool = False, dry_run: bool = False, include_config: bool = False, repo_root: Path = REPO_ROOT, ) -> DeployResult: codex_home = (target or default_codex_home()).expanduser() template = repo_root / "codex_adapter_templates" / "codex" parts = [ copy_tree_contents( template, codex_home, force=force, dry_run=dry_run, exclude=set() if include_config else {Path("config.toml")}, ), ] if not skip_skills: parts.append(copy_registered_skills(codex_home / "skills", force=force, dry_run=dry_run)) return _merge_results("codex", codex_home, parts) def deploy_antigravity( *, target: Path | None = None, force: bool = False, skip_skills: bool = False, skip_agents: bool = False, skip_rules: bool = False, skip_workflows: bool = False, dry_run: bool = False, repo_root: Path = REPO_ROOT, ) -> DeployResult: """Deploy Antigravity workspace rules and skills into a workspace root. This intentionally deploys to a workspace-local `.agents` directory, not global Antigravity/Gemini settings, so existing user configuration is not touched. Existing files are skipped unless `force=True`. """ workspace = (target or repo_root).expanduser() parts: list[DeployResult] = [] if not skip_agents: parts.append(copy_antigravity_agents_file(workspace / ".agents", force=force, dry_run=dry_run)) if not skip_skills: parts.append(copy_registered_skills(workspace / ".agents" / "skills", force=force, dry_run=dry_run)) if not skip_rules: parts.append(copy_antigravity_rules(workspace / ".agents" / "rules", force=force, dry_run=dry_run)) if not skip_workflows: parts.append(copy_antigravity_workflows(workspace / ".agents" / "workflows", force=force, dry_run=dry_run)) return _merge_results("antigravity", workspace, parts) def print_result(result: DeployResult) -> None: action = "planned" if result.planned and not result.written else "written" print(f"{result.platform} adapter deployment") print(f" target: {result.target}") print(f" files {action}: {len(result.planned if action == 'planned' else result.written)}") print(f" files skipped: {len(result.skipped)}") print(f" backups: {len(result.backups)}") if result.written: for path in result.written: print(f" {path}") elif result.planned: for path in result.planned: print(f" {path}") def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Deploy Deep Research surface adapters") sub = parser.add_subparsers(dest="platform", required=True) codex = sub.add_parser("codex", help="Deploy Codex adapter to CODEX_HOME or a target directory") codex.add_argument("--target", type=Path, help="Codex home target; defaults to $CODEX_HOME or ~/.codex") codex.add_argument("--force", action="store_true", help="overwrite existing files and create .bak backups") codex.add_argument("--skip-skills", action="store_true", help="do not copy canonical skills into target/skills") codex.add_argument("--include-config", action="store_true", help="also copy config.toml; off by default to avoid overwriting global Codex config") codex.add_argument("--dry-run", action="store_true", help="show files that would be written") antigravity = sub.add_parser("antigravity", help="Deploy Antigravity workspace rules and skills") antigravity.add_argument("--target", type=Path, help="Workspace root; defaults to this repository") antigravity.add_argument("--force", action="store_true", help="overwrite existing files and create .bak backups") antigravity.add_argument("--skip-agents", action="store_true", help="do not copy role definitions into target/.agents/agents.md") antigravity.add_argument("--skip-skills", action="store_true", help="do not copy canonical skills into target/.agents/skills") antigravity.add_argument("--skip-rules", action="store_true", help="do not copy workspace rules into target/.agents/rules") antigravity.add_argument("--skip-workflows", action="store_true", help="do not copy workflows into target/.agents/workflows") antigravity.add_argument("--dry-run", action="store_true", help="show files that would be written without writing") return parser def main() -> int: args = build_parser().parse_args() if args.platform == "codex": result = deploy_codex( target=args.target, force=args.force, skip_agents=args.skip_agents, skip_skills=args.skip_skills, dry_run=args.dry_run, include_config=args.include_config, ) print_result(result) print() print("Run Codex from this repository after deployment:") if args.include_config: print(" codex --profile deep-research") else: print(" codex") print("Note: config.toml is not copied by default. Use --include-config only if you want the bundled profile.") return 0 if args.platform == "antigravity": result = deploy_antigravity( target=args.target, force=args.force, skip_skills=args.skip_skills, skip_rules=args.skip_rules, skip_workflows=args.skip_workflows, dry_run=args.dry_run, ) print_result(result) print() print("Open the target workspace in Antigravity and enable/mention the workspace rule if needed:") print(" .agents/rules/deep-research-antigravity.md") print("Workflow installed when supported by your Antigravity build:") print(" .agents/workflows/deep-research-native.md") print("Existing files are skipped by default. Use --force only when you want .bak backups and replacement.") return 0 raise SystemExit(f"unsupported platform: {args.platform}") if __name__ == "__main__": raise SystemExit(main())