Split the POSIX CLI into modules
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>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
# 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
|
||||
}
|
||||
Reference in New Issue
Block a user