#!/usr/bin/env python3 """Install Codex Game Studios runtime files into a game project. Current runtime scope: - Copy bundled Codex custom agents from this plugin's `runtime/agents/` directory into the target project's `.codex/agents/` directory. This includes director agents plus Godot, Unity, and Unreal engine specialists. - Copy the Codex project guide template from `project-template/AGENTS.md` into the target project's root `AGENTS.md`. This installer intentionally does not install hooks, rules, MCP servers, or apps yet. """ from __future__ import annotations import argparse import filecmp import shutil import sys from dataclasses import dataclass from pathlib import Path def find_plugin_root(start: Path) -> Path: """Walk upward until the Codex plugin manifest is found.""" for candidate in [start, *start.parents]: if (candidate / ".codex-plugin" / "plugin.json").is_file(): return candidate raise FileNotFoundError( f"Could not find .codex-plugin/plugin.json above: {start}" ) PLUGIN_ROOT = find_plugin_root(Path(__file__).resolve()) SOURCE_AGENTS_DIR = PLUGIN_ROOT / "runtime" / "agents" PROJECT_AGENTS_TEMPLATE = PLUGIN_ROOT / "project-template" / "AGENTS.md" @dataclass(frozen=True) class InstallResult: installed: list[Path] unchanged: list[Path] conflicts: list[Path] def relative_to_target(path: Path, target: Path) -> str: try: return str(path.relative_to(target)) except ValueError: return str(path) def discover_agent_files() -> list[Path]: if not SOURCE_AGENTS_DIR.exists(): raise FileNotFoundError(f"Source agent directory not found: {SOURCE_AGENTS_DIR}") agent_files = sorted(SOURCE_AGENTS_DIR.glob("*.toml")) if not agent_files: raise FileNotFoundError(f"No custom agent TOML files found in: {SOURCE_AGENTS_DIR}") return agent_files def install_agents(target: Path, *, force: bool, dry_run: bool) -> InstallResult: agent_files = discover_agent_files() target_agents_dir = target / ".codex" / "agents" planned: list[tuple[Path, Path]] = [] unchanged: list[Path] = [] conflicts: list[Path] = [] for source in agent_files: destination = target_agents_dir / source.name if source.resolve() == destination.resolve(): unchanged.append(destination) continue if destination.exists(): if filecmp.cmp(source, destination, shallow=False): unchanged.append(destination) continue if not force: conflicts.append(destination) continue planned.append((source, destination)) if conflicts and not force: return InstallResult(installed=[], unchanged=unchanged, conflicts=conflicts) installed = [destination for _, destination in planned] if not dry_run: target_agents_dir.mkdir(parents=True, exist_ok=True) for source, destination in planned: shutil.copy2(source, destination) return InstallResult(installed=installed, unchanged=unchanged, conflicts=conflicts) def install_project_guide( target: Path, *, force: bool, dry_run: bool ) -> InstallResult: if not PROJECT_AGENTS_TEMPLATE.is_file(): raise FileNotFoundError(f"Missing project guide template: {PROJECT_AGENTS_TEMPLATE}") destination = target / "AGENTS.md" if destination.exists(): if filecmp.cmp(PROJECT_AGENTS_TEMPLATE, destination, shallow=False): return InstallResult(installed=[], unchanged=[destination], conflicts=[]) if not force: return InstallResult(installed=[], unchanged=[], conflicts=[destination]) if not dry_run: shutil.copy2(PROJECT_AGENTS_TEMPLATE, destination) return InstallResult(installed=[destination], unchanged=[], conflicts=[]) def print_path_list(label: str, paths: list[Path], target: Path) -> None: if not paths: return print(f"{label}:") for path in paths: print(f"- {relative_to_target(path, target)}") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "target", nargs="?", default=".", help="Target game project root. Defaults to the current directory.", ) parser.add_argument( "--force", action="store_true", help="Overwrite existing agent files when their content differs.", ) parser.add_argument( "--force-agents-md", action="store_true", help="Overwrite an existing target AGENTS.md when its content differs.", ) parser.add_argument( "--dry-run", action="store_true", help="Show what would be installed without writing files.", ) return parser.parse_args() def main() -> int: args = parse_args() target = Path(args.target).expanduser().resolve() print("Codex Game Studios runtime installer") print(f"Plugin root: {PLUGIN_ROOT}") print(f"Target project: {target}") print("Runtime scope: director agents, engine specialists, plus project AGENTS.md") try: agent_result = install_agents(target, force=args.force, dry_run=True) guide_result = install_project_guide( target, force=args.force_agents_md, dry_run=True ) except FileNotFoundError as error: print(f"ERROR: {error}", file=sys.stderr) return 1 print_path_list("Agent conflicts", agent_result.conflicts, target) print_path_list("Project guide conflicts", guide_result.conflicts, target) if agent_result.conflicts or guide_result.conflicts: if agent_result.conflicts: print( "ERROR: Existing agent files differ. Re-run with --force to overwrite them.", file=sys.stderr, ) if guide_result.conflicts: print( "ERROR: Target AGENTS.md differs. Re-run with --force-agents-md to overwrite it.", file=sys.stderr, ) return 2 if not args.dry_run: agent_result = install_agents(target, force=args.force, dry_run=False) guide_result = install_project_guide( target, force=args.force_agents_md, dry_run=False ) print_path_list("Installed agents" if not args.dry_run else "Would install agents", agent_result.installed, target) print_path_list("Unchanged agents", agent_result.unchanged, target) print_path_list("Installed project guide" if not args.dry_run else "Would install project guide", guide_result.installed, target) print_path_list("Unchanged project guide", guide_result.unchanged, target) print("Done.") return 0 if __name__ == "__main__": raise SystemExit(main())