#!/usr/bin/env bash # PrimeRouter — Codex Desktop app install + configure script (macOS). # - Installs the Codex desktop app into /Applications (or ~/Applications # when /Applications is not writable — never needs sudo). # - Downloads the DMG from PrimeRouter's release mirror first and falls # back to OpenAI's CDN when the mirror does not carry it. # - Writes ~/.codex/config.toml pointing at https://primerouter.ai/v1 — # the desktop app shares this config with the Codex CLI. # - Stores the key in ~/.codex/auth.json (Codex's own credential store). The # desktop app is a GUI process and never reads a shell profile, so an env # var was never a dependable channel for it. # - Adopts the model_provider id this machine already uses, so `codex resume` # keeps listing sessions recorded before the switch. # - Backs up config.toml / auth.json to .bak. before overwrite. # - Reads the PrimeRouter API key interactively (never hard-coded here). set -euo pipefail YELLOW='\033[1;33m'; GREEN='\033[1;32m'; RED='\033[1;31m'; NC='\033[0m' say() { printf "%b%s%b\n" "$1" "$2" "$NC"; } ok() { say "$GREEN" "✓ $1"; } warn() { say "$YELLOW" "! $1"; } die() { say "$RED" "✗ $1"; exit 1; } confirm() { local prompt="$1" reply # Set by one-click installer wrappers to run unattended. if [[ "${PRIMEROUTER_AUTO_CONFIRM:-}" = "1" ]]; then return 0; fi printf "%s [y/N] " "$prompt" read -r reply # ThreadListParams.model_providers -> rollout ProviderMatcher; `codex resume # --last` filters the same way, and --all only widens the cwd filter). Writing # a fresh `model_provider = "primerouter"` therefore hides every session the # user recorded under a previous provider id. Nothing is deleted — the rollout # files stay in ~/.codex/sessions — but from the user's seat the history is # gone. So adopt whatever id this machine already uses. PR_PROVIDER_ID="" # Top-level `model_provider = "x"` only, i.e. before the first table header — # TOML scopes every key after `[table]` into that table, which is exactly the # trap the generated config warns about. A `model_provider` sitting below a # header is not the active provider (Codex ignores it and falls back), so # reading it would adopt an id no session was ever recorded under. provider_id_from_config() { [[ -f "$CODEX_CONF" ]] || return 1 sed -n '/^[[:space:]]*\[/q; s/^model_provider[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' \ "$CODEX_CONF" | head -n 1 } # Fallback for users whose config.toml we already overwrote: the id is also # recorded in every rollout's SessionMeta line. Newest 200 plain rollouts is # plenty for a majority vote; compressed (.jsonl.zst) ones are skipped because # recent sessions — the ones that matter — are still plain. provider_id_from_sessions() { local dir="${CODEX_DIR}/sessions" [[ -d "$dir" ]] || return 1 local winner winner=$(find "$dir" -type f -name 'rollout-*.jsonl' 2>/dev/null \ | sort -r | head -n 200 \ | while IFS= read -r f; do head -n 5 "$f" 2>/dev/null \ | sed -n 's/.*"model_provider"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ | head -n 1 done \ | sort | uniq -c | sort -rn | head -n 1 | awk '{print $2}') [[ -n "$winner" ]] || return 1 printf '%s' "$winner" } # TOML bare keys only — anything else would need quoting and is not worth the # blast radius, so fall back to our own id. valid_provider_id() { [[ "$1" =~ ^[A-Za-z0-9_-]+$ ]]; } resolve_provider_id() { local detected="" if [[ -n "${PRIMEROUTER_CODEX_PROVIDER_ID:-}" ]]; then PR_PROVIDER_ID="$PRIMEROUTER_CODEX_PROVIDER_ID" valid_provider_id "$PR_PROVIDER_ID" \ || die "PRIMEROUTER_CODEX_PROVIDER_ID must match [A-Za-z0-9_-]+." return fi detected=$(provider_id_from_config || true) [[ -n "$detected" ]] || detected=$(provider_id_from_sessions || true) detected="${detected//[$'\r\n\t ']/}" if [[ -n "$detected" ]] && valid_provider_id "$detected" \ && [[ "$detected" != "primerouter" ]]; then PR_PROVIDER_ID="$detected" ok "Reusing the existing provider id '${PR_PROVIDER_ID}' so 'codex resume' keeps listing your old sessions." else PR_PROVIDER_ID="primerouter" fi } # Renders the provider table. Emitted twice when we adopt a foreign id, so # `codex -c model_provider=primerouter` keeps working as an escape hatch. provider_table() { printf '[model_providers.%s]\n' "$1" printf 'name = "PrimeRouter"\n' printf 'wire_api = "responses"\n' # No env_key on purpose: ModelProviderInfo::api_key() turns a missing env var # into a hard error before Codex ever looks at auth.json, so env_key and # requires_openai_auth cannot coexist as "primary + fallback". printf 'requires_openai_auth = true\n' printf 'base_url = "%s"\n' "$PR_BASE_URL" } # Removes the env-var plumbing older versions of this script installed. The # LaunchAgent in particular kept the key in plaintext under ~/Library and # published it to every GUI process via `launchctl setenv`. remove_legacy_env_plumbing() { local profile agent_plist profile=$(detect_profile) if [[ -n "$profile" && -f "$profile" ]] \ && grep -q '# >>> PrimeRouter for Codex >>>' "$profile"; then backup_if_exists "$profile" sed -i.tmp '/# >>> PrimeRouter for Codex >>>/,/# <<< PrimeRouter for Codex <</dev/null \ || launchctl unload "$agent_plist" 2>/dev/null || true rm -f "$agent_plist" ok "Removed the old LaunchAgent that stored your key in plaintext." fi launchctl unsetenv PRIMEROUTER_API_KEY 2>/dev/null || true fi } # Writes Codex's own credential store. Same shape `codex login --with-api-key` # produces (AuthDotJson: OPENAI_API_KEY + auth_mode), which is what makes this # independent of the process environment. write_codex_auth() { local key="$1" if [[ -f "$CODEX_AUTH" ]] && grep -q '"tokens"' "$CODEX_AUTH" 2>/dev/null; then warn "An existing ChatGPT login was found in ${CODEX_AUTH}." warn "Codex keeps only one credential, so PrimeRouter replaces it." warn "Restore it later with: codex login (backup kept alongside)" fi backup_if_exists "$CODEX_AUTH" # A backup of a credential file stays private even when the original was lax. chmod 600 "${CODEX_AUTH}".bak.* 2>/dev/null || true # Create locked down first — the key must never exist world-readable, not # even for the instant between creation and chmod. : > "$CODEX_AUTH" chmod 600 "$CODEX_AUTH" cat > "$CODEX_AUTH" </dev/null } is_codex_app() { [[ -d "$1" && "$(app_bundle_id "$1")" == "$CODEX_BUNDLE_ID" ]] } find_codex_app() { local d for d in "/Applications/Codex.app" "${HOME}/Applications/Codex.app" \ "/Applications/ChatGPT.app" "${HOME}/Applications/ChatGPT.app"; do if is_codex_app "$d"; then echo "$d"; return 0; fi done # Renamed copies inside the Applications folders. Spotlight may lag right # after an install, so this is a fallback, not the primary check. local hit hit=$(mdfind -onlyin /Applications -onlyin "${HOME}/Applications" \ "kMDItemCFBundleIdentifier == '${CODEX_BUNDLE_ID}'" 2>/dev/null | head -n 1) if [[ -n "$hit" && -d "$hit" ]]; then echo "$hit"; return 0; fi return 1 } app_path=$(find_codex_app || true) if [[ -n "$app_path" ]]; then ok "Codex desktop app already installed at ${app_path}." else warn "The Codex desktop app is not installed." confirm "Download and install it now?" \ || die "Aborted. Install the Codex app manually then re-run this script." # Older versions of this script put trailing text after the mktemp Xs; # macOS mktemp does not substitute non-trailing Xs and created this file # literally, so a killed run left it behind and every later run died with # "File exists". Clean up that litter unconditionally. rm -f "${TMPDIR:-/tmp}/codex-app.XXXXXX.dmg" mnt="" dl_dir="" cleanup_dmg() { if [[ -n "$mnt" ]]; then hdiutil detach "$mnt" -quiet 2>/dev/null || true rmdir "$mnt" 2>/dev/null || true fi if [[ -n "$dl_dir" ]]; then rm -rf "$dl_dir"; fi } # A manual install attempt often leaves the app sitting in the mounted # DMG window or in ~/Downloads without ever reaching /Applications — copy # from there instead of downloading 600MB again. src="" for cand in /Volumes/Codex*/*.app /Volumes/ChatGPT*/*.app "${HOME}/Downloads/"*.app; do if is_codex_app "$cand"; then src="$cand" ok "Found the Codex desktop app at ${cand}; installing from it instead of downloading." break fi done if [[ -z "$src" ]]; then dl_dir=$(mktemp -d "${TMPDIR:-/tmp}/codex-app.XXXXXX") dmg="${dl_dir}/Codex.dmg" mnt=$(mktemp -d "${TMPDIR:-/tmp}/codex-mnt.XXXXXX") # A broken mirror can answer 200 with a non-DMG body (e.g. an HTML error # page), which curl -f does not catch — so "did it mount" is the real # success check, and anything short of that falls back to OpenAI's CDN. # Keep curl's stderr attached: --progress-bar writes there, and a silent # multi-minute 600MB download looks like a hang. mirror_ok=0 printf "Downloading Codex.dmg (~600 MB) from the PrimeRouter mirror — this can take a few minutes...\n" if curl -fSL --progress-bar -o "$dmg" "$DMG_MIRROR_URL" \ && hdiutil attach "$dmg" -nobrowse -quiet -mountpoint "$mnt" 2>/dev/null; then ok "Downloaded Codex.dmg from the PrimeRouter mirror." mirror_ok=1 fi if [[ "$mirror_ok" != "1" ]]; then warn "PrimeRouter mirror did not serve a usable Codex.dmg; downloading (~600 MB) from OpenAI's CDN (may be slow on some networks)." curl -fSL --progress-bar -o "$dmg" "$DMG_UPSTREAM_URL" \ || { cleanup_dmg; die "Failed to download Codex.dmg. Check your network and re-run this script."; } hdiutil attach "$dmg" -nobrowse -quiet -mountpoint "$mnt" \ || { cleanup_dmg; die "Downloaded file is not a valid disk image. Re-run this script to retry."; } fi src="" for cand in "$mnt"/*.app; do if is_codex_app "$cand"; then src="$cand"; break; fi done if [[ -z "$src" ]]; then cleanup_dmg die "The disk image does not contain the Codex desktop app (bundle ${CODEX_BUNDLE_ID})." fi fi app_name=$(basename "$src") dest="/Applications" [[ -w "$dest" ]] || dest="${HOME}/Applications" mkdir -p "$dest" target="${dest}/${app_name}" # Never clobber an unrelated app that happens to share the (rebranded) # name — e.g. a real ChatGPT desktop install (com.openai.chat). if [[ -d "$target" ]] && ! is_codex_app "$target"; then cleanup_dmg die "${target} exists but is a different app; move it aside and re-run this script." fi # ditto preserves the code signature; overwrite any older copy in place. rm -rf "$target" ditto "$src" "$target" \ || { cleanup_dmg; die "Failed to copy ${app_name} into ${dest}."; } cleanup_dmg app_path="$target" ok "Installed the Codex desktop app to ${app_path} (OpenAI currently brands it \"ChatGPT\")." fi # --- 2. API key --- # Pre-seeded by personalized installers; prompt only when absent. PR_API_KEY="${PRIMEROUTER_API_KEY:-}" if [[ -z "$PR_API_KEY" ]]; then printf "Paste your PrimeRouter API key (input hidden): " read -rs PR_API_KEY "$CODEX_CONF" chmod 600 "$CODEX_CONF" ok "Wrote ${CODEX_CONF} (provider id: ${PR_PROVIDER_ID})" # --- 4. Store the key in Codex's own credential store --- # The desktop app is a GUI process: it never reads .zshrc, and the LaunchAgent # this script used to install both published the key to every GUI process and # still lost it whenever launchd's session env was reset. auth.json is what # Codex itself writes for `codex login --with-api-key`, and it is read from # disk on every launch regardless of how the app was started. write_codex_auth "$PR_API_KEY" # --- 5. Retire the env-var plumbing earlier versions installed --- remove_legacy_env_plumbing # --- 6. Launch --- open "$app_path" 2>/dev/null \ && ok "Codex desktop app launched — it is wired to ${PR_BASE_URL}." \ || ok "Codex desktop app is wired to ${PR_BASE_URL}. Launch it from the Applications folder." warn "If macOS shows a security prompt on first launch, choose \"Open\"."