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>
219 lines
11 KiB
Bash
219 lines
11 KiB
Bash
# shellcheck shell=bash
|
|
# linux/lib/doctor.sh - doctor, and its checks for each provider.
|
|
#
|
|
# 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.
|
|
|
|
# The preset's model ids against what the provider actually offers. A server
|
|
# provider that does not answer is a failure; a hosted catalogue that cannot be
|
|
# fetched is only a warning, since the endpoint may be fine regardless.
|
|
doctor_catalogue() {
|
|
local mode="$1" pf="$2" base="$3" kind title cat t id row declared c2 c3
|
|
kind="$(prov_field "$mode" 10)"; title="$(prov_field "$mode" 3)"
|
|
declared="$(jget "$pf" contextTokens)"
|
|
cat="$(provider_catalogue "$mode" "$base" "$(cm_preset_token "$pf")")"
|
|
if [ -z "$cat" ]; then
|
|
if [ "$(prov_field "$mode" 7)" = 1 ]; then
|
|
local start; start="$(prov_field "$mode" 21)"
|
|
if [ "$(prov_field "$mode" 8)" = lenient ]; then
|
|
warn "$title at $base lists no models - fine for a proxy, but the ids below cannot be checked"
|
|
else
|
|
err "$title not reachable at $base${start:+ - $start}"
|
|
fi
|
|
else
|
|
warn "could not fetch the $title model list"
|
|
fi
|
|
return 0
|
|
fi
|
|
[ "$(prov_field "$mode" 7)" = 1 ] && ok "$title reachable at $base ($(printf '%s\n' "$cat" | grep -c .) models)"
|
|
|
|
for t in "${TIERS[@]}"; do
|
|
id="$(jget "$pf" "models.$t")"; [ -n "$id" ] || continue
|
|
row="$(printf '%s\n' "$cat" | tsv_find "$id")" || row=''
|
|
# Ollama lists every model with its tag, and a bare name means
|
|
# `:latest` - `qwen3-coder` is served as `qwen3-coder:latest`.
|
|
if [ -z "$row" ] && [ "$kind" = ollama ]; then
|
|
case "$id" in *:*) ;; *) row="$(printf '%s\n' "$cat" | tsv_find "$id:latest")" || row='' ;; esac
|
|
fi
|
|
if [ -z "$row" ]; then
|
|
# A fixed list is documentation, not the provider's word: an id
|
|
# missing from it may still be served.
|
|
if [ "$kind" = static ]; then ok "$(printf '%-6s %s (not in the documented list)' "$t" "$id")"
|
|
else err "$t model NOT available from $title: $id"; fi
|
|
continue
|
|
fi
|
|
c2="$(tsv_field "$row" 2)"; c3="$(tsv_field "$row" 3)"
|
|
case "$kind" in
|
|
openrouter) ok "$(printf '%-6s %s [ctx %s]' "$t" "$id" "$c2")"; doctor_ctx_vs "$t" "$c2" "$declared" ;;
|
|
lmstudio) ok "$(printf '%-6s %s [%s, ctx %s]' "$t" "$id" "$c2" "$c3")"; doctor_ctx_vs "$t" "$c3" "$declared" ;;
|
|
ollama) ok "$(printf '%-6s %s [%s %s]' "$t" "$id" "$c2" "$c3")" ;;
|
|
*) ok "$(printf '%-6s %s' "$t" "$id")" ;;
|
|
esac
|
|
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'
|
|
}
|
|
|
|
doctor_ctx_vs() {
|
|
local t="$1" ctx="$2" declared="$3"
|
|
{ [ -n "$declared" ] && [ -n "$ctx" ] && [ "$ctx" -lt "$declared" ]; } 2>/dev/null || return 0
|
|
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
|
|
}
|
|
|
|
# Ollama sets the context window on the server, not per request from Claude
|
|
# Code: 4096 tokens unless `ollama serve` runs with OLLAMA_CONTEXT_LENGTH, and
|
|
# anything past it is cut off without an error. contextTokens in a preset only
|
|
# tells Claude Code what to expect - it cannot change what the server does - so
|
|
# say what the server is actually running where that can be seen, and what to
|
|
# set where it cannot.
|
|
doctor_ollama_context() {
|
|
local pf="$1" base="$2" declared ids id max live ps seen=0 short=0 t body
|
|
declared="$(jget "$pf" contextTokens)"; [ -n "$declared" ] || return 0
|
|
ids="$(for t in "${TIERS[@]}"; do jget "$pf" "models.$t"; done | sort -u | grep . || true)"
|
|
[ -n "$ids" ] || return 0
|
|
ps="$(curl -fsS --max-time 5 "$base/api/ps" 2>/dev/null)"
|
|
while IFS= read -r id; do
|
|
[ -n "$id" ] || continue
|
|
body="$("$PY" -c 'import json,sys; print(json.dumps({"model": sys.argv[1]}))' "$id")"
|
|
max="$(curl -fsS --max-time 8 -X POST -H 'content-type: application/json' -d "$body" \
|
|
"$base/api/show" 2>/dev/null | "$PY" "$JSON" ollama-ctx show 2>/dev/null)"
|
|
if [ -n "$max" ] && [ "$max" -lt "$declared" ] 2>/dev/null; then
|
|
warn "$id supports at most $max tokens, below the declared $declared - lower contextTokens"
|
|
fi
|
|
live="$(printf '%s' "$ps" | "$PY" "$JSON" ollama-ctx ps "$id" 2>/dev/null)"
|
|
[ -n "$live" ] || continue
|
|
seen=1
|
|
if [ "$live" -lt "$declared" ] 2>/dev/null; then
|
|
short=1
|
|
warn "$id is loaded with a $live-token context, below the declared $declared - requests past it are cut off"
|
|
else
|
|
ok "$id is loaded with a $live-token context"
|
|
fi
|
|
done <<EOF_IDS
|
|
$ids
|
|
EOF_IDS
|
|
if [ "$seen" -eq 0 ]; then
|
|
warn "none of these models is loaded, so the server's context window cannot be checked"
|
|
say "Ollama defaults to 4096 tokens; run it with OLLAMA_CONTEXT_LENGTH=$declared or requests past that are cut off silently"
|
|
elif [ "$short" -eq 1 ]; then
|
|
say "restart it with OLLAMA_CONTEXT_LENGTH=$declared (or lower contextTokens to match)"
|
|
fi
|
|
}
|
|
|
|
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
|
|
# Check the string settings.json actually holds, then run that
|
|
# string through a shell. A path this script can quote correctly is
|
|
# no evidence that the recorded one parses.
|
|
local stored expected reswitch
|
|
stored="$(jget "$CM_SETTINGS" apiKeyHelper)"
|
|
expected="$("$PY" -c 'import shlex,sys; sys.stdout.write(shlex.quote(sys.argv[1]))' "$CM_HELPER")"
|
|
reswitch="claude-mode $mode $(jget "$CM_STATE" preset)"
|
|
if [ -z "$stored" ]; then
|
|
err "settings.json has no apiKeyHelper. Run: $reswitch"
|
|
elif [ "$stored" != "$expected" ]; then
|
|
err "apiKeyHelper reads $stored"
|
|
err " but should read $expected - run: $reswitch"
|
|
else
|
|
ok "apiKeyHelper wired as $stored"
|
|
fi
|
|
|
|
if [ -n "$stored" ]; then
|
|
out="$(sh -c "$stored" 2>&1)"
|
|
if [ -n "$out" ] && [ "$out" = "$key" ]; then ok 'apiKeyHelper emits the correct key'
|
|
elif printf '%s' "$out" | grep -q '[[:space:]]'; then
|
|
# Whitespace means a diagnostic, not a credential; a key is
|
|
# one unbroken token and must never be echoed.
|
|
err "apiKeyHelper failed: $out"
|
|
elif [ -n "$out" ]; then err "apiKeyHelper output does not match the vault (got: $(cm_vault_mask "$out"))"
|
|
else err 'apiKeyHelper produced no output'; fi
|
|
fi
|
|
|
|
if [ -n "$key" ] && doctor_has "$mode" openrouter-key; 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
|
|
# No key-info endpoint: the cheapest real check is a 1-token
|
|
# message against the Anthropic-compatible surface itself.
|
|
if [ -n "$key" ] && doctor_has "$mode" message-check; 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 "$(prov_field "$mode" 3) endpoint accepted the key ($base/v1/messages)"
|
|
else
|
|
err "$(prov_field "$mode" 3) request failed"
|
|
fi
|
|
fi
|
|
else
|
|
ok "inline token '$(jget "$pf" auth.token)' (no secret in settings.json)"
|
|
fi
|
|
|
|
doctor_has "$mode" catalogue-models && doctor_catalogue "$mode" "$pf" "$base"
|
|
doctor_has "$mode" ollama-context && doctor_ollama_context "$pf" "$base"
|
|
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
|
|
}
|