Split the POSIX CLI into modules

linux/claude-mode was 3,035 lines. It is now 177: the paths and flags, a
loader, and the command dispatch. Everything else moved, verbatim, into
twelve files under linux/lib/, one per concern - output, core, preflight,
sessions, switch, catalogue, commands, doctor, presets, menu, setup, repair.
The move was done by line range with a check that every original line landed
in exactly one file; the only thing that changed place is the switch's
running-sessions question, which now sits with session detection.

The CLI finds lib/, cm-json.py and cm-vault.sh beside itself, following the
~/.local/bin symlink, so a checkout runs its own code rather than the
installed version's (it used to mix the two). The key helper path written
into settings.json is still the installed one.

linux/install.sh ships bin/lib/, clearing old modules first so a removed one
cannot linger. The package build copies linux/ recursively, which a flat copy
would not.

tests/cli/test_install.sh runs the real installer into a sandbox home: every
file lands, the symlink runs, a reinstall keeps edited presets and drops a
stale module. docs/architecture.md lists the modules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
smoido
2026-09-15 02:00:10 +03:00
co-authored by Claude Opus 5
parent b514e00745
commit 441166d003
17 changed files with 3063 additions and 2905 deletions
+175
View File
@@ -0,0 +1,175 @@
# shellcheck shell=bash
# linux/lib/commands.sh - status, presets, models and set-key.
#
# 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.
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
cmd_status() {
local mode preset
mode="$(state_mode)"; preset="$(state_preset)"
head_ "claude-mode: $mode"
if [ "$mode" = "anthropic" ]; then
say 'native Anthropic login/subscription; no gateway env, no apiKeyHelper'
else
local pf; pf="$(preset_path "$preset")"
say "preset: $preset"
if [ -f "$pf" ]; then
say "baseUrl: $(jget "$pf" baseUrl)"
local t v
for t in "${TIERS[@]}"; do
v="$(jget "$pf" "models.$t")"; [ -n "$v" ] && printf ' %-10s%s\n' "$t:" "$v"
done
v="$(jget "$pf" subagentModel)"; [ -n "$v" ] && printf ' %-10s%s\n' "subagent:" "$v"
v="$(jget "$pf" contextTokens)"; [ -n "$v" ] && printf ' %-10s%s tokens\n' "context:" "$v"
local am; am="$(jget "$pf" auth.mode)"; [ -z "$am" ] && am=vault
if [ "$am" = "vault" ]; then
local ref; ref="$(jget "$pf" auth.keyRef)"; [ -z "$ref" ] && ref=openrouter
printf ' %-10s%s -> %s [%s]\n' "key:" "$ref" "$(cm_vault_mask "$(cm_vault_get "$ref" 2>/dev/null || true)")" "$(cm_vault_backend_label)"
else
printf ' %-10s%s (inline, not a secret)\n' "token:" "$(jget "$pf" auth.token)"
fi
else
err "preset '$preset' not found"
fi
fi
printf '\n settings.json managed keys:\n'
local any=0 line
while IFS= read -r line; do
[ -n "$line" ] || continue
printf ' %s\n' "$line"; any=1
done < <("$PY" "$JSON" settings-env "$CM_SETTINGS" "$CM_STATE" 2>/dev/null)
[ "$any" -eq 0 ] && printf ' (none - clean)\n'
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() {
head_ 'presets'
local active_mode active_preset name provider desc mark
active_mode="$(state_mode)"; active_preset="$(state_preset)"
while IFS=$'\t' read -r name provider desc; do
mark=' '
[ "$name" = "$active_preset" ] && [ "$active_mode" != "anthropic" ] && mark='*'
printf ' %s %-18s [%-10s] %s\n' "$mark" "$name" "$provider" "$desc"
done < <("$PY" "$JSON" presets "$CM_PRESETS")
}
cmd_models() {
local filter='' pname='' refresh=0 as_json=0 a pf='' provider base='' tsv rc
while [ $# -gt 0 ]; do
a="$1"; shift
case "$a" in
--preset)
[ $# -gt 0 ] || { err '--preset needs a preset name'; return 1; }
pname="$1"; shift ;;
--refresh) refresh=1 ;;
--json) as_json=1 ;;
-*) err "unknown option '$a'"; return 1 ;;
*) filter="$a" ;;
esac
done
# The active preset by default. The panel's editor names one instead,
# because the preset being edited is often not the one in use - and two
# LM Studio presets can point at two different servers.
if [ -z "$pname" ] && [ "$(state_mode)" != "anthropic" ]; then
pname="$(state_preset)"
fi
if [ -n "$pname" ]; then
pf="$(preset_path "$pname")"
[ -f "$pf" ] || { err "preset '$pname' not found"; return 1; }
provider="$(jget "$pf" provider)"; [ -z "$provider" ] && provider=openrouter
base="$(jget "$pf" baseUrl)"
else
provider=openrouter # on anthropic, the catalogue worth browsing
fi
local quiet=$(( refresh || as_json )) kind title
kind="$(prov_field "$provider" 10)"; title="$(prov_field "$provider" 3)"
if [ "$quiet" -eq 0 ]; then
case "$kind" in
static) head_ "$title models (from its docs - no public catalogue endpoint)" ;;
openrouter) head_ 'fetching https://openrouter.ai/api/v1/models ...' ;;
*) head_ "models on the $title server at $base" ;;
esac
fi
tsv="$(provider_catalogue "$provider" "$base" "$(cm_preset_token "$pf")")"; rc=$?
if [ "$as_json" -eq 1 ]; then
jget "$CM_MODELS_CACHE" "providers.$provider"
return "$rc"
fi
if [ "$refresh" -eq 1 ]; then
if [ "$rc" -ne 0 ]; then
err "could not fetch the $provider catalogue${base:+ from $base}; the cached list, if any, is kept"
# Not necessarily a fault: plenty of proxies serve Messages and
# nothing else.
[ "$(prov_field "$provider" 8)" = lenient ] && \
say 'some endpoints serve no model list at all - model ids can still be typed by hand'
return 1
fi
ok "cached $(printf '%s\n' "$tsv" | grep -c .) $provider model(s)"
return 0
fi
[ "$rc" -eq 0 ] || { err "could not fetch the $provider catalogue${base:+ from $base}"; return 1; }
local id c2 c3 c4
while IFS=$'\t' read -r id c2 c3 c4; do
[ -n "$id" ] || continue
[ -z "$filter" ] || case "$id" in *"$filter"*) ;; *) continue ;; esac
case "$kind" in
lmstudio) printf ' %-58s %-11s %s\n' "$id" "$c2" "$c3" ;;
static) say "$(printf '%-8s - %s' "$id" "$c2")" ;;
ollama) printf ' %-44s %-8s %s\n' "$id" "$c2" "$c3" ;;
openrouter) printf ' %-52s %10s $%-8s $%s\n' "$id" "$c2" "$c3" "$c4" ;;
*) printf ' %s\n' "$id" ;;
esac
done <<<"$tsv"
}
cmd_set_key() {
local ref="${1:-openrouter}" inline="${2:-}" secret unit
init_root
printf ' storage backend: %s\n' "$(cm_vault_backend_label)"
if [ "$(cm_vault_backend)" = "file" ]; then
warn 'no keyring available - the key will be stored in a 0600 file, NOT encrypted.'
warn 'install libsecret-tools (secret-tool) or pass for encrypted storage.'
fi
if [ -n "$inline" ]; then
secret="$inline"
warn 'the key was given on the command line, so it is in this shell history - the hidden prompt leaves no trace'
else
printf ' paste the API key for ref '\''%s'\'' (input hidden): ' "$ref"
IFS= read -rs secret; printf '\n'
fi
[ -n "$secret" ] || { err 'empty key, aborted'; return 1; }
# A hidden prompt will happily swallow a mis-paste. Guard the two shapes that
# are never a real key - the failure is otherwise invisible until the
# provider answers 401 and the UI just spins.
case "$secret" in
*[[:space:]]*) err 'that value contains whitespace, so it is not an API key (a pasted command line?). Nothing was stored.'; return 1 ;;
claude-mode*) err 'that value is a claude-mode command, not an API key. Nothing was stored.'; return 1 ;;
esac
if [ "${#secret}" -lt 16 ]; then
unit=characters; [ "${#secret}" -eq 1 ] && unit=character
warn "that key is only ${#secret} $unit - unusually short. Storing anyway."
fi
if [ "$ref" = "openrouter" ] && [ "${secret#sk-or-}" = "$secret" ]; then
warn "key does not start with 'sk-or-' - storing anyway"
fi
printf '%s' "$secret" | cm_vault_set "$ref" && ok "stored key '$ref' via $(cm_vault_backend_label)"
}