Files
smoidoandClaude Opus 5 441166d003 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>
2026-09-15 02:00:10 +03:00

161 lines
5.8 KiB
Bash

# shellcheck shell=bash
# linux/lib/core.sh - state, presets, the provider table, and which preset a mode resolves to.
#
# 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.
# ---------------------------------------------------------------------------
# State / presets
# ---------------------------------------------------------------------------
init_root() { mkdir -p "$CM_ROOT" "$CM_BIN" "$CM_PRESETS" "$CM_BACKUPS" "$CM_ROOT/vault"; }
jget() { "$PY" "$JSON" get "$1" "$2" 2>/dev/null; }
state_mode() { local m; m="$(jget "$CM_STATE" mode)"; [ -n "$m" ] && printf '%s' "$m" || printf 'anthropic'; }
state_preset() { jget "$CM_STATE" preset; }
preset_path() { printf '%s/%s.json' "$CM_PRESETS" "$1"; }
# Preset names become file names, so nothing but a plain word gets through - no
# slash, and no leading dot. Without this `preset show ../../etc/passwd`
# printed whatever it pointed at.
valid_preset_name() {
case "$1" in
''|.*|*[!A-Za-z0-9._-]*) return 1 ;;
esac
return 0
}
preset_names() { "$PY" "$JSON" presets "$CM_PRESETS" | cut -f1; }
# Deliberately no awk anywhere in this script: it is absent from minimal images
# (this was found the hard way on a stock Fedora WSL rootfs). Bash can split TSV
# on its own, and cut/sed/grep are far more reliably present.
presets_for_provider() {
local name provider desc
while IFS=$'\t' read -r name provider desc; do
[ "$provider" = "$1" ] && printf '%s\n' "$name"
done < <("$PY" "$JSON" presets "$CM_PRESETS")
}
# Read TSV on stdin, print the whole line whose first field equals $1.
tsv_find() {
local want="$1" line f1
while IFS= read -r line; do
f1="${line%%$'\t'*}"
if [ "$f1" = "$want" ]; then printf '%s' "$line"; return 0; fi
done
return 1
}
tsv_field() { printf '%s' "$1" | cut -f"$2"; }
# ---------------------------------------------------------------------------
# Providers
#
# Every gateway provider is an entry in providers.json; this script only knows
# the *kinds* of behaviour (how a catalogue is fetched, how a server is probed)
# and picks one by name from the entry. The table is read once, in the main
# shell before dispatch - read inside a $( ) it would be re-read on every
# lookup. Columns are cm-json.py's PROVIDER_TSV:
#
# 1 id 2 aliases 3 title 4 label 5 color 6 defaultPreset
# 7 serverEditable 8 probe 9 probePaths 10 catalogueKind 11 perServer
# 12 setupKey 13 setupModels 14 keyUrl 15 guardrail 16 doctor
# 17 defaultKeyRef 18 literalToken 19 defaultBaseUrl 20 serverHint
# 21 serverStart
#
# Always read with cut, never `IFS=$'\t' read`: tab counts as whitespace to
# read, so an empty column (most providers have no aliases) would vanish and
# shift every column after it.
# ---------------------------------------------------------------------------
CM_PROVIDERS_TSV=''
cm_providers_load() {
CM_PROVIDERS_TSV="$("$PY" "$JSON" provider-tsv)" || {
echo "claude-mode: could not read providers.json (next to $CM_BIN)" >&2; exit 1; }
local id
for id in $(provider_ids); do MODES+=("$id"); done
}
provider_ids() { printf '%s\n' "$CM_PROVIDERS_TSV" | cut -f1; }
prov_field() {
local row
row="$(printf '%s\n' "$CM_PROVIDERS_TSV" | tsv_find "$1")" || return 1
tsv_field "$row" "$2"
}
# An id or an alias (z.ai, z-ai) to the provider id; non-zero if neither.
provider_resolve() {
local w line
w="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
[ -n "$w" ] || return 1
while IFS= read -r line; do
[ -n "$line" ] || continue
if [ "${line%%$'\t'*}" = "$w" ]; then printf '%s\n' "$w"; return 0; fi
case ",$(tsv_field "$line" 2)," in
*",$w,"*) printf '%s\n' "${line%%$'\t'*}"; return 0 ;;
esac
done <<EOF_PROVIDERS
$CM_PROVIDERS_TSV
EOF_PROVIDERS
return 1
}
# Whether a provider's entry lists a named doctor check.
doctor_has() {
case ",$(prov_field "$1" 16)," in *",$2,"*) return 0 ;; esac
return 1
}
term_cols() {
local c=''
if command -v tput >/dev/null 2>&1; then c="$(tput cols 2>/dev/null)"; fi
if [ -z "$c" ] && command -v stty >/dev/null 2>&1; then
c="$(stty size 2>/dev/null | cut -d' ' -f2)"
fi
[ -z "$c" ] && c="${COLUMNS:-80}"
case "$c" in ''|*[!0-9]*) c=80 ;; esac
[ "$c" -gt 20 ] || c=80
printf '%s' "$c"
}
# The preset `claude-mode <provider>` uses when none is named: the one chosen
# with `preset default`, while its file still exists, else the built-in name.
# The existence test is what lets a stale choice degrade instead of break.
# cm-json.py's BUILTIN_DEFAULT_PRESET and the widget's Modes.DEFAULT_PRESET
# mirror the built-in names.
builtin_default_preset() { prov_field "$1" 6; }
default_preset_for() {
local chosen
chosen="$(jget "$CM_DEFAULTS" "$1")"
if [ -n "$chosen" ] && valid_preset_name "$chosen" && [ -f "$(preset_path "$chosen")" ]; then
printf '%s' "$chosen"; return 0
fi
builtin_default_preset "$1"
}
resolve_preset() {
local provider="$1" requested="${2:-}" fallback first
if [ -n "$requested" ]; then
[ -f "$(preset_path "$requested")" ] || { err "preset '$requested' not found"; return 1; }
local got; got="$(jget "$(preset_path "$requested")" provider)"
[ -z "$got" ] && got=openrouter
if [ "$got" != "$provider" ]; then
err "preset '$requested' is a '$got' preset, not '$provider'"; return 1
fi
printf '%s' "$requested"; return 0
fi
fallback="$(default_preset_for "$provider")"
if [ -n "$fallback" ] && [ -f "$(preset_path "$fallback")" ]; then
printf '%s' "$fallback"; return 0
fi
first="$(presets_for_provider "$provider" | head -n1)"
[ -n "$first" ] || { err "no preset found for provider '$provider'"; return 1; }
printf '%s' "$first"
}