#!/usr/bin/env bash
# claude-mode - switch Claude Code system-wide between Anthropic, OpenRouter,
# Z.AI and a local LM Studio server, with named model presets.
#
# POSIX port of the Windows/PowerShell build. Same design: the switch rewrites
# the managed keys inside ~/.claude/settings.json, which Claude Code re-reads at
# every startup, so it applies to every new `claude` invocation - CLI, VS Code
# extension, desktop app - with nothing to re-source.
#
# Secrets never enter settings.json; see cm-vault.sh for the storage backends.

set -uo pipefail

CM_ROOT="${CM_ROOT:-$HOME/.claude-mode}"
CM_BIN="$CM_ROOT/bin"
CM_PRESETS="$CM_ROOT/presets"
CM_BACKUPS="$CM_ROOT/backups"
CM_STATE="$CM_ROOT/state.json"
CM_SETTINGS_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
CM_SETTINGS="$CM_SETTINGS_DIR/settings.json"
CM_HELPER="$CM_BIN/claude-key-helper.sh"

PY="${CLAUDE_MODE_PYTHON:-python3}"
JSON="$CM_BIN/cm-json.py"

# shellcheck source=/dev/null
. "$CM_BIN/cm-vault.sh"

CM_FORCE=0

MODES=(anthropic openrouter zai lmstudio)
TIERS=(opus sonnet haiku fable)

# ---------------------------------------------------------------------------
# Colour / output
#
# The palette follows the desktop theme when there is one to follow. Omarchy
# publishes its active theme as a flat colors.toml, so on those systems the
# menu paints in the same colours as the bar and the terminal instead of in
# whatever the sixteen ANSI slots happen to mean today.
#
# That indirection is not decoration. The ANSI slots carry no guarantee about
# relative brightness, and monochrome themes exploit it. Under Omarchy's
# Solitude, slot 36 (headings) resolves to #707070 and slot 31 (FAIL) to
# #565d60; against #cacccc body text on a #101315 ground those are 3.8:1 and
# 2.8:1 where the body text is 11.6:1 - so headings render as fine print and
# an error message becomes the quietest thing on screen. Exactly backwards.
#
# Deriving from the theme instead lets every role be measured against the
# background it will actually be drawn on, and lifted toward the foreground
# when it comes up short. Hue is preserved where the theme has any; where it
# does not, roles resolve to weight rather than to invisible colour.
# ---------------------------------------------------------------------------

CM_THEME_FILE="${CLAUDE_MODE_THEME:-$HOME/.local/state/omarchy/current/theme/colors.toml}"

# Flat `key = "#rrggbb"` lookup. Quotes are optional so this also reads the
# handful of themes that ship the file unquoted.
cm_theme_get() {
    [ -f "$CM_THEME_FILE" ] || return 1
    sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*\"\{0,1\}\([^\"#]*#\{0,1\}[0-9A-Fa-f]*\)\"\{0,1\}[[:space:]]*\$/\1/p" \
        "$CM_THEME_FILE" 2>/dev/null | head -n1
}

cm_hex_ok() { case "$1" in \#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]) return 0 ;; *) return 1 ;; esac; }

# Relative brightness, 0-255. Deliberately the linear Rec.709 weighting without
# the sRGB gamma step: bash has no floats, and this only ever has to answer
# "which of these is further from the background", which it does correctly.
cm_lum() {
    local h="${1#\#}" r g b
    r=$((16#${h:0:2})); g=$((16#${h:2:2})); b=$((16#${h:4:2}))
    printf '%d' $(( (2126 * r + 7152 * g + 722 * b) / 10000 ))
}

# Distance from the theme background, which is what legibility actually is.
cm_dist() { local a b; a="$(cm_lum "$1")"; b="$(cm_lum "$CM_BG")"; printf '%d' $(( a > b ? a - b : b - a )); }

cm_mix() {
    local x="${1#\#}" y="${2#\#}" p="$3" r g b
    r=$(( (16#${x:0:2} * (100 - p) + 16#${y:0:2} * p) / 100 ))
    g=$(( (16#${x:2:2} * (100 - p) + 16#${y:2:2} * p) / 100 ))
    b=$(( (16#${x:4:2} * (100 - p) + 16#${y:4:2} * p) / 100 ))
    printf '#%02x%02x%02x' "$r" "$g" "$b"
}

# Blend a colour toward the foreground until it clears `pct` of the
# foreground's own separation from the background. Hue survives the lift, so a
# themed accent stays recognisably itself; a grey one just ends up brighter.
cm_lift() {
    local c="$1" pct="$2" need step out
    need=$(( $(cm_dist "$CM_FG") * pct / 100 ))
    out="$c"
    for step in 0 15 30 45 60 75 90 100; do
        out="$(cm_mix "$c" "$CM_FG" "$step")"
        [ "$(cm_dist "$out")" -ge "$need" ] && break
    done
    printf '%s' "$out"
}

# Push a colour toward the background - for text that is meant to recede but
# still be readable. Floored, so "dim" never becomes "gone".
cm_sink() {
    local c="$1" pct="$2" need out step
    need=$(( $(cm_dist "$CM_FG") * pct / 100 ))
    out="$c"
    [ "$(cm_dist "$out")" -ge "$need" ] && { printf '%s' "$out"; return; }
    for step in 85 70 55 40 25 10 0; do
        out="$(cm_mix "$CM_BG" "$CM_FG" $((100 - step)))"
        [ "$(cm_dist "$out")" -ge "$need" ] && break
    done
    printf '%s' "$out"
}

# Saturation as a 0-100 proxy. Monochrome themes define `red` as a desaturated
# slate, which carries none of the meaning the role needs.
cm_sat() {
    local h="${1#\#}" r g b mx mn
    r=$((16#${h:0:2})); g=$((16#${h:2:2})); b=$((16#${h:4:2}))
    mx=$r; [ "$g" -gt "$mx" ] && mx=$g; [ "$b" -gt "$mx" ] && mx=$b
    mn=$r; [ "$g" -lt "$mn" ] && mn=$g; [ "$b" -lt "$mn" ] && mn=$b
    [ "$mx" -eq 0 ] && { printf '0'; return; }
    printf '%d' $(( (mx - mn) * 100 / mx ))
}

cm_sgr() { local h="${1#\#}"; printf '\033[38;2;%d;%d;%dm' $((16#${h:0:2})) $((16#${h:2:2})) $((16#${h:4:2})); }

# Pick a themed colour for a role, falling back through the theme's own keys
# and finally to the foreground. `minPct` is the share of the foreground's
# contrast the role has to clear before it is allowed on screen.
cm_role() {
    local minPct="$1" want c
    shift
    for want in "$@"; do
        c="$(cm_theme_get "$want")"
        cm_hex_ok "$c" || continue
        cm_sgr "$(cm_lift "$c" "$minPct")"
        return 0
    done
    cm_sgr "$CM_FG"
}

cm_truecolor() { case "${COLORTERM:-}" in truecolor|24bit) return 0 ;; *) return 1 ;; esac; }

cm_theme_palette() {
    local bg fg red

    cm_truecolor || return 1
    bg="$(cm_theme_get background)"; fg="$(cm_theme_get foreground)"
    cm_hex_ok "$bg" && cm_hex_ok "$fg" || return 1

    CM_BG="$bg"; CM_FG="$fg"
    CM_THEME_MODE="$(cm_theme_get mode)"

    C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'

    # Body text sets the bar every other role is measured against.
    C_WHITE="$(cm_sgr "$CM_FG")"
    C_GRAY="$(cm_sgr "$(cm_mix "$CM_FG" "$CM_BG" 20)")"

    # Headings and the selection accent. Lifted to 85% of body contrast: a
    # heading that is dimmer than the text beneath it is not a heading.
    C_CYAN="$(cm_role 85 accent blue cyan)"
    # Banner shading - subordinate to the heading but still structural.
    C_DKCYAN="$(cm_sgr "$(cm_sink "$(cm_mix "$(cm_theme_get accent)" "$CM_BG" 35)" 42)")"

    # Deliberately recessive: help lines, hints, detail rows. The floor keeps
    # it off the background rather than merged into it.
    C_DIM="$(cm_sgr "$(cm_sink "$(cm_theme_get muted)" 30)")"

    C_GREEN="$(cm_role 55 green bright_green)"
    C_YELLOW="$(cm_role 65 yellow bright_yellow)"
    C_MAGENTA="$(cm_role 70 magenta bright_magenta)"

    # FAIL has to out-shout everything else, so it takes the theme's saturated
    # red where one exists and a lifted fallback where it does not. Solitude's
    # `red` is #565d60 - a slate with no hue left in it - which is why the
    # saturated `bright_red` is preferred over the nominal one here.
    red="$(cm_theme_get bright_red)"
    if ! cm_hex_ok "$red" || [ "$(cm_sat "$red")" -lt 25 ]; then
        red="$(cm_theme_get red)"
    fi
    if cm_hex_ok "$red" && [ "$(cm_sat "$red")" -ge 25 ]; then
        C_RED="$(cm_sgr "$(cm_lift "$red" 50)")"
    else
        # No usable red anywhere in the theme. Weight carries the role instead,
        # over a hue that at least leans warm.
        C_RED="$C_BOLD$(cm_sgr "$(cm_lift '#d2685f' 60)")"
    fi
    return 0
}

if [ ! -t 1 ] || [ -n "${NO_COLOR:-}" ]; then
    C_RESET=''; C_DIM=''; C_CYAN=''; C_GREEN=''; C_YELLOW=''; C_RED=''
    C_MAGENTA=''; C_WHITE=''; C_GRAY=''; C_DKCYAN=''; C_BOLD=''
    CM_THEME_MODE=''
elif ! cm_theme_palette; then
    # No theme to read, or a terminal that cannot render one. Same sixteen
    # slots as before with the two roles the slots get wrong corrected:
    # bright red for FAIL, because slot 31 is a muted maroon under a good many
    # palettes, and bold on headings, which no palette can take away.
    C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'
    C_DIM=$'\033[90m'; C_CYAN=$'\033[36m'; C_GREEN=$'\033[32m'
    C_YELLOW=$'\033[33m'; C_RED=$'\033[91m'; C_MAGENTA=$'\033[35m'; C_WHITE=$'\033[97m'
    C_GRAY=$'\033[37m'; C_DKCYAN=$'\033[36;2m'
    CM_THEME_MODE=''
fi

say()   { printf '  %s\n' "$*"; }
ok()    { printf '  %sok  %s %s\n'  "$C_GREEN"  "$C_RESET" "$*"; }
# warn/err go to stderr: several of these functions run inside $( ), where
# anything on stdout is captured as the return value instead of being shown.
warn()  { printf '  %swarn%s %s\n'  "$C_YELLOW" "$C_RESET" "$*" >&2; }
err()   { printf '  %sFAIL%s %s\n'  "$C_BOLD$C_RED" "$C_RESET" "$*" >&2; }
head_() { printf '\n%s%s%s\n' "$C_BOLD$C_CYAN" "$*" "$C_RESET"; }

mode_color() {
    case "$1" in
        anthropic)  printf '%s' "$C_MAGENTA" ;;
        openrouter) printf '%s' "$C_CYAN" ;;
        zai)        printf '%s' "$C_GREEN" ;;
        lmstudio)   printf '%s' "$C_YELLOW" ;;
        *)          printf '%s' "$C_GRAY" ;;
    esac
}

mode_label() {
    case "$1" in
        anthropic)  printf 'Anthropic  - your subscription login, no gateway' ;;
        openrouter) printf 'OpenRouter - remote, pay-per-token, any vendor' ;;
        zai)        printf 'Z.AI       - GLM coding plan' ;;
        lmstudio)   printf 'LM Studio  - local server, offline, free' ;;
    esac
}

# Pure ASCII on purpose - renders identically in every terminal and locale.
show_banner() {
    local mode="$1" preset="$2" tag
    printf '\n'
    printf '%s   ____ _                 _        __  __           _      %s\n' "$C_DKCYAN" "$C_RESET"
    printf '%s  / ___| | __ _ _   _  __| | ___  |  \\/  | ___   __| | ___ %s\n' "$C_CYAN" "$C_RESET"
    printf '%s | |   | |/ _` | | | |/ _` |/ _ \\ | |\\/| |/ _ \\ / _` |/ _ \\%s\n' "$C_CYAN" "$C_RESET"
    printf '%s | |___| | (_| | |_| | (_| |  __/ | |  | | (_) | (_| |  __/%s\n' "$C_CYAN" "$C_RESET"
    printf '%s  \\____|_|\\__,_|\\__,_|\\__,_|\\___| |_|  |_|\\___/ \\__,_|\\___|%s\n' "$C_DKCYAN" "$C_RESET"
    printf '  %s-----------------------------------------------------------%s\n' "$C_DIM" "$C_RESET"
    tag="$mode"; [ -n "$preset" ] && tag="$mode / $preset"
    printf '   %snow%s %s%s%s   %sswitch Claude Code between providers%s\n' \
        "$C_DIM" "$C_RESET" "$(mode_color "$mode")" "$tag" "$C_RESET" "$C_DIM" "$C_RESET"
}

usage() {
cat <<'EOF'
claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio

  claude-mode                            interactive menu
  claude-mode status                     active mode, preset, model map

  claude-mode anthropic                  native login (clears all gateway config)
  claude-mode openrouter [preset]        remote gateway        (default: default)
  claude-mode zai [preset]               Z.AI GLM coding plan  (default: zai)
  claude-mode lmstudio [preset]          local LM Studio       (default: lmstudio)

  claude-mode presets                    list presets
  claude-mode preset show <name>
  claude-mode preset new <name> [from]   create a preset (copies 'from')
  claude-mode preset set <name> <tier> <model-id>
  claude-mode preset all <name> <model-id>
  claude-mode preset url <name> <base-url>     point a preset at another server
  claude-mode preset auth <name> none|key [ref]  whether that server needs a key
  claude-mode preset rm <name>

  claude-mode set-key [ref] [--terminal] store an API key (hidden prompt)
  claude-mode models [filter]            models available from the active provider
  claude-mode doctor                     verify auth, endpoint, model ids, env
  claude-mode repair                     strip [1m] tags from cached model ids
  claude-mode health                     refresh health.json (machine-readable state)
  claude-mode preflight <mode> [preset]  check a mode can actually serve, without switching
  claude-mode sessions [--stop|--restart]
                                         running sessions; close or reopen them
  claude-mode <mode> --force             switch even if preflight says no
EOF
}

# ---------------------------------------------------------------------------
# State / presets
# ---------------------------------------------------------------------------

init_root() { mkdir -p "$CM_ROOT" "$CM_BIN" "$CM_PRESETS" "$CM_BACKUPS" "$CM_ROOT/vault"; }

jget() { "$PY" "$JSON" get "$1" "$2" 2>/dev/null; }

state_mode()   { local m; m="$(jget "$CM_STATE" mode)";   [ -n "$m" ] && printf '%s' "$m" || printf 'anthropic'; }
state_preset() { jget "$CM_STATE" preset; }

preset_path() { printf '%s/%s.json' "$CM_PRESETS" "$1"; }

preset_names() { "$PY" "$JSON" presets "$CM_PRESETS" | cut -f1; }

# Deliberately no awk anywhere in this script: it is absent from minimal images
# (this was found the hard way on a stock Fedora WSL rootfs). Bash can split TSV
# on its own, and cut/sed/grep are far more reliably present.
presets_for_provider() {
    local name provider desc
    while IFS=$'\t' read -r name provider desc; do
        [ "$provider" = "$1" ] && printf '%s\n' "$name"
    done < <("$PY" "$JSON" presets "$CM_PRESETS")
}

# Read TSV on stdin, print the whole line whose first field equals $1.
tsv_find() {
    local want="$1" line f1
    while IFS= read -r line; do
        f1="${line%%$'\t'*}"
        if [ "$f1" = "$want" ]; then printf '%s' "$line"; return 0; fi
    done
    return 1
}

tsv_field() { printf '%s' "$1" | cut -f"$2"; }

term_cols() {
    local c=''
    if command -v tput >/dev/null 2>&1; then c="$(tput cols 2>/dev/null)"; fi
    if [ -z "$c" ] && command -v stty >/dev/null 2>&1; then
        c="$(stty size 2>/dev/null | cut -d' ' -f2)"
    fi
    [ -z "$c" ] && c="${COLUMNS:-80}"
    case "$c" in ''|*[!0-9]*) c=80 ;; esac
    [ "$c" -gt 20 ] || c=80
    printf '%s' "$c"
}

default_preset_for() {
    case "$1" in
        openrouter) printf 'default' ;;
        zai)        printf 'zai' ;;
        lmstudio)   printf 'lmstudio' ;;
    esac
}

resolve_preset() {
    local provider="$1" requested="${2:-}" fallback first
    if [ -n "$requested" ]; then
        [ -f "$(preset_path "$requested")" ] || { err "preset '$requested' not found"; return 1; }
        local got; got="$(jget "$(preset_path "$requested")" provider)"
        [ -z "$got" ] && got=openrouter
        if [ "$got" != "$provider" ]; then
            err "preset '$requested' is a '$got' preset, not '$provider'"; return 1
        fi
        printf '%s' "$requested"; return 0
    fi
    fallback="$(default_preset_for "$provider")"
    if [ -n "$fallback" ] && [ -f "$(preset_path "$fallback")" ]; then
        printf '%s' "$fallback"; return 0
    fi
    first="$(presets_for_provider "$provider" | head -n1)"
    [ -n "$first" ] || { err "no preset found for provider '$provider'"; return 1; }
    printf '%s' "$first"
}

# ---------------------------------------------------------------------------
# Preflight
#
# A switch rewrites settings.json and is picked up by the next `claude` launch,
# so a switch into a mode that cannot actually serve requests does not fail
# loudly - it succeeds, and then every session started afterwards is broken in a
# way that points at Claude Code rather than at here. The LM Studio case is the
# sharp one: its token is an inline placeholder, so nothing about the switch
# needs the server to exist, and pointing at a server that is not running yields
# a config that looks perfectly healthy and answers nothing.
#
# So the preconditions are checked before the write, not after it, and the
# failure names the thing to go and fix.
# ---------------------------------------------------------------------------

# Set by cm_preflight for callers that want to report rather than re-derive.
CM_PF_CODE=''; CM_PF_TITLE=''; CM_PF_DETAIL=''; CM_PF_REMEDY=''
CM_PF_KIND=''; CM_PF_KEYREF=''; CM_PF_BASEURL=''

cm_pf_set() {
    CM_PF_CODE="$1"; CM_PF_TITLE="$2"; CM_PF_DETAIL="$3"
    CM_PF_REMEDY="$4"; CM_PF_KIND="$5"
}

# Where the server lives changes how long to wait for it, not whether to ask.
# An LM Studio instance is just as absent when it is a LAN box that is asleep or
# a tunnel that is down as when it is a loopback port with nothing behind it,
# and the failure is identical from here - so all of them get probed, with a
# longer patience for anything off-machine.
cm_url_is_local() {
    case "$1" in
        *://127.0.0.1*|*://localhost*|*://0.0.0.0*|*://\[::1\]*) return 0 ;;
        *) return 1 ;;
    esac
}

cm_probe_timeout() { cm_url_is_local "$1" && printf '4' || printf '10'; }

# Probe result, one word on stdout:
#   ok        server answered
#   auth      server is there and refused the credential (401/403)
#   notfound  something answered, but not an LM Studio API (404/wrong host)
#   refused   nothing answered at all - down, unreachable, DNS, TLS, timeout
#   skip      no curl, so no opinion
#
# The distinction matters because the remedies are opposites: `refused` means go
# and start the server, `auth` means the server is fine and the key is not.
cm_probe_server() {
    local base="${1%/}" token="${2:-}" t code ep
    command -v curl >/dev/null 2>&1 || { printf 'skip'; return 0; }
    t="$(cm_probe_timeout "$base")"

    for ep in /api/v0/models /v1/models; do
        code="$(curl -s -o /dev/null -w '%{http_code}' --max-time "$t" \
                ${token:+-H "Authorization: Bearer $token"} \
                "$base$ep" 2>/dev/null)"
        case "$code" in
            200|204)  printf 'ok'; return 0 ;;
            401|403)  printf 'auth'; return 0 ;;
            000|'')   continue ;;
            *)        continue ;;
        esac
    done

    # A non-zero HTTP code on the last try means something is listening; only a
    # total failure to connect leaves it at 000.
    case "$code" in
        000|'') printf 'refused' ;;
        *)      printf 'notfound' ;;
    esac
}

# cm_preflight <mode> [preset] - 0 = clear to switch, 1 = blocked (see CM_PF_*)
cm_preflight() {
    local mode="$1" preset="${2:-}" pf auth_mode key_ref base

    CM_PF_CODE='ok'; CM_PF_TITLE=''; CM_PF_DETAIL=''; CM_PF_REMEDY=''
    CM_PF_KIND=''; CM_PF_KEYREF=''; CM_PF_BASEURL=''

    [ "$mode" = "anthropic" ] && return 0

    pf="$(preset_path "$preset")"
    if [ ! -f "$pf" ]; then
        cm_pf_set 'no-preset' "Preset '$preset' not found" \
            "No preset file at $pf." "claude-mode presets" 'none'
        return 1
    fi

    local got; got="$(jget "$pf" provider)"; [ -z "$got" ] && got=openrouter
    if [ "$got" != "$mode" ]; then
        cm_pf_set 'provider-mismatch' "Preset '$preset' is not a $mode preset" \
            "It declares provider '$got'." "claude-mode presets" 'none'
        return 1
    fi

    base="$(jget "$pf" baseUrl)"; CM_PF_BASEURL="$base"
    auth_mode="$(jget "$pf" auth.mode)"; [ -z "$auth_mode" ] && auth_mode=vault

    if [ "$auth_mode" = "vault" ]; then
        key_ref="$(jget "$pf" auth.keyRef)"; [ -z "$key_ref" ] && key_ref=openrouter
        CM_PF_KEYREF="$key_ref"
        if ! cm_vault_has "$key_ref"; then
            cm_pf_set 'missing-key' "No API key stored for '$key_ref'" \
                "$(mode_label "$mode" | sed 's/  */ /g') needs a key before it can serve anything. It is kept in $(cm_vault_backend_label), never in settings.json." \
                "claude-mode set-key $key_ref" 'set-key'
            return 1
        fi
        if [ ! -x "$CM_HELPER" ]; then
            cm_pf_set 'helper-missing' 'Key helper is missing or not executable' \
                "Expected an executable at $CM_HELPER; Claude Code reads the key through it." \
                "bash linux/install.sh" 'reinstall'
            return 1
        fi
    fi

    # LM Studio is checked wherever it is; other providers only when they are
    # pointed at this machine. A public gateway that is briefly unreachable is
    # the network's problem and not worth blocking a config change over.
    if [ -n "$base" ] && { [ "$mode" = "lmstudio" ] || cm_url_is_local "$base"; }; then
        local token='' probe where
        if [ "$auth_mode" = "vault" ]; then
            token="$(cm_vault_get "$key_ref" 2>/dev/null || true)"
        else
            token="$(jget "$pf" auth.token)"
        fi
        probe="$(cm_probe_server "$base" "$token")"
        cm_url_is_local "$base" && where='on this machine' || where='at that address'

        case "$probe" in
            ok|skip) ;;
            auth)
                if [ "$auth_mode" = "vault" ]; then
                    cm_pf_set 'server-auth' 'The server rejected the stored key' \
                        "$base is running but refused the key held as '$key_ref'. Either the key is wrong, or the server expects a different one." \
                        "claude-mode set-key $key_ref" 'set-key'
                else
                    cm_pf_set 'server-auth' 'The server wants an API key' \
                        "$base is running but is refusing an unauthenticated request. This preset is set to send LM Studio's placeholder token, which only works on a server with authentication switched off." \
                        "claude-mode preset auth $preset key" 'needs-key'
                fi
                return 1 ;;
            notfound)
                cm_pf_set 'server-wrong' 'That address answered, but not as LM Studio' \
                    "Something is listening at $base, but neither /api/v0/models nor /v1/models is there. Check the port, or whether a proxy in front of it is rewriting the path." \
                    "claude-mode preset url $preset <base-url>" 'set-url'
                return 1 ;;
            *)
                cm_pf_set 'server-unreachable' 'The server is not responding' \
                    "Nothing is answering at $base $where. Switching would leave every new session pointed at a server that is not there." \
                    "claude-mode preset url $preset <base-url>" 'start-server'
                return 1 ;;
        esac
    fi

    return 0
}

cmd_preflight() {
    local mode="${1:-}" preset="${2:-}" p
    case "$mode" in
        anthropic) ;;
        openrouter|zai|lmstudio)
            p="$(resolve_preset "$mode" "$preset" 2>/dev/null)" || p="$preset"
            preset="$p" ;;
        z.ai|z-ai) mode=zai; p="$(resolve_preset zai "$preset" 2>/dev/null)" || p="$preset"; preset="$p" ;;
        *) err "unknown mode '$mode'"; return 1 ;;
    esac

    if cm_preflight "$mode" "$preset"; then
        "$PY" "$JSON" preflight-json ok "$mode" "$preset" '' '' '' '' '' '' ''
        return 0
    fi
    "$PY" "$JSON" preflight-json blocked "$mode" "$preset" \
        "$CM_PF_CODE" "$CM_PF_TITLE" "$CM_PF_DETAIL" "$CM_PF_REMEDY" "$CM_PF_KIND" \
        "$CM_PF_KEYREF" "$CM_PF_BASEURL"
    return 1
}

# ---------------------------------------------------------------------------
# Running sessions
#
# Claude Code reads settings.json once, at startup. A switch therefore does
# nothing to a session already running - it keeps talking to the old provider on
# the old key until it is restarted, which is the confusing part: the bar says
# one thing and the session in front of you is doing another.
#
# Worse for a session mid-request. The key it is using can be pulled out from
# under it (anthropic mode deletes the helper outright), so an in-flight turn
# can fail on the next tool call rather than at a clean boundary.
#
# Sessions are found through /proc/<pid>/exe rather than by matching process
# names. `claude` is a real ELF binary here, so the symlink resolves to it
# exactly, and a name match would sweep up every shell that merely mentions
# claude in its command line - including the ones this tool is invoked from.
# ---------------------------------------------------------------------------

cm_is_claude_pid() {
    local exe
    exe="$(readlink "/proc/$1/exe" 2>/dev/null)" || return 1
    case "$exe" in
        */claude|*/claude-code) return 0 ;;
        *) return 1 ;;
    esac
}

# /proc/<pid>/stat has the command name in parentheses, and it may contain
# spaces - so fields are only safe to count after the last ')'. Everything below
# indexes into that remainder, where field 1 is the process state.
cm_stat_rest() {
    local s
    s="$(cat "/proc/$1/stat" 2>/dev/null)" || return 1
    printf '%s' "${s##*) }"
}

cm_ppid_of() {
    local r; r="$(cm_stat_rest "$1")" || return 1
    printf '%s' "$r" | cut -d' ' -f2
}

# utime + stime, in jiffies. Sampled twice to tell a session that is thinking
# from one that is sitting at a prompt.
cm_cputime_of() {
    local r u s; r="$(cm_stat_rest "$1")" || return 1
    u="$(printf '%s' "$r" | cut -d' ' -f12)"
    s="$(printf '%s' "$r" | cut -d' ' -f13)"
    case "$u$s" in ''|*[!0-9]*) printf '0'; return 0 ;; esac
    printf '%s' $((u + s))
}

# The session this very command is running inside, if any. It is listed like the
# others but never acted on by default: killing the session that asked for the
# kill is not a thing anyone means.
cm_self_session() {
    local p="${PPID:-0}" guard=0
    while [ "$p" -gt 1 ] && [ "$guard" -lt 40 ]; do
        if cm_is_claude_pid "$p"; then printf '%s' "$p"; return 0; fi
        p="$(cm_ppid_of "$p")" || return 1
        case "$p" in ''|*[!0-9]*) return 1 ;; esac
        guard=$((guard + 1))
    done
    return 1
}

# TSV: pid \t ppid \t tty \t busy \t cwd \t self \t parent-cmd
cm_session_rows() {
    local self pid ppid tty cwd busy isself d
    self="$(cm_self_session 2>/dev/null || true)"

    local pids=() before=() after=() pp
    for d in /proc/[0-9]*; do
        pid="${d#/proc/}"
        cm_is_claude_pid "$pid" || continue
        # A session's parent is a terminal or a shell. A busy session also forks
        # children off its own binary while it works, and those inherit the same
        # /proc/<pid>/exe - so without this the count climbs and falls with how
        # hard the machine is thinking, and the list fills with pids that are
        # gone a second later. Anything whose parent is itself claude is one of
        # those, not a session.
        pp="$(cm_ppid_of "$pid")" || continue
        cm_is_claude_pid "$pp" && continue
        pids+=("$pid")
        before+=("$(cm_cputime_of "$pid")")
    done
    [ "${#pids[@]}" -gt 0 ] || return 0

    # A single shared sample window rather than one per process, so the whole
    # listing costs 300ms no matter how many sessions are open.
    sleep 0.3
    local i
    for i in "${!pids[@]}"; do after+=("$(cm_cputime_of "${pids[$i]}")"); done

    for i in "${!pids[@]}"; do
        pid="${pids[$i]}"
        [ -d "/proc/$pid" ] || continue
        ppid="$(cm_ppid_of "$pid")"
        cwd="$(readlink "/proc/$pid/cwd" 2>/dev/null)"; [ -n "$cwd" ] || cwd='?'
        tty="$(ps -o tty= -p "$pid" 2>/dev/null | tr -d ' ')"; [ -n "$tty" ] || tty='?'
        # 0.3s of wall clock is ~30 jiffies at the usual 100Hz; a few of them
        # spent is a session doing work rather than waiting on a keystroke.
        busy=no
        [ $(( ${after[$i]:-0} - ${before[$i]:-0} )) -ge 3 ] && busy=yes
        isself=no; [ "$pid" = "$self" ] && isself=yes
        printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
            "$pid" "$ppid" "$tty" "$busy" "$cwd" "$isself" \
            "$(tr '\0' ' ' < "/proc/$ppid/cmdline" 2>/dev/null | sed 's/[[:space:]]*$//')"
    done
}

cm_session_count() { cm_session_rows | grep -c . || true; }

cmd_sessions() {
    local action=list dry=0 assume=0 json=0 a
    for a in "$@"; do
        case "$a" in
            --json)    json=1 ;;
            --stop)    action=stop ;;
            --restart) action=restart ;;
            --dry-run) dry=1 ;;
            -y|--yes)  assume=1 ;;
            list|'')   ;;
            *) err "unknown option '$a'"; return 1 ;;
        esac
    done

    local rows n busy
    rows="$(cm_session_rows)"
    n="$(printf '%s' "$rows" | grep -c . || true)"

    if [ "$json" -eq 1 ]; then
        printf '%s\n' "$rows" | "$PY" "$JSON" sessions-json
        return 0
    fi

    if [ "${n:-0}" -eq 0 ]; then
        head_ 'running sessions'
        ok 'no Claude Code sessions running'
        return 0
    fi

    head_ "running sessions ($n)"
    local pid ppid tty b cwd isself pcmd tag
    while IFS=$'\t' read -r pid ppid tty b cwd isself pcmd; do
        [ -n "$pid" ] || continue
        tag=''
        [ "$b" = yes ]      && tag=" ${C_YELLOW}working${C_RESET}"
        [ "$isself" = yes ] && tag="$tag ${C_DIM}(this session - never touched)${C_RESET}"
        printf '  %-8s %-8s %s%s\n' "$pid" "$tty" "$cwd" "$tag"
    done <<EOF_ROWS
$rows
EOF_ROWS

    if [ "$action" = "list" ]; then
        printf '\n  %sthese keep the provider they started with until restarted%s\n' "$C_DIM" "$C_RESET"
        printf '  %sclaude-mode sessions --stop     close them%s\n' "$C_DIM" "$C_RESET"
        printf '  %sclaude-mode sessions --restart  close and reopen each in its own directory%s\n' "$C_DIM" "$C_RESET"
        printf '  %s--dry-run shows what either would do, and does nothing%s\n' "$C_DIM" "$C_RESET"
        return 0
    fi

    busy="$(printf '%s' "$rows" | cut -f4 | grep -c '^yes$' || true)"

    if [ "$dry" -eq 1 ]; then
        printf '\n  %sdry run - nothing will be signalled%s\n' "$C_DIM" "$C_RESET"
        cm_session_act "$action" "$rows" 1
        return 0
    fi

    # Stopping someone's editor mid-thought is not undoable, so an interactive
    # run asks first. --yes is for the bar widget, which has already asked in
    # its own dialog and would otherwise hang here with nowhere to type.
    if [ "$assume" -eq 0 ]; then
        if ! ui_interactive; then
            err 'refusing to stop sessions without a confirmation'
            say 'pass --yes if you mean it, or --dry-run to see what would happen'
            return 1
        fi
        printf '\n'
        if [ "${busy:-0}" -gt 0 ]; then
            warn "$busy of these is mid-request and will lose that turn"
        fi
        if [ "$action" = restart ]; then
            printf '  close and reopen these sessions? [y/N] '
        else
            printf '  close these sessions? [y/N] '
        fi
        local reply; IFS= read -r reply
        case "$reply" in
            y|Y|yes|YES) ;;
            *) say 'left alone'; return 0 ;;
        esac
    fi

    cm_session_act "$action" "$rows" 0
}

# Terminate, and optionally reopen. SIGTERM only: Claude Code cleans up its
# transcript on the way out, and SIGKILL would cost that for no gain.
cm_session_act() {
    local action="$1" rows="$2" dry="${3:-0}" pid ppid tty busy cwd isself pcmd acted=0 skipped=0
    local -a relaunch_cwd relaunch_cmd

    while IFS=$'\t' read -r pid ppid tty busy cwd isself pcmd; do
        [ -n "$pid" ] || continue
        if [ "$isself" = yes ]; then
            warn "skipping $pid - that is the session running this command"
            skipped=$((skipped + 1))
            continue
        fi
        if [ "$action" = "restart" ]; then
            relaunch_cwd+=("$cwd")
            relaunch_cmd+=("$pcmd")
        fi
        if [ "$dry" -eq 1 ]; then
            say "would stop $pid ($cwd)"
            acted=$((acted + 1))
        elif kill -TERM "$pid" 2>/dev/null; then
            ok "stopped $pid ($cwd)"
            acted=$((acted + 1))
        else
            err "could not stop $pid"
        fi
    done <<EOF_ROWS
$rows
EOF_ROWS

    [ "$acted" -gt 0 ] && [ "$dry" -eq 0 ] && sleep 0.6

    if [ "$action" = "restart" ] && [ "${#relaunch_cwd[@]}" -gt 0 ]; then
        local i c t
        for i in "${!relaunch_cwd[@]}"; do
            c="${relaunch_cwd[$i]}"; t="${relaunch_cmd[$i]}"
            [ -d "$c" ] || c="$HOME"
            # The parent of a session started from the app launcher is the
            # terminal that was told to run claude, so re-running its command
            # line reproduces the session exactly - same terminal, same flags.
            # A session started by hand inside an existing shell has no such
            # parent to copy, and there is no way to type into that shell from
            # here, so it gets a fresh terminal in the same directory instead.
            case "$t" in
                *" -e "*claude*|*" --command"*claude*) ;;
                *) t="$(cm_terminal_cmd) -e claude" ;;
            esac
            if [ "$dry" -eq 1 ]; then
                say "would reopen in $c: $t"
            else
                ( cd "$c" && setsid nohup $t >/dev/null 2>&1 & )
                ok "reopened in $c"
            fi
        done
    fi

    [ "$skipped" -gt 0 ] && say 'this session was left running'
    return 0
}

# Named after the switch, not before it: the switch has already happened, and
# these are the sessions it did not reach.
cm_report_live_sessions() {
    local rows n busy
    rows="$(cm_session_rows)"
    n="$(printf '%s' "$rows" | grep -c . || true)"
    [ "${n:-0}" -gt 0 ] || return 0

    busy="$(printf '%s' "$rows" | cut -f4 | grep -c '^yes$' || true)"
    printf '\n'
    warn "$n Claude Code session(s) still running on the previous provider"
    if [ "${busy:-0}" -gt 0 ]; then
        warn "$busy of them is mid-request - it may fail on its next call rather than at a clean stop"
    fi
    printf '  %sclaude-mode sessions           what is running%s\n' "$C_DIM" "$C_RESET"
    printf '  %sclaude-mode sessions --restart close and reopen them on the new provider%s\n' "$C_DIM" "$C_RESET"
}

cm_terminal_cmd() {
    local t
    for t in "${TERMINAL:-}" foot alacritty ghostty kitty; do
        [ -n "$t" ] || continue
        command -v "$t" >/dev/null 2>&1 && { printf '%s' "$t"; return 0; }
    done
    printf 'xterm'
}

# ---------------------------------------------------------------------------
# Switching
# ---------------------------------------------------------------------------

backup_settings() {
    [ -f "$CM_SETTINGS" ] || return 0
    local stamp dest
    stamp="$(date +%Y%m%d-%H%M%S-%3N 2>/dev/null || date +%Y%m%d-%H%M%S)"
    dest="$CM_BACKUPS/settings.$stamp.json"
    cp "$CM_SETTINGS" "$dest"
    # keep the 20 most recent
    ls -1t "$CM_BACKUPS"/settings.*.json 2>/dev/null | tail -n +21 | while read -r f; do rm -f "$f"; done
    printf '%s' "$dest"
}

set_mode() {
    local mode="$1" preset_name="${2:-}" preset_file="" backup
    init_root
    mkdir -p "$CM_SETTINGS_DIR"

    if [ "$mode" != "anthropic" ]; then
        preset_file="$(preset_path "$preset_name")"

        # Everything that has to be true before the write is checked in one
        # place, shared with `claude-mode preflight` and the bar widget, so a
        # switch cannot succeed into a mode that has no key or no server.
        if [ "$CM_FORCE" -eq 0 ] && ! cm_preflight "$mode" "$preset_name"; then
            err "$CM_PF_TITLE"
            [ -n "$CM_PF_DETAIL" ] && say "$CM_PF_DETAIL"
            [ -n "$CM_PF_REMEDY" ] && printf '  %sfix:%s %s\n' "$C_DIM" "$C_RESET" "$CM_PF_REMEDY"
            printf '  %s--force switches anyway%s\n' "$C_DIM" "$C_RESET"
            return 1
        fi
        [ -f "$preset_file" ] || { err "preset '$preset_name' not found"; return 1; }
    fi

    backup="$(backup_settings)"

    if ! "$PY" "$JSON" apply "$CM_SETTINGS" "$CM_STATE" "$mode" "$preset_file" "$CM_HELPER" >/dev/null; then
        err "failed to update $CM_SETTINGS"
        return 1
    fi

    if [ "$mode" = "anthropic" ]; then
        head_ "switched to: anthropic"
    else
        head_ "switched to: $mode / preset '$preset_name'"
    fi
    [ -n "$backup" ] && ok "settings.json backed up to $backup"

    if [ "$mode" = "anthropic" ]; then
        ok "all gateway env + apiKeyHelper removed; native Anthropic login is authoritative"
    else
        ok "base url  $(jget "$preset_file" baseUrl)"
        local t v
        for t in "${TIERS[@]}"; do
            v="$(jget "$preset_file" "models.$t")"
            [ -n "$v" ] && ok "$(printf '%-7s -> %s' "$t" "$v")"
        done
        v="$(jget "$preset_file" contextTokens)"
        if [ -n "$v" ]; then ok "context -> $v tokens (max + auto-compact window)"
        else warn "no contextTokens in this preset - Claude Code will guess a small window and compact early"; fi
        local am; am="$(jget "$preset_file" auth.mode)"; [ -z "$am" ] && am=vault
        if [ "$am" = "vault" ]; then ok "auth      via apiKeyHelper (key never enters settings.json)"
        else ok "auth      inline placeholder token '$(jget "$preset_file" auth.token)' (not a secret)"; fi
    fi

    check_stray_env "$mode"

    if [ "$mode" != "anthropic" ]; then
        local am2 ref2
        am2="$(jget "$preset_file" auth.mode)"; [ -z "$am2" ] && am2=vault
        if [ "$am2" = "vault" ]; then
            ref2="$(jget "$preset_file" auth.keyRef)"; [ -z "$ref2" ] && ref2=openrouter
            show_guardrail_status "$mode" "$(cm_vault_get "$ref2" 2>/dev/null || true)"
        fi
    fi
    # Auto-repair on the way into a gateway: a [1m] tag on a non-Anthropic id is
    # meaningless there and breaks compaction. Anthropic ids are left alone, so
    # switching back to anthropic keeps whatever 1M selection was made.
    check_stale_models "$mode"
    write_health "$mode" "$preset_name"

    cm_report_live_sessions

    printf '\n  %srestart claude (and reload the VS Code window) to pick this up%s\n' "$C_DIM" "$C_RESET"
}

# ---------------------------------------------------------------------------
# Stray environment variables
#
# Windows has User/Machine registry scopes; here the equivalent persistence is a
# shell rc file, so that is what gets scanned. An export there outranks
# settings.json for any shell that sources it.
# ---------------------------------------------------------------------------

managed_keys() { "$PY" "$JSON" managed "$CM_STATE"; }

# Set by show_guardrail_status when it probes, so health.json can carry the
# tri-state result rather than re-probing.
CM_GUARDRAIL=''

cm_version() {
    local v=''
    [ -f "$CM_ROOT/VERSION" ] && v="$(tr -d '[:space:]' < "$CM_ROOT/VERSION")"
    printf '%s' "${v:-0.0.0}"
}

# Machine-readable state for a fleet reader. See cm-json.py cmd_health for the
# contract; the short version is: no key material, model lists carry an
# `anthropic` flag, guardrailStatus is tri-state.
write_health() {
    local mode="$1" preset="$2" backend=''
    case "$(cm_vault_backend)" in
        security)    backend='keychain' ;;
        secret-tool) backend='secret-tool' ;;
        pass)        backend='pass' ;;
        file)        backend='file' ;;
    esac
    "$PY" "$JSON" health "$CM_ROOT" "$HOME/.claude.json" "$mode" "$preset" \
        "$CM_GUARDRAIL" "$backend" "$(cm_version)" 2>/dev/null || true
}

# Ask OpenRouter whether this key can still reach Anthropic models, by trying the
# cheapest possible request against one. Free when the guardrail blocks it; a
# fraction of a cent when it does not, which is exactly the case worth knowing.
#
# OpenRouter only: Z.AI and LM Studio have no equivalent control, so there is
# nothing actionable to print for them.
show_guardrail_status() {
    local mode="$1" key="$2" code
    [ "$mode" = "openrouter" ] || return 0
    [ -n "$key" ] || return 0

    code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 25 \
        -X POST 'https://openrouter.ai/api/v1/messages' \
        -H 'content-type: application/json' -H "x-api-key: $key" \
        -H "authorization: Bearer $key" -H 'anthropic-version: 2023-06-01' \
        -d '{"model":"claude-opus-5","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}' 2>/dev/null)"

    # 403/404 is OpenRouter refusing the model, which is what a guardrail looks
    # like. 401 is the KEY being rejected - that says nothing about the guardrail
    # and must not read as an all-clear.
    case "$code" in
        403|404)
            CM_GUARDRAIL=active
            printf '  %sguardrail  %s%sactive%s%s - Anthropic models blocked for this key%s\n' \
                "$C_DIM" "$C_RESET" "$C_GREEN" "$C_RESET" "$C_DIM" "$C_RESET"
            ;;
        401)
            CM_GUARDRAIL=unknown
            printf '  %sguardrail  %s%sunknown%s%s - OpenRouter rejected the key, so it could not be checked%s\n' \
                "$C_DIM" "$C_RESET" "$C_YELLOW" "$C_RESET" "$C_DIM" "$C_RESET"
            ;;
        200)
            CM_GUARDRAIL=not_set
            printf '  %sguardrail  %s%sNOT SET%s%s - Anthropic models reachable, billed at list price%s\n' \
                "$C_DIM" "$C_RESET" "$C_RED" "$C_RESET" "$C_DIM" "$C_RESET"
            printf '             %sopenrouter.ai -> Guardrails -> new, select this key,%s\n' "$C_DIM" "$C_RESET"
            printf '             %sthen exclude anthropic models (or allow only the ones you use)%s\n' "$C_DIM" "$C_RESET"
            ;;
        *)
            # Only an explicit rejection proves the guardrail; anything else says
            # nothing, and a false all-clear on a safety check is worse than none.
            CM_GUARDRAIL=unknown
            printf '  %sguardrail  %s%sunknown%s%s - could not reach OpenRouter to check%s\n' \
                "$C_DIM" "$C_RESET" "$C_YELLOW" "$C_RESET" "$C_DIM" "$C_RESET"
            ;;
    esac
}

# Claude Code caches a resolved model per (entrypoint, model, org) in
# ~/.claude.json. A session running before a switch, or a model picked from a
# gateway's own catalogue, keeps that id - and the gateway then bills it at full
# list price. Detect and say so; claude-mode cannot police runtime model choice.
check_stale_models() {
    local mode="$1" cfg="$HOME/.claude.json" found
    [ "$mode" = "anthropic" ] && return 0
    [ -f "$cfg" ] || return 0
    found="$("$PY" "$JSON" stale-models "$cfg" 2>/dev/null)"
    [ -n "$found" ] || return 0

    local na nt
    na="$(printf '%s\n' "$found" | grep -c '^anthropic' || true)"
    nt="$(printf '%s\n' "$found" | grep -c '^tagged' || true)"

    if [ "${na:-0}" -gt 0 ]; then
        printf '  %ssessions   %s%s%s cached Anthropic model ids%s%s - restart running claude sessions%s\n' \
            "$C_DIM" "$C_RESET" "$C_YELLOW" "$na" "$C_RESET" "$C_DIM" "$C_RESET"
    fi
    if [ "${nt:-0}" -gt 0 ]; then
        printf '  %stagged     %s%s%s model id(s) carry a [1m] tag%s%s - breaks compaction on gateways; claude-mode repair%s\n' \
            "$C_DIM" "$C_RESET" "$C_YELLOW" "$nt" "$C_RESET" "$C_DIM" "$C_RESET"
    fi
    return 1
}

# Strip extended-context tags from cached model ids. Backed up first; the file is
# the user's own Claude Code config, not ours.
# $1 = 'all' to include Anthropic ids, '' for gateway ids only.
# $2 = 'quiet' for the one-line form used on a mode switch.
cmd_repair() {
    local scope="${1:-}" quiet="${2:-}" cfg="$HOME/.claude.json" bak out ns nk
    [ -f "$cfg" ] || { [ "$quiet" = "quiet" ] || err 'no ~/.claude.json'; return 0; }
    init_root
    bak="$CM_BACKUPS/claude.json.$(date +%Y%m%d-%H%M%S).bak"
    cp "$cfg" "$bak"

    out="$("$PY" "$JSON" strip-tags "$cfg" "$scope" 2>/dev/null)"
    ns="$(printf '%s\n' "$out" | grep -c '^strip' || true)"
    nk="$(printf '%s\n' "$out" | grep -c '^keep'  || true)"

    if [ "${ns:-0}" -eq 0 ]; then
        rm -f "$bak"
        if [ "$quiet" != "quiet" ]; then
            if [ "${nk:-0}" -gt 0 ]; then
                ok "nothing to strip - $nk tagged id(s) are Anthropic models, where the tag is meaningful"
                printf '%s\n' "$out" | grep '^keep' | cut -f2 | sed 's/^/    keeping /'
                printf '  %suse --all to strip those too (downgrades them to the 200k variant)%s\n' "$C_DIM" "$C_RESET"
            else
                ok 'no tagged model ids in ~/.claude.json'
            fi
        fi
        return 0
    fi

    if [ "$quiet" = "quiet" ]; then
        printf '  %srepaired   %s%s%s gateway model id(s) had a [1m] tag stripped%s\n' \
            "$C_DIM" "$C_RESET" "$C_GREEN" "$ns" "$C_RESET"
    else
        printf '%s\n' "$out" | grep '^strip' | cut -f2 | sed 's/^/    /'
        printf '%s\n' "$out" | grep '^keep'  | cut -f2 | sed 's/^/    keeping /'
        ok "stripped $ns tag(s); backup at $bak"
        printf '  %srestart claude for this to take effect%s\n' "$C_DIM" "$C_RESET"
    fi
}

check_stray_env() {
    local mode="$1" problems=0 k f
    local rcfiles=("$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile" "$HOME/.zshrc" "$HOME/.zshenv" "/etc/environment")

    if [ "$mode" != "anthropic" ] && [ -n "${ANTHROPIC_API_KEY:-}" ]; then
        warn 'ANTHROPIC_API_KEY is set in THIS shell. The `claude` wrapper strips it; other shells are unaffected.'
    fi

    while IFS= read -r k; do
        [ -n "$k" ] || continue
        for f in "${rcfiles[@]}"; do
            [ -f "$f" ] || continue
            # our own managed block is not a stray export
            if grep -qE "^[[:space:]]*(export[[:space:]]+)?$k=" "$f" 2>/dev/null; then
                err "$k is exported in $f - it overrides claude-mode for every new shell"
                problems=$((problems+1))
            fi
        done
    done < <(managed_keys)

    return $problems
}

# ---------------------------------------------------------------------------
# Provider catalogues
# ---------------------------------------------------------------------------

or_catalogue() {
    curl -fsS --max-time 30 https://openrouter.ai/api/v1/models 2>/dev/null | "$PY" "$JSON" or-models 2>/dev/null
}

lms_catalogue() {
    local base="${1%/}"
    curl -fsS --max-time 10 "$base/api/v0/models" 2>/dev/null | "$PY" "$JSON" lms-models 2>/dev/null
}

# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------

cmd_status() {
    local mode preset
    mode="$(state_mode)"; preset="$(state_preset)"
    head_ "claude-mode: $mode"

    if [ "$mode" = "anthropic" ]; then
        say 'native Anthropic login/subscription; no gateway env, no apiKeyHelper'
    else
        local pf; pf="$(preset_path "$preset")"
        say "preset:   $preset"
        if [ -f "$pf" ]; then
            say "baseUrl:  $(jget "$pf" baseUrl)"
            local t v
            for t in "${TIERS[@]}"; do
                v="$(jget "$pf" "models.$t")"; [ -n "$v" ] && printf '  %-10s%s\n' "$t:" "$v"
            done
            v="$(jget "$pf" subagentModel)";  [ -n "$v" ] && printf '  %-10s%s\n' "subagent:" "$v"
            v="$(jget "$pf" contextTokens)";  [ -n "$v" ] && printf '  %-10s%s tokens\n' "context:" "$v"
            local am; am="$(jget "$pf" auth.mode)"; [ -z "$am" ] && am=vault
            if [ "$am" = "vault" ]; then
                local ref; ref="$(jget "$pf" auth.keyRef)"; [ -z "$ref" ] && ref=openrouter
                printf '  %-10s%s -> %s  [%s]\n' "key:" "$ref" "$(cm_vault_mask "$(cm_vault_get "$ref" 2>/dev/null || true)")" "$(cm_vault_backend_label)"
            else
                printf '  %-10s%s (inline, not a secret)\n' "token:" "$(jget "$pf" auth.token)"
            fi
        else
            err "preset '$preset' not found"
        fi
    fi

    printf '\n  settings.json managed keys:\n'
    local any=0 line
    while IFS= read -r line; do
        [ -n "$line" ] || continue
        printf '    %s\n' "$line"; any=1
    done < <("$PY" "$JSON" settings-env "$CM_SETTINGS" "$CM_STATE" 2>/dev/null)
    [ "$any" -eq 0 ] && printf '    (none - clean)\n'

    printf '\n'
    check_stray_env "$mode" || true

    # Keep the machine-readable mirror current for readers that poll it (the
    # Omarchy bar widget among them) rather than leaving it as stale as the
    # last switch.
    write_health "$mode" "$preset"
}

cmd_presets() {
    head_ 'presets'
    local active_mode active_preset name provider desc mark
    active_mode="$(state_mode)"; active_preset="$(state_preset)"
    while IFS=$'\t' read -r name provider desc; do
        mark=' '
        [ "$name" = "$active_preset" ] && [ "$active_mode" != "anthropic" ] && mark='*'
        printf '  %s %-18s [%-10s] %s\n' "$mark" "$name" "$provider" "$desc"
    done < <("$PY" "$JSON" presets "$CM_PRESETS")
}

cmd_models() {
    local filter="${1:-}" mode preset pf
    mode="$(state_mode)"; preset="$(state_preset)"; pf="$(preset_path "$preset")"

    case "$mode" in
        lmstudio)
            head_ "models installed in LM Studio at $(jget "$pf" baseUrl)"
            lms_catalogue "$(jget "$pf" baseUrl)" | while IFS=$'\t' read -r id st ctx; do
                [ -z "$filter" ] || case "$id" in *"$filter"*) ;; *) continue ;; esac
                printf '  %-58s %-11s %s\n' "$id" "$st" "$ctx"
            done
            ;;
        zai)
            head_ 'Z.AI GLM models (from Z.AI docs - no public catalogue endpoint)'
            say 'glm-5.2  - flagship coding model (opus/sonnet tier)'
            say 'glm-4.7  - fast/cheap tier (haiku tier)'
            ;;
        *)
            head_ 'fetching https://openrouter.ai/api/v1/models ...'
            or_catalogue | while IFS=$'\t' read -r id ctx pin pout; do
                [ -z "$filter" ] || case "$id" in *"$filter"*) ;; *) continue ;; esac
                printf '  %-52s %10s  $%-8s $%s\n' "$id" "$ctx" "$pin" "$pout"
            done
            ;;
    esac
}

cmd_set_key() {
    local ref="${1:-openrouter}" secret
    init_root
    printf '  storage backend: %s\n' "$(cm_vault_backend_label)"
    if [ "$(cm_vault_backend)" = "file" ]; then
        warn 'no keyring available - the key will be stored in a 0600 file, NOT encrypted.'
        warn 'install libsecret-tools (secret-tool) or pass for encrypted storage.'
    fi
    printf '  paste the API key for ref '\''%s'\'' (input hidden): ' "$ref"
    IFS= read -rs secret; printf '\n'
    [ -n "$secret" ] || { err 'empty key, aborted'; return 1; }

    # A hidden prompt will happily swallow a mis-paste. Guard the two shapes that
    # are never a real key - the failure is otherwise invisible until the
    # provider answers 401 and the UI just spins.
    case "$secret" in
        *[[:space:]]*) err 'that value contains whitespace, so it is not an API key (a pasted command line?). Nothing was stored.'; return 1 ;;
        claude-mode*)  err 'that value is a claude-mode command, not an API key. Nothing was stored.'; return 1 ;;
    esac
    [ "${#secret}" -lt 16 ] && warn "that key is only ${#secret} characters - unusually short. Storing anyway."
    if [ "$ref" = "openrouter" ] && [ "${secret#sk-or-}" = "$secret" ]; then
        warn "key does not start with 'sk-or-' - storing anyway"
    fi
    printf '%s' "$secret" | cm_vault_set "$ref" && ok "stored key '$ref' via $(cm_vault_backend_label)"
}

cmd_doctor() {
    local mode preset pf
    mode="$(state_mode)"; preset="$(state_preset)"; pf="$(preset_path "$preset")"
    head_ "doctor - mode '$mode'"

    if "$PY" -c "import json,sys; json.load(open(sys.argv[1])) if __import__('os').path.exists(sys.argv[1]) else None" "$CM_SETTINGS" 2>/dev/null; then
        ok 'settings.json parses'
    else
        err 'settings.json does not parse'; return 1
    fi

    if [ -x "$CM_HELPER" ]; then ok "key helper present: $CM_HELPER"; else err "key helper missing/not executable: $CM_HELPER"; fi
    ok "secret backend: $(cm_vault_backend_label)"

    if [ "$mode" != "anthropic" ]; then
        local am base; am="$(jget "$pf" auth.mode)"; [ -z "$am" ] && am=vault
        base="$(jget "$pf" baseUrl)"; base="${base%/}"

        if [ "$am" = "vault" ]; then
            local ref key out
            ref="$(jget "$pf" auth.keyRef)"; [ -z "$ref" ] && ref=openrouter
            if key="$(cm_vault_get "$ref" 2>/dev/null)"; then
                ok "vault '$ref' resolves -> $(cm_vault_mask "$key")"
                case "$key" in
                    *[[:space:]]*|claude-mode*)
                        err "the stored '$ref' value looks like a pasted command, not a key. Re-run: claude-mode set-key $ref" ;;
                esac
            else
                err "vault '$ref' missing. Run: claude-mode set-key $ref"; key=''
            fi
            out="$("$CM_HELPER" 2>/dev/null)"
            if [ -n "$out" ] && [ "$out" = "$key" ]; then ok 'apiKeyHelper emits the correct key'
            elif [ -n "$out" ]; then err 'apiKeyHelper output does not match the vault'
            else err 'apiKeyHelper produced no output'; fi

            if [ -n "$key" ] && [ "$mode" = "openrouter" ]; then
                local kinfo
                kinfo="$(curl -fsS --max-time 20 -H "Authorization: Bearer $key" https://openrouter.ai/api/v1/key 2>/dev/null)"
                if [ -n "$kinfo" ]; then
                    ok 'OpenRouter accepted the key'
                    printf '%s' "$kinfo" | "$PY" -c "
import json,sys
d=json.load(sys.stdin).get('data',{})
lim=d.get('limit'); use=d.get('usage',0)
if lim is None: print('  ok    spend %.2f this month (no key limit set)' % use)
else: print('  ok    spend %.2f of %.2f limit (%s), %.2f remaining' % (use, lim, d.get('limit_reset','?'), d.get('limit_remaining',0)))
" 2>/dev/null
                else
                    err 'OpenRouter rejected the key'
                fi

                show_guardrail_status "$mode" "$key"
            fi
            if [ -n "$key" ] && [ "$mode" = "zai" ]; then
                if curl -fsS --max-time 45 -X POST "$base/v1/messages" \
                        -H 'content-type: application/json' -H "x-api-key: $key" \
                        -H "authorization: Bearer $key" -H 'anthropic-version: 2023-06-01' \
                        -d "{\"model\":\"$(jget "$pf" models.haiku)\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" >/dev/null 2>&1; then
                    ok "Z.AI endpoint accepted the key ($base/v1/messages)"
                else
                    err 'Z.AI request failed'
                fi
            fi
        else
            ok "inline token '$(jget "$pf" auth.token)' (no secret in settings.json)"
        fi

        if [ "$mode" = "lmstudio" ]; then
            local cat; cat="$(lms_catalogue "$base")"
            if [ -n "$cat" ]; then
                ok "LM Studio reachable at $base ($(printf '%s\n' "$cat" | wc -l | tr -d ' ') models installed)"
                local t id row st ctx
                for t in "${TIERS[@]}"; do
                    id="$(jget "$pf" "models.$t")"; [ -n "$id" ] || continue
                    row="$(printf '%s\n' "$cat" | tsv_find "$id")" || row=''
                    if [ -z "$row" ]; then err "$t model NOT installed in LM Studio: $id"; continue; fi
                    st="$(tsv_field "$row" 2)"; ctx="$(tsv_field "$row" 3)"
                    ok "$t $id [$st, ctx $ctx]"
                done
            else
                err "LM Studio not reachable at $base - start the server (Developer > Start Server)"
            fi
        elif [ "$mode" = "openrouter" ]; then
            local cat; cat="$(or_catalogue)"
            if [ -n "$cat" ]; then
                local t id row ctx declared
                declared="$(jget "$pf" contextTokens)"
                for t in "${TIERS[@]}"; do
                    id="$(jget "$pf" "models.$t")"; [ -n "$id" ] || continue
                    row="$(printf '%s\n' "$cat" | tsv_find "$id")" || row=''
                    if [ -z "$row" ]; then err "$t model NOT available from OpenRouter: $id"; continue; fi
                    ctx="$(tsv_field "$row" 2)"
                    ok "$(printf '%-6s %s  [ctx %s]' "$t" "$id" "$ctx")"
                    if [ -n "$declared" ] && [ -n "$ctx" ] && [ "$ctx" -lt "$declared" ] 2>/dev/null; then
                        if [ "$t" = "haiku" ]; then
                            warn "$t model has $ctx ctx, below the declared $declared - harmless, haiku runs short background tasks"
                        else
                            warn "$t model has $ctx ctx, below the declared $declared - this tier can overflow"
                        fi
                    fi
                done
                [ -n "$declared" ] && ok "declared context window: $declared tokens" \
                    || warn 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early'
            else
                warn 'could not fetch the OpenRouter catalogue'
            fi
        fi
    fi

    printf '\n'
    if check_stale_models "$mode"; then
        [ "$mode" != "anthropic" ] && ok 'no cached Anthropic model ids'
    fi

    printf '\n'
    if check_stray_env "$mode"; then ok 'no rc-file overrides'; fi

    write_health "$mode" "$preset"

    printf '\n'
    if command -v claude >/dev/null 2>&1; then
        say "claude: $(claude --version 2>&1 | head -n1)  [$(command -v claude)]"
    else
        warn 'claude is not on PATH'
    fi
}

cmd_preset() {
    local sub="${1:-}" name="${2:-}"
    case "$sub" in
        show)
            [ -n "$name" ] || { err 'usage: claude-mode preset show <name>'; return 1; }
            cat "$(preset_path "$name")"
            ;;
        new)
            [ -n "$name" ] || { err 'usage: claude-mode preset new <name> [copy-from]'; return 1; }
            [ -f "$(preset_path "$name")" ] && { err "preset '$name' already exists"; return 1; }
            local from="${3:-default}"
            [ -f "$(preset_path "$from")" ] || { err "source preset '$from' not found"; return 1; }
            cp "$(preset_path "$from")" "$(preset_path "$name")"
            ok "created $(preset_path "$name") from '$from'"
            ;;
        rm)
            [ -n "$name" ] || { err 'usage: claude-mode preset rm <name>'; return 1; }
            [ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
            if [ "$name" = "$(state_preset)" ] && [ "$(state_mode)" != "anthropic" ]; then
                err "preset '$name' is active. Switch away first."; return 1
            fi
            rm -f "$(preset_path "$name")"; ok "deleted preset '$name'"
            ;;
        set)
            local tier="${3:-}" model="${4:-}"
            [ -n "$name" ] && [ -n "$tier" ] && [ -n "$model" ] || { err 'usage: claude-mode preset set <name> <tier> <model-id>'; return 1; }
            "$PY" "$JSON" set-tier "$(preset_path "$name")" "$tier" "$model" || return 1
            ok "$name : $tier -> $model"
            reapply_if_active "$name"
            ;;
        all)
            local model="${3:-}"
            [ -n "$name" ] && [ -n "$model" ] || { err 'usage: claude-mode preset all <name> <model-id>'; return 1; }
            local t
            for t in "${TIERS[@]}" subagent; do
                "$PY" "$JSON" set-tier "$(preset_path "$name")" "$t" "$model" || return 1
            done
            ok "$name : all tiers + subagent -> $model"
            reapply_if_active "$name"
            ;;
        url)
            local url="${3:-}"
            [ -n "$name" ] && [ -n "$url" ] || { err 'usage: claude-mode preset url <name> <base-url>'; return 1; }
            [ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
            "$PY" "$JSON" set-url "$(preset_path "$name")" "$url" >/dev/null || return 1
            ok "$name : baseUrl -> $url"
            reapply_if_active "$name"
            ;;
        auth)
            local amode="${3:-}" ref="${4:-lmstudio}"
            [ -n "$name" ] && [ -n "$amode" ] || { err 'usage: claude-mode preset auth <name> none|key [keyRef]'; return 1; }
            [ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
            "$PY" "$JSON" set-auth "$(preset_path "$name")" "$amode" "$ref" >/dev/null || return 1
            if [ "$amode" = "key" ]; then
                ok "$name : auth -> vault key '$ref'"
                cm_vault_has "$ref" || warn "no key stored yet for '$ref' - run: claude-mode set-key $ref"
            else
                ok "$name : auth -> none (inline placeholder token)"
            fi
            reapply_if_active "$name"
            ;;
        *) usage ;;
    esac
}

reapply_if_active() {
    local name="$1"
    if [ "$(state_mode)" != "anthropic" ] && [ "$(state_preset)" = "$name" ]; then
        say 're-applying active preset...'
        set_mode "$(state_mode)" "$name"
    fi
}

# ---------------------------------------------------------------------------
# Interactive UI
# ---------------------------------------------------------------------------

ui_interactive() { [ -t 0 ] && [ -t 1 ]; }

# Read one keypress, normalised to a word.
read_key() {
    local k rest extra
    IFS= read -rsn1 k 2>/dev/null || return 1
    if [ "$k" = $'\033' ]; then
        IFS= read -rsn2 -t 0.05 rest 2>/dev/null || rest=''
        case "$rest" in
            '[A') printf 'up' ;;
            '[B') printf 'down' ;;
            '[C') printf 'right' ;;
            '[D') printf 'left' ;;
            '[H') printf 'home' ;;
            '[F') printf 'end' ;;
            '[5') IFS= read -rsn1 -t 0.05 extra 2>/dev/null; printf 'pgup' ;;
            '[6') IFS= read -rsn1 -t 0.05 extra 2>/dev/null; printf 'pgdn' ;;
            '')   printf 'esc' ;;
            *)    printf 'other' ;;
        esac
        return 0
    fi
    case "$k" in
        '')            printf 'enter' ;;
        $'\177'|$'\b') printf 'backspace' ;;
        *)             printf 'char:%s' "$k" ;;
    esac
}

# Selection state shared with the callers, so bash does not have to return
# structured data from a function.
UI_LABELS=(); UI_DETAILS=(); UI_ACCENTS=(); UI_SEL=-1

ui_reset_items() { UI_LABELS=(); UI_DETAILS=(); UI_ACCENTS=(); }
ui_add_item() { UI_LABELS+=("$1"); UI_DETAILS+=("${2:-}"); UI_ACCENTS+=("${3:-$C_CYAN}"); }

# ui_select <title> <status>  -> sets UI_SEL (-1 = cancelled)
ui_select() {
    local title="$1" status="${2:-}"
    local n=${#UI_LABELS[@]} idx=0 cols first=1 i key detail
    UI_SEL=-1
    [ "$n" -gt 0 ] || return 1
    cols=$(term_cols)

    printf '\033[?25l'                       # hide cursor
    trap 'printf "\033[?25h"' RETURN

    while true; do
        if [ "$first" -eq 1 ]; then first=0; else printf '\033[%dA' $((n + 4)); fi

        printf '\033[2K\n'
        printf '\033[2K  %s%s%s' "$C_BOLD$C_CYAN" "$title" "$C_RESET"
        [ -n "$status" ] && printf '   %s%s%s' "$C_DIM" "$status" "$C_RESET"
        printf '\n'
        printf '\033[2K  %sup/down  move     enter  select     esc  cancel%s\n' "$C_DIM" "$C_RESET"

        for ((i = 0; i < n; i++)); do
            if [ "$i" -eq "$idx" ]; then
                printf '\033[2K%s  > %-*s%s\n' "$(printf '\033[7m')${UI_ACCENTS[$i]}" $((cols - 5)) "${UI_LABELS[$i]}" "$C_RESET"
            else
                printf '\033[2K    %s\n' "${UI_LABELS[$i]}"
            fi
        done

        detail="${UI_DETAILS[$idx]}"
        printf '\033[2K      %s%s%s\n' "$C_DIM" "${detail:0:$((cols - 8))}" "$C_RESET"

        key="$(read_key)" || { UI_SEL=-1; return 1; }
        case "$key" in
            up)    idx=$(( (idx - 1 + n) % n )) ;;
            down)  idx=$(( (idx + 1) % n )) ;;
            home)  idx=0 ;;
            end)   idx=$((n - 1)) ;;
            enter) UI_SEL=$idx; return 0 ;;
            esc)   UI_SEL=-1;   return 1 ;;
            char:q|char:Q) UI_SEL=-1; return 1 ;;
            char:[1-9])
                local d="${key#char:}"
                [ "$d" -le "$n" ] && { UI_SEL=$((d - 1)); return 0; }
                ;;
        esac
    done
}

# ui_filter_select <title> <status> - same, plus a type-to-filter box.
# Items come from UI_LABELS/UI_DETAILS; sets UI_SEL as an index into them.
ui_filter_select() {
    local title="$1" status="${2:-}"
    local n=${#UI_LABELS[@]} query='' idx=0 off=0 rows=12 cols first=1
    local -a match_idx
    UI_SEL=-1
    [ "$n" -gt 0 ] || return 1
    cols=$(term_cols)

    printf '\033[?25l'
    trap 'printf "\033[?25h"' RETURN

    while true; do
        match_idx=()
        local i lower_q; lower_q="$(printf '%s' "$query" | tr '[:upper:]' '[:lower:]')"
        for ((i = 0; i < n; i++)); do
            if [ -z "$query" ]; then match_idx+=("$i")
            else
                local lab; lab="$(printf '%s' "${UI_LABELS[$i]}" | tr '[:upper:]' '[:lower:]')"
                case "$lab" in *"$lower_q"*) match_idx+=("$i") ;; esac
            fi
        done
        local m=${#match_idx[@]}
        [ "$idx" -ge "$m" ] && idx=$(( m > 0 ? m - 1 : 0 ))
        [ "$idx" -lt "$off" ] && off=$idx
        [ "$idx" -ge $((off + rows)) ] && off=$((idx - rows + 1))
        [ "$off" -lt 0 ] && off=0

        if [ "$first" -eq 1 ]; then first=0; else printf '\033[%dA' $((rows + 6)); fi

        printf '\033[2K\n'
        printf '\033[2K  %s%s%s' "$C_BOLD$C_CYAN" "$title" "$C_RESET"
        [ -n "$status" ] && printf '   %s%s%s' "$C_DIM" "$status" "$C_RESET"
        printf '\n'
        printf '\033[2K  %stype to filter    up/down  move    enter  select    esc  cancel%s\n' "$C_DIM" "$C_RESET"
        printf '\033[2K  %sfilter:%s %s_\n' "$C_WHITE" "$C_RESET" "$query"

        local r real
        for ((r = 0; r < rows; r++)); do
            local pos=$((off + r))
            if [ "$pos" -ge "$m" ]; then printf '\033[2K\n'; continue; fi
            real=${match_idx[$pos]}
            if [ "$pos" -eq "$idx" ]; then
                printf '\033[2K%s  > %-*s%s\n' "$(printf '\033[7m')$C_CYAN" $((cols - 5)) "${UI_LABELS[$real]}" "$C_RESET"
            else
                printf '\033[2K    %s\n' "${UI_LABELS[$real]}"
            fi
        done

        if [ "$m" -eq 0 ]; then
            printf '\033[2K  %s(no match)%s\n' "$C_DIM" "$C_RESET"
            printf '\033[2K\n'
        else
            printf '\033[2K  %s%d of %d%s\n' "$C_DIM" $((idx + 1)) "$m" "$([ "$m" -ne "$n" ] && printf ' (filtered from %d)' "$n")$C_RESET"
            printf '\033[2K      %s%s%s\n' "$C_DIM" "${UI_DETAILS[${match_idx[$idx]}]:0:$((cols - 8))}" "$C_RESET"
        fi

        local key; key="$(read_key)" || { UI_SEL=-1; return 1; }
        case "$key" in
            up)    [ "$m" -gt 0 ] && idx=$(( (idx - 1 + m) % m )) ;;
            down)  [ "$m" -gt 0 ] && idx=$(( (idx + 1) % m )) ;;
            pgup)  idx=$(( idx - rows )); [ "$idx" -lt 0 ] && idx=0 ;;
            pgdn)  idx=$(( idx + rows )); [ "$idx" -ge "$m" ] && idx=$(( m > 0 ? m - 1 : 0 )) ;;
            home)  idx=0 ;;
            end)   idx=$(( m > 0 ? m - 1 : 0 )) ;;
            enter) [ "$m" -gt 0 ] && { UI_SEL=${match_idx[$idx]}; return 0; } ;;
            esc)   UI_SEL=-1; return 1 ;;
            backspace) query="${query%?}"; idx=0; off=0 ;;
            char:*) query="$query${key#char:}"; idx=0; off=0 ;;
        esac
    done
}

preset_summary_line() { "$PY" "$JSON" summary "$1" 2>/dev/null | sed -n '2p'; }

# These two draw a UI *and* produce a value. They must not be called inside
# $( ) - command substitution captures stdout, so the whole interface would be
# swallowed into the variable and the user would see nothing happen. They
# publish their result in UI_PICKED instead.
UI_PICKED=''

ui_pick_preset() {
    local provider="$1" def names name
    UI_PICKED=''
    def="$(default_preset_for "$provider")"
    mapfile -t names < <(presets_for_provider "$provider")
    [ "${#names[@]}" -gt 0 ] || { err "no presets for '$provider'"; return 1; }

    ui_reset_items
    local i=0 defidx=0
    for name in "${names[@]}"; do
        local label="$name"
        [ "$name" = "$def" ] && { label="$name   (default)"; defidx=$i; }
        ui_add_item "$label" "$(preset_summary_line "$(preset_path "$name")")"
        i=$((i + 1))
    done
    ui_select "preset for $provider" '' || return 1
    UI_PICKED="${names[$UI_SEL]}"
}

ui_pick_model() {
    local pf="$1" tier="$2" current="$3" provider base
    UI_PICKED=''
    provider="$(jget "$pf" provider)"; base="$(jget "$pf" baseUrl)"

    ui_reset_items
    ui_add_item '<type an id manually>' 'enter any model id by hand'

    local ids=() id ctx a b st
    case "$provider" in
        openrouter)
            while IFS=$'\t' read -r id ctx a b; do
                [ -n "$id" ] || continue
                ids+=("$id"); ui_add_item "$id" "context $ctx    \$$a in / \$$b out per 1M"
            done < <(or_catalogue)
            ;;
        lmstudio)
            while IFS=$'\t' read -r id st ctx; do
                [ -n "$id" ] || continue
                ids+=("$id"); ui_add_item "$id" "state: $st    max context: $ctx"
            done < <(lms_catalogue "$base")
            ;;
        zai)
            ids+=('glm-5.2'); ui_add_item 'glm-5.2' 'flagship coding model - opus/sonnet tier'
            ids+=('glm-4.7'); ui_add_item 'glm-4.7' 'fast/cheap tier - haiku'
            ;;
    esac

    if [ "${#ids[@]}" -gt 0 ]; then
        ui_filter_select "model for '$tier'" "current: $current" || return 1
        if [ "$UI_SEL" -gt 0 ]; then UI_PICKED="${ids[$((UI_SEL - 1))]}"; return 0; fi
    fi

    printf '\n  %scurrent %s : %s%s\n' "$C_DIM" "$tier" "$current" "$C_RESET"
    printf '  new model id for %s (blank = cancel): ' "$tier"
    local val; IFS= read -r val
    [ -n "$val" ] || return 1
    UI_PICKED="$val"
}

ui_edit_preset() {
    local name="${1:-}"
    if [ -z "$name" ]; then
        local names; mapfile -t names < <(preset_names)
        [ "${#names[@]}" -gt 0 ] || { warn 'no presets'; return; }
        ui_reset_items
        local n
        for n in "${names[@]}"; do
            ui_add_item "$(printf '%-18s [%s]' "$n" "$(jget "$(preset_path "$n")" provider)")" \
                        "$(preset_summary_line "$(preset_path "$n")")"
        done
        ui_select 'edit which preset' '' || return
        name="${names[$UI_SEL]}"
    fi

    local pf; pf="$(preset_path "$name")"
    while true; do
        ui_reset_items
        local tiers=() t v
        while IFS=$'\t' read -r t v; do
            tiers+=("$t")
            ui_add_item "$(printf '%-9s %s' "$t" "$v")" "change which model backs the '$t' tier"
        done < <("$PY" "$JSON" models "$pf")

        ui_select "$name  [$(jget "$pf" provider)]" 'esc = done' || return
        local tier="${tiers[$UI_SEL]}"
        local cur; cur="$(tsv_field "$("$PY" "$JSON" models "$pf" | tsv_find "$tier")" 2)"
        ui_pick_model "$pf" "$tier" "$cur" || continue
        local val="$UI_PICKED"
        [ -n "$val" ] || continue
        "$PY" "$JSON" set-tier "$pf" "$tier" "$val" && ok "$name : $tier -> $val"
        reapply_if_active "$name"
    done
}

ui_new_preset() {
    ui_reset_items
    local provs=(openrouter zai lmstudio) p
    for p in "${provs[@]}"; do ui_add_item "$p" "$(mode_label "$p")"; done
    ui_select 'new preset - which provider' '' || return
    local provider="${provs[$UI_SEL]}"

    local sibs; mapfile -t sibs < <(presets_for_provider "$provider")
    ui_reset_items
    ui_add_item '<blank>' "empty $provider preset - pick every model yourself"
    local s
    for s in "${sibs[@]}"; do ui_add_item "copy of $s" "$(preset_summary_line "$(preset_path "$s")")"; done
    ui_select 'start from' '' || return
    local choice=$UI_SEL

    printf '\n  %snew %s preset%s\n' "$C_CYAN" "$provider" "$C_RESET"
    printf '  name (letters, digits, dash; blank = cancel): '
    local name; IFS= read -r name
    [ -n "$name" ] || return
    case "$name" in
        *[!A-Za-z0-9._-]*) err "invalid name '$name'"; return ;;
    esac
    [ -f "$(preset_path "$name")" ] && { err "preset '$name' already exists"; return; }

    if [ "$choice" -eq 0 ]; then
        "$PY" "$JSON" scaffold "$provider" > "$(preset_path "$name")"
    else
        cp "$(preset_path "${sibs[$((choice - 1))]}")" "$(preset_path "$name")"
    fi
    ok "created preset '$name' ($provider)"
    ui_edit_preset "$name"
}

ui_more_menu() {
    while true; do
        ui_reset_items
        ui_add_item 'status'       'show the full active configuration'
        ui_add_item 'edit presets' 'pick models per tier from the provider catalogue'
        ui_add_item 'new preset'   'create a preset - blank or copied from an existing one'
        ui_add_item 'doctor'       'verify auth, endpoint, model ids, context window'
        ui_add_item 'back'         'return to the mode menu'
        ui_add_item 'quit'         ''
        ui_select 'claude-mode - more' "currently: $(state_mode)" || return 0
        case "$UI_SEL" in
            0) cmd_status ;;
            1) ui_edit_preset ;;
            2) ui_new_preset ;;
            3) cmd_doctor ;;
            4) return 0 ;;
            5) return 2 ;;
        esac
    done
}

ui_menu() {
    if ! ui_interactive; then cmd_status; return; fi

    local cur preset
    cur="$(state_mode)"; preset="$(state_preset)"
    [ "$cur" = "anthropic" ] && preset=''
    show_banner "$cur" "$preset"

    while true; do
        cur="$(state_mode)"; preset="$(state_preset)"
        local desc="$cur"
        [ "$cur" != "anthropic" ] && [ -n "$preset" ] && desc="$cur / $preset"

        local choices=() m
        for m in "${MODES[@]}"; do [ "$m" != "$cur" ] && choices+=("$m"); done

        ui_reset_items
        for m in "${choices[@]}"; do
            ui_add_item "switch to  $m" "$(mode_label "$m")" "$(mode_color "$m")"
        done
        ui_add_item 'more ...' 'status, presets, doctor'

        ui_select 'claude-mode' "currently: $desc" || return

        if [ "$UI_SEL" -eq "${#choices[@]}" ]; then
            ui_more_menu; [ $? -eq 2 ] && return
            continue
        fi

        local mode="${choices[$UI_SEL]}"
        if [ "$mode" = "anthropic" ]; then set_mode anthropic; return; fi
        ui_pick_preset "$mode" || continue
        [ -n "$UI_PICKED" ] || continue
        set_mode "$mode" "$UI_PICKED"
        return
    done
}

# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------

command -v "$PY" >/dev/null 2>&1 || { echo "claude-mode: $PY not found (set CLAUDE_MODE_PYTHON)" >&2; exit 1; }
init_root

# --force is global and may appear anywhere; strip it before the mode arguments
# are positional-matched below.
_args=()
for _a in "$@"; do
    case "$_a" in
        --force) CM_FORCE=1 ;;
        *)       _args+=("$_a") ;;
    esac
done
set -- ${_args[@]+"${_args[@]}"}

cmd="${1:-}"; [ $# -gt 0 ] && shift
case "$cmd" in
    ''|menu)     ui_menu ;;
    status)      cmd_status ;;
    anthropic)   set_mode anthropic ;;
    openrouter|zai|lmstudio)
                 p="$(resolve_preset "$cmd" "${1:-}")" || exit 1
                 set_mode "$cmd" "$p" ;;
    z.ai|z-ai)   p="$(resolve_preset zai "${1:-}")" || exit 1; set_mode zai "$p" ;;
    presets)     cmd_presets ;;
    preset)      cmd_preset "$@" ;;
    set-key)
                 # --terminal is for callers with no stdin to offer: the bar
                 # widget cannot host a hidden password prompt, so it asks the
                 # terminal to host one instead.
                 _ref=openrouter; _term=0
                 for _a in "$@"; do
                     case "$_a" in
                         --terminal) _term=1 ;;
                         *) _ref="$_a" ;;
                     esac
                 done
                 if [ "$_term" -eq 1 ]; then
                     _t="$(cm_terminal_cmd)"
                     setsid nohup "$_t" -e bash -lc \
                        "'$0' set-key '$_ref'; printf '\n  press enter to close '; read -r _" \
                        >/dev/null 2>&1 &
                     ok "opened $_t to store the '$_ref' key"
                 else
                     cmd_set_key "$_ref"
                 fi ;;
    models)      cmd_models "${1:-}" ;;
    doctor)      cmd_doctor ;;
    health)      write_health "$(state_mode)" "$(state_preset)" ;;
    preflight)   cmd_preflight "${1:-}" "${2:-}" ;;
    sessions)    cmd_sessions "$@" ;;
    repair)
                 scope=''
                 for a in "$@"; do [ "$a" = "--all" ] && scope=all; done
                 cmd_repair "$scope" ;;
    help|--help|-h) usage ;;
    *)           err "unknown command '$cmd'"; usage; exit 1 ;;
esac
