Created
February 7, 2026 11:33
-
-
Save dive/f11d246559d008288da24e75e5e800b5 to your computer and use it in GitHub Desktop.
Replace Xcode's bundled Codex agent with your local Codex CLI (Xcode 26.3 RC+)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Replace Xcode's bundled Codex agent with your locally-installed Codex CLI. | |
| Xcode ships its own Codex binary and configuration under | |
| ~/Library/Developer/Xcode/CodingAssistant/. This script swaps those files | |
| for symlinks pointing to your PATH-installed `codex` and ~/.codex/ config, | |
| so Xcode uses the same CLI version and settings you use in the terminal. | |
| Only files inside ~/Library/Developer/Xcode/CodingAssistant/ are touched; | |
| /Applications/Xcode.app is never modified. | |
| What gets linked: | |
| - Codex agent binary (Agents/Versions/<ver>/codex -> `which codex`) | |
| - Config (codex/config.toml -> ~/.codex/config.toml) | |
| - Models cache (codex/models_cache.json -> ~/.codex/models_cache.json) | |
| Safety: | |
| - Originals are backed up with a .bak suffix before replacement. | |
| - If a .bak already exists and differs, a timestamped copy (.bak.<epoch>) is | |
| created so nothing is ever silently lost. | |
| - --revert restores from the most recent backup. | |
| - --dry-run previews every action without writing to disk. | |
| - --status shows current state at a glance. | |
| Requires: macOS, Xcode 26+ with Codex support enabled, `codex` CLI in PATH. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import filecmp | |
| import os | |
| import shutil | |
| import sys | |
| import time | |
| from pathlib import Path | |
| BACKUP_SUFFIX = ".bak" | |
| XCODE_ROOT = Path.home() / "Library/Developer/Xcode/CodingAssistant" | |
| XCODE_CODEX_DIR = XCODE_ROOT / "codex" | |
| XCODE_AGENTS_DIR = XCODE_ROOT / "Agents/Versions" | |
| XCODE_CONFIG = XCODE_CODEX_DIR / "config.toml" | |
| XCODE_MODELS_CACHE = XCODE_CODEX_DIR / "models_cache.json" | |
| LOCAL_CODEX_CONFIG = Path.home() / ".codex/config.toml" | |
| LOCAL_CODEX_MODELS_CACHE = Path.home() / ".codex/models_cache.json" | |
| def fmt_path(path: Path) -> str: | |
| home = str(Path.home()) | |
| s = str(path) | |
| if s.startswith(home): | |
| return s.replace(home, "~", 1) | |
| return s | |
| def discover_agent_paths() -> list[Path]: | |
| if not XCODE_AGENTS_DIR.is_dir(): | |
| return [] | |
| candidates = sorted(XCODE_AGENTS_DIR.glob("*/codex")) | |
| return candidates | |
| def select_agent_path(candidates: list[Path]) -> Path: | |
| if len(candidates) == 1: | |
| return candidates[0] | |
| if not sys.stdin.isatty(): | |
| die( | |
| f"Multiple Xcode agent versions found and stdin is not a TTY.\n" | |
| + "\n".join(f" {i + 1}) {fmt_path(c)}" for i, c in enumerate(candidates)) | |
| + "\nRe-run interactively to select one.", | |
| ) | |
| print("Multiple Xcode Codex agent versions found:") | |
| for i, path in enumerate(candidates): | |
| print(f" {i + 1}) {fmt_path(path)}") | |
| while True: | |
| choice = input(f"Select version [1-{len(candidates)}]: ").strip() | |
| if choice.isdigit() and 1 <= int(choice) <= len(candidates): | |
| return candidates[int(choice) - 1] | |
| print(f"Invalid choice. Enter a number between 1 and {len(candidates)}.") | |
| def managed_targets(agent_path: Path | None) -> tuple[tuple[str, Path], ...]: | |
| targets: list[tuple[str, Path]] = [] | |
| if agent_path is not None: | |
| targets.append(("Xcode codex agent", agent_path)) | |
| targets.append(("Xcode codex config", XCODE_CONFIG)) | |
| targets.append(("Xcode codex models cache", XCODE_MODELS_CACHE)) | |
| return tuple(targets) | |
| def die(message: str, code: int = 1) -> None: | |
| print(message, file=sys.stderr) | |
| raise SystemExit(code) | |
| def warn(message: str) -> None: | |
| print(f"Warning: {message}", file=sys.stderr) | |
| def path_exists(path: Path) -> bool: | |
| return path.exists() or path.is_symlink() | |
| def backup_path(path: Path) -> Path: | |
| return path.with_name(path.name + BACKUP_SUFFIX) | |
| def run_or_dry_run(action: str, dry_run: bool) -> None: | |
| prefix = "[dry-run] " if dry_run else "" | |
| print(f"{prefix}{action}") | |
| def read_symlink_target(path: Path) -> Path | None: | |
| if not path.is_symlink(): | |
| return None | |
| link = path.readlink() | |
| if link.is_absolute(): | |
| return link | |
| return path.parent / link | |
| def points_to(path: Path, expected: Path) -> bool: | |
| target = read_symlink_target(path) | |
| if target is None: | |
| return False | |
| return target.resolve(strict=False) == expected.resolve(strict=False) | |
| def detect_codex_executable() -> Path | None: | |
| found = shutil.which("codex") | |
| if not found: | |
| return None | |
| # Intentionally keep the path returned by PATH lookup (do not resolve | |
| # symlinks), so prompts match `which codex`. | |
| return Path(found) | |
| def validate_codex_executable(path: Path) -> None: | |
| resolved = Path(os.path.realpath(path)) | |
| if not resolved.is_file(): | |
| die( | |
| f"`codex` found at {fmt_path(path)} but it is not a regular file " | |
| f"(resolves to {fmt_path(resolved)}). Check your installation.", | |
| ) | |
| if not os.access(resolved, os.X_OK): | |
| die( | |
| f"`codex` found at {fmt_path(path)} but it is not executable. " | |
| "Check file permissions.", | |
| ) | |
| def describe_path(path: Path) -> str: | |
| if path.is_symlink(): | |
| target = read_symlink_target(path) | |
| if target is None: | |
| return "symlink (unreadable target)" | |
| return f"symlink -> {fmt_path(target)}" | |
| if path.is_file(): | |
| return "file" | |
| if path.is_dir(): | |
| return "directory" | |
| return "missing" | |
| def remove_path(path: Path, dry_run: bool) -> None: | |
| if not path_exists(path): | |
| return | |
| if path.is_symlink() or path.is_file(): | |
| run_or_dry_run(f"remove {fmt_path(path)}", dry_run) | |
| if not dry_run: | |
| path.unlink() | |
| return | |
| die( | |
| f"Refusing to remove non-file path: {fmt_path(path)}. " | |
| "Please inspect it manually.", | |
| code=2, | |
| ) | |
| def assert_safe_path(path: Path, operation: str) -> None: | |
| if not path_exists(path): | |
| return | |
| if path.is_symlink() or path.is_file(): | |
| return | |
| die( | |
| f"Refusing to {operation}: unexpected path type at {fmt_path(path)} " | |
| f"({describe_path(path)}). Please inspect it manually.", | |
| code=2, | |
| ) | |
| def files_match(a: Path, b: Path) -> bool: | |
| if a.is_symlink() or b.is_symlink(): | |
| return ( | |
| a.is_symlink() | |
| and b.is_symlink() | |
| and a.readlink() == b.readlink() | |
| ) | |
| if a.is_file() and b.is_file(): | |
| return filecmp.cmp(a, b, shallow=False) | |
| return False | |
| def move_to_backup(path: Path, dry_run: bool) -> None: | |
| bak = backup_path(path) | |
| if not path_exists(path): | |
| run_or_dry_run(f"skip backup (missing): {fmt_path(path)}", dry_run) | |
| return | |
| assert_safe_path(path, "backup") | |
| if path_exists(bak): | |
| if files_match(path, bak): | |
| run_or_dry_run( | |
| f"backup unchanged, keeping {fmt_path(bak)}", dry_run | |
| ) | |
| return | |
| ts = int(time.time()) | |
| bak = path.with_name(f"{path.name}.bak.{ts}") | |
| run_or_dry_run( | |
| f"backup exists and differs, using {fmt_path(bak)}", dry_run | |
| ) | |
| run_or_dry_run(f"backup {fmt_path(path)} -> {fmt_path(bak)}", dry_run) | |
| if not dry_run: | |
| path.rename(bak) | |
| def symlink_to(source: Path, target: Path, dry_run: bool) -> None: | |
| if not target.parent.exists(): | |
| run_or_dry_run(f"mkdir -p {fmt_path(target.parent)}", dry_run) | |
| if not dry_run: | |
| target.parent.mkdir(parents=True, exist_ok=True) | |
| if path_exists(target): | |
| remove_path(target, dry_run) | |
| run_or_dry_run(f"ln -s {fmt_path(source)} -> {fmt_path(target)}", dry_run) | |
| if not dry_run: | |
| target.symlink_to(source) | |
| def confirm(message: str) -> bool: | |
| answer = input(f"{message} [y/N]: ").strip().lower() | |
| return answer in {"y", "yes"} | |
| def preflight_check(link_specs: list[tuple[str, Path, Path]]) -> None: | |
| errors: list[str] = [] | |
| for label, source, target in link_specs: | |
| if not source.exists(): | |
| continue | |
| parent = target.parent | |
| if not parent.exists(): | |
| continue | |
| if not os.access(parent, os.W_OK): | |
| errors.append( | |
| f"{label}: target directory not writable: {fmt_path(parent)}" | |
| ) | |
| if errors: | |
| die( | |
| "Preflight check failed:\n" + "\n".join(f" - {e}" for e in errors), | |
| code=2, | |
| ) | |
| def apply_switch( | |
| codex_path: Path, agent_codex: Path, dry_run: bool | |
| ) -> None: | |
| link_specs = [ | |
| ("Xcode codex agent", codex_path, agent_codex), | |
| ("Xcode codex config", LOCAL_CODEX_CONFIG, XCODE_CONFIG), | |
| ("Xcode codex models cache", LOCAL_CODEX_MODELS_CACHE, XCODE_MODELS_CACHE), | |
| ] | |
| preflight_check(link_specs) | |
| for label, source, target in link_specs: | |
| if not source.exists(): | |
| warn( | |
| f"source for {label} does not exist: {fmt_path(source)}; " | |
| f"skipping link for {fmt_path(target)}" | |
| ) | |
| continue | |
| if points_to(target, source): | |
| run_or_dry_run(f"already linked: {fmt_path(target)}", dry_run) | |
| continue | |
| move_to_backup(target, dry_run) | |
| symlink_to(source, target, dry_run) | |
| def find_best_backup(path: Path) -> Path | None: | |
| primary = backup_path(path) | |
| if path_exists(primary): | |
| return primary | |
| timestamped = sorted( | |
| path.parent.glob(path.name + BACKUP_SUFFIX + ".*"), | |
| key=lambda p: p.stat().st_mtime if p.exists() else 0, | |
| reverse=True, | |
| ) | |
| return timestamped[0] if timestamped else None | |
| def restore_from_backup(path: Path, dry_run: bool) -> bool: | |
| bak = find_best_backup(path) | |
| if bak is None: | |
| run_or_dry_run(f"no backup found for {fmt_path(path)}", dry_run) | |
| return False | |
| assert_safe_path(bak, "restore") | |
| if path_exists(path): | |
| remove_path(path, dry_run) | |
| run_or_dry_run(f"restore {fmt_path(bak)} -> {fmt_path(path)}", dry_run) | |
| if not dry_run: | |
| bak.rename(path) | |
| return True | |
| def revert_switch(dry_run: bool) -> bool: | |
| revert_targets: list[Path] = [] | |
| for candidate in XCODE_AGENTS_DIR.glob("*/codex.bak") if XCODE_AGENTS_DIR.is_dir() else []: | |
| revert_targets.append(candidate.with_name("codex")) | |
| if not revert_targets: | |
| for candidate in XCODE_AGENTS_DIR.glob("*/codex") if XCODE_AGENTS_DIR.is_dir() else []: | |
| if candidate.is_symlink(): | |
| revert_targets.append(candidate) | |
| revert_targets.extend([XCODE_CONFIG, XCODE_MODELS_CACHE]) | |
| restored_any = False | |
| for target in revert_targets: | |
| if restore_from_backup(target, dry_run): | |
| restored_any = True | |
| return restored_any | |
| def current_state(codex_path: Path | None) -> str: | |
| agent_candidates = discover_agent_paths() | |
| agent_ok = False | |
| if codex_path is not None: | |
| agent_ok = any( | |
| points_to(agent, codex_path) for agent in agent_candidates | |
| ) | |
| config_ok = points_to(XCODE_CONFIG, LOCAL_CODEX_CONFIG) | |
| cache_ok = points_to(XCODE_MODELS_CACHE, LOCAL_CODEX_MODELS_CACHE) | |
| checks = (agent_ok, config_ok, cache_ok) | |
| if all(checks): | |
| return "switched" | |
| targets = managed_targets(agent_candidates[0] if agent_candidates else None) | |
| if not any(checks) and all(not p.is_symlink() for _, p in targets): | |
| return "stock" | |
| return "mixed" | |
| def print_status(codex_path: Path | None) -> None: | |
| agent_candidates = discover_agent_paths() | |
| print("Xcode Codex switch status") | |
| print(f"- Xcode codex dir: {fmt_path(XCODE_CODEX_DIR)}") | |
| print(f" exists: {'yes' if XCODE_CODEX_DIR.is_dir() else 'no'}") | |
| print("- Discovered agent versions:") | |
| if not agent_candidates: | |
| print(" none found") | |
| else: | |
| for path in agent_candidates: | |
| print(f" {fmt_path(path)}") | |
| print("- PATH codex executable:") | |
| if codex_path is None: | |
| print(" not found") | |
| else: | |
| print(f" {fmt_path(codex_path)}") | |
| targets = managed_targets(agent_candidates[0] if agent_candidates else None) | |
| print("- Managed paths:") | |
| for label, path in targets: | |
| print(f" {label}: {fmt_path(path)}") | |
| print(f" current: {describe_path(path)}") | |
| bak = backup_path(path) | |
| print( | |
| f" backup: {'present' if path_exists(bak) else 'missing'}" | |
| f" ({fmt_path(bak)})" | |
| ) | |
| print("- Expected links:") | |
| if not agent_candidates: | |
| print(" codex agent target: unknown (no agent versions found)") | |
| elif codex_path is None: | |
| print(" codex agent target: unknown (codex not found in PATH)") | |
| else: | |
| for agent in agent_candidates: | |
| print(f" codex agent target: {fmt_path(agent)}") | |
| print( | |
| f" match: {'yes' if points_to(agent, codex_path) else 'no'}" | |
| ) | |
| print(f" config target: {fmt_path(LOCAL_CODEX_CONFIG)}") | |
| print(f" match: {'yes' if points_to(XCODE_CONFIG, LOCAL_CODEX_CONFIG) else 'no'}") | |
| print(f" models cache target: {fmt_path(LOCAL_CODEX_MODELS_CACHE)}") | |
| print( | |
| " match: " | |
| f"{'yes' if points_to(XCODE_MODELS_CACHE, LOCAL_CODEX_MODELS_CACHE) else 'no'}" | |
| ) | |
| print(f"- Overall state: {current_state(codex_path)}") | |
| def main() -> int: | |
| parser = argparse.ArgumentParser( | |
| description="Switch Xcode Codex agent to local codex installation." | |
| ) | |
| mode = parser.add_mutually_exclusive_group() | |
| mode.add_argument( | |
| "--revert", | |
| action="store_true", | |
| help="Restore files from .bak backups.", | |
| ) | |
| mode.add_argument( | |
| "--status", | |
| action="store_true", | |
| help="Show current switch status and exit.", | |
| ) | |
| parser.add_argument( | |
| "--dry-run", | |
| action="store_true", | |
| help="Preview actions without changing files.", | |
| ) | |
| parser.add_argument( | |
| "--yes", | |
| action="store_true", | |
| help="Run non-interactively and assume yes at confirmation prompt.", | |
| ) | |
| args = parser.parse_args() | |
| codex_path = detect_codex_executable() | |
| if args.status: | |
| print_status(codex_path) | |
| return 0 | |
| if args.revert: | |
| restored = revert_switch(dry_run=args.dry_run) | |
| if restored: | |
| print("Done.") | |
| return 0 | |
| else: | |
| print("Nothing to revert (no backups found).", file=sys.stderr) | |
| return 1 | |
| if not XCODE_CODEX_DIR.is_dir(): | |
| die( | |
| "Xcode Codex support is not installed. In Xcode, open " | |
| "Settings → Intelligence → OpenAI → Codex, install it, then rerun this script." | |
| ) | |
| if codex_path is None: | |
| die( | |
| "Cannot find `codex` in your PATH. Install the Codex CLI first: " | |
| "https://developers.openai.com/codex/cli" | |
| ) | |
| validate_codex_executable(codex_path) | |
| agent_candidates = discover_agent_paths() | |
| if not agent_candidates: | |
| die( | |
| "No Xcode Codex agent versions found under " | |
| f"{fmt_path(XCODE_AGENTS_DIR)}. " | |
| "Ensure Xcode Codex is fully installed, then rerun this script." | |
| ) | |
| agent_codex = select_agent_path(agent_candidates) | |
| link_specs = [ | |
| ("Codex agent binary", codex_path, agent_codex), | |
| ("Config", LOCAL_CODEX_CONFIG, XCODE_CONFIG), | |
| ("Models cache", LOCAL_CODEX_MODELS_CACHE, XCODE_MODELS_CACHE), | |
| ] | |
| print("\nThis will create the following symlinks:") | |
| for label, source, target in link_specs: | |
| if not source.exists(): | |
| print(f" {label}: skip (source missing: {fmt_path(source)})") | |
| elif points_to(target, source): | |
| print(f" {label}: skip (already linked)") | |
| else: | |
| print(f" {label}: {fmt_path(target)}") | |
| print(f" -> {fmt_path(source)}") | |
| print(f"\nBackups are saved next to originals with a {BACKUP_SUFFIX} suffix.") | |
| print("Only ~/Library/Developer/Xcode/CodingAssistant/ is modified.\n") | |
| if args.yes: | |
| print("Auto-confirm enabled (--yes).") | |
| else: | |
| if not sys.stdin.isatty(): | |
| die("Non-interactive stdin detected. Re-run with --yes to proceed.") | |
| if not confirm("Proceed?"): | |
| print("Cancelled.") | |
| return 0 | |
| apply_switch( | |
| codex_path=codex_path, agent_codex=agent_codex, dry_run=args.dry_run | |
| ) | |
| print("Done.") | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
What this does
Xcode 26.3 RC+ bundles its own Codex binary (a bit dated, v0.87.0) & config. It does not support the recent GPT 5.3 Codex model as well. This script replaces them with symlinks to your locally-installed
codexCLI &~/.codex/config, so Xcode uses the same version & settings as your terminal.Only
~/Library/Developer/Xcode/CodingAssistant/is modified –Xcode.appis never touched.Files managed
Agents/Versions/<ver>/codexwhich codexcodex/config.toml~/.codex/config.tomlcodex/models_cache.json~/.codex/models_cache.jsonOriginals are backed up as
.bak(or.bak.<timestamp>if a backup already exists).Prerequisites
PATH(install guide)Quick start
Run directly from this Gist
Or download & run locally
Usage