Skip to content

Instantly share code, notes, and snippets.

@dive
Created February 7, 2026 11:33
Show Gist options
  • Select an option

  • Save dive/f11d246559d008288da24e75e5e800b5 to your computer and use it in GitHub Desktop.

Select an option

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+)
#!/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())
@dive
Copy link
Author

dive commented Feb 7, 2026

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 codex CLI & ~/.codex/ config, so Xcode uses the same version & settings as your terminal.

Only ~/Library/Developer/Xcode/CodingAssistant/ is modified – Xcode.app is never touched.

Files managed

File Symlinked to
Agents/Versions/<ver>/codex which codex
codex/config.toml ~/.codex/config.toml
codex/models_cache.json ~/.codex/models_cache.json

Originals are backed up as .bak (or .bak.<timestamp> if a backup already exists).

Prerequisites

  1. Xcode 26.3 RC+ with Codex enabled (Settings → Intelligence → OpenAI → Codex)
  2. Codex CLI installed & in your PATH (install guide)

Quick start

Run directly from this Gist

# Preview what will happen (safe, no changes made):
curl -fsSL https://gist.githubusercontent.com/dive/f11d246559d008288da24e75e5e800b5/raw/515654e6ff570816ce1650b33d0f80fc82000b70/switch-xcode-codex.py | python3 - --dry-run

# Apply the switch:
curl -fsSL https://gist.githubusercontent.com/dive/f11d246559d008288da24e75e5e800b5/raw/515654e6ff570816ce1650b33d0f80fc82000b70/switch-xcode-codex.py | python3  - --yes

Or download & run locally

curl -fsSL https://gist.githubusercontent.com/dive/f11d246559d008288da24e75e5e800b5/raw/515654e6ff570816ce1650b33d0f80fc82000b70/switch-xcode-codex.py -o switch-xcode-codex.py
chmod +x switch-xcode-codex.py
./switch-xcode-codex.py

Usage

# Interactive (asks for confirmation):
`python3 switch-xcode-codex.py`

# Preview without making changes:
`python3 switch-xcode-codex.py --dry-run`

# Non-interactive (CI/scripts):
`python3 switch-xcode-codex.py --yes`

# Check current state:
`python3 switch-xcode-codex.py --status`

# Restore originals from backups:
`python3 switch-xcode-codex.py --revert`

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment