Linux port: theme-aware TUI, switch preflight, session control
Four changes to the POSIX build, found while getting it working on Omarchy. Colour follows the desktop theme. The sixteen ANSI slots carry no guarantee about relative brightness and monochrome themes exploit that: under Omarchy's Solitude, slot 36 (headings) resolves to #707070 and slot 31 (FAIL) to #565d60, which against #cacccc body text on a #101315 ground is 3.8:1 and 2.8:1 where the body text is 11.6:1. Headings rendered as fine print and errors as the quietest thing on screen. The palette is now derived from the theme's own colors.toml, with each role measured against the background it will actually be drawn on and lifted toward the foreground when it falls short - hue kept where the theme has any, weight substituted where it does not. Headings go 3.8:1 -> 9.4:1 and FAIL 2.8:1 -> 5.2:1. Falls back to the ANSI slots off Omarchy, with the two roles the slots get wrong corrected. health.json was only ever written by a switch, so a fresh install had none at all and any reader had to guess. It is now refreshed by `status` and seeded at install, and `claude-mode health` forces it. The installer also copies VERSION, which cm_version() has always read and nothing ever wrote - every health.json until now reported 0.0.0. Preflight, because a switch that cannot work does not fail loudly: it succeeds, and every session started afterwards breaks in a way that points at Claude Code rather than at here. Keys, the helper, the preset's provider and the server are all checked before the write. LM Studio is the sharp case - its token is an inline placeholder, so nothing about the switch needs the server to exist. Session control, because Claude Code reads settings.json once at startup: a switch leaves running sessions on the old provider until they are restarted, and one mid-request can lose that turn outright. `sessions` lists them, `--stop` and `--restart` act on them behind a confirmation, `--dry-run` shows the plan. Sessions are found through /proc/<pid>/exe rather than by process name, which would sweep up every shell that merely mentions claude - including the one this runs from. Two exclusions: the calling session, and forks of a session. A busy session spawns children off its own binary that inherit the same exe, and without filtering those the count climbed and fell with load - it read 2, 5, 11 and 40 for the same two sessions before the parent check went in. LM Studio is no longer assumed to be on this machine. `preset url` and `preset auth` move it to a LAN box, a tunnel or a proxy and turn authentication on, and the probe distinguishes ok / auth / notfound / refused, because "start the server" and "your key is wrong" are opposite remedies. It is probed wherever it lives - a sleeping LAN box is exactly as absent as an empty loopback port - while remote gateways are not, since those being briefly unreachable is the network's problem and a missing key never fixes itself.
This commit is contained in:
+716
-22
@@ -26,20 +26,184 @@ JSON="$CM_BIN/cm-json.py"
|
||||
# shellcheck source=/dev/null
|
||||
. "$CM_BIN/cm-vault.sh"
|
||||
|
||||
CM_FORCE=0
|
||||
|
||||
MODES=(anthropic openrouter zai lmstudio)
|
||||
TIERS=(opus sonnet haiku fable)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Colour / output
|
||||
#
|
||||
# The palette follows the desktop theme when there is one to follow. Omarchy
|
||||
# publishes its active theme as a flat colors.toml, so on those systems the
|
||||
# menu paints in the same colours as the bar and the terminal instead of in
|
||||
# whatever the sixteen ANSI slots happen to mean today.
|
||||
#
|
||||
# That indirection is not decoration. The ANSI slots carry no guarantee about
|
||||
# relative brightness, and monochrome themes exploit it. Under Omarchy's
|
||||
# Solitude, slot 36 (headings) resolves to #707070 and slot 31 (FAIL) to
|
||||
# #565d60; against #cacccc body text on a #101315 ground those are 3.8:1 and
|
||||
# 2.8:1 where the body text is 11.6:1 - so headings render as fine print and
|
||||
# an error message becomes the quietest thing on screen. Exactly backwards.
|
||||
#
|
||||
# Deriving from the theme instead lets every role be measured against the
|
||||
# background it will actually be drawn on, and lifted toward the foreground
|
||||
# when it comes up short. Hue is preserved where the theme has any; where it
|
||||
# does not, roles resolve to weight rather than to invisible colour.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
||||
C_RESET=$'\033[0m'; C_DIM=$'\033[90m'; C_CYAN=$'\033[36m'; C_GREEN=$'\033[32m'
|
||||
C_YELLOW=$'\033[33m'; C_RED=$'\033[31m'; C_MAGENTA=$'\033[35m'; C_WHITE=$'\033[97m'
|
||||
C_GRAY=$'\033[37m'; C_DKCYAN=$'\033[36;2m'
|
||||
else
|
||||
CM_THEME_FILE="${CLAUDE_MODE_THEME:-$HOME/.local/state/omarchy/current/theme/colors.toml}"
|
||||
|
||||
# Flat `key = "#rrggbb"` lookup. Quotes are optional so this also reads the
|
||||
# handful of themes that ship the file unquoted.
|
||||
cm_theme_get() {
|
||||
[ -f "$CM_THEME_FILE" ] || return 1
|
||||
sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*\"\{0,1\}\([^\"#]*#\{0,1\}[0-9A-Fa-f]*\)\"\{0,1\}[[:space:]]*\$/\1/p" \
|
||||
"$CM_THEME_FILE" 2>/dev/null | head -n1
|
||||
}
|
||||
|
||||
cm_hex_ok() { case "$1" in \#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]) return 0 ;; *) return 1 ;; esac; }
|
||||
|
||||
# Relative brightness, 0-255. Deliberately the linear Rec.709 weighting without
|
||||
# the sRGB gamma step: bash has no floats, and this only ever has to answer
|
||||
# "which of these is further from the background", which it does correctly.
|
||||
cm_lum() {
|
||||
local h="${1#\#}" r g b
|
||||
r=$((16#${h:0:2})); g=$((16#${h:2:2})); b=$((16#${h:4:2}))
|
||||
printf '%d' $(( (2126 * r + 7152 * g + 722 * b) / 10000 ))
|
||||
}
|
||||
|
||||
# Distance from the theme background, which is what legibility actually is.
|
||||
cm_dist() { local a b; a="$(cm_lum "$1")"; b="$(cm_lum "$CM_BG")"; printf '%d' $(( a > b ? a - b : b - a )); }
|
||||
|
||||
cm_mix() {
|
||||
local x="${1#\#}" y="${2#\#}" p="$3" r g b
|
||||
r=$(( (16#${x:0:2} * (100 - p) + 16#${y:0:2} * p) / 100 ))
|
||||
g=$(( (16#${x:2:2} * (100 - p) + 16#${y:2:2} * p) / 100 ))
|
||||
b=$(( (16#${x:4:2} * (100 - p) + 16#${y:4:2} * p) / 100 ))
|
||||
printf '#%02x%02x%02x' "$r" "$g" "$b"
|
||||
}
|
||||
|
||||
# Blend a colour toward the foreground until it clears `pct` of the
|
||||
# foreground's own separation from the background. Hue survives the lift, so a
|
||||
# themed accent stays recognisably itself; a grey one just ends up brighter.
|
||||
cm_lift() {
|
||||
local c="$1" pct="$2" need step out
|
||||
need=$(( $(cm_dist "$CM_FG") * pct / 100 ))
|
||||
out="$c"
|
||||
for step in 0 15 30 45 60 75 90 100; do
|
||||
out="$(cm_mix "$c" "$CM_FG" "$step")"
|
||||
[ "$(cm_dist "$out")" -ge "$need" ] && break
|
||||
done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# Push a colour toward the background - for text that is meant to recede but
|
||||
# still be readable. Floored, so "dim" never becomes "gone".
|
||||
cm_sink() {
|
||||
local c="$1" pct="$2" need out step
|
||||
need=$(( $(cm_dist "$CM_FG") * pct / 100 ))
|
||||
out="$c"
|
||||
[ "$(cm_dist "$out")" -ge "$need" ] && { printf '%s' "$out"; return; }
|
||||
for step in 85 70 55 40 25 10 0; do
|
||||
out="$(cm_mix "$CM_BG" "$CM_FG" $((100 - step)))"
|
||||
[ "$(cm_dist "$out")" -ge "$need" ] && break
|
||||
done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# Saturation as a 0-100 proxy. Monochrome themes define `red` as a desaturated
|
||||
# slate, which carries none of the meaning the role needs.
|
||||
cm_sat() {
|
||||
local h="${1#\#}" r g b mx mn
|
||||
r=$((16#${h:0:2})); g=$((16#${h:2:2})); b=$((16#${h:4:2}))
|
||||
mx=$r; [ "$g" -gt "$mx" ] && mx=$g; [ "$b" -gt "$mx" ] && mx=$b
|
||||
mn=$r; [ "$g" -lt "$mn" ] && mn=$g; [ "$b" -lt "$mn" ] && mn=$b
|
||||
[ "$mx" -eq 0 ] && { printf '0'; return; }
|
||||
printf '%d' $(( (mx - mn) * 100 / mx ))
|
||||
}
|
||||
|
||||
cm_sgr() { local h="${1#\#}"; printf '\033[38;2;%d;%d;%dm' $((16#${h:0:2})) $((16#${h:2:2})) $((16#${h:4:2})); }
|
||||
|
||||
# Pick a themed colour for a role, falling back through the theme's own keys
|
||||
# and finally to the foreground. `minPct` is the share of the foreground's
|
||||
# contrast the role has to clear before it is allowed on screen.
|
||||
cm_role() {
|
||||
local minPct="$1" want c
|
||||
shift
|
||||
for want in "$@"; do
|
||||
c="$(cm_theme_get "$want")"
|
||||
cm_hex_ok "$c" || continue
|
||||
cm_sgr "$(cm_lift "$c" "$minPct")"
|
||||
return 0
|
||||
done
|
||||
cm_sgr "$CM_FG"
|
||||
}
|
||||
|
||||
cm_truecolor() { case "${COLORTERM:-}" in truecolor|24bit) return 0 ;; *) return 1 ;; esac; }
|
||||
|
||||
cm_theme_palette() {
|
||||
local bg fg red
|
||||
|
||||
cm_truecolor || return 1
|
||||
bg="$(cm_theme_get background)"; fg="$(cm_theme_get foreground)"
|
||||
cm_hex_ok "$bg" && cm_hex_ok "$fg" || return 1
|
||||
|
||||
CM_BG="$bg"; CM_FG="$fg"
|
||||
CM_THEME_MODE="$(cm_theme_get mode)"
|
||||
|
||||
C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'
|
||||
|
||||
# Body text sets the bar every other role is measured against.
|
||||
C_WHITE="$(cm_sgr "$CM_FG")"
|
||||
C_GRAY="$(cm_sgr "$(cm_mix "$CM_FG" "$CM_BG" 20)")"
|
||||
|
||||
# Headings and the selection accent. Lifted to 85% of body contrast: a
|
||||
# heading that is dimmer than the text beneath it is not a heading.
|
||||
C_CYAN="$(cm_role 85 accent blue cyan)"
|
||||
# Banner shading - subordinate to the heading but still structural.
|
||||
C_DKCYAN="$(cm_sgr "$(cm_sink "$(cm_mix "$(cm_theme_get accent)" "$CM_BG" 35)" 42)")"
|
||||
|
||||
# Deliberately recessive: help lines, hints, detail rows. The floor keeps
|
||||
# it off the background rather than merged into it.
|
||||
C_DIM="$(cm_sgr "$(cm_sink "$(cm_theme_get muted)" 30)")"
|
||||
|
||||
C_GREEN="$(cm_role 55 green bright_green)"
|
||||
C_YELLOW="$(cm_role 65 yellow bright_yellow)"
|
||||
C_MAGENTA="$(cm_role 70 magenta bright_magenta)"
|
||||
|
||||
# FAIL has to out-shout everything else, so it takes the theme's saturated
|
||||
# red where one exists and a lifted fallback where it does not. Solitude's
|
||||
# `red` is #565d60 - a slate with no hue left in it - which is why the
|
||||
# saturated `bright_red` is preferred over the nominal one here.
|
||||
red="$(cm_theme_get bright_red)"
|
||||
if ! cm_hex_ok "$red" || [ "$(cm_sat "$red")" -lt 25 ]; then
|
||||
red="$(cm_theme_get red)"
|
||||
fi
|
||||
if cm_hex_ok "$red" && [ "$(cm_sat "$red")" -ge 25 ]; then
|
||||
C_RED="$(cm_sgr "$(cm_lift "$red" 50)")"
|
||||
else
|
||||
# No usable red anywhere in the theme. Weight carries the role instead,
|
||||
# over a hue that at least leans warm.
|
||||
C_RED="$C_BOLD$(cm_sgr "$(cm_lift '#d2685f' 60)")"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
if [ ! -t 1 ] || [ -n "${NO_COLOR:-}" ]; then
|
||||
C_RESET=''; C_DIM=''; C_CYAN=''; C_GREEN=''; C_YELLOW=''; C_RED=''
|
||||
C_MAGENTA=''; C_WHITE=''; C_GRAY=''; C_DKCYAN=''
|
||||
C_MAGENTA=''; C_WHITE=''; C_GRAY=''; C_DKCYAN=''; C_BOLD=''
|
||||
CM_THEME_MODE=''
|
||||
elif ! cm_theme_palette; then
|
||||
# No theme to read, or a terminal that cannot render one. Same sixteen
|
||||
# slots as before with the two roles the slots get wrong corrected:
|
||||
# bright red for FAIL, because slot 31 is a muted maroon under a good many
|
||||
# palettes, and bold on headings, which no palette can take away.
|
||||
C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'
|
||||
C_DIM=$'\033[90m'; C_CYAN=$'\033[36m'; C_GREEN=$'\033[32m'
|
||||
C_YELLOW=$'\033[33m'; C_RED=$'\033[91m'; C_MAGENTA=$'\033[35m'; C_WHITE=$'\033[97m'
|
||||
C_GRAY=$'\033[37m'; C_DKCYAN=$'\033[36;2m'
|
||||
CM_THEME_MODE=''
|
||||
fi
|
||||
|
||||
say() { printf ' %s\n' "$*"; }
|
||||
@@ -47,8 +211,8 @@ ok() { printf ' %sok %s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
|
||||
# warn/err go to stderr: several of these functions run inside $( ), where
|
||||
# anything on stdout is captured as the return value instead of being shown.
|
||||
warn() { printf ' %swarn%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; }
|
||||
err() { printf ' %sFAIL%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; }
|
||||
head_() { printf '\n%s%s%s\n' "$C_CYAN" "$*" "$C_RESET"; }
|
||||
err() { printf ' %sFAIL%s %s\n' "$C_BOLD$C_RED" "$C_RESET" "$*" >&2; }
|
||||
head_() { printf '\n%s%s%s\n' "$C_BOLD$C_CYAN" "$*" "$C_RESET"; }
|
||||
|
||||
mode_color() {
|
||||
case "$1" in
|
||||
@@ -101,12 +265,19 @@ claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio
|
||||
claude-mode preset new <name> [from] create a preset (copies 'from')
|
||||
claude-mode preset set <name> <tier> <model-id>
|
||||
claude-mode preset all <name> <model-id>
|
||||
claude-mode preset url <name> <base-url> point a preset at another server
|
||||
claude-mode preset auth <name> none|key [ref] whether that server needs a key
|
||||
claude-mode preset rm <name>
|
||||
|
||||
claude-mode set-key [ref] store an API key (hidden prompt)
|
||||
claude-mode set-key [ref] [--terminal] store an API key (hidden prompt)
|
||||
claude-mode models [filter] models available from the active provider
|
||||
claude-mode doctor verify auth, endpoint, model ids, env
|
||||
claude-mode repair strip [1m] tags from cached model ids
|
||||
claude-mode health refresh health.json (machine-readable state)
|
||||
claude-mode preflight <mode> [preset] check a mode can actually serve, without switching
|
||||
claude-mode sessions [--stop|--restart]
|
||||
running sessions; close or reopen them
|
||||
claude-mode <mode> --force switch even if preflight says no
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -187,6 +358,468 @@ resolve_preset() {
|
||||
printf '%s' "$first"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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:-}" t code ep
|
||||
command -v curl >/dev/null 2>&1 || { printf 'skip'; return 0; }
|
||||
t="$(cm_probe_timeout "$base")"
|
||||
|
||||
for ep in /api/v0/models /v1/models; 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
|
||||
|
||||
base="$(jget "$pf" baseUrl)"; CM_PF_BASEURL="$base"
|
||||
auth_mode="$(jget "$pf" auth.mode)"; [ -z "$auth_mode" ] && auth_mode=vault
|
||||
|
||||
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
|
||||
|
||||
# LM Studio is checked wherever it is; other providers only when they are
|
||||
# pointed at this machine. A public gateway that is briefly unreachable is
|
||||
# the network's problem and not worth blocking a config change over.
|
||||
if [ -n "$base" ] && { [ "$mode" = "lmstudio" ] || 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")"
|
||||
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 LM Studio'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)
|
||||
cm_pf_set 'server-wrong' 'That address answered, but not as LM Studio' \
|
||||
"Something is listening at $base, but neither /api/v0/models nor /v1/models is 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
|
||||
case "$mode" in
|
||||
anthropic) ;;
|
||||
openrouter|zai|lmstudio)
|
||||
p="$(resolve_preset "$mode" "$preset" 2>/dev/null)" || p="$preset"
|
||||
preset="$p" ;;
|
||||
z.ai|z-ai) mode=zai; p="$(resolve_preset zai "$preset" 2>/dev/null)" || p="$preset"; preset="$p" ;;
|
||||
*) err "unknown mode '$mode'"; return 1 ;;
|
||||
esac
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Running sessions
|
||||
#
|
||||
# Claude Code reads settings.json once, at startup. A switch therefore does
|
||||
# nothing to a session already running - it keeps talking to the old provider on
|
||||
# the old key until it is restarted, which is the confusing part: the bar says
|
||||
# one thing and the session in front of you is doing another.
|
||||
#
|
||||
# Worse for a session mid-request. The key it is using can be pulled out from
|
||||
# under it (anthropic mode deletes the helper outright), so an in-flight turn
|
||||
# can fail on the next tool call rather than at a clean boundary.
|
||||
#
|
||||
# Sessions are found through /proc/<pid>/exe rather than by matching process
|
||||
# names. `claude` is a real ELF binary here, so the symlink resolves to it
|
||||
# exactly, and a name match would sweep up every shell that merely mentions
|
||||
# claude in its command line - including the ones this tool is invoked from.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
cm_is_claude_pid() {
|
||||
local exe
|
||||
exe="$(readlink "/proc/$1/exe" 2>/dev/null)" || return 1
|
||||
case "$exe" in
|
||||
*/claude|*/claude-code) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# /proc/<pid>/stat has the command name in parentheses, and it may contain
|
||||
# spaces - so fields are only safe to count after the last ')'. Everything below
|
||||
# indexes into that remainder, where field 1 is the process state.
|
||||
cm_stat_rest() {
|
||||
local s
|
||||
s="$(cat "/proc/$1/stat" 2>/dev/null)" || return 1
|
||||
printf '%s' "${s##*) }"
|
||||
}
|
||||
|
||||
cm_ppid_of() {
|
||||
local r; r="$(cm_stat_rest "$1")" || return 1
|
||||
printf '%s' "$r" | cut -d' ' -f2
|
||||
}
|
||||
|
||||
# utime + stime, in jiffies. Sampled twice to tell a session that is thinking
|
||||
# from one that is sitting at a prompt.
|
||||
cm_cputime_of() {
|
||||
local r u s; r="$(cm_stat_rest "$1")" || return 1
|
||||
u="$(printf '%s' "$r" | cut -d' ' -f12)"
|
||||
s="$(printf '%s' "$r" | cut -d' ' -f13)"
|
||||
case "$u$s" in ''|*[!0-9]*) printf '0'; return 0 ;; esac
|
||||
printf '%s' $((u + s))
|
||||
}
|
||||
|
||||
# The session this very command is running inside, if any. It is listed like the
|
||||
# others but never acted on by default: killing the session that asked for the
|
||||
# kill is not a thing anyone means.
|
||||
cm_self_session() {
|
||||
local p="${PPID:-0}" guard=0
|
||||
while [ "$p" -gt 1 ] && [ "$guard" -lt 40 ]; do
|
||||
if cm_is_claude_pid "$p"; then printf '%s' "$p"; return 0; fi
|
||||
p="$(cm_ppid_of "$p")" || return 1
|
||||
case "$p" in ''|*[!0-9]*) return 1 ;; esac
|
||||
guard=$((guard + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# TSV: pid \t ppid \t tty \t busy \t cwd \t self \t parent-cmd
|
||||
cm_session_rows() {
|
||||
local self pid ppid tty cwd busy isself d
|
||||
self="$(cm_self_session 2>/dev/null || true)"
|
||||
|
||||
local pids=() before=() after=() pp
|
||||
for d in /proc/[0-9]*; do
|
||||
pid="${d#/proc/}"
|
||||
cm_is_claude_pid "$pid" || continue
|
||||
# A session's parent is a terminal or a shell. A busy session also forks
|
||||
# children off its own binary while it works, and those inherit the same
|
||||
# /proc/<pid>/exe - so without this the count climbs and falls with how
|
||||
# hard the machine is thinking, and the list fills with pids that are
|
||||
# gone a second later. Anything whose parent is itself claude is one of
|
||||
# those, not a session.
|
||||
pp="$(cm_ppid_of "$pid")" || continue
|
||||
cm_is_claude_pid "$pp" && continue
|
||||
pids+=("$pid")
|
||||
before+=("$(cm_cputime_of "$pid")")
|
||||
done
|
||||
[ "${#pids[@]}" -gt 0 ] || return 0
|
||||
|
||||
# A single shared sample window rather than one per process, so the whole
|
||||
# listing costs 300ms no matter how many sessions are open.
|
||||
sleep 0.3
|
||||
local i
|
||||
for i in "${!pids[@]}"; do after+=("$(cm_cputime_of "${pids[$i]}")"); done
|
||||
|
||||
for i in "${!pids[@]}"; do
|
||||
pid="${pids[$i]}"
|
||||
[ -d "/proc/$pid" ] || continue
|
||||
ppid="$(cm_ppid_of "$pid")"
|
||||
cwd="$(readlink "/proc/$pid/cwd" 2>/dev/null)"; [ -n "$cwd" ] || cwd='?'
|
||||
tty="$(ps -o tty= -p "$pid" 2>/dev/null | tr -d ' ')"; [ -n "$tty" ] || tty='?'
|
||||
# 0.3s of wall clock is ~30 jiffies at the usual 100Hz; a few of them
|
||||
# spent is a session doing work rather than waiting on a keystroke.
|
||||
busy=no
|
||||
[ $(( ${after[$i]:-0} - ${before[$i]:-0} )) -ge 3 ] && busy=yes
|
||||
isself=no; [ "$pid" = "$self" ] && isself=yes
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"$pid" "$ppid" "$tty" "$busy" "$cwd" "$isself" \
|
||||
"$(tr '\0' ' ' < "/proc/$ppid/cmdline" 2>/dev/null | sed 's/[[:space:]]*$//')"
|
||||
done
|
||||
}
|
||||
|
||||
cm_session_count() { cm_session_rows | grep -c . || true; }
|
||||
|
||||
cmd_sessions() {
|
||||
local action=list dry=0 assume=0 json=0 a
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--json) json=1 ;;
|
||||
--stop) action=stop ;;
|
||||
--restart) action=restart ;;
|
||||
--dry-run) dry=1 ;;
|
||||
-y|--yes) assume=1 ;;
|
||||
list|'') ;;
|
||||
*) err "unknown option '$a'"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
local rows n busy
|
||||
rows="$(cm_session_rows)"
|
||||
n="$(printf '%s' "$rows" | grep -c . || true)"
|
||||
|
||||
if [ "$json" -eq 1 ]; then
|
||||
printf '%s\n' "$rows" | "$PY" "$JSON" sessions-json
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "${n:-0}" -eq 0 ]; then
|
||||
head_ 'running sessions'
|
||||
ok 'no Claude Code sessions running'
|
||||
return 0
|
||||
fi
|
||||
|
||||
head_ "running sessions ($n)"
|
||||
local pid ppid tty b cwd isself pcmd tag
|
||||
while IFS=$'\t' read -r pid ppid tty b cwd isself pcmd; do
|
||||
[ -n "$pid" ] || continue
|
||||
tag=''
|
||||
[ "$b" = yes ] && tag=" ${C_YELLOW}working${C_RESET}"
|
||||
[ "$isself" = yes ] && tag="$tag ${C_DIM}(this session - never touched)${C_RESET}"
|
||||
printf ' %-8s %-8s %s%s\n' "$pid" "$tty" "$cwd" "$tag"
|
||||
done <<EOF_ROWS
|
||||
$rows
|
||||
EOF_ROWS
|
||||
|
||||
if [ "$action" = "list" ]; then
|
||||
printf '\n %sthese keep the provider they started with until restarted%s\n' "$C_DIM" "$C_RESET"
|
||||
printf ' %sclaude-mode sessions --stop close them%s\n' "$C_DIM" "$C_RESET"
|
||||
printf ' %sclaude-mode sessions --restart close and reopen each in its own directory%s\n' "$C_DIM" "$C_RESET"
|
||||
printf ' %s--dry-run shows what either would do, and does nothing%s\n' "$C_DIM" "$C_RESET"
|
||||
return 0
|
||||
fi
|
||||
|
||||
busy="$(printf '%s' "$rows" | cut -f4 | grep -c '^yes$' || true)"
|
||||
|
||||
if [ "$dry" -eq 1 ]; then
|
||||
printf '\n %sdry run - nothing will be signalled%s\n' "$C_DIM" "$C_RESET"
|
||||
cm_session_act "$action" "$rows" 1
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Stopping someone's editor mid-thought is not undoable, so an interactive
|
||||
# run asks first. --yes is for the bar widget, which has already asked in
|
||||
# its own dialog and would otherwise hang here with nowhere to type.
|
||||
if [ "$assume" -eq 0 ]; then
|
||||
if ! ui_interactive; then
|
||||
err 'refusing to stop sessions without a confirmation'
|
||||
say 'pass --yes if you mean it, or --dry-run to see what would happen'
|
||||
return 1
|
||||
fi
|
||||
printf '\n'
|
||||
if [ "${busy:-0}" -gt 0 ]; then
|
||||
warn "$busy of these is mid-request and will lose that turn"
|
||||
fi
|
||||
if [ "$action" = restart ]; then
|
||||
printf ' close and reopen these sessions? [y/N] '
|
||||
else
|
||||
printf ' close these sessions? [y/N] '
|
||||
fi
|
||||
local reply; IFS= read -r reply
|
||||
case "$reply" in
|
||||
y|Y|yes|YES) ;;
|
||||
*) say 'left alone'; return 0 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
cm_session_act "$action" "$rows" 0
|
||||
}
|
||||
|
||||
# Terminate, and optionally reopen. SIGTERM only: Claude Code cleans up its
|
||||
# transcript on the way out, and SIGKILL would cost that for no gain.
|
||||
cm_session_act() {
|
||||
local action="$1" rows="$2" dry="${3:-0}" pid ppid tty busy cwd isself pcmd acted=0 skipped=0
|
||||
local -a relaunch_cwd relaunch_cmd
|
||||
|
||||
while IFS=$'\t' read -r pid ppid tty busy cwd isself pcmd; do
|
||||
[ -n "$pid" ] || continue
|
||||
if [ "$isself" = yes ]; then
|
||||
warn "skipping $pid - that is the session running this command"
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
if [ "$action" = "restart" ]; then
|
||||
relaunch_cwd+=("$cwd")
|
||||
relaunch_cmd+=("$pcmd")
|
||||
fi
|
||||
if [ "$dry" -eq 1 ]; then
|
||||
say "would stop $pid ($cwd)"
|
||||
acted=$((acted + 1))
|
||||
elif kill -TERM "$pid" 2>/dev/null; then
|
||||
ok "stopped $pid ($cwd)"
|
||||
acted=$((acted + 1))
|
||||
else
|
||||
err "could not stop $pid"
|
||||
fi
|
||||
done <<EOF_ROWS
|
||||
$rows
|
||||
EOF_ROWS
|
||||
|
||||
[ "$acted" -gt 0 ] && [ "$dry" -eq 0 ] && sleep 0.6
|
||||
|
||||
if [ "$action" = "restart" ] && [ "${#relaunch_cwd[@]}" -gt 0 ]; then
|
||||
local i c t
|
||||
for i in "${!relaunch_cwd[@]}"; do
|
||||
c="${relaunch_cwd[$i]}"; t="${relaunch_cmd[$i]}"
|
||||
[ -d "$c" ] || c="$HOME"
|
||||
# The parent of a session started from the app launcher is the
|
||||
# terminal that was told to run claude, so re-running its command
|
||||
# line reproduces the session exactly - same terminal, same flags.
|
||||
# A session started by hand inside an existing shell has no such
|
||||
# parent to copy, and there is no way to type into that shell from
|
||||
# here, so it gets a fresh terminal in the same directory instead.
|
||||
case "$t" in
|
||||
*" -e "*claude*|*" --command"*claude*) ;;
|
||||
*) t="$(cm_terminal_cmd) -e claude" ;;
|
||||
esac
|
||||
if [ "$dry" -eq 1 ]; then
|
||||
say "would reopen in $c: $t"
|
||||
else
|
||||
( cd "$c" && setsid nohup $t >/dev/null 2>&1 & )
|
||||
ok "reopened in $c"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
[ "$skipped" -gt 0 ] && say 'this session was left running'
|
||||
return 0
|
||||
}
|
||||
|
||||
# Named after the switch, not before it: the switch has already happened, and
|
||||
# these are the sessions it did not reach.
|
||||
cm_report_live_sessions() {
|
||||
local rows n busy
|
||||
rows="$(cm_session_rows)"
|
||||
n="$(printf '%s' "$rows" | grep -c . || true)"
|
||||
[ "${n:-0}" -gt 0 ] || return 0
|
||||
|
||||
busy="$(printf '%s' "$rows" | cut -f4 | grep -c '^yes$' || true)"
|
||||
printf '\n'
|
||||
warn "$n Claude Code session(s) still running on the previous provider"
|
||||
if [ "${busy:-0}" -gt 0 ]; then
|
||||
warn "$busy of them is mid-request - it may fail on its next call rather than at a clean stop"
|
||||
fi
|
||||
printf ' %sclaude-mode sessions what is running%s\n' "$C_DIM" "$C_RESET"
|
||||
printf ' %sclaude-mode sessions --restart close and reopen them on the new provider%s\n' "$C_DIM" "$C_RESET"
|
||||
}
|
||||
|
||||
cm_terminal_cmd() {
|
||||
local t
|
||||
for t in "${TERMINAL:-}" foot alacritty ghostty kitty; do
|
||||
[ -n "$t" ] || continue
|
||||
command -v "$t" >/dev/null 2>&1 && { printf '%s' "$t"; return 0; }
|
||||
done
|
||||
printf 'xterm'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Switching
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -209,18 +842,18 @@ set_mode() {
|
||||
|
||||
if [ "$mode" != "anthropic" ]; then
|
||||
preset_file="$(preset_path "$preset_name")"
|
||||
[ -f "$preset_file" ] || { err "preset '$preset_name' not found"; return 1; }
|
||||
|
||||
local auth_mode key_ref
|
||||
auth_mode="$(jget "$preset_file" auth.mode)"; [ -z "$auth_mode" ] && auth_mode=vault
|
||||
if [ "$auth_mode" = "vault" ]; then
|
||||
key_ref="$(jget "$preset_file" auth.keyRef)"; [ -z "$key_ref" ] && key_ref=openrouter
|
||||
if ! cm_vault_has "$key_ref"; then
|
||||
err "no key stored for ref '$key_ref'. Run: claude-mode set-key $key_ref"
|
||||
return 1
|
||||
fi
|
||||
[ -x "$CM_HELPER" ] || { err "key helper missing/not executable: $CM_HELPER"; return 1; }
|
||||
# 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"
|
||||
[ -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
|
||||
[ -f "$preset_file" ] || { err "preset '$preset_name' not found"; return 1; }
|
||||
fi
|
||||
|
||||
backup="$(backup_settings)"
|
||||
@@ -270,6 +903,8 @@ set_mode() {
|
||||
check_stale_models "$mode"
|
||||
write_health "$mode" "$preset_name"
|
||||
|
||||
cm_report_live_sessions
|
||||
|
||||
printf '\n %srestart claude (and reload the VS Code window) to pick this up%s\n' "$C_DIM" "$C_RESET"
|
||||
}
|
||||
|
||||
@@ -502,6 +1137,11 @@ cmd_status() {
|
||||
|
||||
printf '\n'
|
||||
check_stray_env "$mode" || true
|
||||
|
||||
# Keep the machine-readable mirror current for readers that poll it (the
|
||||
# Omarchy bar widget among them) rather than leaving it as stale as the
|
||||
# last switch.
|
||||
write_health "$mode" "$preset"
|
||||
}
|
||||
|
||||
cmd_presets() {
|
||||
@@ -735,6 +1375,27 @@ cmd_preset() {
|
||||
ok "$name : all tiers + subagent -> $model"
|
||||
reapply_if_active "$name"
|
||||
;;
|
||||
url)
|
||||
local url="${3:-}"
|
||||
[ -n "$name" ] && [ -n "$url" ] || { err 'usage: claude-mode preset url <name> <base-url>'; return 1; }
|
||||
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
|
||||
"$PY" "$JSON" set-url "$(preset_path "$name")" "$url" >/dev/null || return 1
|
||||
ok "$name : baseUrl -> $url"
|
||||
reapply_if_active "$name"
|
||||
;;
|
||||
auth)
|
||||
local amode="${3:-}" ref="${4:-lmstudio}"
|
||||
[ -n "$name" ] && [ -n "$amode" ] || { err 'usage: claude-mode preset auth <name> none|key [keyRef]'; return 1; }
|
||||
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
|
||||
"$PY" "$JSON" set-auth "$(preset_path "$name")" "$amode" "$ref" >/dev/null || return 1
|
||||
if [ "$amode" = "key" ]; then
|
||||
ok "$name : auth -> vault key '$ref'"
|
||||
cm_vault_has "$ref" || warn "no key stored yet for '$ref' - run: claude-mode set-key $ref"
|
||||
else
|
||||
ok "$name : auth -> none (inline placeholder token)"
|
||||
fi
|
||||
reapply_if_active "$name"
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
}
|
||||
@@ -802,7 +1463,7 @@ ui_select() {
|
||||
if [ "$first" -eq 1 ]; then first=0; else printf '\033[%dA' $((n + 4)); fi
|
||||
|
||||
printf '\033[2K\n'
|
||||
printf '\033[2K %s%s%s' "$C_CYAN" "$title" "$C_RESET"
|
||||
printf '\033[2K %s%s%s' "$C_BOLD$C_CYAN" "$title" "$C_RESET"
|
||||
[ -n "$status" ] && printf ' %s%s%s' "$C_DIM" "$status" "$C_RESET"
|
||||
printf '\n'
|
||||
printf '\033[2K %sup/down move enter select esc cancel%s\n' "$C_DIM" "$C_RESET"
|
||||
@@ -867,7 +1528,7 @@ ui_filter_select() {
|
||||
if [ "$first" -eq 1 ]; then first=0; else printf '\033[%dA' $((rows + 6)); fi
|
||||
|
||||
printf '\033[2K\n'
|
||||
printf '\033[2K %s%s%s' "$C_CYAN" "$title" "$C_RESET"
|
||||
printf '\033[2K %s%s%s' "$C_BOLD$C_CYAN" "$title" "$C_RESET"
|
||||
[ -n "$status" ] && printf ' %s%s%s' "$C_DIM" "$status" "$C_RESET"
|
||||
printf '\n'
|
||||
printf '\033[2K %stype to filter up/down move enter select esc cancel%s\n' "$C_DIM" "$C_RESET"
|
||||
@@ -1110,6 +1771,17 @@ ui_menu() {
|
||||
command -v "$PY" >/dev/null 2>&1 || { echo "claude-mode: $PY not found (set CLAUDE_MODE_PYTHON)" >&2; exit 1; }
|
||||
init_root
|
||||
|
||||
# --force is global and may appear anywhere; strip it before the mode arguments
|
||||
# are positional-matched below.
|
||||
_args=()
|
||||
for _a in "$@"; do
|
||||
case "$_a" in
|
||||
--force) CM_FORCE=1 ;;
|
||||
*) _args+=("$_a") ;;
|
||||
esac
|
||||
done
|
||||
set -- ${_args[@]+"${_args[@]}"}
|
||||
|
||||
cmd="${1:-}"; [ $# -gt 0 ] && shift
|
||||
case "$cmd" in
|
||||
''|menu) ui_menu ;;
|
||||
@@ -1121,9 +1793,31 @@ case "$cmd" in
|
||||
z.ai|z-ai) p="$(resolve_preset zai "${1:-}")" || exit 1; set_mode zai "$p" ;;
|
||||
presets) cmd_presets ;;
|
||||
preset) cmd_preset "$@" ;;
|
||||
set-key) cmd_set_key "${1:-openrouter}" ;;
|
||||
set-key)
|
||||
# --terminal is for callers with no stdin to offer: the bar
|
||||
# widget cannot host a hidden password prompt, so it asks the
|
||||
# terminal to host one instead.
|
||||
_ref=openrouter; _term=0
|
||||
for _a in "$@"; do
|
||||
case "$_a" in
|
||||
--terminal) _term=1 ;;
|
||||
*) _ref="$_a" ;;
|
||||
esac
|
||||
done
|
||||
if [ "$_term" -eq 1 ]; then
|
||||
_t="$(cm_terminal_cmd)"
|
||||
setsid nohup "$_t" -e bash -lc \
|
||||
"'$0' set-key '$_ref'; printf '\n press enter to close '; read -r _" \
|
||||
>/dev/null 2>&1 &
|
||||
ok "opened $_t to store the '$_ref' key"
|
||||
else
|
||||
cmd_set_key "$_ref"
|
||||
fi ;;
|
||||
models) cmd_models "${1:-}" ;;
|
||||
doctor) cmd_doctor ;;
|
||||
health) write_health "$(state_mode)" "$(state_preset)" ;;
|
||||
preflight) cmd_preflight "${1:-}" "${2:-}" ;;
|
||||
sessions) cmd_sessions "$@" ;;
|
||||
repair)
|
||||
scope=''
|
||||
for a in "$@"; do [ "$a" = "--all" ] && scope=all; done
|
||||
|
||||
@@ -450,11 +450,139 @@ def cmd_health(argv):
|
||||
h["staleModelIds"] = entries(i for i in ids if is_anthropic_model(i))
|
||||
h["taggedModelIds"] = tagged
|
||||
|
||||
# The switchable catalogue, so a reader (the Omarchy bar widget) can offer
|
||||
# every preset without re-walking the presets directory itself. Names,
|
||||
# providers, descriptions and model maps only - the same shape already
|
||||
# published for the active preset, and no key material rides along.
|
||||
catalogue = []
|
||||
pdir = os.path.join(root, "presets")
|
||||
if os.path.isdir(pdir):
|
||||
for fname in sorted(os.listdir(pdir)):
|
||||
if not fname.endswith(".json"):
|
||||
continue
|
||||
pp = load(os.path.join(pdir, fname), {})
|
||||
if not isinstance(pp, dict):
|
||||
continue
|
||||
pauth = pp.get("auth") or {}
|
||||
pmode = pauth.get("mode", "vault")
|
||||
catalogue.append({
|
||||
"name": fname[:-5],
|
||||
"provider": pp.get("provider", "openrouter"),
|
||||
"description": pp.get("description", ""),
|
||||
"contextTokens": int(pp["contextTokens"]) if pp.get("contextTokens") else None,
|
||||
"models": {t: pp.get("models", {}).get(t, "")
|
||||
for t in TIERS if pp.get("models", {}).get(t)},
|
||||
# Enough for a reader to render an editing form without opening
|
||||
# the preset file: where the server is, and whether it is set to
|
||||
# send a real credential. The credential itself never appears -
|
||||
# only which named slot it would come from.
|
||||
"baseUrl": pp.get("baseUrl", ""),
|
||||
"authMode": pmode,
|
||||
"keyRef": pauth.get("keyRef", "") if pmode == "vault" else "",
|
||||
})
|
||||
h["presets"] = catalogue
|
||||
|
||||
save(os.path.join(root, "health.json"), h)
|
||||
|
||||
|
||||
|
||||
def cmd_preflight_json(argv):
|
||||
"""Emit a preflight verdict as JSON.
|
||||
|
||||
usage: preflight-json <ok|blocked> <mode> <preset> <code> <title> <detail>
|
||||
<remedy> <remedyKind> <keyRef> <baseUrl>
|
||||
|
||||
The shell side does the checking; this exists so the strings reach a reader
|
||||
correctly quoted rather than through hand-rolled escaping in bash.
|
||||
"""
|
||||
(status, mode, preset, code, title, detail,
|
||||
remedy, kind, key_ref, base_url) = (argv + [""] * 10)[:10]
|
||||
out = {
|
||||
"ok": status == "ok",
|
||||
"mode": mode,
|
||||
"preset": preset,
|
||||
"code": code or ("ok" if status == "ok" else "blocked"),
|
||||
"title": title,
|
||||
"detail": detail,
|
||||
"remedy": remedy,
|
||||
"remedyKind": kind,
|
||||
"keyRef": key_ref,
|
||||
"baseUrl": base_url,
|
||||
}
|
||||
print(json.dumps(out))
|
||||
|
||||
|
||||
def cmd_sessions_json(argv):
|
||||
"""Convert the session TSV on stdin to JSON.
|
||||
|
||||
Columns, in order: pid, ppid, tty, busy, cwd, self, parentCmd. `busy` is a
|
||||
sampled-CPU heuristic, not a promise, and is reported as such.
|
||||
"""
|
||||
rows = []
|
||||
for line in sys.stdin.read().splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
parts += [""] * (7 - len(parts))
|
||||
pid, ppid, tty, busy, cwd, is_self, pcmd = parts[:7]
|
||||
try:
|
||||
pid_i = int(pid)
|
||||
except ValueError:
|
||||
continue
|
||||
rows.append({
|
||||
"pid": pid_i,
|
||||
"ppid": int(ppid) if ppid.isdigit() else 0,
|
||||
"tty": tty,
|
||||
"busy": busy == "yes",
|
||||
"cwd": cwd,
|
||||
"self": is_self == "yes",
|
||||
"parentCmd": pcmd,
|
||||
})
|
||||
print(json.dumps({"count": len(rows), "busy": sum(1 for r in rows if r["busy"]),
|
||||
"sessions": rows}))
|
||||
|
||||
|
||||
def cmd_set_url(argv):
|
||||
"""set-url <preset> <baseUrl>
|
||||
|
||||
LM Studio is not necessarily on this machine. It can be another box on the
|
||||
LAN, or something reached through a tunnel or a reverse proxy, so the base
|
||||
URL is editable rather than fixed at the loopback address it ships with.
|
||||
"""
|
||||
path, url = argv[0], argv[1].strip().rstrip("/")
|
||||
if not re.match(r"^https?://[^\s/]+", url):
|
||||
raise SystemExit("base URL must start with http:// or https://")
|
||||
p = load(path)
|
||||
p["baseUrl"] = url
|
||||
save(path, p)
|
||||
print(url)
|
||||
|
||||
|
||||
def cmd_set_auth(argv):
|
||||
"""set-auth <preset> none|key [keyRef]
|
||||
|
||||
`none` is LM Studio's out-of-the-box state: it accepts any token, so the
|
||||
literal placeholder is written inline and is explicitly not a secret. `key`
|
||||
is for a server with authentication switched on, where the token is a real
|
||||
credential and belongs in the vault like every other one - settings.json
|
||||
never sees it either way.
|
||||
"""
|
||||
path, mode = argv[0], argv[1]
|
||||
key_ref = argv[2] if len(argv) > 2 and argv[2] else "lmstudio"
|
||||
p = load(path)
|
||||
if mode == "none":
|
||||
p["auth"] = {"mode": "literal", "token": "lmstudio"}
|
||||
elif mode == "key":
|
||||
p["auth"] = {"mode": "vault", "keyRef": key_ref}
|
||||
else:
|
||||
raise SystemExit("auth mode must be 'none' or 'key'")
|
||||
save(path, p)
|
||||
print(json.dumps(p["auth"]))
|
||||
|
||||
COMMANDS = {
|
||||
"health": cmd_health,
|
||||
"preflight-json": cmd_preflight_json,
|
||||
"sessions-json": cmd_sessions_json,
|
||||
"stale-models": cmd_stale_models,
|
||||
"strip-tags": cmd_strip_tags,
|
||||
"or-models": cmd_or_models,
|
||||
@@ -463,6 +591,8 @@ COMMANDS = {
|
||||
"summary": cmd_summary,
|
||||
"models": cmd_models,
|
||||
"set-tier": cmd_set_tier,
|
||||
"set-url": cmd_set_url,
|
||||
"set-auth": cmd_set_auth,
|
||||
"scaffold": cmd_scaffold,
|
||||
"get": cmd_get,
|
||||
"presets": cmd_presets,
|
||||
|
||||
@@ -57,6 +57,14 @@ install -m 0644 "$SRC/cm-json.py" "$ROOT/bin/cm-json.py"
|
||||
install -m 0644 "$SRC/cm-vault.sh" "$ROOT/bin/cm-vault.sh"
|
||||
green 'copied claude-mode + helpers'
|
||||
|
||||
# cm_version() reads this; without it every health.json reported 0.0.0.
|
||||
VERSION_SRC="$SRC/../VERSION"
|
||||
[ -f "$VERSION_SRC" ] || VERSION_SRC="$SRC/VERSION"
|
||||
if [ -f "$VERSION_SRC" ]; then
|
||||
install -m 0644 "$VERSION_SRC" "$ROOT/VERSION"
|
||||
green "version $(tr -d '[:space:]' < "$ROOT/VERSION")"
|
||||
fi
|
||||
|
||||
# --- presets (shared with the Windows build) -------------------------------
|
||||
PRESET_SRC="$SRC/../presets"
|
||||
[ -d "$PRESET_SRC" ] || PRESET_SRC="$SRC/presets"
|
||||
@@ -133,6 +141,9 @@ if [ "$SKIP_KEY" -eq 0 ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- machine-readable state ------------------------------------------------
|
||||
"$ROOT/bin/claude-mode" health >/dev/null 2>&1 && green 'health.json seeded'
|
||||
|
||||
printf '\ndone. Open a new shell, then:\n'
|
||||
printf ' claude-mode\n'
|
||||
printf ' claude-mode status\n'
|
||||
|
||||
Reference in New Issue
Block a user