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
+277
View File
@@ -0,0 +1,277 @@
# shellcheck shell=bash
# linux/lib/output.sh - colours (a palette derived from the desktop theme), output helpers, the banner and usage.
#
# 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.
# ---------------------------------------------------------------------------
# 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.
# ---------------------------------------------------------------------------
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_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' "$*"; }
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_BOLD$C_RED" "$C_RESET" "$*" >&2; }
head_() { printf '\n%s%s%s\n' "$C_BOLD$C_CYAN" "$*" "$C_RESET"; }
mode_color() {
[ "$1" = anthropic ] && { printf '%s' "$C_MAGENTA"; return; }
case "$(prov_field "$1" 5)" in
cyan) printf '%s' "$C_CYAN" ;;
green) printf '%s' "$C_GREEN" ;;
yellow) printf '%s' "$C_YELLOW" ;;
magenta) printf '%s' "$C_MAGENTA" ;;
white) printf '%s' "$C_WHITE" ;;
dkcyan) printf '%s' "$C_DKCYAN" ;;
*) printf '%s' "$C_GRAY" ;;
esac
}
mode_label() {
[ "$1" = anthropic ] && { printf 'Anthropic - your subscription login, no gateway'; return; }
prov_field "$1" 4
}
# Pure ASCII on purpose - renders identically in every terminal and locale.
show_banner() {
local mode="$1" preset="$2" tag
printf '\n'
printf '%s ____ _ _ __ __ _ %s\n' "$C_DKCYAN" "$C_RESET"
printf '%s / ___| | __ _ _ _ __| | ___ | \\/ | ___ __| | ___ %s\n' "$C_CYAN" "$C_RESET"
printf '%s | | | |/ _` | | | |/ _` |/ _ \\ | |\\/| |/ _ \\ / _` |/ _ \\%s\n' "$C_CYAN" "$C_RESET"
printf '%s | |___| | (_| | |_| | (_| | __/ | | | | (_) | (_| | __/%s\n' "$C_CYAN" "$C_RESET"
printf '%s \\____|_|\\__,_|\\__,_|\\__,_|\\___| |_| |_|\\___/ \\__,_|\\___|%s\n' "$C_DKCYAN" "$C_RESET"
printf ' %s-----------------------------------------------------------%s\n' "$C_DIM" "$C_RESET"
tag="$mode"; [ -n "$preset" ] && tag="$mode / $preset"
printf ' %snow%s %s%s%s %sswitch Claude Code between providers%s\n' \
"$C_DIM" "$C_RESET" "$(mode_color "$mode")" "$tag" "$C_RESET" "$C_DIM" "$C_RESET"
}
usage() {
cat <<'EOF'
claude-mode - switch Claude Code between Anthropic and gateway providers
claude-mode interactive menu
claude-mode status active mode, preset, model map
claude-mode anthropic native login (clears all gateway config)
EOF
# One line per provider in providers.json, so a new one documents itself.
local id
for id in $(provider_ids); do
printf ' claude-mode %-26s %s (default: %s)\n' "$id [preset]" \
"$(prov_field "$id" 4 | sed 's/^[^-]*- //')" "$(default_preset_for "$id")"
done
cat <<'EOF'
claude-mode presets list presets
claude-mode preset show <name>
claude-mode preset new <name> [from] create a preset (copies 'from', else 'default')
claude-mode preset new <name> --provider <p> [--blank]
copy that provider's default, or start empty
claude-mode preset rename <name> <new-name>
claude-mode preset default [provider] [name|--clear]
which preset 'claude-mode <provider>' picks
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] [key] store an API key (hidden prompt; key for scripts)
claude-mode models [filter] models available from the active provider
claude-mode models --preset <name> [--refresh|--json]
that preset's provider instead; --refresh only
updates the panel's cached list
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 setup <mode> [--terminal] first-run setup: key, server, models
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 repair-session [id] [--apply]
make a session resumable again after a bad switch,
keeping the cut turns as markdown + a context note
claude-mode repair-session --ignore <id> | --unignore <id> | --unignore-all | --ignored
stop (or resume) counting one broken session
claude-mode repair-session --all [--max-age <days>]
broken sessions untouched for longer are hidden
(default 7; 0 shows them all)
claude-mode <mode> --force switch even if preflight says no
claude-mode <mode> --yes switch without asking about running sessions
EOF
}