# shellcheck shell=bash # linux/lib/switch.sh - the write into settings.json, and what is checked and reported around it. # # Part of the claude-mode CLI: sourced by linux/claude-mode, which sets the # CM_* paths, PY and JSON used here. Not meant to run on its own. # --------------------------------------------------------------------------- # 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" # Standing in a terminal with the fix one keystroke away, printing # the command to type next is a poor substitute for running it. if ui_interactive && [ "$CM_PF_KIND" = "setup" ]; then printf '\n' if ask_yes "set up $mode now?"; then cmd_setup "$mode" "$preset_name" || return 1 cm_preflight "$mode" "$preset_name" || { err "$CM_PF_TITLE"; return 1 } else return 1 fi else [ -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 fi [ -f "$preset_file" ] || { err "preset '$preset_name' not found"; return 1; } fi cm_confirm_sessions "$mode" || return 1 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_apply_session_action 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 # An OpenRouter feature, flagged per provider rather than by name. [ "$(prov_field "$mode" 15)" = 1 ] || 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 }