diff --git a/linux/claude-mode b/linux/claude-mode index 32a9eef..937c47c 100755 --- a/linux/claude-mode +++ b/linux/claude-mode @@ -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 [from] create a preset (copies 'from') claude-mode preset set claude-mode preset all + claude-mode preset url point a preset at another server + claude-mode preset auth none|key [ref] whether that server needs a key claude-mode preset rm - 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 [preset] check a mode can actually serve, without switching + claude-mode sessions [--stop|--restart] + running sessions; close or reopen them + claude-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 [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 " '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 " '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//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//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//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 </dev/null; then + ok "stopped $pid ($cwd)" + acted=$((acted + 1)) + else + err "could not stop $pid" + fi + done </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 '; 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 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 diff --git a/linux/cm-json.py b/linux/cm-json.py index 9396e0e..7bc5940 100644 --- a/linux/cm-json.py +++ b/linux/cm-json.py @@ -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 <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, diff --git a/linux/install.sh b/linux/install.sh index d815a2b..ef13f6f 100755 --- a/linux/install.sh +++ b/linux/install.sh @@ -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'