#!/usr/bin/env bash # PrimeRouter — Codex CLI (@openai/codex) install + configure script. # - Installs Node + @openai/codex if missing (prompts before touching system). # - Falls back to a per-user npm prefix when the global one is not writable, # so it never needs sudo. # - Writes ~/.codex/config.toml pointing at https://primerouter.ai/v1. # - Stores the key in ~/.codex/auth.json (Codex's own credential store) so it # resolves from every launch context — terminal, GUI app, IDE extension. # - 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 /dev/null) || return 1 [[ -n "$prefix" ]] || return 1 for d in "${prefix}/lib/node_modules" "${prefix}/bin"; do while [[ ! -e "$d" ]]; do d=$(dirname "$d"); done [[ -w "$d" ]] || return 1 done } # Prefers the mirror registry (consistently fast from our users' networks; # the default registry is often reachable but too slow) and falls back to # the default registry when the mirror is unreachable. Respects a registry # the user already configured in .npmrc. NPM_REGISTRY_FLAG="" choose_npm_registry() { local configured configured=$(npm config get registry 2>/dev/null || echo "") case "$configured" in ""|"${NPM_DEFAULT_REGISTRY}"|"${NPM_DEFAULT_REGISTRY}/") ;; *) return ;; # custom registry already configured — leave it alone esac if curl -fsSL --max-time 8 -o /dev/null "${NPM_MIRROR_REGISTRY}/-/ping" 2>/dev/null; then NPM_REGISTRY_FLAG="--registry=${NPM_MIRROR_REGISTRY}" else warn "${NPM_MIRROR_REGISTRY} is unreachable from this network; using the default npm registry." fi } # Shell profile that new terminals actually read (macOS terminals start login # shells, which read .bash_profile, not .bashrc). detect_profile() { case "${SHELL:-}" in */zsh) echo "${HOME}/.zshrc" ;; */bash) if [[ "$(uname -s)" == "Darwin" ]]; then echo "${HOME}/.bash_profile" else echo "${HOME}/.bashrc" fi ;; *) echo "" ;; esac } # Persists the per-user npm prefix on PATH. Block markers are shared by all # PrimeRouter install scripts so re-runs and other tools stay deduplicated. persist_user_prefix_path() { local profile path_line path_line="export PATH=\"${USER_NPM_PREFIX}/bin:\$PATH\"" profile=$(detect_profile) if [[ -z "$profile" ]]; then warn "Unknown shell. Add this line to your shell profile manually:" printf " %s\n" "$path_line" return fi backup_if_exists "$profile" if [[ -f "$profile" ]]; then sed -i.tmp '/# >>> PrimeRouter npm prefix >>>/,/# <<< PrimeRouter npm prefix <<>> PrimeRouter npm prefix >>>\n' printf '%s\n' "$path_line" printf '# <<< PrimeRouter npm prefix <<<\n' } >> "$profile" ok "Added ${USER_NPM_PREFIX}/bin to PATH in ${profile}" } # npm install -g with automatic fallback to a per-user prefix when the global # one is not writable — never needs sudo. npm_install_global() { local pkg="$1" bin_name="$2" choose_npm_registry if npm_global_writable; then npm install -g ${NPM_REGISTRY_FLAG} "$pkg" \ || die "npm install failed. Check the npm output above, then re-run this script." else warn "npm's global directory ($(npm prefix -g)) is not writable by this user." warn "Installing into ${USER_NPM_PREFIX} instead (no sudo needed)." mkdir -p "${USER_NPM_PREFIX}" NPM_CONFIG_PREFIX="${USER_NPM_PREFIX}" npm install -g ${NPM_REGISTRY_FLAG} "$pkg" \ || die "npm install failed. Check the npm output above, then re-run this script." export PATH="${USER_NPM_PREFIX}/bin:${PATH}" persist_user_prefix_path fi command -v "$bin_name" >/dev/null 2>&1 \ || die "Install finished but '${bin_name}' is still not on PATH. Open a new terminal and re-run this script." ok "Installed ${pkg}." } # --- End shared npm install hardening --- # --- Shared Codex provider-id adoption (keep identical across PrimeRouter # scripts) --- # Codex's resume picker lists only the sessions whose recorded model_provider # id equals the currently configured one (tui resume_picker -> # 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 2>&1; then need_node=1 else major=$(node -v | sed -E 's/^v([0-9]+).*/\1/') if [[ "$major" -lt 22 ]]; then need_node=1; fi fi if [[ "$need_node" -eq 1 ]]; then warn "Node.js v22+ is required to install @openai/codex." confirm "Install Node.js LTS now?" \ || die "Aborted. Install Node.js manually then re-run this script." curl -fsSL "${INSTALL_BASE}/nodejs.sh" | bash fi # --- 2. @openai/codex --- if ! command -v codex >/dev/null 2>&1; then warn "Codex CLI (@openai/codex) is not on PATH." confirm "Install it via npm now?" \ || die "Aborted. Install @openai/codex manually then re-run." npm_install_global "@openai/codex" "codex" fi # --- 3. 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})" # --- 5. Store the key in Codex's own credential store --- # auth.json instead of an env var: with env_key set, Codex hard-fails the # request when the variable is missing (it is not a fallback), which is what # broke users whose launch context did not inherit the shell profile — GUI # app, IDE extension, a terminal opened before the profile was written. write_codex_auth "$PR_API_KEY" # --- 6. Retire the env-var plumbing earlier versions installed --- remove_legacy_env_plumbing ok "Codex CLI is wired to ${PR_BASE_URL}. Run: codex"