linux/claude-mode was 3,035 lines. It is now 177: the paths and flags, a loader, and the command dispatch. Everything else moved, verbatim, into twelve files under linux/lib/, one per concern - output, core, preflight, sessions, switch, catalogue, commands, doctor, presets, menu, setup, repair. The move was done by line range with a check that every original line landed in exactly one file; the only thing that changed place is the switch's running-sessions question, which now sits with session detection. The CLI finds lib/, cm-json.py and cm-vault.sh beside itself, following the ~/.local/bin symlink, so a checkout runs its own code rather than the installed version's (it used to mix the two). The key helper path written into settings.json is still the installed one. linux/install.sh ships bin/lib/, clearing old modules first so a removed one cannot linger. The package build copies linux/ recursively, which a flat copy would not. tests/cli/test_install.sh runs the real installer into a sandbox home: every file lands, the symlink runs, a reinstall keeps edited presets and drops a stale module. docs/architecture.md lists the modules. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
284 lines
12 KiB
Bash
284 lines
12 KiB
Bash
# 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
|
|
}
|