#!/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 installer intentionally does not install hooks, rules, MCP servers, apps, or AGENTS.md templates 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" @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 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( "--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: custom agents only") try: result = install_agents(target, force=args.force, dry_run=args.dry_run) except FileNotFoundError as error: print(f"ERROR: {error}", file=sys.stderr) return 1 print_path_list("Installed" if not args.dry_run else "Would install", result.installed, target) print_path_list("Unchanged", result.unchanged, target) print_path_list("Conflicts", result.conflicts, target) if result.conflicts: print( "ERROR: Existing agent files differ. Re-run with --force to overwrite them.", file=sys.stderr, ) return 2 print("Done.") return 0 if __name__ == "__main__": raise SystemExit(main())