#!/bin/bash
# =============================================================================
#  AirLLM - One-File Setup  (macOS / Linux / WSL)
#  https://api.airllm.ai
#
#  Points the coding tool you choose (or ALL supported CLI coding tools and
#  agent frameworks) at the AirLLM gateway, and makes sure it ACTUALLY takes
#  effect:
#
#    1. Writes each tool's own CONFIG FILE (the reliable part):
#         - Claude Code -> ~/.claude/settings.json           ("env" block)
#         - Codex       -> ~/.codex/config.toml               (custom provider)
#         - OpenCode    -> ~/.config/opencode/opencode.json   (custom provider)
#         - Aider       -> ~/.aider.conf.yml                  (model + base URL + key)
#    2. Exports environment variables in your shell profile for the rest
#         (Codex's key, GitHub Copilot CLI BYOK, agent frameworks) - each tool
#         gets its own managed block, so setting up one tool never disturbs
#         another. Only the agent-frameworks block sets the shared OPENAI_* /
#         ANTHROPIC_* variables, and always as base URL + key together.
#    3. A saved Claude/Anthropic or ChatGPT login does NOT need to be removed:
#         gateway credentials take precedence (Claude Code) and Codex ignores
#         its ChatGPT login for custom providers. The script just tells you if
#         it spots one.
#
#  WHY CONFIG FILES (the #1 setup problem people hit): shell exports only exist
#  inside the terminal that set them. A tool launched from an IDE, the Dock, or
#  a fresh shell never sees them. Config files are read at startup in EVERY
#  context.  (Refs: code.claude.com/docs/en/llm-gateway-connect)
#
#  UI-configured tools (Cursor, Continue, Cline, Roo Code, OpenHands, Xcode)
#  can't be set from a script - their settings are printed for you instead.
#
#  HOW TO USE
#    1. REQUIRED: replace your_airllm_api_key below with your AirLLM API key.
#       (Skipped if you downloaded this file with the key pre-filled.)
#    2. Run:      bash airllm-setup-mac.sh
#    3. Restart your terminal (or run:  source ~/.zshrc)
#
#  Safe to re-run: it merges into existing config (never clobbers other keys)
#  and replaces its own blocks in your shell profile without duplicating them.
# =============================================================================

# -----------------------------------------------------------------------------
#  STEP 1 - PASTE YOUR AIRLLM API KEY BETWEEN THE QUOTES vvv
# -----------------------------------------------------------------------------
AIRLLM_API_KEY="your_airllm_api_key"

# Default model. airllm-auto picks a model for each request. For coding tools
# you can use instead: airllm-openai, airllm-anthropic or airllm-agent.
# airllm-coding and airllm-thinking are chat aliases (for chat apps, not
# coding agents). With a Token-plan key, a catalog model id listed at
# https://airllm.ai/models works too (for example z-ai/glm-5).
AIRLLM_MODEL="airllm-auto"

# Which tool to configure. Set automatically when you pick a tool on the
# download page. Valid values:
#   all            claude-code    codex          opencode      aider
#   copilot-cli    agent-frameworks
#   cursor         continue       cline          roo-code      openhands   xcode
# (the last row are UI-configured tools - their settings are printed, nothing
#  on your machine is changed)
AIRLLM_TOOL="all"

# Gateway endpoints (do not change). The API has the standard shape -
# /v1/messages, /v1/chat/completions, /v1/responses, /v1/models - but tools
# differ in whether they add the /v1 themselves, so each gets the one it needs:
#   AIRLLM_BASE_URL    tools that append /v1/... themselves: Claude Code and
#                      other Anthropic-style clients, Xcode
#   AIRLLM_OPENAI_URL  OpenAI-style tools that append /chat/completions or
#                      /responses: Codex, OpenCode, Aider, Copilot CLI, Cursor,
#                      Continue, Cline, Roo Code, OpenHands, agent frameworks
# (Giving a tool the other one doubles or drops the /v1 and every call 404s.)
AIRLLM_BASE_URL="https://api.airllm.ai/"
AIRLLM_OPENAI_URL="https://api.airllm.ai/v1"

# ============================================================================
#  No edits needed below this line.
# ============================================================================
# Every file this script creates holds your API key, so it is created owner-only (0600/0700).
# Files that already exist are tightened to 0600 at the end (see "lock down" below).
umask 077
BOLD=$(printf '\033[1m'); GRN=$(printf '\033[32m'); YLW=$(printf '\033[33m')
RED=$(printf '\033[31m'); CYN=$(printf '\033[36m'); RST=$(printf '\033[0m')
ok()   { echo "  ${GRN}[ok]${RST} $1"; }
warn() { echo "  ${YLW}[!]${RST} $1"; }
note() { echo "  ${CYN}[i]${RST} $1"; }

# Configure this tool this run? (true for the tool itself, or when tool = all)
want() { [ "$AIRLLM_TOOL" = "all" ] || [ "$AIRLLM_TOOL" = "$1" ]; }

TOOL_LABEL="$AIRLLM_TOOL"
case "$AIRLLM_TOOL" in
  all)              TOOL_LABEL="all supported tools" ;;
  claude-code)      TOOL_LABEL="Claude Code" ;;
  codex)            TOOL_LABEL="Codex" ;;
  opencode)         TOOL_LABEL="OpenCode" ;;
  aider)            TOOL_LABEL="Aider" ;;
  copilot-cli)      TOOL_LABEL="GitHub Copilot CLI" ;;
  agent-frameworks) TOOL_LABEL="agent frameworks" ;;
  cursor)           TOOL_LABEL="Cursor" ;;
  continue)         TOOL_LABEL="Continue" ;;
  cline)            TOOL_LABEL="Cline" ;;
  roo-code)         TOOL_LABEL="Roo Code" ;;
  openhands)        TOOL_LABEL="OpenHands" ;;
  xcode)            TOOL_LABEL="Xcode" ;;
  *)
    echo ""
    echo "  ${RED}Unknown AIRLLM_TOOL value: \"$AIRLLM_TOOL\". Nothing was changed.${RST}"
    echo "  Valid values: all, claude-code, codex, opencode, aider, copilot-cli,"
    echo "  agent-frameworks, cursor, continue, cline, roo-code, openhands, xcode"
    exit 1 ;;
esac

echo ""
echo "  ${BOLD}AirLLM one-file setup - macOS / Linux / WSL${RST}"
echo "  Target: ${CYN}${TOOL_LABEL}${RST}"
echo "  ==========================================="
echo ""

# --- API key -----------------------------------------------------------------
if [ "$AIRLLM_API_KEY" = "your_airllm_api_key" ] || [ -z "$AIRLLM_API_KEY" ]; then
  echo "  ${YLW}No API key found in this file.${RST}"
  echo "  Open airllm-setup-mac.sh in a text editor and replace"
  echo "  your_airllm_api_key with your real key - or paste it now."
  echo ""
  printf "  Paste your AirLLM API key (or Ctrl+C to quit): "
  read -rs AIRLLM_API_KEY          # -s: not echoed, so it never lands in scrollback or a screen share
  echo ""
  echo ""
fi
if [ -z "$AIRLLM_API_KEY" ] || [ "$AIRLLM_API_KEY" = "your_airllm_api_key" ]; then
  echo "  ${RED}No API key provided. Nothing was changed.${RST}"
  exit 1
fi
# The key and model are written into shell profiles, JSON, TOML and YAML below. Refuse anything
# that is not a plain token, so no value can ever break out of those contexts (e.g. $(...) or a
# quote inside a double-quoted export would run on every new terminal).
case "$AIRLLM_API_KEY" in
  *[!A-Za-z0-9._-]*) echo "  ${RED}That API key contains unexpected characters. Nothing was changed.${RST}"; exit 1 ;;
esac
case "$AIRLLM_MODEL" in
  ""|*[!A-Za-z0-9._:/@+~-]*) echo "  ${RED}Unexpected model name \"$AIRLLM_MODEL\". Nothing was changed.${RST}"; exit 1 ;;
esac

# Print the settings for a UI-configured tool (Cursor, Cline, ...). These tools
# store provider settings inside their own UI, so a script can't write them.
print_ui_tool() {
  echo ""
  echo "  ${BOLD}$1 - enter these in the tool's settings UI:${RST}"
  echo "  ---------------------------------------------------------------"
  if [ "$1" = "Xcode" ]; then
    echo "    Where    : Xcode -> Settings -> Intelligence -> Add a Model Provider"
    echo "    URL      : ${CYN}$AIRLLM_BASE_URL${RST}   (no /v1 - Xcode appends it)"
  else
    echo "    Base URL : ${CYN}$AIRLLM_OPENAI_URL${RST}"
  fi
  echo "    API key  : ${CYN}$AIRLLM_API_KEY${RST}"
  if [ "$1" = "OpenHands" ]; then
    echo "    Model    : ${CYN}openai/$AIRLLM_MODEL${RST}   (Custom Model - LiteLLM needs the openai/ prefix)"
  else
    echo "    Model    : ${CYN}$AIRLLM_MODEL${RST}"
  fi
}

# =============================================================================
#  1. Claude Code  ->  ~/.claude/settings.json  (the reliable channel)
# =============================================================================
# MERGE our keys into the existing settings.json so other settings survive.
# Every value below is a valid AirLLM model, so Claude Code's background calls
# (titles, summaries) also hit the gateway instead of failing.
# ANTHROPIC_AUTH_TOKEN (bearer) is used, NOT ANTHROPIC_API_KEY - the token
# needs no interactive "trust this key?" approval and, per the official docs,
# takes precedence over any saved claude.ai login (so no sign-out is needed).
write_claude_settings() {
  local dir="$HOME/.claude" file="$HOME/.claude/settings.json"
  mkdir -p "$dir"
  CLAUDE_STATUS="fail"
  if command -v node >/dev/null 2>&1; then
    AL_FILE="$file" AL_BASE="$AIRLLM_BASE_URL" AL_TOK="$AIRLLM_API_KEY" AL_MDL="$AIRLLM_MODEL" node -e '
      const fs=require("fs"), f=process.env.AL_FILE, m=process.env.AL_MDL;
      let s={}, bad=false;
      if(fs.existsSync(f)){ try{ s=JSON.parse(fs.readFileSync(f,"utf8")||"{}")||{}; }catch(e){ bad=true; s={}; } }
      if(typeof s!=="object"||Array.isArray(s)){ bad=true; s={}; }
      if(bad) fs.copyFileSync(f, f+".airllm-bak");   // unparseable content is preserved, never lost
      let env=s.env; if(typeof env!=="object"||env===null||Array.isArray(env)) env={};
      s.env=Object.assign({}, env, {
        ANTHROPIC_BASE_URL:process.env.AL_BASE, ANTHROPIC_AUTH_TOKEN:process.env.AL_TOK,
        API_TIMEOUT_MS:"3000000", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC:"1",
        ANTHROPIC_MODEL:m, ANTHROPIC_DEFAULT_HAIKU_MODEL:m, ANTHROPIC_DEFAULT_SONNET_MODEL:m,
        ANTHROPIC_DEFAULT_OPUS_MODEL:m, ANTHROPIC_DEFAULT_FABLE_MODEL:m,
        ANTHROPIC_SMALL_FAST_MODEL:m, CLAUDE_CODE_SUBAGENT_MODEL:m, CLAUDE_CODE_SUBAGENT_MODEL_FORCE:"1" });
      fs.writeFileSync(f, JSON.stringify(s,null,2)+"\n");
    ' && CLAUDE_STATUS="merged"
  elif command -v python3 >/dev/null 2>&1; then
    AL_FILE="$file" AL_BASE="$AIRLLM_BASE_URL" AL_TOK="$AIRLLM_API_KEY" AL_MDL="$AIRLLM_MODEL" python3 - <<'PYEOF' && CLAUDE_STATUS="merged"
import json, os, shutil
f=os.environ["AL_FILE"]; m=os.environ["AL_MDL"]
s={}; bad=False
if os.path.exists(f):
    try:
        s=json.load(open(f))
        if not isinstance(s, dict): bad=True; s={}
    except Exception:
        bad=True; s={}
if bad: shutil.copyfile(f, f+".airllm-bak")  # unparseable content is preserved, never lost
env=s.get("env")
if not isinstance(env, dict): env={}
env.update({"ANTHROPIC_BASE_URL":os.environ["AL_BASE"], "ANTHROPIC_AUTH_TOKEN":os.environ["AL_TOK"],
            "API_TIMEOUT_MS":"3000000", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1",
            "ANTHROPIC_MODEL":m, "ANTHROPIC_DEFAULT_HAIKU_MODEL":m, "ANTHROPIC_DEFAULT_SONNET_MODEL":m,
            "ANTHROPIC_DEFAULT_OPUS_MODEL":m, "ANTHROPIC_DEFAULT_FABLE_MODEL":m,
            "ANTHROPIC_SMALL_FAST_MODEL":m, "CLAUDE_CODE_SUBAGENT_MODEL":m, "CLAUDE_CODE_SUBAGENT_MODEL_FORCE":"1"})
s["env"]=env
json.dump(s, open(f,"w"), indent=2); open(f,"a").write("\n")
PYEOF
  elif command -v jq >/dev/null 2>&1; then
    [ -f "$file" ] || echo '{}' > "$file"
    local tmp; tmp=$(mktemp)
    jq --arg b "$AIRLLM_BASE_URL" --arg t "$AIRLLM_API_KEY" --arg m "$AIRLLM_MODEL" \
      '.env = (.env // {}) + {ANTHROPIC_BASE_URL:$b, ANTHROPIC_AUTH_TOKEN:$t, ANTHROPIC_MODEL:$m,
       API_TIMEOUT_MS:"3000000", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC:"1",
       ANTHROPIC_DEFAULT_HAIKU_MODEL:$m, ANTHROPIC_DEFAULT_SONNET_MODEL:$m,
       ANTHROPIC_DEFAULT_OPUS_MODEL:$m, ANTHROPIC_DEFAULT_FABLE_MODEL:$m,
       ANTHROPIC_SMALL_FAST_MODEL:$m, CLAUDE_CODE_SUBAGENT_MODEL:$m, CLAUDE_CODE_SUBAGENT_MODEL_FORCE:"1"}' \
      "$file" > "$tmp" 2>/dev/null && mv "$tmp" "$file" && CLAUDE_STATUS="merged" || rm -f "$tmp"
  fi
  if [ "$CLAUDE_STATUS" != "merged" ]; then
    [ -f "$file" ] && cp "$file" "$file.airllm-bak" 2>/dev/null && CLAUDE_STATUS="fresh-backup" || CLAUDE_STATUS="fresh"
    cat > "$file" <<EOF
{
  "env": {
    "ANTHROPIC_BASE_URL": "$AIRLLM_BASE_URL",
    "ANTHROPIC_AUTH_TOKEN": "$AIRLLM_API_KEY",
    "API_TIMEOUT_MS": "3000000",
    "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
    "ANTHROPIC_MODEL": "$AIRLLM_MODEL",
    "ANTHROPIC_DEFAULT_HAIKU_MODEL": "$AIRLLM_MODEL",
    "ANTHROPIC_DEFAULT_SONNET_MODEL": "$AIRLLM_MODEL",
    "ANTHROPIC_DEFAULT_OPUS_MODEL": "$AIRLLM_MODEL",
    "ANTHROPIC_DEFAULT_FABLE_MODEL": "$AIRLLM_MODEL",
    "ANTHROPIC_SMALL_FAST_MODEL": "$AIRLLM_MODEL"
  }
}
EOF
  fi
}
# Mark first-run onboarding as done in ~/.claude.json so a FRESH Claude Code
# install goes straight to the gateway instead of the setup wizard. Merged -
# every other field in the file is preserved. (Community-verified field; the
# load-bearing config is settings.json above.)
mark_claude_onboarded() {
  local f="$HOME/.claude.json"
  if command -v node >/dev/null 2>&1; then
    AL_FILE="$f" node -e '
      const fs=require("fs"), f=process.env.AL_FILE;
      let s={};
      if(fs.existsSync(f)){ try{ s=JSON.parse(fs.readFileSync(f,"utf8"))||{}; }catch(e){ process.exit(0); } }
      if(typeof s!=="object"||Array.isArray(s)) process.exit(0);
      s.hasCompletedOnboarding=true;
      fs.writeFileSync(f, JSON.stringify(s,null,2)+"\n");
    ' 2>/dev/null
  elif command -v python3 >/dev/null 2>&1; then
    AL_FILE="$f" python3 - <<'PYEOF' 2>/dev/null
import json, os
f=os.environ["AL_FILE"]
try:
    s=json.load(open(f)) if os.path.exists(f) else {}
    if not isinstance(s, dict): raise SystemExit
except Exception:
    raise SystemExit
s["hasCompletedOnboarding"]=True
json.dump(s, open(f,"w"), indent=2); open(f,"a").write("\n")
PYEOF
  fi
}
claude_logged_in() {
  [ -f "$HOME/.claude/.credentials.json" ] && return 0
  [ -f "$HOME/.claude.json" ] && grep -q '"oauthAccount"' "$HOME/.claude.json" 2>/dev/null && return 0
  if [ "$(uname)" = "Darwin" ] && command -v security >/dev/null 2>&1; then
    security find-generic-password -s "Claude Code-credentials" >/dev/null 2>&1 && return 0
  fi
  return 1
}
if want claude-code; then
  write_claude_settings
  case "$CLAUDE_STATUS" in
    merged)       ok "Claude Code configured -> ~/.claude/settings.json (merged)" ;;
    fresh)        ok "Claude Code configured -> ~/.claude/settings.json (created)" ;;
    fresh-backup) warn "Claude Code -> ~/.claude/settings.json written; your old file was backed up to settings.json.airllm-bak (no JSON tool found to merge). Copy any custom settings back from the backup." ;;
  esac
  mark_claude_onboarded
  # A saved login does NOT block the gateway: ANTHROPIC_AUTH_TOKEN takes
  # precedence (code.claude.com/docs/en/authentication). Just mention it.
  if claude_logged_in; then
    note "Claude Code has a saved Anthropic login. That's fine - the AirLLM token takes precedence and the login stays saved but unused. If Claude Code ever warns about auth, run /logout inside claude once."
  fi
fi

# =============================================================================
#  2. Codex  ->  ~/.codex/config.toml  (custom provider, Responses API)
# =============================================================================
# Codex only speaks the Responses API for custom providers (chat/completions
# support was removed in Feb 2026), so wire_api = "responses" is required and
# the AirLLM gateway serves /v1/responses. The key is read from the
# AIRLLM_API_KEY environment variable (env_key), exported below - Codex's own
# name, so other OpenAI tools in the shell never pick up the AirLLM key (an
# OPENAI_API_KEY without a matching base URL goes to api.openai.com).
# Existing config.toml files ARE merged when it's safe: TOML top-level keys
# must sit before the first [table], so we prepend model/model_provider and
# append the provider table - valid as long as the file doesn't already define
# those keys (e.g. Codex's own auto-written project-trust entries are fine).
# A backup is kept next to the file. Real multi-provider setups are left alone.
if want codex; then
  CODEX_FILE="$HOME/.codex/config.toml"
  mkdir -p "$HOME/.codex"
  codex_provider_block() {
    cat <<EOF
[model_providers.airllm]
name = "AirLLM"
base_url = "$AIRLLM_OPENAI_URL"
env_key = "AIRLLM_API_KEY"
wire_api = "responses"
EOF
  }
  if [ -f "$CODEX_FILE" ]; then
    # Anchored, non-comment matches only: a commented-out block (# [model_pro...)
    # must NOT count as "already configured".
    if grep -Eq '^[[:space:]]*\[model_providers\.airllm\]' "$CODEX_FILE" 2>/dev/null; then
      # A block this script wrote before read the key from OPENAI_API_KEY: move it to
      # AIRLLM_API_KEY (inside the airllm table only).
      if grep -q 'by airllm-setup' "$CODEX_FILE" \
        && ! grep -Eq '^[[:space:]]*env_key[[:space:]]*=[[:space:]]*"AIRLLM_API_KEY"' "$CODEX_FILE"; then
        CODEX_TMP=$(mktemp) && awk '
          /^[[:space:]]*\[/ { inair = ($0 ~ /^[[:space:]]*\[model_providers\.airllm\][[:space:]]*$/) }
          inair && /^[[:space:]]*env_key[[:space:]]*=/ { print "env_key = \"AIRLLM_API_KEY\""; next }
          { print }
        ' "$CODEX_FILE" > "$CODEX_TMP" && cat "$CODEX_TMP" > "$CODEX_FILE"
        rm -f "$CODEX_TMP"
      fi
      # Don't just claim success - make sure the existing block still has the
      # right endpoint, key variable and wire API (an old or hand-written block may not).
      if grep -Eq "^[[:space:]]*base_url[[:space:]]*=[[:space:]]*\"$AIRLLM_OPENAI_URL\"" "$CODEX_FILE" \
        && grep -Eq '^[[:space:]]*env_key[[:space:]]*=[[:space:]]*"AIRLLM_API_KEY"' "$CODEX_FILE" \
        && grep -Eq '^[[:space:]]*wire_api[[:space:]]*=[[:space:]]*"responses"' "$CODEX_FILE"; then
        if grep -Eq "^model[[:space:]]*=[[:space:]]*\"$AIRLLM_MODEL\"" "$CODEX_FILE"; then
          CODEX_STATUS="exists"
        elif grep -q 'by airllm-setup' "$CODEX_FILE"; then
          # A file this script wrote: refresh the default-model line so a newly
          # chosen model actually lands on re-runs.
          CODEX_TMP=$(mktemp) && awk -v m="$AIRLLM_MODEL" '
            !done && /^model[[:space:]]*=/ { print "model = \"" m "\""; done=1; next }
            { print }
          ' "$CODEX_FILE" > "$CODEX_TMP" && cat "$CODEX_TMP" > "$CODEX_FILE"
          rm -f "$CODEX_TMP"
          if grep -Eq "^model[[:space:]]*=[[:space:]]*\"$AIRLLM_MODEL\"" "$CODEX_FILE"; then
            CODEX_STATUS="model-updated"
          else
            CODEX_STATUS="exists-other-model"
          fi
        else
          CODEX_STATUS="exists-other-model"
        fi
      else
        CODEX_STATUS="exists-mismatch"
      fi
    elif { CODEX_HEAD=$(awk '/^[[:space:]]*\[/{exit} {print}' "$CODEX_FILE"); \
           printf '%s\n' "$CODEX_HEAD" | grep -Eq '^[[:space:]]*model_provider[[:space:]]*=' && \
           ! printf '%s\n' "$CODEX_HEAD" | grep -Eq '^[[:space:]]*model_provider[[:space:]]*=[[:space:]]*"airllm"'; } \
      || grep -Eq '^[[:space:]]*\[model_providers\][[:space:]]*$' "$CODEX_FILE"; then
      # Leave alone ONLY a deliberate provider choice we must not hijack: its own
      # model_provider = "<non-airllm>", or a bare [model_providers] table we
      # can't extend blindly. A lone  model = "gpt-x"  (just a saved model pick,
      # no provider) is NOT this - it falls through to the merge below, which is
      # what points Codex at AirLLM instead of its default (ChatGPT-login) provider.
      CODEX_STATUS="skip"
    else
      cp "$CODEX_FILE" "$CODEX_FILE.airllm-bak"
      CODEX_TMP=$(mktemp) || CODEX_TMP=""
      # Prepend our model + model_provider, keep the rest of the file, append the
      # provider table. Any EXISTING top-level model/model_provider line is
      # dropped first (it would duplicate our keys and make the TOML invalid) -
      # but only in the head, before the first [table]; keys inside tables (e.g.
      # a [profiles.x] model) are untouched.
      if [ -n "$CODEX_TMP" ] && {
        echo "# AirLLM defaults - added by airllm-setup-mac.sh"
        echo "model = \"$AIRLLM_MODEL\""
        echo "model_provider = \"airllm\""
        echo ""
        awk '
          /^[[:space:]]*\[/ { intable=1 }
          intable==0 && /^[[:space:]]*model[[:space:]]*=/ { next }
          intable==0 && /^[[:space:]]*model_provider[[:space:]]*=/ { next }
          { print }
        ' "$CODEX_FILE"
        echo ""
        echo "# AirLLM provider for Codex - added by airllm-setup-mac.sh"
        codex_provider_block
      } > "$CODEX_TMP" && cat "$CODEX_TMP" > "$CODEX_FILE"; then
        CODEX_STATUS="merged"
      else
        CODEX_STATUS="merge-fail"
      fi
      rm -f "$CODEX_TMP"
    fi
  else
    {
      echo "# AirLLM provider for Codex - written by airllm-setup-mac.sh"
      echo "model = \"$AIRLLM_MODEL\""
      echo "model_provider = \"airllm\""
      echo ""
      codex_provider_block
    } > "$CODEX_FILE"
    CODEX_STATUS="written"
  fi
  # Ensure the model-metadata keys exist (all paths, incl. older installs).
  # Codex has no built-in entry for the AirLLM alias - without these it warns
  # "Model metadata for 'airllm-auto' not found" and falls back to guessed
  # context/output limits (degraded compaction + output sizing). Inserted
  # top-level right after model_provider - NEVER appended after a [table].
  if [ -f "$CODEX_FILE" ] \
      && grep -Eq '^model_provider[[:space:]]*=[[:space:]]*"airllm"' "$CODEX_FILE" \
      && ! grep -Eq '^model_context_window' "$CODEX_FILE"; then
    CODEX_TMP=$(mktemp) && awk '
      { print }
      !done && /^model_provider[[:space:]]*=[[:space:]]*"airllm"/ {
        print "# Model metadata for the AirLLM alias - added by airllm-setup-mac.sh"
        print "model_context_window = 200000"
        print "model_max_output_tokens = 32000"
        done=1
      }
    ' "$CODEX_FILE" > "$CODEX_TMP" && cat "$CODEX_TMP" > "$CODEX_FILE"
    rm -f "$CODEX_TMP"
    ok "Codex model metadata set (context 200k / output 32k)"
  fi

  case "$CODEX_STATUS" in
    written) ok "Codex configured -> ~/.codex/config.toml" ;;
    merged)  ok "Codex configured -> ~/.codex/config.toml (merged; backup at config.toml.airllm-bak)" ;;
    exists)  ok "Codex already points at AirLLM in ~/.codex/config.toml" ;;
    model-updated) ok "Codex already pointed at AirLLM - default model updated to $AIRLLM_MODEL" ;;
    exists-other-model) warn "Codex: ~/.codex/config.toml points at AirLLM but its default model is not $AIRLLM_MODEL - left as-is. Edit its  model =  line to switch." ;;
    exists-mismatch) warn "Codex: ~/.codex/config.toml has an [model_providers.airllm] block with UNEXPECTED settings - left as-is. Make sure it has  base_url = \"$AIRLLM_OPENAI_URL\",  env_key = \"AIRLLM_API_KEY\"  and  wire_api = \"responses\"  (Codex removed every other wire API in Feb 2026)." ;;
    merge-fail) warn "Codex: could not rewrite ~/.codex/config.toml (temp-file failure) - file left unchanged." ;;
    skip)    warn "Codex: ~/.codex/config.toml already sets its own model_provider - left as-is. To use AirLLM set  model_provider = \"airllm\"  and  model = \"$AIRLLM_MODEL\"  at the top, plus a [model_providers.airllm] block with base_url = \"$AIRLLM_OPENAI_URL\", env_key = \"AIRLLM_API_KEY\", wire_api = \"responses\"." ;;
  esac
  # A ChatGPT login in ~/.codex/auth.json is IGNORED for custom providers -
  # Codex reads the env_key instead. No sign-out needed; just mention it.
  if [ -f "$HOME/.codex/auth.json" ]; then
    note "Codex has a saved ChatGPT login (~/.codex/auth.json). That's fine - custom providers use their env_key (AIRLLM_API_KEY) and ignore the login. Run 'codex logout' only if you want it gone."
  fi
fi

# =============================================================================
#  3. OpenCode  ->  ~/.config/opencode/opencode.json  (custom provider)
# =============================================================================
# Registers AirLLM as an OpenAI-compatible provider and makes it the default
# model, so plain `opencode` works. The key lives in the config file itself -
# no env var needed. Merged with node/python3; fresh file when none exists;
# left alone (with instructions) if it exists and can't be merged.
if want opencode; then
  write_opencode() {
    local dir="$HOME/.config/opencode" file="$HOME/.config/opencode/opencode.json"
    mkdir -p "$dir"
    OPENCODE_STATUS="fail"
    if command -v node >/dev/null 2>&1; then
      AL_FILE="$file" AL_URL="$AIRLLM_OPENAI_URL" AL_TOK="$AIRLLM_API_KEY" AL_MDL="$AIRLLM_MODEL" node -e '
        const fs=require("fs"), f=process.env.AL_FILE, m=process.env.AL_MDL;
        let s={}, bad=false;
        if(fs.existsSync(f)){ try{ s=JSON.parse(fs.readFileSync(f,"utf8")||"{}")||{}; }catch(e){ bad=true; s={}; } }
        if(typeof s!=="object"||Array.isArray(s)){ bad=true; s={}; }
        if(bad) fs.copyFileSync(f, f+".airllm-bak");   // unparseable content is preserved, never lost
        if(!s.$schema) s.$schema="https://opencode.ai/config.json";
        let prov=s.provider; if(typeof prov!=="object"||prov===null||Array.isArray(prov)) prov={};
        s.provider=Object.assign({}, prov);
        s.provider.airllm={ npm:"@ai-sdk/openai-compatible", name:"AirLLM",
          options:{ baseURL:process.env.AL_URL, apiKey:process.env.AL_TOK },
          models:{ [m]:{ name:m } } };
        s.model="airllm/"+m;
        fs.writeFileSync(f, JSON.stringify(s,null,2)+"\n");
      ' && OPENCODE_STATUS="merged"
    elif command -v python3 >/dev/null 2>&1; then
      AL_FILE="$file" AL_URL="$AIRLLM_OPENAI_URL" AL_TOK="$AIRLLM_API_KEY" AL_MDL="$AIRLLM_MODEL" python3 - <<'PYEOF' && OPENCODE_STATUS="merged"
import json, os, shutil
f=os.environ["AL_FILE"]; m=os.environ["AL_MDL"]
s={}; bad=False
if os.path.exists(f):
    try:
        s=json.load(open(f))
        if not isinstance(s, dict): bad=True; s={}
    except Exception:
        bad=True; s={}
if bad: shutil.copyfile(f, f+".airllm-bak")  # unparseable content is preserved, never lost
s.setdefault("$schema", "https://opencode.ai/config.json")
prov=s.get("provider")
if not isinstance(prov, dict): prov={}
prov["airllm"]={"npm":"@ai-sdk/openai-compatible", "name":"AirLLM",
                "options":{"baseURL":os.environ["AL_URL"],"apiKey":os.environ["AL_TOK"]},
                "models":{m:{"name":m}}}
s["provider"]=prov; s["model"]="airllm/"+m
json.dump(s, open(f,"w"), indent=2); open(f,"a").write("\n")
PYEOF
    fi
    if [ "$OPENCODE_STATUS" != "merged" ] && [ ! -f "$file" ]; then
      cat > "$file" <<EOF
{
  "\$schema": "https://opencode.ai/config.json",
  "model": "airllm/$AIRLLM_MODEL",
  "provider": {
    "airllm": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "AirLLM",
      "options": { "baseURL": "$AIRLLM_OPENAI_URL", "apiKey": "$AIRLLM_API_KEY" },
      "models": { "$AIRLLM_MODEL": { "name": "$AIRLLM_MODEL" } }
    }
  }
}
EOF
      OPENCODE_STATUS="fresh"
    fi
  }
  write_opencode
  case "$OPENCODE_STATUS" in
    merged) ok "OpenCode configured -> ~/.config/opencode/opencode.json (merged)" ;;
    fresh)  ok "OpenCode configured -> ~/.config/opencode/opencode.json (created)" ;;
    *)      warn "OpenCode: could not merge existing ~/.config/opencode/opencode.json (no JSON tool). Add an \"airllm\" provider with npm \"@ai-sdk/openai-compatible\" and baseURL $AIRLLM_OPENAI_URL manually." ;;
  esac
fi

# =============================================================================
#  4. Aider  ->  ~/.aider.conf.yml  (default model + base URL + key)
# =============================================================================
# The key is written into the file too, so plain `aider` works from any
# launcher - no env var required. A file this script wrote earlier is REWRITTEN
# on every run (its header marks it), so a regenerated key or changed model
# always lands. A file written by hand is never touched - we print what to set.
if want aider; then
  AIDER_FILE="$HOME/.aider.conf.yml"
  write_aider() {
    cat > "$AIDER_FILE" <<EOF
# AirLLM defaults - written by airllm-setup (safe to delete; re-created on each run)
model: openai/$AIRLLM_MODEL
openai-api-base: $AIRLLM_OPENAI_URL
openai-api-key: $AIRLLM_API_KEY
EOF
  }
  if [ ! -f "$AIDER_FILE" ]; then
    write_aider
    ok "Aider configured -> ~/.aider.conf.yml"
  elif grep -q 'written by airllm-setup' "$AIDER_FILE" 2>/dev/null; then
    write_aider
    ok "Aider configured -> ~/.aider.conf.yml (refreshed - key and model are current)"
  elif grep -q 'airllm' "$AIDER_FILE" 2>/dev/null; then
    warn "Aider: ~/.aider.conf.yml mentions AirLLM but wasn't written by this script - left as-is. VERIFY it has:  model: openai/$AIRLLM_MODEL  plus  openai-api-base: $AIRLLM_OPENAI_URL  and  openai-api-key: (your CURRENT AirLLM key - old keys stop working after a regenerate)"
  else
    warn "Aider: ~/.aider.conf.yml already exists - left as-is. To default to AirLLM set:  model: openai/$AIRLLM_MODEL  plus  openai-api-base: $AIRLLM_OPENAI_URL  and  openai-api-key: (your AirLLM key)"
  fi
fi

# =============================================================================
#  5. Shell profile env vars - one managed block PER TOOL, so configuring one
#     tool never removes another tool's earlier setup. Each block is replaced
#     in place on re-runs (never duplicated).
# =============================================================================
# Pick the file the user's terminal ACTUALLY reads:
#   - zsh reads ~/.zshrc for every interactive shell (login or not);
#   - macOS Terminal/iTerm open LOGIN bash shells, which read ~/.bash_profile,
#     NOT ~/.bashrc - writing only .bashrc there configures nothing;
#   - Linux/WSL interactive bash reads ~/.bashrc;
#   - fish ignores POSIX profiles entirely: it gets auto-sourced files in
#     ~/.config/fish/conf.d/ written in fish syntax (set -gx) instead.
FISH_CONFD=""
case "${SHELL:-}" in
  */zsh)  PROFILE="$HOME/.zshrc" ;;
  */bash) if [ "$(uname)" = "Darwin" ]; then PROFILE="$HOME/.bash_profile"; else PROFILE="$HOME/.bashrc"; fi ;;
  */fish) FISH_CONFD="$HOME/.config/fish/conf.d"; PROFILE="$HOME/.config/fish/conf.d/airllm-*.fish" ;;
  *)      PROFILE="$HOME/.profile" ;;
esac

# replace_block <tool-id> <export lines...>: swap out this tool's managed block.
# - fish: each tool owns ~/.config/fish/conf.d/airllm-<id>.fish (auto-sourced),
#   with the export lines translated to fish `set -gx`.
# - POSIX shells: a marker-fenced block in $PROFILE. Marker lines are compared
#   with any trailing CR stripped (CRLF profiles happen on WSL), and the old
#   block is only removed when BOTH markers are present - a half-deleted block
#   can never make the filter eat the rest of the profile. The rewrite goes
#   through `cat >` (not mv), so a symlinked profile (stow/chezmoi) keeps
#   pointing at its real file.
# remove_block <tool-id>: drop this tool's managed block (both markers required, as above).
remove_block() {
  local id="$1"
  if [ -n "$FISH_CONFD" ]; then rm -f "$FISH_CONFD/airllm-$id.fish"; return 0; fi
  [ -f "$PROFILE" ] || return 0
  local start="# >>> AirLLM setup: $id >>> (managed by airllm-setup-mac.sh - do not edit inside)"
  local end="# <<< AirLLM setup: $id <<<"
  if { grep -qFx "$start" "$PROFILE" || grep -qFx "$start"$'\r' "$PROFILE"; } \
    && { grep -qFx "$end" "$PROFILE" || grep -qFx "$end"$'\r' "$PROFILE"; }; then
    local tmp; tmp=$(mktemp) || return 1
    awk -v s="$start" -v e="$end" '
      { line = $0; sub(/\r$/, "", line) }
      line == s { skip = 1; next }
      line == e { skip = 0; next }
      !skip     { print }
    ' "$PROFILE" > "$tmp" || { rm -f "$tmp"; return 1; }
    cat "$tmp" > "$PROFILE" || { rm -f "$tmp"; return 1; }
    rm -f "$tmp"
  fi
}

replace_block() {
  local id="$1"; shift
  if [ -n "$FISH_CONFD" ]; then
    mkdir -p "$FISH_CONFD" || return 1
    {
      echo "# AirLLM setup: $id (managed by airllm-setup-mac.sh - safe to delete)"
      local line name rest
      for line in "$@"; do
        line="${line#export }"
        name="${line%%=*}"
        rest="${line#*=}"
        echo "set -gx $name $rest"
      done
    } > "$FISH_CONFD/airllm-$id.fish" || return 1
    return 0
  fi
  touch "$PROFILE" || return 1
  remove_block "$id" || return 1
  local start="# >>> AirLLM setup: $id >>> (managed by airllm-setup-mac.sh - do not edit inside)"
  local end="# <<< AirLLM setup: $id <<<"
  {
    echo "$start"
    printf '%s\n' "$@"
    echo "$end"
  } >> "$PROFILE"
}

# Migration: drop the single all-in-one block older versions of this script
# wrote, but only when re-running for ALL tools (its contents are re-created
# below) and only when both of its markers are present.
if [ "$AIRLLM_TOOL" = "all" ] && [ -z "$FISH_CONFD" ]; then
  OLD_S="# >>> AirLLM setup >>> (managed by airllm-setup-mac.sh - do not edit inside)"
  OLD_E="# <<< AirLLM setup <<<"
  if [ -f "$PROFILE" ] \
    && { grep -qFx "$OLD_S" "$PROFILE" || grep -qFx "$OLD_S"$'\r' "$PROFILE"; } \
    && { grep -qFx "$OLD_E" "$PROFILE" || grep -qFx "$OLD_E"$'\r' "$PROFILE"; }; then
    TMP=$(mktemp) && awk -v s="$OLD_S" -v e="$OLD_E" '
      { line = $0; sub(/\r$/, "", line) }
      line == s { skip = 1; next }
      line == e { skip = 0; next }
      !skip     { print }
    ' "$PROFILE" > "$TMP" && cat "$TMP" > "$PROFILE" && rm -f "$TMP"
  fi
fi

ENV_TOUCHED=""; ENV_FAILED=""
if want claude-code; then
  if replace_block claude-code \
    "export ANTHROPIC_BASE_URL=\"$AIRLLM_BASE_URL\"            # fallback - settings.json is primary" \
    "export ANTHROPIC_AUTH_TOKEN=\"$AIRLLM_API_KEY\"" \
    "export API_TIMEOUT_MS=\"3000000\"                       # gateway routing can exceed default timeouts" \
    "export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=\"1\"   # keep non-model calls off the gateway" \
    "export ANTHROPIC_MODEL=\"$AIRLLM_MODEL\"" \
    "export ANTHROPIC_DEFAULT_HAIKU_MODEL=\"$AIRLLM_MODEL\"    # background tasks -> gateway" \
    "export ANTHROPIC_SMALL_FAST_MODEL=\"$AIRLLM_MODEL\"       # deprecated alias, kept for older versions"
  then ENV_TOUCHED="yes"; else ENV_FAILED="yes"; fi
fi
if want codex; then
  if replace_block codex \
    "export AIRLLM_API_KEY=\"$AIRLLM_API_KEY\"                 # Codex env_key (its own name: other OpenAI tools are unaffected)"
  then ENV_TOUCHED="yes"; else ENV_FAILED="yes"; fi
fi
# Aider reads ~/.aider.conf.yml from any launcher, so it needs no variables (AIDER_* ones would
# override a hand-written config). Older versions exported OPENAI_API_BASE + OPENAI_API_KEY for
# it, which every other OpenAI tool in the shell picked up - remove that block.
if want aider; then
  remove_block aider || ENV_FAILED="yes"
fi
if want copilot-cli; then
  if replace_block copilot-cli \
    "export COPILOT_PROVIDER_TYPE=\"openai\"                   # GitHub Copilot CLI (BYOK)" \
    "export COPILOT_PROVIDER_BASE_URL=\"$AIRLLM_OPENAI_URL\"" \
    "export COPILOT_PROVIDER_API_KEY=\"$AIRLLM_API_KEY\"" \
    "export COPILOT_MODEL=\"$AIRLLM_MODEL\""
  then
    ENV_TOUCHED="yes"
    ok "GitHub Copilot CLI configured (BYOK env vars). GitHub sign-in is NOT required with a custom provider."
  else ENV_FAILED="yes"; fi
fi
if want agent-frameworks; then
  if replace_block agent-frameworks \
    "export OPENAI_BASE_URL=\"$AIRLLM_OPENAI_URL\"             # LangChain / LlamaIndex / CrewAI / AutoGen / PydanticAI ..." \
    "export OPENAI_API_KEY=\"$AIRLLM_API_KEY\"" \
    "export OPENAI_MODEL=\"$AIRLLM_MODEL\"" \
    "export OPENAI_API_BASE=\"$AIRLLM_OPENAI_URL\"             # legacy alias some frameworks still read" \
    "export ANTHROPIC_BASE_URL=\"$AIRLLM_BASE_URL\"            # Anthropic-SDK-based frameworks" \
    "export ANTHROPIC_AUTH_TOKEN=\"$AIRLLM_API_KEY\"" \
    "export ANTHROPIC_MODEL=\"$AIRLLM_MODEL\""
  then
    ENV_TOUCHED="yes"
    ok "Agent frameworks configured (OpenAI + Anthropic SDK env vars)."
    # These are the shared SDK variables, so say what they now mean for keys set elsewhere.
    if [ -n "${OPENAI_API_KEY:-}" ] && [ "$OPENAI_API_KEY" != "$AIRLLM_API_KEY" ]; then
      warn "OPENAI_API_KEY was set to another key in this terminal. New terminals send OpenAI-SDK calls to AirLLM (OPENAI_BASE_URL) with your AirLLM key - if you also call OpenAI directly, pass that key per project together with its own base URL, or it goes to AirLLM."
    fi
    if [ -n "${ANTHROPIC_API_KEY:-}" ]; then
      warn "ANTHROPIC_API_KEY is set. Anthropic-SDK frameworks would send it to AirLLM alongside the AirLLM token - unset it in your shell profile."
    fi
  else ENV_FAILED="yes"; fi
fi
[ -n "$ENV_TOUCHED" ] && ok "Environment variables written to $PROFILE"
[ -n "$ENV_FAILED" ] && warn "Some environment variables could NOT be written to $PROFILE - check permissions and re-run."

# Heads-up if a stale ANTHROPIC_API_KEY is set - it can shadow the gateway token.
if want claude-code && [ -n "${ANTHROPIC_API_KEY:-}" ]; then
  warn "ANTHROPIC_API_KEY is set in your environment. For a gateway, unset it (the script uses ANTHROPIC_AUTH_TOKEN) to avoid a 'trust this key?' prompt."
fi

# --- lock down: owner-only access to every file that now holds the key -------------
# New files were created 0600 (umask above); files that already existed kept their old mode
# (usually 0644 = readable by every local account), so tighten them. chmod follows symlinks,
# so a stow/chezmoi-managed profile's real file is covered. Failures (not our file) are ignored.
for f in "$HOME/.claude/settings.json" "$HOME/.claude/settings.json.airllm-bak" \
         "$HOME/.config/opencode/opencode.json" "$HOME/.config/opencode/opencode.json.airllm-bak" \
         "$HOME/.aider.conf.yml" "$HOME/.codex/config.toml" "$HOME/.codex/config.toml.airllm-bak"; do
  [ -f "$f" ] && chmod 600 "$f" 2>/dev/null
done
if [ -n "$FISH_CONFD" ]; then
  chmod 600 "$FISH_CONFD"/airllm-*.fish 2>/dev/null
elif [ -n "$ENV_TOUCHED" ] && [ -f "$PROFILE" ]; then
  chmod 600 "$PROFILE" 2>/dev/null
  note "Your shell profile ($PROFILE) now contains your API key and was set to owner-only (600). Don't commit it to a public dotfiles repo."
fi

# --- UI-configured tools ------------------------------------------------------
case "$AIRLLM_TOOL" in
  cursor)    print_ui_tool "Cursor" ;;
  continue)  print_ui_tool "Continue" ;;
  cline)     print_ui_tool "Cline" ;;
  roo-code)  print_ui_tool "Roo Code" ;;
  openhands) print_ui_tool "OpenHands" ;;
  xcode)     print_ui_tool "Xcode" ;;
esac

# --- connectivity test (GET /v1/models: lightweight, non-billable) ------------
echo ""
printf "  Testing gateway connection ... "
# The header is fed on stdin (-H @-), not as an argument: arguments are visible to every local
# user in `ps` for as long as the request runs.
HTTP_CODE=$(printf 'Authorization: Bearer %s\n' "$AIRLLM_API_KEY" | curl -s -o /dev/null -w '%{http_code}' \
  --connect-timeout 8 --max-time 20 -H @- "$AIRLLM_OPENAI_URL/models" 2>/dev/null)
case "$HTTP_CODE" in
  200)      echo "${GRN}ok - gateway reachable, key accepted.${RST}" ;;
  401|403)  echo "${YLW}reachable, but the key was rejected (HTTP $HTTP_CODE). Check your key.${RST}" ;;
  *)        echo "${YLW}could not verify (HTTP ${HTTP_CODE:-000}). Config was still written.${RST}" ;;
esac

# --- summary -------------------------------------------------------------------
echo ""
if [ "$AIRLLM_TOOL" = "all" ]; then
  echo "  ${BOLD}UI-configured tools - enter these in each tool's settings:${RST}"
  echo "  ---------------------------------------------------------------"
  echo "    Cursor / Continue / Cline / Roo Code / OpenHands"
  echo "      Base URL : ${CYN}$AIRLLM_OPENAI_URL${RST}"
  echo "      API key  : ${CYN}(your AirLLM API key)${RST}"
  echo "      Model    : ${CYN}$AIRLLM_MODEL${RST}   (OpenHands: ${CYN}openai/$AIRLLM_MODEL${RST})"
  echo ""
  echo "    Xcode (Settings -> Intelligence -> Add a Model Provider)"
  echo "      URL      : ${CYN}$AIRLLM_BASE_URL${RST}   (no /v1 - Xcode appends it)"
  echo "      API key  : ${CYN}(your AirLLM API key)${RST}"
  echo ""
  echo "    Any other tool"
  echo "      Asks for an OpenAI-compatible base URL  -> ${CYN}$AIRLLM_OPENAI_URL${RST}"
  echo "      Adds /v1/... itself (Anthropic-style)   -> ${CYN}$AIRLLM_BASE_URL${RST}"
  echo ""
  echo "  ${GRN}${BOLD}Done.${RST} Claude Code, Codex, OpenCode and Aider are configured via their"
  echo "  own config files, so they work from any launcher. For the env-var tools,"
  if [ -n "$FISH_CONFD" ]; then
    echo "  restart your terminal (fish auto-loads the new conf.d files)."
  else
    echo "  restart your terminal (or run: ${BOLD}source $PROFILE${RST})."
  fi
else
  case "$AIRLLM_TOOL" in
    cursor|continue|cline|roo-code|openhands|xcode)
      echo "  ${GRN}${BOLD}Done.${RST} $TOOL_LABEL stores its settings in its own UI - paste the values"
      echo "  printed above. Nothing on this machine was changed." ;;
    *)
      echo "  ${GRN}${BOLD}Done.${RST} $TOOL_LABEL is configured. If it reads environment variables,"
      if [ -n "$FISH_CONFD" ]; then
        echo "  restart your terminal first (fish auto-loads the new conf.d files)."
      else
        echo "  restart your terminal first (or run: ${BOLD}source $PROFILE${RST})."
      fi ;;
  esac
fi
echo ""
