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>
217 lines
10 KiB
Bash
217 lines
10 KiB
Bash
# shellcheck shell=bash
|
|
# linux/lib/preflight.sh - what has to be true before a switch may write.
|
|
#
|
|
# 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.
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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:-}" paths="${3:-/api/v0/models,/v1/models}" t code ep
|
|
command -v curl >/dev/null 2>&1 || { printf 'skip'; return 0; }
|
|
t="$(cm_probe_timeout "$base")"
|
|
|
|
for ep in $(printf '%s' "$paths" | tr ',' ' '); 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
|
|
|
|
if ! preset_configured "$pf"; then
|
|
cm_pf_set 'needs-setup' "$(mode_label "$mode" | cut -d- -f1 | sed 's/ *$//') has not been set up yet" \
|
|
"The shipped preset is a starting point: it has no key stored, and its model ids are whatever was on the machine this was packaged on. Setup asks for what it needs and picks models from the provider's own catalogue." \
|
|
"claude-mode setup $mode" 'setup'
|
|
return 1
|
|
fi
|
|
|
|
# A preset with no tier mapped - a fresh `preset new --blank` - would switch
|
|
# cleanly and leave Claude Code asking the gateway for its own default
|
|
# Anthropic models: billed at full list price on OpenRouter, refused by the
|
|
# others. Nothing else here would catch it, since the cost guard only sees
|
|
# ids that are actually set.
|
|
if ! "$PY" "$JSON" models "$pf" 2>/dev/null | grep -v '^subagent' | cut -f2 | grep -q .; then
|
|
cm_pf_set 'no-models' "Preset '$preset' has no models set" \
|
|
"Every tier is empty, so Claude Code would ask for its own default Anthropic models instead - billed at full price through OpenRouter, refused by the other providers." \
|
|
"claude-mode preset set $preset <tier> <model-id>" 'edit-preset'
|
|
return 1
|
|
fi
|
|
|
|
base="$(jget "$pf" baseUrl)"; CM_PF_BASEURL="$base"
|
|
auth_mode="$(jget "$pf" auth.mode)"; [ -z "$auth_mode" ] && auth_mode=vault
|
|
|
|
# A custom endpoint ships with no address, since there is no sensible one
|
|
# to guess; switching to it would point every session at nothing.
|
|
if [ -z "$base" ]; then
|
|
cm_pf_set 'no-url' "Preset '$preset' has no server address" \
|
|
"$(prov_field "$mode" 3) needs to know where the server is before anything can be sent to it." \
|
|
"claude-mode preset url $preset <base-url>" 'set-url'
|
|
return 1
|
|
fi
|
|
|
|
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
|
|
|
|
# A server provider is checked wherever it is (probe "always", or
|
|
# "lenient" for a proxy that may list no models); a public gateway only
|
|
# when it has been pointed at this machine (probe "local"). A public
|
|
# gateway that is briefly unreachable is the network's problem and not
|
|
# worth blocking a config change over.
|
|
local probe_rule title
|
|
probe_rule="$(prov_field "$mode" 8)"; title="$(prov_field "$mode" 3)"
|
|
if [ "$probe_rule" = always ] || [ "$probe_rule" = lenient ] || 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" "$(prov_field "$mode" 9)")"
|
|
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 $title'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)
|
|
# A proxy in front of a Messages API often serves no model
|
|
# list at all; for one of those, something answering is
|
|
# as much as can be checked.
|
|
[ "$probe_rule" = lenient ] && return 0
|
|
cm_pf_set 'server-wrong' "That address answered, but not as $title" \
|
|
"Something is listening at $base, but $(prov_field "$mode" 9 | sed 's/,/ and /g') is not 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
|
|
if [ "$mode" != anthropic ]; then
|
|
mode="$(provider_resolve "$mode")" || { err "unknown mode '${1:-}'"; return 1; }
|
|
p="$(resolve_preset "$mode" "$preset" 2>/dev/null)" || p="$preset"
|
|
preset="$p"
|
|
fi
|
|
|
|
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
|
|
}
|