#!/usr/bin/env bash # ============================================================================= # Clodex - Codex App installer for macOS # ============================================================================= # Connects Codex App to Clodex (https://clodex.xyz/v1) without replacing # auth.json or unrelated config.toml settings. # # Usage: # ./install.sh [model] # # Examples: # ./install.sh clodex_YOUR_KEY # ./install.sh clodex_YOUR_KEY gpt-5.6-terra # CODEX_REASONING_EFFORT=ultra ./install.sh clodex_YOUR_KEY gpt-5.6-sol # ============================================================================= set -euo pipefail API_KEY="${1:-${CLODEX_API_KEY:-}}" MODEL_ARG="${2:-}" BASE_URL="${CODEX_BASE_URL:-${CLODEX_BASE_URL:-https://clodex.xyz/v1}}" REQUESTED_MODEL="${MODEL_ARG:-${CODEX_DEFAULT_MODEL:-}}" REASONING_EFFORT="${CODEX_REASONING_EFFORT:-high}" PROVIDER_ID="${CODEX_PROVIDER_ID:-clodex}" PROVIDER_LABEL="${CODEX_PROVIDER_LABEL:-Clodex}" ENV_VAR_NAME="${CODEX_ENV_VAR_NAME:-CLODEX_API_KEY}" AGENT_LABEL="${CODEX_AGENT_LABEL:-com.clodex.env}" MODEL_CATALOG_URL="${CODEX_MODEL_CATALOG_URL:-https://clodex.xyz/api/downloads/codex/clodex-models.json}" CODEX_DIR="${CODEX_HOME:-$HOME/.codex}" AUTH_PATH="$CODEX_DIR/auth.json" CONFIG_PATH="$CODEX_DIR/config.toml" DOTENV_PATH="$CODEX_DIR/.env" LEGACY_ENV_PATH="$CODEX_DIR/clodex.env" MODEL_CATALOG_PATH="$CODEX_DIR/clodex-models.json" AGENT_DIR="$HOME/Library/LaunchAgents" AGENT_PATH="$AGENT_DIR/${AGENT_LABEL}.plist" LEGACY_OPENAI_AGENT_PATH="$AGENT_DIR/${AGENT_LABEL}.openai.plist" BACKUP_DIR="$CODEX_DIR/backups/clodex-install-$(date +%Y%m%d-%H%M%S)" SUPPORTED_MODELS="gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna gpt-5.3-codex-spark" MODEL_CATALOG_DOWNLOAD="" MODEL_RESPONSE_PATH="" SELECTED_MODEL="" step() { printf '[Clodex] %s\n' "$1" } done_message() { printf '[OK] %s\n' "$1" } fail() { printf '[ERROR] %s\n' "$1" >&2 exit 1 } cleanup() { [[ -z "$MODEL_CATALOG_DOWNLOAD" || ! -f "$MODEL_CATALOG_DOWNLOAD" ]] || rm -f "$MODEL_CATALOG_DOWNLOAD" [[ -z "$MODEL_RESPONSE_PATH" || ! -f "$MODEL_RESPONSE_PATH" ]] || rm -f "$MODEL_RESPONSE_PATH" } trap cleanup EXIT contains_word() { local needle="$1" local haystack="$2" [[ " $haystack " == *" $needle "* ]] } supported_efforts_for_model() { case "$1" in gpt-5.6-sol|gpt-5.6-terra) printf '%s\n' "low medium high xhigh max ultra" ;; gpt-5.6-luna) printf '%s\n' "low medium high xhigh max" ;; gpt-5.3-codex-spark) printf '%s\n' "low medium high xhigh" ;; *) return 1 ;; esac } validate_model_catalog() { local catalog_path="$1" [[ -s "$catalog_path" ]] || return 1 for required_model in gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna gpt-5.3-codex-spark; do grep -Fq "\"slug\": \"$required_model\"" "$catalog_path" || return 1 done grep -Fq '"display_name": "GPT-5.6 Sol"' "$catalog_path" || return 1 grep -Fq '"display_name": "GPT-5.6 Terra"' "$catalog_path" || return 1 grep -Fq '"display_name": "GPT-5.6 Luna"' "$catalog_path" || return 1 grep -Fq '"display_name": "GPT-5.3-Codex-Spark"' "$catalog_path" || return 1 [[ "$(grep -Fc '"visibility": "list"' "$catalog_path")" -eq 4 ]] || return 1 [[ "$(grep -Fc '"use_responses_lite": false' "$catalog_path")" -eq 4 ]] || return 1 [[ "$(grep -Fc '"shell_type": "shell_command"' "$catalog_path")" -eq 4 ]] || return 1 [[ "$(grep -Fc '"apply_patch_tool_type": "freeform"' "$catalog_path")" -eq 4 ]] || return 1 [[ "$(grep -Fc '"supports_parallel_tool_calls": true' "$catalog_path")" -eq 4 ]] || return 1 [[ "$(grep -Fc '"tool_mode": "code_mode_only"' "$catalog_path")" -eq 4 ]] || return 1 [[ "$(grep -Fc '"effort": "max"' "$catalog_path")" -eq 3 ]] || return 1 [[ "$(grep -Fc '"effort": "ultra"' "$catalog_path")" -eq 2 ]] || return 1 } download_model_catalog() { local destination="$1" local request_url="$MODEL_CATALOG_URL" local separator="?" local script_dir local local_catalog [[ "$request_url" != *\?* ]] || separator="&" if [[ "$request_url" == http://* || "$request_url" == https://* ]]; then request_url="${request_url}${separator}installer=$(date +%s)" fi if [[ "$request_url" == http://* || "$request_url" == https://* ]]; then if curl \ --fail \ --silent \ --show-error \ --location \ --retry 3 \ --retry-delay 1 \ --retry-all-errors \ -H 'Cache-Control: no-cache' \ "$request_url" \ -o "$destination" && validate_model_catalog "$destination"; then return 0 fi elif [[ -f "$request_url" ]]; then cp "$request_url" "$destination" if validate_model_catalog "$destination"; then return 0 fi fi script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" for local_catalog in \ "$script_dir/clodex-models.json" \ "$script_dir/../clodex-models.json"; do if [[ -f "$local_catalog" ]]; then cp "$local_catalog" "$destination" if validate_model_catalog "$destination"; then return 0 fi fi done rm -f "$destination" return 1 } model_is_available() { local model="$1" local escaped_model="${model//./\\.}" grep -Eq '"id"[[:space:]]*:[[:space:]]*"'"$escaped_model"'"' "$MODEL_RESPONSE_PATH" } choose_model() { local candidate if [[ -n "$REQUESTED_MODEL" ]]; then contains_word "$REQUESTED_MODEL" "$SUPPORTED_MODELS" || fail "Unsupported model: $REQUESTED_MODEL" model_is_available "$REQUESTED_MODEL" || fail "Model $REQUESTED_MODEL is not available for this API key." printf '%s' "$REQUESTED_MODEL" return 0 fi for candidate in gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna; do if model_is_available "$candidate"; then printf '%s' "$candidate" return 0 fi done fail "This API key has no supported Clodex Codex model." } validate_key_and_choose_model() { local http_code local supported_efforts MODEL_RESPONSE_PATH="$(mktemp "${TMPDIR:-/tmp}/clodex-models-response.XXXXXX")" step "Checking API key and available Clodex Codex models" if ! http_code="$(curl \ --silent \ --show-error \ --location \ --connect-timeout 10 \ --max-time 30 \ --retry 2 \ --retry-all-errors \ -o "$MODEL_RESPONSE_PATH" \ -w '%{http_code}' \ -H "Authorization: Bearer $API_KEY" \ "$BASE_URL/models")"; then fail "Could not connect to $BASE_URL/models." fi case "$http_code" in 200) ;; 401) fail "Clodex rejected the API key (HTTP 401). Copy a current key and run the installer again." ;; 403) fail "Clodex denied this API key (HTTP 403). Check its status and restrictions." ;; *) fail "Clodex model check failed with HTTP $http_code." ;; esac SELECTED_MODEL="$(choose_model)" supported_efforts="$(supported_efforts_for_model "$SELECTED_MODEL")" contains_word "$REASONING_EFFORT" "$supported_efforts" || fail "Unsupported reasoning effort $REASONING_EFFORT for $SELECTED_MODEL. Supported: $supported_efforts" done_message "API key accepted; selected model: $SELECTED_MODEL" } backup_file() { local source_path="$1" if [[ -f "$source_path" ]]; then cp -p "$source_path" "$BACKUP_DIR/$(basename "$source_path")" done_message "Backup saved: $BACKUP_DIR/$(basename "$source_path")" fi } read_managed_key_from_file() { local path="$1" local line local value [[ -f "$path" ]] || return 0 while IFS= read -r line; do case "$line" in "#"*) continue ;; "export CLODEX_API_KEY="*|"CLODEX_API_KEY="*) line="${line#export }" value="${line#*=}" value="${value#\"}" value="${value%\"}" printf '%s' "$value" return 0 ;; esac done < "$path" } read_old_managed_key() { local value for path in "$LEGACY_ENV_PATH" "$DOTENV_PATH"; do value="$(read_managed_key_from_file "$path")" if [[ -n "$value" ]]; then printf '%s' "$value" return 0 fi done } update_dotenv() { local path="$1" local preserved_path local output_path preserved_path="$(mktemp "${TMPDIR:-/tmp}/clodex-dotenv-preserved.XXXXXX")" output_path="$(mktemp "${TMPDIR:-/tmp}/clodex-dotenv-output.XXXXXX")" if [[ -f "$path" ]]; then awk -v name="$ENV_VAR_NAME" ' $0 == "# Managed by Clodex installer: " name { next } $0 ~ "^[[:space:]]*(export[[:space:]]+)?" name "[[:space:]]*=" { next } { print } ' "$path" > "$preserved_path" else : > "$preserved_path" fi { cat "$preserved_path" [[ ! -s "$preserved_path" ]] || printf '\n' printf '# Managed by Clodex installer: %s\n' "$ENV_VAR_NAME" printf '%s=%s\n' "$ENV_VAR_NAME" "$API_KEY" } > "$output_path" chmod 600 "$output_path" mv -f "$output_path" "$path" rm -f "$preserved_path" } filter_existing_codex_config() { local path="$1" local output_path="$2" if [[ ! -f "$path" ]]; then : > "$output_path" return 0 fi awk ' BEGIN { top_level = 1; skip_provider = 0 } /^[[:space:]]*\[/ { normalized = $0 gsub(/[[:space:]]/, "", normalized) skip_provider = normalized ~ /^\[model_providers\.(clodex|"clodex")(\.|\])/ top_level = 0 } skip_provider { next } top_level && /^[[:space:]]*#[[:space:]]*Generated by Clodex installer/ { next } top_level && /^[[:space:]]*(model|model_reasoning_effort|model_provider)[[:space:]]*=/ { next } top_level && /^[[:space:]]*model_catalog_json[[:space:]]*=/ { next } { print } ' "$path" > "$output_path" } update_clodex_config() { local preserved_path local output_path preserved_path="$(mktemp "${TMPDIR:-/tmp}/clodex-config-preserved.XXXXXX")" output_path="$(mktemp "${TMPDIR:-/tmp}/clodex-config-output.XXXXXX")" filter_existing_codex_config "$CONFIG_PATH" "$preserved_path" { printf '# Generated by Clodex installer; unrelated settings are preserved.\n' printf 'model = "%s"\n' "$SELECTED_MODEL" printf 'model_reasoning_effort = "%s"\n' "$REASONING_EFFORT" printf 'model_provider = "%s"\n' "$PROVIDER_ID" printf 'model_catalog_json = "%s"\n' "$MODEL_CATALOG_PATH" if [[ -s "$preserved_path" ]]; then printf '\n' cat "$preserved_path" fi printf '\n[model_providers.%s]\n' "$PROVIDER_ID" printf 'name = "%s"\n' "$PROVIDER_LABEL" printf 'base_url = "%s"\n' "$BASE_URL" printf 'wire_api = "responses"\n' printf 'env_key = "%s"\n' "$ENV_VAR_NAME" printf 'supports_websockets = false\n' printf 'requires_openai_auth = false\n' } > "$output_path" chmod 600 "$output_path" mv -f "$output_path" "$CONFIG_PATH" rm -f "$preserved_path" } write_legacy_env_file() { cat > "$LEGACY_ENV_PATH" < "$AGENT_PATH" < Label $AGENT_LABEL ProgramArguments /bin/launchctl setenv $ENV_VAR_NAME $API_KEY RunAtLoad EOF chmod 600 "$AGENT_PATH" } remove_legacy_openai_environment() { local old_managed_key="$1" local current_openai_key="" if [[ "${CLODEX_INSTALL_SKIP_LAUNCHCTL:-0}" != "1" ]]; then current_openai_key="$(launchctl getenv OPENAI_API_KEY 2>/dev/null || true)" launchctl bootout "gui/$(id -u)" "$LEGACY_OPENAI_AGENT_PATH" >/dev/null 2>&1 || true if [[ -n "$old_managed_key" && "$current_openai_key" == "$old_managed_key" ]]; then launchctl unsetenv OPENAI_API_KEY >/dev/null 2>&1 || true done_message "Removed legacy Clodex value from OPENAI_API_KEY." fi fi [[ ! -f "$LEGACY_OPENAI_AGENT_PATH" ]] || rm -f "$LEGACY_OPENAI_AGENT_PATH" } activate_environment() { export "$ENV_VAR_NAME=$API_KEY" [[ "${CLODEX_INSTALL_SKIP_LAUNCHCTL:-0}" == "1" ]] && return 0 launchctl setenv "$ENV_VAR_NAME" "$API_KEY" >/dev/null 2>&1 launchctl bootout "gui/$(id -u)" "$AGENT_PATH" >/dev/null 2>&1 || true launchctl bootstrap "gui/$(id -u)" "$AGENT_PATH" >/dev/null 2>&1 } stop_codex_app() { [[ "${CLODEX_INSTALL_SKIP_RESTART:-0}" == "1" ]] && return 0 if pgrep -f '/Applications/ChatGPT.app/Contents/MacOS/ChatGPT' >/dev/null 2>&1 || pgrep -x Codex >/dev/null 2>&1; then step "Closing Codex App" osascript -e 'tell application id "com.openai.codex" to quit' >/dev/null 2>&1 || true osascript -e 'tell application "Codex" to quit' >/dev/null 2>&1 || true sleep 3 fi } start_codex_app() { [[ "${CLODEX_INSTALL_SKIP_RESTART:-0}" == "1" ]] && return 0 if [[ -d "/Applications/ChatGPT.app" ]]; then open -b com.openai.codex >/dev/null 2>&1 || open -a "/Applications/ChatGPT.app" >/dev/null 2>&1 || true elif [[ -d "/Applications/Codex.app" ]]; then open -a Codex >/dev/null 2>&1 || true fi } validate_written_state() { local required_config for required_config in \ "model = \"$SELECTED_MODEL\"" \ "model_reasoning_effort = \"$REASONING_EFFORT\"" \ "model_provider = \"$PROVIDER_ID\"" \ "model_catalog_json = \"$MODEL_CATALOG_PATH\"" \ "[model_providers.$PROVIDER_ID]" \ "base_url = \"$BASE_URL\"" \ 'wire_api = "responses"' \ "env_key = \"$ENV_VAR_NAME\"" \ 'requires_openai_auth = false'; do grep -Fq "$required_config" "$CONFIG_PATH" || fail "Config validation failed, missing: $required_config" done [[ -s "$MODEL_CATALOG_PATH" ]] || fail "Config validation failed: model catalog is missing." validate_model_catalog "$MODEL_CATALOG_PATH" || fail "Config validation failed: model catalog is invalid." grep -Eq "^[[:space:]]*$ENV_VAR_NAME[[:space:]]*=" "$DOTENV_PATH" || fail "Desktop app environment validation failed: $ENV_VAR_NAME is missing from $DOTENV_PATH." [[ -s "$AUTH_PATH" ]] || done_message "auth.json is absent; custom provider authentication uses $ENV_VAR_NAME." } if [[ -z "$API_KEY" ]]; then fail "API key is missing. Run: ./install.sh clodex_YOUR_KEY [model]" fi API_KEY="${API_KEY//$'\r'/}" API_KEY="${API_KEY//$'\n'/}" if [[ ! "$API_KEY" =~ ^(clodex_|sk-)[A-Za-z0-9._-]{16,}$ ]]; then fail "The Clodex API key is invalid or incomplete." fi BASE_URL="${BASE_URL%/}" MODEL_CATALOG_DOWNLOAD="$(mktemp "${TMPDIR:-/tmp}/clodex-model-catalog.XXXXXX")" if ! download_model_catalog "$MODEL_CATALOG_DOWNLOAD"; then fail "Failed to download a valid Codex model catalog; tool metadata is incomplete." fi validate_key_and_choose_model mkdir -p "$CODEX_DIR" "$AGENT_DIR" "$BACKUP_DIR" umask 077 OLD_MANAGED_KEY="$(read_old_managed_key || true)" backup_file "$CONFIG_PATH" backup_file "$AUTH_PATH" backup_file "$DOTENV_PATH" backup_file "$LEGACY_ENV_PATH" backup_file "$MODEL_CATALOG_PATH" backup_file "$AGENT_PATH" backup_file "$LEGACY_OPENAI_AGENT_PATH" stop_codex_app update_dotenv "$DOTENV_PATH" write_legacy_env_file cp "$MODEL_CATALOG_DOWNLOAD" "$MODEL_CATALOG_PATH" chmod 600 "$MODEL_CATALOG_PATH" done_message "Installed Clodex Codex model catalog: $MODEL_CATALOG_PATH" update_clodex_config write_launch_agent remove_legacy_openai_environment "$OLD_MANAGED_KEY" activate_environment validate_written_state start_codex_app cat <