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:
+33
-8
@@ -46,7 +46,7 @@ Under `~/.claude-mode/`:
|
|||||||
|
|
||||||
| file | written by | read by |
|
| file | written by | read by |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `bin/claude-mode`, `bin/cm-json.py`, `bin/cm-vault.sh`, `bin/claude-key-helper.sh` | the installer | — (Windows: `claude-mode.ps1` at the top, `bin/claude-key-helper.ps1` + `.cmd`) |
|
| `bin/claude-mode`, `bin/lib/*.sh`, `bin/cm-json.py`, `bin/cm-vault.sh`, `bin/claude-key-helper.sh` | the installer | — (Windows: `claude-mode.ps1` at the top, `bin/claude-key-helper.ps1` + `.cmd`) |
|
||||||
| `providers.json` | the installer, every time | the CLI, the key helper's engine, the widget (via `health.json`) |
|
| `providers.json` | the installer, every time | the CLI, the key helper's engine, the widget (via `health.json`) |
|
||||||
| `presets/*.json` | the installer seeds them; `preset`, `setup`, the panel edit them | everything |
|
| `presets/*.json` | the installer seeds them; `preset`, `setup`, the panel edit them | everything |
|
||||||
| `state.json` | a switch; `preset rename` of the active preset | the CLI, the key helper, the widget |
|
| `state.json` | a switch; `preset rename` of the active preset | the CLI, the key helper, the widget |
|
||||||
@@ -68,8 +68,9 @@ anything else), `~/.claude/projects/*/*.jsonl` (Claude Code's transcripts, which
|
|||||||
```
|
```
|
||||||
claude-mode.ps1, install.ps1 the Windows build and its installer
|
claude-mode.ps1, install.ps1 the Windows build and its installer
|
||||||
bin/ the Windows key helper
|
bin/ the Windows key helper
|
||||||
linux/ the POSIX port: claude-mode, cm-json.py, cm-vault.sh,
|
linux/ the POSIX port: claude-mode (settings + dispatch),
|
||||||
claude-key-helper.sh, install.sh, bootstrap.sh
|
lib/*.sh (the rest, one file per concern), cm-json.py,
|
||||||
|
cm-vault.sh, claude-key-helper.sh, install.sh, bootstrap.sh
|
||||||
omarchy/ the bar widget and its installer
|
omarchy/ the bar widget and its installer
|
||||||
providers.json, presets/ shared by both builds, byte for byte
|
providers.json, presets/ shared by both builds, byte for byte
|
||||||
scripts/ test.sh, bump-version.sh, build-package.ps1
|
scripts/ test.sh, bump-version.sh, build-package.ps1
|
||||||
@@ -77,6 +78,28 @@ tests/ static, python, cli and windows suites
|
|||||||
docs/ this
|
docs/ this
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### The POSIX CLI's modules
|
||||||
|
|
||||||
|
`linux/claude-mode` holds the paths and flags, loads these in order, and
|
||||||
|
dispatches the command line. It finds them — and `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.
|
||||||
|
|
||||||
|
| `linux/lib/` | what is in it |
|
||||||
|
|---|---|
|
||||||
|
| `output.sh` | the theme-derived colour palette, `ok`/`warn`/`err`, the banner, `usage` |
|
||||||
|
| `core.sh` | state and preset helpers, the provider table (`prov_field`, `provider_resolve`), `resolve_preset` |
|
||||||
|
| `preflight.sh` | `cm_preflight`, the server probe, `claude-mode preflight` |
|
||||||
|
| `sessions.sh` | finding running sessions, `claude-mode sessions`, and the question a switch asks about them |
|
||||||
|
| `switch.sh` | `set_mode`, settings backups, stray env vars, `health.json`, the guardrail check, `claude-mode repair` |
|
||||||
|
| `catalogue.sh` | `provider_catalogue` and the model cache |
|
||||||
|
| `commands.sh` | `status`, `presets`, `models`, `set-key` |
|
||||||
|
| `doctor.sh` | `doctor` and its per-provider checks |
|
||||||
|
| `presets.sh` | `claude-mode preset …` and re-applying the preset in use |
|
||||||
|
| `menu.sh` | the interactive menu and its pickers |
|
||||||
|
| `setup.sh` | `claude-mode setup` |
|
||||||
|
| `repair.sh` | `claude-mode repair-session` and dismissals |
|
||||||
|
|
||||||
## Contracts between the pieces
|
## Contracts between the pieces
|
||||||
|
|
||||||
These are the interfaces one piece relies on another to keep. Each is pinned by a
|
These are the interfaces one piece relies on another to keep. Each is pinned by a
|
||||||
@@ -120,11 +143,13 @@ test.
|
|||||||
| a provider's endpoint, auth template, setup, checks or look | `providers.json` | the same file |
|
| a provider's endpoint, auth template, setup, checks or look | `providers.json` | the same file |
|
||||||
| a shipped preset | `presets/*.json` | the same files |
|
| a shipped preset | `presets/*.json` | the same files |
|
||||||
| what a switch writes to `settings.json` | `cm-json.py` `cmd_apply`, `BASE_MANAGED` | `Set-ClaudeMode`, `$script:BaseManagedEnvKeys` |
|
| what a switch writes to `settings.json` | `cm-json.py` `cmd_apply`, `BASE_MANAGED` | `Set-ClaudeMode`, `$script:BaseManagedEnvKeys` |
|
||||||
| what refuses a switch | `linux/claude-mode` `cm_preflight` | `Set-ClaudeMode` guards |
|
| a command's arguments | the dispatch at the end of `linux/claude-mode` | the dispatch at the end of `claude-mode.ps1` |
|
||||||
| how a model list is fetched | `provider_catalogue` + a parser in `cm-json.py` | `Get-ProviderCatalogue` |
|
| what refuses a switch | `lib/preflight.sh` `cm_preflight` | `Set-ClaudeMode` guards |
|
||||||
| a doctor check | `cmd_doctor`, `doctor_*` | `Invoke-Doctor`, `Test-PresetCatalogue`, `Test-OllamaContext` |
|
| how a model list is fetched | `lib/catalogue.sh` `provider_catalogue` + a parser in `cm-json.py` | `Get-ProviderCatalogue` |
|
||||||
| setup | `cmd_setup`, `setup_server`, `setup_models` | — |
|
| a doctor check | `lib/doctor.sh` | `Invoke-Doctor`, `Test-PresetCatalogue`, `Test-OllamaContext` |
|
||||||
| session detection and repair | `cmd_sessions`, `cmd_repair_session`; `cm-json.py` `cmd_scan_sessions`, `cmd_repair_session` | — |
|
| setup | `lib/setup.sh` | — |
|
||||||
|
| session detection | `lib/sessions.sh` | — |
|
||||||
|
| session repair | `lib/repair.sh`; `cm-json.py` `cmd_scan_sessions`, `cmd_repair_session` | — |
|
||||||
| key storage | `cm-vault.sh` | the DPAPI functions in `claude-mode.ps1` |
|
| key storage | `cm-vault.sh` | the DPAPI functions in `claude-mode.ps1` |
|
||||||
| the bar icon and state | `omarchy/smoido.claude-mode/BarWidget.qml` | — |
|
| the bar icon and state | `omarchy/smoido.claude-mode/BarWidget.qml` | — |
|
||||||
| the panel | `omarchy/smoido.claude-mode/Panel.qml` | — |
|
| the panel | `omarchy/smoido.claude-mode/Panel.qml` | — |
|
||||||
|
|||||||
+37
-2895
File diff suppressed because it is too large
Load Diff
@@ -72,6 +72,11 @@ install -m 0755 "$SRC/claude-mode" "$ROOT/bin/claude-mode"
|
|||||||
install -m 0755 "$SRC/claude-key-helper.sh" "$ROOT/bin/claude-key-helper.sh"
|
install -m 0755 "$SRC/claude-key-helper.sh" "$ROOT/bin/claude-key-helper.sh"
|
||||||
install -m 0644 "$SRC/cm-json.py" "$ROOT/bin/cm-json.py"
|
install -m 0644 "$SRC/cm-json.py" "$ROOT/bin/cm-json.py"
|
||||||
install -m 0644 "$SRC/cm-vault.sh" "$ROOT/bin/cm-vault.sh"
|
install -m 0644 "$SRC/cm-vault.sh" "$ROOT/bin/cm-vault.sh"
|
||||||
|
# The CLI is split across lib/*.sh, loaded from beside bin/claude-mode. Cleared
|
||||||
|
# first, so a module removed from the source does not linger and get loaded.
|
||||||
|
mkdir -p "$ROOT/bin/lib"
|
||||||
|
rm -f "$ROOT/bin/lib/"*.sh
|
||||||
|
for f in "$SRC"/lib/*.sh; do install -m 0644 "$f" "$ROOT/bin/lib/"; done
|
||||||
green 'copied claude-mode + helpers'
|
green 'copied claude-mode + helpers'
|
||||||
|
|
||||||
# cm_version() reads this; without it every health.json reported 0.0.0.
|
# cm_version() reads this; without it every health.json reported 0.0.0.
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# shellcheck shell=bash
|
||||||
|
# linux/lib/catalogue.sh - fetching a provider's model list, and the cache every fetch leaves.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provider catalogues
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Every fetch leaves a copy behind for a reader that cannot afford the round
|
||||||
|
# trip - the bar panel's model picker. It is written here, in the only places a
|
||||||
|
# catalogue is ever fetched, so every command that already pays for the network
|
||||||
|
# (models, doctor, setup, the menu's picker) keeps it fresh at no extra cost.
|
||||||
|
cm_cache_catalogue() {
|
||||||
|
"$PY" "$JSON" cache-models "$CM_MODELS_CACHE" "$1" "$2" "${3:-}" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# One catalogue fetch, for any provider: fetched according to its
|
||||||
|
# catalogue.kind, parsed to TSV, and left in the cache for the panel. Prints
|
||||||
|
# the TSV; the status says whether the fetch itself worked. TSV columns by kind:
|
||||||
|
#
|
||||||
|
# openrouter id ctx $in $out lmstudio id state ctx
|
||||||
|
# ollama id params quant family openai id
|
||||||
|
# static id note (a list kept in providers.json)
|
||||||
|
provider_catalogue() {
|
||||||
|
local pid="$1" base="${2%/}" token="${3:-}" out rc
|
||||||
|
case "$(prov_field "$pid" 10)" in
|
||||||
|
openrouter)
|
||||||
|
out="$(curl -fsS --max-time 30 https://openrouter.ai/api/v1/models 2>/dev/null \
|
||||||
|
| "$PY" "$JSON" or-models 2>/dev/null)" ;;
|
||||||
|
lmstudio)
|
||||||
|
out="$(curl -fsS --max-time 10 ${token:+-H "Authorization: Bearer $token"} \
|
||||||
|
"$base/api/v0/models" 2>/dev/null | "$PY" "$JSON" lms-models 2>/dev/null)" ;;
|
||||||
|
ollama)
|
||||||
|
out="$(curl -fsS --max-time 10 ${token:+-H "Authorization: Bearer $token"} \
|
||||||
|
"$base/api/tags" 2>/dev/null | "$PY" "$JSON" ollama-models 2>/dev/null)" ;;
|
||||||
|
openai)
|
||||||
|
# Both header styles: a proxy in front of Anthropic wants
|
||||||
|
# x-api-key, one in front of anything else wants Bearer.
|
||||||
|
out="$(curl -fsS --max-time 15 ${token:+-H "Authorization: Bearer $token"} \
|
||||||
|
${token:+-H "x-api-key: $token"} -H 'anthropic-version: 2023-06-01' \
|
||||||
|
"$base/v1/models" 2>/dev/null | "$PY" "$JSON" openai-models 2>/dev/null)" ;;
|
||||||
|
static)
|
||||||
|
out="$("$PY" "$JSON" provider-static "$pid" 2>/dev/null)" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
rc=$?
|
||||||
|
cm_cache_catalogue "$pid" "$([ "$rc" -eq 0 ] && echo 1 || echo 0)" "$base" <<<"$out"
|
||||||
|
[ -n "$out" ] && printf '%s\n' "$out"
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The credential a preset would send. Empty when there is nothing to send.
|
||||||
|
cm_preset_token() {
|
||||||
|
local pf="$1" am ref
|
||||||
|
am="$(jget "$pf" auth.mode)"; [ -z "$am" ] && am=vault
|
||||||
|
if [ "$am" = "vault" ]; then
|
||||||
|
ref="$(jget "$pf" auth.keyRef)"; [ -z "$ref" ] && ref=openrouter
|
||||||
|
cm_vault_get "$ref" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
jget "$pf" auth.token
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# The token passed to provider_catalogue is not optional decoration. A local
|
||||||
|
# server with authentication switched on answers its model list with 401 like
|
||||||
|
# anything else, so without it the catalogue comes back empty and every caller
|
||||||
|
# silently believes the server has no models - on exactly the setups that need
|
||||||
|
# the list most.
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# 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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# shellcheck shell=bash
|
||||||
|
# linux/lib/doctor.sh - doctor, and its checks for each provider.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
# The preset's model ids against what the provider actually offers. A server
|
||||||
|
# provider that does not answer is a failure; a hosted catalogue that cannot be
|
||||||
|
# fetched is only a warning, since the endpoint may be fine regardless.
|
||||||
|
doctor_catalogue() {
|
||||||
|
local mode="$1" pf="$2" base="$3" kind title cat t id row declared c2 c3
|
||||||
|
kind="$(prov_field "$mode" 10)"; title="$(prov_field "$mode" 3)"
|
||||||
|
declared="$(jget "$pf" contextTokens)"
|
||||||
|
cat="$(provider_catalogue "$mode" "$base" "$(cm_preset_token "$pf")")"
|
||||||
|
if [ -z "$cat" ]; then
|
||||||
|
if [ "$(prov_field "$mode" 7)" = 1 ]; then
|
||||||
|
local start; start="$(prov_field "$mode" 21)"
|
||||||
|
if [ "$(prov_field "$mode" 8)" = lenient ]; then
|
||||||
|
warn "$title at $base lists no models - fine for a proxy, but the ids below cannot be checked"
|
||||||
|
else
|
||||||
|
err "$title not reachable at $base${start:+ - $start}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "could not fetch the $title model list"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
[ "$(prov_field "$mode" 7)" = 1 ] && ok "$title reachable at $base ($(printf '%s\n' "$cat" | grep -c .) models)"
|
||||||
|
|
||||||
|
for t in "${TIERS[@]}"; do
|
||||||
|
id="$(jget "$pf" "models.$t")"; [ -n "$id" ] || continue
|
||||||
|
row="$(printf '%s\n' "$cat" | tsv_find "$id")" || row=''
|
||||||
|
# Ollama lists every model with its tag, and a bare name means
|
||||||
|
# `:latest` - `qwen3-coder` is served as `qwen3-coder:latest`.
|
||||||
|
if [ -z "$row" ] && [ "$kind" = ollama ]; then
|
||||||
|
case "$id" in *:*) ;; *) row="$(printf '%s\n' "$cat" | tsv_find "$id:latest")" || row='' ;; esac
|
||||||
|
fi
|
||||||
|
if [ -z "$row" ]; then
|
||||||
|
# A fixed list is documentation, not the provider's word: an id
|
||||||
|
# missing from it may still be served.
|
||||||
|
if [ "$kind" = static ]; then ok "$(printf '%-6s %s (not in the documented list)' "$t" "$id")"
|
||||||
|
else err "$t model NOT available from $title: $id"; fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
c2="$(tsv_field "$row" 2)"; c3="$(tsv_field "$row" 3)"
|
||||||
|
case "$kind" in
|
||||||
|
openrouter) ok "$(printf '%-6s %s [ctx %s]' "$t" "$id" "$c2")"; doctor_ctx_vs "$t" "$c2" "$declared" ;;
|
||||||
|
lmstudio) ok "$(printf '%-6s %s [%s, ctx %s]' "$t" "$id" "$c2" "$c3")"; doctor_ctx_vs "$t" "$c3" "$declared" ;;
|
||||||
|
ollama) ok "$(printf '%-6s %s [%s %s]' "$t" "$id" "$c2" "$c3")" ;;
|
||||||
|
*) ok "$(printf '%-6s %s' "$t" "$id")" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -n "$declared" ] && ok "declared context window: $declared tokens" \
|
||||||
|
|| warn 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early'
|
||||||
|
}
|
||||||
|
|
||||||
|
doctor_ctx_vs() {
|
||||||
|
local t="$1" ctx="$2" declared="$3"
|
||||||
|
{ [ -n "$declared" ] && [ -n "$ctx" ] && [ "$ctx" -lt "$declared" ]; } 2>/dev/null || return 0
|
||||||
|
if [ "$t" = haiku ]; then
|
||||||
|
warn "$t model has $ctx ctx, below the declared $declared - harmless, haiku runs short background tasks"
|
||||||
|
else
|
||||||
|
warn "$t model has $ctx ctx, below the declared $declared - this tier can overflow"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ollama sets the context window on the server, not per request from Claude
|
||||||
|
# Code: 4096 tokens unless `ollama serve` runs with OLLAMA_CONTEXT_LENGTH, and
|
||||||
|
# anything past it is cut off without an error. contextTokens in a preset only
|
||||||
|
# tells Claude Code what to expect - it cannot change what the server does - so
|
||||||
|
# say what the server is actually running where that can be seen, and what to
|
||||||
|
# set where it cannot.
|
||||||
|
doctor_ollama_context() {
|
||||||
|
local pf="$1" base="$2" declared ids id max live ps seen=0 short=0 t body
|
||||||
|
declared="$(jget "$pf" contextTokens)"; [ -n "$declared" ] || return 0
|
||||||
|
ids="$(for t in "${TIERS[@]}"; do jget "$pf" "models.$t"; done | sort -u | grep . || true)"
|
||||||
|
[ -n "$ids" ] || return 0
|
||||||
|
ps="$(curl -fsS --max-time 5 "$base/api/ps" 2>/dev/null)"
|
||||||
|
while IFS= read -r id; do
|
||||||
|
[ -n "$id" ] || continue
|
||||||
|
body="$("$PY" -c 'import json,sys; print(json.dumps({"model": sys.argv[1]}))' "$id")"
|
||||||
|
max="$(curl -fsS --max-time 8 -X POST -H 'content-type: application/json' -d "$body" \
|
||||||
|
"$base/api/show" 2>/dev/null | "$PY" "$JSON" ollama-ctx show 2>/dev/null)"
|
||||||
|
if [ -n "$max" ] && [ "$max" -lt "$declared" ] 2>/dev/null; then
|
||||||
|
warn "$id supports at most $max tokens, below the declared $declared - lower contextTokens"
|
||||||
|
fi
|
||||||
|
live="$(printf '%s' "$ps" | "$PY" "$JSON" ollama-ctx ps "$id" 2>/dev/null)"
|
||||||
|
[ -n "$live" ] || continue
|
||||||
|
seen=1
|
||||||
|
if [ "$live" -lt "$declared" ] 2>/dev/null; then
|
||||||
|
short=1
|
||||||
|
warn "$id is loaded with a $live-token context, below the declared $declared - requests past it are cut off"
|
||||||
|
else
|
||||||
|
ok "$id is loaded with a $live-token context"
|
||||||
|
fi
|
||||||
|
done <<EOF_IDS
|
||||||
|
$ids
|
||||||
|
EOF_IDS
|
||||||
|
if [ "$seen" -eq 0 ]; then
|
||||||
|
warn "none of these models is loaded, so the server's context window cannot be checked"
|
||||||
|
say "Ollama defaults to 4096 tokens; run it with OLLAMA_CONTEXT_LENGTH=$declared or requests past that are cut off silently"
|
||||||
|
elif [ "$short" -eq 1 ]; then
|
||||||
|
say "restart it with OLLAMA_CONTEXT_LENGTH=$declared (or lower contextTokens to match)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_doctor() {
|
||||||
|
local mode preset pf
|
||||||
|
mode="$(state_mode)"; preset="$(state_preset)"; pf="$(preset_path "$preset")"
|
||||||
|
head_ "doctor - mode '$mode'"
|
||||||
|
|
||||||
|
if "$PY" -c "import json,sys; json.load(open(sys.argv[1])) if __import__('os').path.exists(sys.argv[1]) else None" "$CM_SETTINGS" 2>/dev/null; then
|
||||||
|
ok 'settings.json parses'
|
||||||
|
else
|
||||||
|
err 'settings.json does not parse'; return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -x "$CM_HELPER" ]; then ok "key helper present: $CM_HELPER"; else err "key helper missing/not executable: $CM_HELPER"; fi
|
||||||
|
ok "secret backend: $(cm_vault_backend_label)"
|
||||||
|
|
||||||
|
if [ "$mode" != "anthropic" ]; then
|
||||||
|
local am base; am="$(jget "$pf" auth.mode)"; [ -z "$am" ] && am=vault
|
||||||
|
base="$(jget "$pf" baseUrl)"; base="${base%/}"
|
||||||
|
|
||||||
|
if [ "$am" = "vault" ]; then
|
||||||
|
local ref key out
|
||||||
|
ref="$(jget "$pf" auth.keyRef)"; [ -z "$ref" ] && ref=openrouter
|
||||||
|
if key="$(cm_vault_get "$ref" 2>/dev/null)"; then
|
||||||
|
ok "vault '$ref' resolves -> $(cm_vault_mask "$key")"
|
||||||
|
case "$key" in
|
||||||
|
*[[:space:]]*|claude-mode*)
|
||||||
|
err "the stored '$ref' value looks like a pasted command, not a key. Re-run: claude-mode set-key $ref" ;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
err "vault '$ref' missing. Run: claude-mode set-key $ref"; key=''
|
||||||
|
fi
|
||||||
|
# Check the string settings.json actually holds, then run that
|
||||||
|
# string through a shell. A path this script can quote correctly is
|
||||||
|
# no evidence that the recorded one parses.
|
||||||
|
local stored expected reswitch
|
||||||
|
stored="$(jget "$CM_SETTINGS" apiKeyHelper)"
|
||||||
|
expected="$("$PY" -c 'import shlex,sys; sys.stdout.write(shlex.quote(sys.argv[1]))' "$CM_HELPER")"
|
||||||
|
reswitch="claude-mode $mode $(jget "$CM_STATE" preset)"
|
||||||
|
if [ -z "$stored" ]; then
|
||||||
|
err "settings.json has no apiKeyHelper. Run: $reswitch"
|
||||||
|
elif [ "$stored" != "$expected" ]; then
|
||||||
|
err "apiKeyHelper reads $stored"
|
||||||
|
err " but should read $expected - run: $reswitch"
|
||||||
|
else
|
||||||
|
ok "apiKeyHelper wired as $stored"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$stored" ]; then
|
||||||
|
out="$(sh -c "$stored" 2>&1)"
|
||||||
|
if [ -n "$out" ] && [ "$out" = "$key" ]; then ok 'apiKeyHelper emits the correct key'
|
||||||
|
elif printf '%s' "$out" | grep -q '[[:space:]]'; then
|
||||||
|
# Whitespace means a diagnostic, not a credential; a key is
|
||||||
|
# one unbroken token and must never be echoed.
|
||||||
|
err "apiKeyHelper failed: $out"
|
||||||
|
elif [ -n "$out" ]; then err "apiKeyHelper output does not match the vault (got: $(cm_vault_mask "$out"))"
|
||||||
|
else err 'apiKeyHelper produced no output'; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$key" ] && doctor_has "$mode" openrouter-key; then
|
||||||
|
local kinfo
|
||||||
|
kinfo="$(curl -fsS --max-time 20 -H "Authorization: Bearer $key" https://openrouter.ai/api/v1/key 2>/dev/null)"
|
||||||
|
if [ -n "$kinfo" ]; then
|
||||||
|
ok 'OpenRouter accepted the key'
|
||||||
|
printf '%s' "$kinfo" | "$PY" -c "
|
||||||
|
import json,sys
|
||||||
|
d=json.load(sys.stdin).get('data',{})
|
||||||
|
lim=d.get('limit'); use=d.get('usage',0)
|
||||||
|
if lim is None: print(' ok spend %.2f this month (no key limit set)' % use)
|
||||||
|
else: print(' ok spend %.2f of %.2f limit (%s), %.2f remaining' % (use, lim, d.get('limit_reset','?'), d.get('limit_remaining',0)))
|
||||||
|
" 2>/dev/null
|
||||||
|
else
|
||||||
|
err 'OpenRouter rejected the key'
|
||||||
|
fi
|
||||||
|
|
||||||
|
show_guardrail_status "$mode" "$key"
|
||||||
|
fi
|
||||||
|
# No key-info endpoint: the cheapest real check is a 1-token
|
||||||
|
# message against the Anthropic-compatible surface itself.
|
||||||
|
if [ -n "$key" ] && doctor_has "$mode" message-check; then
|
||||||
|
if curl -fsS --max-time 45 -X POST "$base/v1/messages" \
|
||||||
|
-H 'content-type: application/json' -H "x-api-key: $key" \
|
||||||
|
-H "authorization: Bearer $key" -H 'anthropic-version: 2023-06-01' \
|
||||||
|
-d "{\"model\":\"$(jget "$pf" models.haiku)\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" >/dev/null 2>&1; then
|
||||||
|
ok "$(prov_field "$mode" 3) endpoint accepted the key ($base/v1/messages)"
|
||||||
|
else
|
||||||
|
err "$(prov_field "$mode" 3) request failed"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok "inline token '$(jget "$pf" auth.token)' (no secret in settings.json)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
doctor_has "$mode" catalogue-models && doctor_catalogue "$mode" "$pf" "$base"
|
||||||
|
doctor_has "$mode" ollama-context && doctor_ollama_context "$pf" "$base"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
if check_stale_models "$mode"; then
|
||||||
|
[ "$mode" != "anthropic" ] && ok 'no cached Anthropic model ids'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
if check_stray_env "$mode"; then ok 'no rc-file overrides'; fi
|
||||||
|
|
||||||
|
write_health "$mode" "$preset"
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
if command -v claude >/dev/null 2>&1; then
|
||||||
|
say "claude: $(claude --version 2>&1 | head -n1) [$(command -v claude)]"
|
||||||
|
else
|
||||||
|
warn 'claude is not on PATH'
|
||||||
|
fi
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
# shellcheck shell=bash
|
||||||
|
# linux/lib/menu.sh - the interactive terminal menu and its pickers.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Interactive UI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ui_interactive() { [ -t 0 ] && [ -t 1 ]; }
|
||||||
|
|
||||||
|
# Read one keypress, normalised to a word. Escape sequences are consumed
|
||||||
|
# whole: leaving `[C` behind in the tty is what made zsh report
|
||||||
|
# "bad pattern: [C" after the menu exited.
|
||||||
|
# Read the bytes that follow an Escape, up to two of them, and print them.
|
||||||
|
#
|
||||||
|
# There is no portable short-timeout `read` here. bash 3.2 - which is what
|
||||||
|
# macOS ships - rejects fractional timeouts outright, and its `read -t 0`
|
||||||
|
# availability poll reports nothing even when bytes are sitting in the buffer
|
||||||
|
# (verified against 3.2.57), so polling cannot be used to tell a bare Escape
|
||||||
|
# from the start of a sequence.
|
||||||
|
#
|
||||||
|
# On a tty the terminal itself answers this: with icanon off and min 0 /
|
||||||
|
# time 1, a plain read returns the moment a byte arrives and gives up after
|
||||||
|
# ~0.1s otherwise. So Escape costs 0.1s, not the full second `-t 1` would
|
||||||
|
# take. Off a tty there is no such knob, so that path falls back to `-t 1`.
|
||||||
|
cm_read_esc_tail() {
|
||||||
|
local save out='' a raw=0
|
||||||
|
if [ -t 0 ] && save="$(stty -g 2>/dev/null)"; then
|
||||||
|
stty -echo -icanon min 0 time 1 2>/dev/null
|
||||||
|
raw=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$raw" -eq 1 ]; then IFS= read -r a 2>/dev/null; else
|
||||||
|
IFS= read -rsn1 -t 1 a 2>/dev/null || a=''; fi
|
||||||
|
out="$a"
|
||||||
|
|
||||||
|
case "$out" in
|
||||||
|
'O') # SS3 - one more byte is the key itself
|
||||||
|
if [ "$raw" -eq 1 ]; then IFS= read -r a 2>/dev/null;
|
||||||
|
else IFS= read -rsn1 -t 1 a 2>/dev/null || a=''; fi
|
||||||
|
out="$out$a" ;;
|
||||||
|
'[') # CSI - parameters, then one final byte. Consume the lot: a
|
||||||
|
# half-eaten sequence is what leaves `[C` in the tty for the
|
||||||
|
# shell to report as a globbing error.
|
||||||
|
while [ "${#out}" -lt 8 ]; do
|
||||||
|
if [ "$raw" -eq 1 ]; then IFS= read -r a 2>/dev/null;
|
||||||
|
else IFS= read -rsn1 -t 1 a 2>/dev/null || a=''; fi
|
||||||
|
[ -n "$a" ] || break
|
||||||
|
out="$out$a"
|
||||||
|
case "$a" in
|
||||||
|
[0-9]|';'|'?'|'<'|'>'|'='|':'|' ') continue ;;
|
||||||
|
*) break ;;
|
||||||
|
esac
|
||||||
|
done ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[ "$raw" -eq 1 ] && stty "$save" 2>/dev/null
|
||||||
|
printf '%s' "$out"
|
||||||
|
}
|
||||||
|
|
||||||
|
read_key() {
|
||||||
|
local k
|
||||||
|
IFS= read -rsn1 k 2>/dev/null || return 1
|
||||||
|
if [ "$k" = $'\033' ]; then
|
||||||
|
local tail; tail="$(cm_read_esc_tail)"
|
||||||
|
case "$tail" in
|
||||||
|
'[A') printf 'up' ;;
|
||||||
|
'[B') printf 'down' ;;
|
||||||
|
'[C') printf 'right' ;;
|
||||||
|
'[D') printf 'left' ;;
|
||||||
|
'[H') printf 'home' ;;
|
||||||
|
'[F') printf 'end' ;;
|
||||||
|
'[5~') printf 'pgup' ;;
|
||||||
|
'[6~') printf 'pgdn' ;;
|
||||||
|
'OA') printf 'up' ;; # SS3 variants, cursor mode
|
||||||
|
'OB') printf 'down' ;;
|
||||||
|
'OC') printf 'right' ;;
|
||||||
|
'OD') printf 'left' ;;
|
||||||
|
'') printf 'esc' ;;
|
||||||
|
*) printf 'other' ;;
|
||||||
|
esac
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "$k" in
|
||||||
|
'') printf 'enter' ;;
|
||||||
|
$'\177'|$'\b') printf 'backspace' ;;
|
||||||
|
*) printf 'char:%s' "$k" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Selection state shared with the callers, so bash does not have to return
|
||||||
|
# structured data from a function.
|
||||||
|
UI_LABELS=(); UI_DETAILS=(); UI_ACCENTS=(); UI_SEL=-1
|
||||||
|
|
||||||
|
ui_reset_items() { UI_LABELS=(); UI_DETAILS=(); UI_ACCENTS=(); }
|
||||||
|
ui_add_item() { UI_LABELS+=("$1"); UI_DETAILS+=("${2:-}"); UI_ACCENTS+=("${3:-$C_CYAN}"); }
|
||||||
|
|
||||||
|
# ui_select <title> <status> -> sets UI_SEL (-1 = cancelled)
|
||||||
|
ui_select() {
|
||||||
|
local title="$1" status="${2:-}"
|
||||||
|
local n=${#UI_LABELS[@]} idx=0 cols first=1 i key detail
|
||||||
|
UI_SEL=-1
|
||||||
|
[ "$n" -gt 0 ] || return 1
|
||||||
|
cols=$(term_cols)
|
||||||
|
|
||||||
|
printf '\033[?25l' # hide cursor
|
||||||
|
trap 'printf "\033[?25h"' RETURN
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
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_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"
|
||||||
|
|
||||||
|
for ((i = 0; i < n; i++)); do
|
||||||
|
if [ "$i" -eq "$idx" ]; then
|
||||||
|
printf '\033[2K%s > %-*s%s\n' "$(printf '\033[7m')${UI_ACCENTS[$i]}" $((cols - 5)) "${UI_LABELS[$i]}" "$C_RESET"
|
||||||
|
else
|
||||||
|
printf '\033[2K %s\n' "${UI_LABELS[$i]}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
detail="${UI_DETAILS[$idx]}"
|
||||||
|
printf '\033[2K %s%s%s\n' "$C_DIM" "${detail:0:$((cols - 8))}" "$C_RESET"
|
||||||
|
|
||||||
|
key="$(read_key)" || { UI_SEL=-1; return 1; }
|
||||||
|
case "$key" in
|
||||||
|
up) idx=$(( (idx - 1 + n) % n )) ;;
|
||||||
|
down) idx=$(( (idx + 1) % n )) ;;
|
||||||
|
home) idx=0 ;;
|
||||||
|
end) idx=$((n - 1)) ;;
|
||||||
|
enter) UI_SEL=$idx; return 0 ;;
|
||||||
|
esc) UI_SEL=-1; return 1 ;;
|
||||||
|
char:q|char:Q) UI_SEL=-1; return 1 ;;
|
||||||
|
char:[1-9])
|
||||||
|
local d="${key#char:}"
|
||||||
|
[ "$d" -le "$n" ] && { UI_SEL=$((d - 1)); return 0; }
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ui_filter_select <title> <status> - same, plus a type-to-filter box.
|
||||||
|
# Items come from UI_LABELS/UI_DETAILS; sets UI_SEL as an index into them.
|
||||||
|
ui_filter_select() {
|
||||||
|
local title="$1" status="${2:-}"
|
||||||
|
local n=${#UI_LABELS[@]} query='' idx=0 off=0 rows=12 cols first=1
|
||||||
|
local -a match_idx
|
||||||
|
UI_SEL=-1
|
||||||
|
[ "$n" -gt 0 ] || return 1
|
||||||
|
cols=$(term_cols)
|
||||||
|
|
||||||
|
printf '\033[?25l'
|
||||||
|
trap 'printf "\033[?25h"' RETURN
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
match_idx=()
|
||||||
|
local i lower_q; lower_q="$(printf '%s' "$query" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
for ((i = 0; i < n; i++)); do
|
||||||
|
if [ -z "$query" ]; then match_idx+=("$i")
|
||||||
|
else
|
||||||
|
local lab; lab="$(printf '%s' "${UI_LABELS[$i]}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
case "$lab" in *"$lower_q"*) match_idx+=("$i") ;; esac
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
local m=${#match_idx[@]}
|
||||||
|
[ "$idx" -ge "$m" ] && idx=$(( m > 0 ? m - 1 : 0 ))
|
||||||
|
[ "$idx" -lt "$off" ] && off=$idx
|
||||||
|
[ "$idx" -ge $((off + rows)) ] && off=$((idx - rows + 1))
|
||||||
|
[ "$off" -lt 0 ] && off=0
|
||||||
|
|
||||||
|
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_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"
|
||||||
|
printf '\033[2K %sfilter:%s %s_\n' "$C_WHITE" "$C_RESET" "$query"
|
||||||
|
|
||||||
|
local r real
|
||||||
|
for ((r = 0; r < rows; r++)); do
|
||||||
|
local pos=$((off + r))
|
||||||
|
if [ "$pos" -ge "$m" ]; then printf '\033[2K\n'; continue; fi
|
||||||
|
real=${match_idx[$pos]}
|
||||||
|
if [ "$pos" -eq "$idx" ]; then
|
||||||
|
printf '\033[2K%s > %-*s%s\n' "$(printf '\033[7m')$C_CYAN" $((cols - 5)) "${UI_LABELS[$real]}" "$C_RESET"
|
||||||
|
else
|
||||||
|
printf '\033[2K %s\n' "${UI_LABELS[$real]}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$m" -eq 0 ]; then
|
||||||
|
printf '\033[2K %s(no match)%s\n' "$C_DIM" "$C_RESET"
|
||||||
|
printf '\033[2K\n'
|
||||||
|
else
|
||||||
|
printf '\033[2K %s%d of %d%s\n' "$C_DIM" $((idx + 1)) "$m" "$([ "$m" -ne "$n" ] && printf ' (filtered from %d)' "$n")$C_RESET"
|
||||||
|
printf '\033[2K %s%s%s\n' "$C_DIM" "${UI_DETAILS[${match_idx[$idx]}]:0:$((cols - 8))}" "$C_RESET"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local key; key="$(read_key)" || { UI_SEL=-1; return 1; }
|
||||||
|
case "$key" in
|
||||||
|
up) [ "$m" -gt 0 ] && idx=$(( (idx - 1 + m) % m )) ;;
|
||||||
|
down) [ "$m" -gt 0 ] && idx=$(( (idx + 1) % m )) ;;
|
||||||
|
pgup) idx=$(( idx - rows )); [ "$idx" -lt 0 ] && idx=0 ;;
|
||||||
|
pgdn) idx=$(( idx + rows )); [ "$idx" -ge "$m" ] && idx=$(( m > 0 ? m - 1 : 0 )) ;;
|
||||||
|
home) idx=0 ;;
|
||||||
|
end) idx=$(( m > 0 ? m - 1 : 0 )) ;;
|
||||||
|
enter) [ "$m" -gt 0 ] && { UI_SEL=${match_idx[$idx]}; return 0; } ;;
|
||||||
|
esc) UI_SEL=-1; return 1 ;;
|
||||||
|
backspace) query="${query%?}"; idx=0; off=0 ;;
|
||||||
|
char:*) query="$query${key#char:}"; idx=0; off=0 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
preset_summary_line() { "$PY" "$JSON" summary "$1" 2>/dev/null | sed -n '2p'; }
|
||||||
|
|
||||||
|
# These two draw a UI *and* produce a value. They must not be called inside
|
||||||
|
# $( ) - command substitution captures stdout, so the whole interface would be
|
||||||
|
# swallowed into the variable and the user would see nothing happen. They
|
||||||
|
# publish their result in UI_PICKED instead.
|
||||||
|
UI_PICKED=''
|
||||||
|
|
||||||
|
ui_pick_preset() {
|
||||||
|
local provider="$1" def names name
|
||||||
|
UI_PICKED=''
|
||||||
|
# resolve_preset, not default_preset_for: the mark should land on whatever
|
||||||
|
# `claude-mode <provider>` would actually pick, fallbacks included.
|
||||||
|
def="$(resolve_preset "$provider" 2>/dev/null)"
|
||||||
|
names=()
|
||||||
|
while IFS= read -r name; do names+=("$name"); done < <(presets_for_provider "$provider")
|
||||||
|
[ "${#names[@]}" -gt 0 ] || { err "no presets for '$provider'"; return 1; }
|
||||||
|
|
||||||
|
ui_reset_items
|
||||||
|
local i=0 defidx=0
|
||||||
|
for name in "${names[@]}"; do
|
||||||
|
local label="$name"
|
||||||
|
[ "$name" = "$def" ] && { label="$name (default)"; defidx=$i; }
|
||||||
|
ui_add_item "$label" "$(preset_summary_line "$(preset_path "$name")")"
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
ui_select "preset for $provider" '' || return 1
|
||||||
|
UI_PICKED="${names[$UI_SEL]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
ui_pick_model() {
|
||||||
|
local pf="$1" tier="$2" current="$3" provider base
|
||||||
|
UI_PICKED=''
|
||||||
|
provider="$(jget "$pf" provider)"; base="$(jget "$pf" baseUrl)"
|
||||||
|
|
||||||
|
ui_reset_items
|
||||||
|
ui_add_item '<type an id manually>' 'enter any model id by hand'
|
||||||
|
|
||||||
|
local ids=() id ctx a b st kind
|
||||||
|
kind="$(prov_field "$provider" 10)"
|
||||||
|
while IFS=$'\t' read -r id a b st; do
|
||||||
|
[ -n "$id" ] || continue
|
||||||
|
ids+=("$id")
|
||||||
|
case "$kind" in
|
||||||
|
openrouter) ui_add_item "$id" "context $a \$$b in / \$$st out per 1M" ;;
|
||||||
|
lmstudio) ui_add_item "$id" "state: $a max context: $b" ;;
|
||||||
|
ollama) ui_add_item "$id" "$a $b" ;;
|
||||||
|
*) ui_add_item "$id" "$a" ;;
|
||||||
|
esac
|
||||||
|
done < <(provider_catalogue "$provider" "$base" "$(cm_preset_token "$pf")")
|
||||||
|
|
||||||
|
if [ "${#ids[@]}" -gt 0 ]; then
|
||||||
|
ui_filter_select "model for '$tier'" "current: $current" || return 1
|
||||||
|
if [ "$UI_SEL" -gt 0 ]; then UI_PICKED="${ids[$((UI_SEL - 1))]}"; return 0; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\n %scurrent %s : %s%s\n' "$C_DIM" "$tier" "$current" "$C_RESET"
|
||||||
|
printf ' new model id for %s (blank = cancel): ' "$tier"
|
||||||
|
local val; IFS= read -r val
|
||||||
|
[ -n "$val" ] || return 1
|
||||||
|
UI_PICKED="$val"
|
||||||
|
}
|
||||||
|
|
||||||
|
ui_edit_preset() {
|
||||||
|
local name="${1:-}"
|
||||||
|
if [ -z "$name" ]; then
|
||||||
|
local names=() n
|
||||||
|
while IFS= read -r n; do names+=("$n"); done < <(preset_names)
|
||||||
|
[ "${#names[@]}" -gt 0 ] || { warn 'no presets'; return; }
|
||||||
|
ui_reset_items
|
||||||
|
for n in "${names[@]}"; do
|
||||||
|
ui_add_item "$(printf '%-18s [%s]' "$n" "$(jget "$(preset_path "$n")" provider)")" \
|
||||||
|
"$(preset_summary_line "$(preset_path "$n")")"
|
||||||
|
done
|
||||||
|
ui_select 'edit which preset' '' || return
|
||||||
|
name="${names[$UI_SEL]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local pf; pf="$(preset_path "$name")"
|
||||||
|
while true; do
|
||||||
|
ui_reset_items
|
||||||
|
local tiers=() t v
|
||||||
|
while IFS=$'\t' read -r t v; do
|
||||||
|
tiers+=("$t")
|
||||||
|
ui_add_item "$(printf '%-9s %s' "$t" "$v")" "change which model backs the '$t' tier"
|
||||||
|
done < <("$PY" "$JSON" models "$pf")
|
||||||
|
|
||||||
|
ui_select "$name [$(jget "$pf" provider)]" 'esc = done' || return
|
||||||
|
local tier="${tiers[$UI_SEL]}"
|
||||||
|
local cur; cur="$(tsv_field "$("$PY" "$JSON" models "$pf" | tsv_find "$tier")" 2)"
|
||||||
|
ui_pick_model "$pf" "$tier" "$cur" || continue
|
||||||
|
local val="$UI_PICKED"
|
||||||
|
[ -n "$val" ] || continue
|
||||||
|
"$PY" "$JSON" set-tier "$pf" "$tier" "$val" && ok "$name : $tier -> $val"
|
||||||
|
reapply_if_active "$name"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
ui_new_preset() {
|
||||||
|
ui_reset_items
|
||||||
|
local provs=($(provider_ids)) p
|
||||||
|
for p in "${provs[@]}"; do ui_add_item "$p" "$(mode_label "$p")"; done
|
||||||
|
ui_select 'new preset - which provider' '' || return
|
||||||
|
local provider="${provs[$UI_SEL]}"
|
||||||
|
|
||||||
|
local sibs=() s
|
||||||
|
while IFS= read -r s; do sibs+=("$s"); done < <(presets_for_provider "$provider")
|
||||||
|
ui_reset_items
|
||||||
|
ui_add_item '<blank>' "empty $provider preset - pick every model yourself"
|
||||||
|
for s in "${sibs[@]}"; do ui_add_item "copy of $s" "$(preset_summary_line "$(preset_path "$s")")"; done
|
||||||
|
ui_select 'start from' '' || return
|
||||||
|
local choice=$UI_SEL
|
||||||
|
|
||||||
|
printf '\n %snew %s preset%s\n' "$C_CYAN" "$provider" "$C_RESET"
|
||||||
|
printf ' name (letters, digits, dash; blank = cancel): '
|
||||||
|
local name; IFS= read -r name
|
||||||
|
[ -n "$name" ] || return
|
||||||
|
valid_preset_name "$name" || { err "invalid name '$name'"; return; }
|
||||||
|
[ -f "$(preset_path "$name")" ] && { err "preset '$name' already exists"; return; }
|
||||||
|
|
||||||
|
if [ "$choice" -eq 0 ]; then
|
||||||
|
"$PY" "$JSON" scaffold "$provider" > "$(preset_path "$name")"
|
||||||
|
else
|
||||||
|
cp "$(preset_path "${sibs[$((choice - 1))]}")" "$(preset_path "$name")"
|
||||||
|
fi
|
||||||
|
ok "created preset '$name' ($provider)"
|
||||||
|
ui_edit_preset "$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
ui_more_menu() {
|
||||||
|
while true; do
|
||||||
|
ui_reset_items
|
||||||
|
ui_add_item 'status' 'show the full active configuration'
|
||||||
|
ui_add_item 'edit presets' 'pick models per tier from the provider catalogue'
|
||||||
|
ui_add_item 'new preset' 'create a preset - blank or copied from an existing one'
|
||||||
|
ui_add_item 'doctor' 'verify auth, endpoint, model ids, context window'
|
||||||
|
ui_add_item 'back' 'return to the mode menu'
|
||||||
|
ui_add_item 'quit' ''
|
||||||
|
ui_select 'claude-mode - more' "currently: $(state_mode)" || return 0
|
||||||
|
case "$UI_SEL" in
|
||||||
|
0) cmd_status ;;
|
||||||
|
1) ui_edit_preset ;;
|
||||||
|
2) ui_new_preset ;;
|
||||||
|
3) cmd_doctor ;;
|
||||||
|
4) return 0 ;;
|
||||||
|
5) return 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
ui_menu() {
|
||||||
|
if ! ui_interactive; then cmd_status; return; fi
|
||||||
|
|
||||||
|
local cur preset
|
||||||
|
cur="$(state_mode)"; preset="$(state_preset)"
|
||||||
|
[ "$cur" = "anthropic" ] && preset=''
|
||||||
|
show_banner "$cur" "$preset"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
cur="$(state_mode)"; preset="$(state_preset)"
|
||||||
|
local desc="$cur"
|
||||||
|
[ "$cur" != "anthropic" ] && [ -n "$preset" ] && desc="$cur / $preset"
|
||||||
|
|
||||||
|
local choices=() m
|
||||||
|
for m in "${MODES[@]}"; do [ "$m" != "$cur" ] && choices+=("$m"); done
|
||||||
|
|
||||||
|
ui_reset_items
|
||||||
|
for m in "${choices[@]}"; do
|
||||||
|
ui_add_item "switch to $m" "$(mode_label "$m")" "$(mode_color "$m")"
|
||||||
|
done
|
||||||
|
ui_add_item 'more ...' 'status, presets, doctor'
|
||||||
|
|
||||||
|
ui_select 'claude-mode' "currently: $desc" || return
|
||||||
|
|
||||||
|
if [ "$UI_SEL" -eq "${#choices[@]}" ]; then
|
||||||
|
ui_more_menu; [ $? -eq 2 ] && return
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
local mode="${choices[$UI_SEL]}"
|
||||||
|
if [ "$mode" = "anthropic" ]; then set_mode anthropic; return; fi
|
||||||
|
ui_pick_preset "$mode" || continue
|
||||||
|
[ -n "$UI_PICKED" ] || continue
|
||||||
|
set_mode "$mode" "$UI_PICKED"
|
||||||
|
return
|
||||||
|
done
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# shellcheck shell=bash
|
||||||
|
# linux/lib/preflight.sh - what has to be true before a switch may write.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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:-}" paths="${3:-/api/v0/models,/v1/models}" t code ep
|
||||||
|
command -v curl >/dev/null 2>&1 || { printf 'skip'; return 0; }
|
||||||
|
t="$(cm_probe_timeout "$base")"
|
||||||
|
|
||||||
|
for ep in $(printf '%s' "$paths" | tr ',' ' '); 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
|
||||||
|
|
||||||
|
if ! preset_configured "$pf"; then
|
||||||
|
cm_pf_set 'needs-setup' "$(mode_label "$mode" | cut -d- -f1 | sed 's/ *$//') has not been set up yet" \
|
||||||
|
"The shipped preset is a starting point: it has no key stored, and its model ids are whatever was on the machine this was packaged on. Setup asks for what it needs and picks models from the provider's own catalogue." \
|
||||||
|
"claude-mode setup $mode" 'setup'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A preset with no tier mapped - a fresh `preset new --blank` - would switch
|
||||||
|
# cleanly and leave Claude Code asking the gateway for its own default
|
||||||
|
# Anthropic models: billed at full list price on OpenRouter, refused by the
|
||||||
|
# others. Nothing else here would catch it, since the cost guard only sees
|
||||||
|
# ids that are actually set.
|
||||||
|
if ! "$PY" "$JSON" models "$pf" 2>/dev/null | grep -v '^subagent' | cut -f2 | grep -q .; then
|
||||||
|
cm_pf_set 'no-models' "Preset '$preset' has no models set" \
|
||||||
|
"Every tier is empty, so Claude Code would ask for its own default Anthropic models instead - billed at full price through OpenRouter, refused by the other providers." \
|
||||||
|
"claude-mode preset set $preset <tier> <model-id>" 'edit-preset'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
base="$(jget "$pf" baseUrl)"; CM_PF_BASEURL="$base"
|
||||||
|
auth_mode="$(jget "$pf" auth.mode)"; [ -z "$auth_mode" ] && auth_mode=vault
|
||||||
|
|
||||||
|
# A custom endpoint ships with no address, since there is no sensible one
|
||||||
|
# to guess; switching to it would point every session at nothing.
|
||||||
|
if [ -z "$base" ]; then
|
||||||
|
cm_pf_set 'no-url' "Preset '$preset' has no server address" \
|
||||||
|
"$(prov_field "$mode" 3) needs to know where the server is before anything can be sent to it." \
|
||||||
|
"claude-mode preset url $preset <base-url>" 'set-url'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# A server provider is checked wherever it is (probe "always", or
|
||||||
|
# "lenient" for a proxy that may list no models); a public gateway only
|
||||||
|
# when it has been pointed at this machine (probe "local"). A public
|
||||||
|
# gateway that is briefly unreachable is the network's problem and not
|
||||||
|
# worth blocking a config change over.
|
||||||
|
local probe_rule title
|
||||||
|
probe_rule="$(prov_field "$mode" 8)"; title="$(prov_field "$mode" 3)"
|
||||||
|
if [ "$probe_rule" = always ] || [ "$probe_rule" = lenient ] || 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" "$(prov_field "$mode" 9)")"
|
||||||
|
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 $title'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)
|
||||||
|
# A proxy in front of a Messages API often serves no model
|
||||||
|
# list at all; for one of those, something answering is
|
||||||
|
# as much as can be checked.
|
||||||
|
[ "$probe_rule" = lenient ] && return 0
|
||||||
|
cm_pf_set 'server-wrong' "That address answered, but not as $title" \
|
||||||
|
"Something is listening at $base, but $(prov_field "$mode" 9 | sed 's/,/ and /g') is not 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
|
||||||
|
if [ "$mode" != anthropic ]; then
|
||||||
|
mode="$(provider_resolve "$mode")" || { err "unknown mode '${1:-}'"; return 1; }
|
||||||
|
p="$(resolve_preset "$mode" "$preset" 2>/dev/null)" || p="$preset"
|
||||||
|
preset="$p"
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# shellcheck shell=bash
|
||||||
|
# linux/lib/presets.sh - the preset subcommand, and re-applying the preset in use.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
cmd_preset() {
|
||||||
|
local sub="${1:-}" name="${2:-}"
|
||||||
|
if [ -n "$name" ] && ! valid_preset_name "$name"; then
|
||||||
|
err "invalid preset name '$name' - letters, digits, . _ and - only, not starting with a dot"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
case "$sub" in
|
||||||
|
show)
|
||||||
|
[ -n "$name" ] || { err 'usage: claude-mode preset show <name>'; return 1; }
|
||||||
|
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
|
||||||
|
cat "$(preset_path "$name")"
|
||||||
|
;;
|
||||||
|
new)
|
||||||
|
[ -n "$name" ] || { err 'usage: claude-mode preset new <name> [from] | <name> --provider <p> [--blank]'; return 1; }
|
||||||
|
[ -f "$(preset_path "$name")" ] && { err "preset '$name' already exists"; return 1; }
|
||||||
|
local from='' provider='' blank=0 a
|
||||||
|
shift 2
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
a="$1"; shift
|
||||||
|
case "$a" in
|
||||||
|
--provider)
|
||||||
|
[ $# -gt 0 ] || { err "--provider needs one of: $(provider_ids | tr '\n' ' ')"; return 1; }
|
||||||
|
provider="$1"; shift ;;
|
||||||
|
--blank) blank=1 ;;
|
||||||
|
-*) err "unknown option '$a'"; return 1 ;;
|
||||||
|
*) from="$a" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ -n "$provider" ]; then
|
||||||
|
local want="$provider"
|
||||||
|
provider="$(provider_resolve "$want")" || {
|
||||||
|
err "unknown provider '$want' - one of: $(provider_ids | tr '\n' ' ')"; return 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$blank" -eq 1 ]; then
|
||||||
|
[ -n "$provider" ] || { err '--blank needs --provider to know which template'; return 1; }
|
||||||
|
[ -z "$from" ] || { err '--blank starts from nothing; drop the source preset'; return 1; }
|
||||||
|
"$PY" "$JSON" scaffold "$provider" > "$(preset_path "$name")" || {
|
||||||
|
rm -f "$(preset_path "$name")"; return 1; }
|
||||||
|
ok "created blank $provider preset '$name'"
|
||||||
|
say 'every tier is empty: set them with claude-mode preset set, or the bar panel'
|
||||||
|
write_health "$(state_mode)" "$(state_preset)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A literal `default` is only right for OpenRouter. Given a provider
|
||||||
|
# and no source, start from that provider's own default preset.
|
||||||
|
if [ -z "$from" ]; then
|
||||||
|
if [ -n "$provider" ]; then from="$(resolve_preset "$provider")" || return 1
|
||||||
|
else from=default; fi
|
||||||
|
fi
|
||||||
|
valid_preset_name "$from" || { err "invalid source preset name '$from'"; return 1; }
|
||||||
|
[ -f "$(preset_path "$from")" ] || { err "source preset '$from' not found"; return 1; }
|
||||||
|
if [ -n "$provider" ]; then
|
||||||
|
local got; got="$(jget "$(preset_path "$from")" provider)"; [ -z "$got" ] && got=openrouter
|
||||||
|
[ "$got" = "$provider" ] || { err "'$from' is for $got, not $provider"; return 1; }
|
||||||
|
fi
|
||||||
|
cp "$(preset_path "$from")" "$(preset_path "$name")"
|
||||||
|
ok "created $(preset_path "$name") from '$from'"
|
||||||
|
write_health "$(state_mode)" "$(state_preset)"
|
||||||
|
;;
|
||||||
|
rename)
|
||||||
|
local new="${3:-}" out
|
||||||
|
[ -n "$name" ] && [ -n "$new" ] || { err 'usage: claude-mode preset rename <name> <new-name>'; return 1; }
|
||||||
|
valid_preset_name "$new" || { err "invalid preset name '$new' - letters, digits, . _ and - only, not starting with a dot"; return 1; }
|
||||||
|
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
|
||||||
|
[ "$name" = "$new" ] && { ok "already called '$new'"; return 0; }
|
||||||
|
[ -e "$(preset_path "$new")" ] && { err "preset '$new' already exists"; return 1; }
|
||||||
|
out="$("$PY" "$JSON" preset-rename "$CM_PRESETS" "$name" "$new" "$CM_STATE" "$CM_DEFAULTS" 2>&1)" || {
|
||||||
|
err "$out"; return 1; }
|
||||||
|
ok "renamed '$name' -> '$new'"
|
||||||
|
case "$out" in
|
||||||
|
*'"active": true'*)
|
||||||
|
say 'it is the active preset; state.json now names it, and running sessions keep working' ;;
|
||||||
|
esac
|
||||||
|
case "$out" in
|
||||||
|
*'"default": true'*) say 'it was chosen as its provider'"'"'s default, and that follows the new name' ;;
|
||||||
|
esac
|
||||||
|
write_health "$(state_mode)" "$(state_preset)"
|
||||||
|
;;
|
||||||
|
rm)
|
||||||
|
[ -n "$name" ] || { err 'usage: claude-mode preset rm <name>'; return 1; }
|
||||||
|
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
|
||||||
|
if [ "$name" = "$(state_preset)" ] && [ "$(state_mode)" != "anthropic" ]; then
|
||||||
|
err "preset '$name' is active. Switch away first."; return 1
|
||||||
|
fi
|
||||||
|
local prov; prov="$(jget "$(preset_path "$name")" provider)"; [ -z "$prov" ] && prov=openrouter
|
||||||
|
rm -f "$(preset_path "$name")"; ok "deleted preset '$name'"
|
||||||
|
if [ "$(jget "$CM_DEFAULTS" "$prov")" = "$name" ]; then
|
||||||
|
"$PY" "$JSON" set-default "$CM_DEFAULTS" "$prov" "" >/dev/null
|
||||||
|
say "it was the chosen default for $prov; 'claude-mode $prov' means '$(resolve_preset "$prov" 2>/dev/null || printf 'nothing')' now"
|
||||||
|
fi
|
||||||
|
# Allowed, but not quietly: with no preset left, `claude-mode <provider>`
|
||||||
|
# has nothing to resolve to.
|
||||||
|
if [ -z "$(presets_for_provider "$prov")" ]; then
|
||||||
|
warn "that was the last $prov preset - 'claude-mode $prov' has nothing to switch to now"
|
||||||
|
say "create one with: claude-mode preset new <name> --provider $prov [--blank]"
|
||||||
|
fi
|
||||||
|
write_health "$(state_mode)" "$(state_preset)"
|
||||||
|
;;
|
||||||
|
default)
|
||||||
|
# Which preset `claude-mode <provider>` means when none is named.
|
||||||
|
local prov="$name" target="${3:-}" p cur got
|
||||||
|
if [ -z "$prov" ]; then
|
||||||
|
head_ 'default preset per provider'
|
||||||
|
for p in $(provider_ids); do
|
||||||
|
cur="$(jget "$CM_DEFAULTS" "$p")"
|
||||||
|
if [ -n "$cur" ] && [ -f "$(preset_path "$cur")" ]; then
|
||||||
|
printf ' %-11s %s (chosen)\n' "$p" "$cur"
|
||||||
|
else
|
||||||
|
# "built-in" only when it really is the built-in name;
|
||||||
|
# with that preset gone, resolve_preset takes the first.
|
||||||
|
local got_p why
|
||||||
|
got_p="$(resolve_preset "$p" 2>/dev/null)" || got_p=''
|
||||||
|
if [ -z "$got_p" ]; then why='no preset left'
|
||||||
|
elif [ "$got_p" = "$(builtin_default_preset "$p")" ]; then why='built-in'
|
||||||
|
else why='first by name'; fi
|
||||||
|
printf ' %-11s %s (%s%s)\n' "$p" "${got_p:--}" "$why" \
|
||||||
|
"$([ -n "$cur" ] && printf "; chosen '%s' no longer exists" "$cur")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
printf '\n %sclaude-mode preset default <provider> <name> (or --clear)%s\n' "$C_DIM" "$C_RESET"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
prov="$(provider_resolve "$name")" || {
|
||||||
|
err "unknown provider '$name' - one of: $(provider_ids | tr '\n' ' ')"; return 1; }
|
||||||
|
if [ -z "$target" ]; then
|
||||||
|
resolve_preset "$prov" && printf '\n'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [ "$target" = "--clear" ]; then
|
||||||
|
"$PY" "$JSON" set-default "$CM_DEFAULTS" "$prov" "" >/dev/null || return 1
|
||||||
|
ok "$prov : no longer chosen; 'claude-mode $prov' means '$(resolve_preset "$prov" 2>/dev/null || printf 'nothing - no preset left')'"
|
||||||
|
else
|
||||||
|
valid_preset_name "$target" || { err "invalid preset name '$target'"; return 1; }
|
||||||
|
[ -f "$(preset_path "$target")" ] || { err "preset '$target' not found"; return 1; }
|
||||||
|
got="$(jget "$(preset_path "$target")" provider)"; [ -z "$got" ] && got=openrouter
|
||||||
|
[ "$got" = "$prov" ] || { err "'$target' is for $got, not $prov"; return 1; }
|
||||||
|
"$PY" "$JSON" set-default "$CM_DEFAULTS" "$prov" "$target" >/dev/null || return 1
|
||||||
|
ok "$prov : 'claude-mode $prov' now means '$target'"
|
||||||
|
fi
|
||||||
|
write_health "$(state_mode)" "$(state_preset)"
|
||||||
|
;;
|
||||||
|
set)
|
||||||
|
local tier="${3:-}" model="${4:-}"
|
||||||
|
[ -n "$name" ] && [ -n "$tier" ] && [ -n "$model" ] || { err 'usage: claude-mode preset set <name> <tier> <model-id>'; return 1; }
|
||||||
|
# set-tier saves whatever it loaded, so a mistyped name would
|
||||||
|
# otherwise quietly become a new, half-empty preset.
|
||||||
|
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
|
||||||
|
"$PY" "$JSON" set-tier "$(preset_path "$name")" "$tier" "$model" || return 1
|
||||||
|
ok "$name : $tier -> $model"
|
||||||
|
reapply_if_active "$name" models
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
local model="${3:-}"
|
||||||
|
[ -n "$name" ] && [ -n "$model" ] || { err 'usage: claude-mode preset all <name> <model-id>'; return 1; }
|
||||||
|
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
|
||||||
|
local t
|
||||||
|
for t in "${TIERS[@]}" subagent; do
|
||||||
|
"$PY" "$JSON" set-tier "$(preset_path "$name")" "$t" "$model" || return 1
|
||||||
|
done
|
||||||
|
ok "$name : all tiers + subagent -> $model"
|
||||||
|
reapply_if_active "$name" models
|
||||||
|
;;
|
||||||
|
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:-}"
|
||||||
|
[ -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; }
|
||||||
|
# The provider's own slot name by default: lmstudio, ollama, custom.
|
||||||
|
[ -n "$ref" ] || ref="$(prov_field "$(jget "$(preset_path "$name")" provider)" 17)"
|
||||||
|
[ -n "$ref" ] || ref=lmstudio
|
||||||
|
"$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
|
||||||
|
}
|
||||||
|
|
||||||
|
# scope `models` marks an edit that changed tier mappings only. What makes a
|
||||||
|
# switch dangerous to running sessions is the endpoint or the key changing under
|
||||||
|
# them, and a tier edit changes neither - so there is nothing to ask them about,
|
||||||
|
# and the bar panel (which cannot answer a prompt) can edit the active preset.
|
||||||
|
reapply_if_active() {
|
||||||
|
local name="$1" scope="${2:-}" mode
|
||||||
|
mode="$(state_mode)"
|
||||||
|
if [ "$mode" != "anthropic" ] && [ "$(state_preset)" = "$name" ]; then
|
||||||
|
say 're-applying active preset...'
|
||||||
|
[ "$scope" = models ] && CM_SAME_ENDPOINT=1
|
||||||
|
if ! set_mode "$mode" "$name"; then
|
||||||
|
CM_SAME_ENDPOINT=0
|
||||||
|
# Last on stderr on purpose: it is the line the panel shows, and
|
||||||
|
# the file *was* written, which the failure above does not say.
|
||||||
|
err "saved, but re-applying the active preset failed - run: claude-mode $mode $name"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
CM_SAME_ENDPOINT=0
|
||||||
|
fi
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
# shellcheck shell=bash
|
||||||
|
# linux/lib/repair.sh - repairing, dismissing and listing session transcripts.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Transcript repair
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Claude Code files transcripts under ~/.claude/projects/<slug>, and the slug
|
||||||
|
# is not "slashes to dashes": *every* non-alphanumeric character becomes one
|
||||||
|
# dash, nothing collapsed. Verified against 2.1.269 - a directory named
|
||||||
|
# `slug._test x` was filed as `-tmp-cmtest-slug--test-x`, so the dot, the
|
||||||
|
# underscore and the space each became a dash of their own. Paths with a dot in
|
||||||
|
# them are ordinary on macOS (iCloud Drive sits under `Mobile Documents`), and
|
||||||
|
# a slash-only rule points at a directory that does not exist.
|
||||||
|
cm_project_slug() {
|
||||||
|
printf '%s' "$1" | sed 's|[^a-zA-Z0-9]|-|g'
|
||||||
|
}
|
||||||
|
|
||||||
|
# It is also the *physical* directory that gets slugged. Claude Code asks the OS
|
||||||
|
# for its working directory and symlinks come back resolved, so a session
|
||||||
|
# started in /tmp/x is filed under -private-tmp-x on macOS (where /tmp is a
|
||||||
|
# symlink) while the shell's $PWD still reads /tmp/x and looks in -tmp-x.
|
||||||
|
# The logical path is tried first, since that is what a user types and what a
|
||||||
|
# plain project looks like; the resolved one is the fallback.
|
||||||
|
cm_project_dir() {
|
||||||
|
local path="${1:-$PWD}" slug phys
|
||||||
|
slug="$(cm_project_slug "$path")"
|
||||||
|
if [ -d "$CM_SETTINGS_DIR/projects/$slug" ]; then
|
||||||
|
printf '%s/projects/%s' "$CM_SETTINGS_DIR" "$slug"; return 0
|
||||||
|
fi
|
||||||
|
phys="$(cd "$path" 2>/dev/null && pwd -P)" || phys=''
|
||||||
|
if [ -n "$phys" ] && [ "$phys" != "$path" ]; then
|
||||||
|
slug="$(cm_project_slug "$phys")"
|
||||||
|
fi
|
||||||
|
printf '%s/projects/%s' "$CM_SETTINGS_DIR" "$slug"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Modification time as an epoch second. GNU stat spells it -c %Y, BSD/macOS
|
||||||
|
# stat spells it -f %m.
|
||||||
|
cm_file_mtime() {
|
||||||
|
stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dismissing is bookkeeping, not repair: the transcript is left exactly as it
|
||||||
|
# is, and only whether the scan - and so the bar's warning dot - counts it
|
||||||
|
# changes. Hence no confirmation, and one flag to undo it.
|
||||||
|
cm_ignore_session() {
|
||||||
|
local act="$1" id="${2:-}" out op pruned projects="$CM_SETTINGS_DIR/projects"
|
||||||
|
case "$act" in
|
||||||
|
list)
|
||||||
|
out="$("$PY" "$JSON" ignore-session "$CM_IGNORED" "$projects" list 2>&1)" || {
|
||||||
|
err "$out"; return 1; }
|
||||||
|
head_ 'dismissed sessions'
|
||||||
|
printf '%s' "$out" | "$PY" -c "
|
||||||
|
import json,sys
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
D,X='\033[90m','\033[0m'
|
||||||
|
if not d['sessions']:
|
||||||
|
print(' none')
|
||||||
|
for s in d['sessions']:
|
||||||
|
state = 'still broken' if s['broken'] else ('transcript gone' if not s['exists'] else 'no longer broken')
|
||||||
|
print(' %-38s %s' % (s['sessionId'], s['project']))
|
||||||
|
print(' %signored %s, %s%s' % (D, s['ignoredAt'][:10], state, X))
|
||||||
|
print()
|
||||||
|
print(' %sclaude-mode repair-session --unignore <id> (or --unignore-all)%s' % (D,X))
|
||||||
|
"
|
||||||
|
return 0 ;;
|
||||||
|
ignore|unignore)
|
||||||
|
[ -n "$id" ] || { err "--$act needs a session id"; return 1; }
|
||||||
|
[ "$act" = ignore ] && op=add || op=remove ;;
|
||||||
|
unignore-all)
|
||||||
|
op=clear ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
out="$("$PY" "$JSON" ignore-session "$CM_IGNORED" "$projects" "$op" "${id%.jsonl}" 2>&1)" || {
|
||||||
|
err "$out"; return 1; }
|
||||||
|
id="${id%.jsonl}"
|
||||||
|
case "$act:$out" in
|
||||||
|
ignore:*'"changed": true'*) ok "hidden $id"
|
||||||
|
say "${C_DIM}claude-mode repair-session --unignore $id brings it back${C_RESET}" ;;
|
||||||
|
ignore:*) ok "$id was already hidden" ;;
|
||||||
|
unignore:*'"changed": true'*) ok "restored $id" ;;
|
||||||
|
unignore:*) ok "$id was not hidden" ;;
|
||||||
|
*'"changed": true'*) ok 'restored every dismissed session' ;;
|
||||||
|
*) ok 'nothing was dismissed' ;;
|
||||||
|
esac
|
||||||
|
pruned="$(printf '%s' "$out" | sed -n 's/.*"pruned": \([0-9]*\).*/\1/p')"
|
||||||
|
[ "${pruned:-0}" -gt 0 ] && say "${C_DIM}(forgot $pruned whose transcript no longer exists)${C_RESET}"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_repair_session() {
|
||||||
|
local target='' apply=0 reinject=1 scan_all=0 as_json=0 dir='' a file verdict age
|
||||||
|
local ignore_act='' max_age="${CM_IGNORE_AGE_DAYS:-}"
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
a="$1"; shift
|
||||||
|
case "$a" in
|
||||||
|
--apply) apply=1 ;;
|
||||||
|
--dry-run) apply=0 ;;
|
||||||
|
--no-reinject) reinject=0 ;;
|
||||||
|
--list) target='--list' ;;
|
||||||
|
--all) scan_all=1 ;;
|
||||||
|
--json) as_json=1; scan_all=1 ;;
|
||||||
|
# The id may follow the flag or stand anywhere as the positional, so
|
||||||
|
# `--ignore <id>` and `<id> --ignore` both do what they say.
|
||||||
|
--ignore|--unignore)
|
||||||
|
ignore_act="${a#--}"
|
||||||
|
if [ $# -gt 0 ] && [ "${1#-}" = "$1" ]; then target="$1"; shift; fi ;;
|
||||||
|
--unignore-all) ignore_act='unignore-all' ;;
|
||||||
|
--ignored) ignore_act='list' ;;
|
||||||
|
--max-age)
|
||||||
|
[ $# -gt 0 ] || { err '--max-age needs a number of days'; return 1; }
|
||||||
|
max_age="$1"; shift ;;
|
||||||
|
-*) err "unknown option '$a'"; return 1 ;;
|
||||||
|
*) target="$a" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ -n "$max_age" ] && ! [[ "$max_age" =~ ^[0-9]+$ ]]; then
|
||||||
|
err "max age is a whole number of days, not '$max_age' (--max-age / CM_IGNORE_AGE_DAYS)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$ignore_act" ]; then
|
||||||
|
cm_ignore_session "$ignore_act" "$target"
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A session you need to repair is one you could not resume, which is a poor
|
||||||
|
# position from which to remember which project it belonged to. --all drops
|
||||||
|
# the working-directory scoping and reports only what is actually broken.
|
||||||
|
if [ "$scan_all" -eq 1 ]; then
|
||||||
|
local scan
|
||||||
|
scan="$("$PY" "$JSON" scan-sessions "$CM_SETTINGS_DIR/projects" "$max_age" "$CM_IGNORED" 2>&1)" || {
|
||||||
|
err "could not scan transcripts: $scan"; return 1; }
|
||||||
|
|
||||||
|
if [ "$as_json" -eq 1 ]; then
|
||||||
|
printf '%s\n' "$scan"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
head_ 'scanning every session transcript'
|
||||||
|
printf '%s' "$scan" | "$PY" -c "
|
||||||
|
import json,sys
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
G,Y,D,X='\033[32m','\033[33m','\033[90m','\033[0m'
|
||||||
|
for b in d['broken']:
|
||||||
|
print(' %-38s %s' % (b['sessionId'], b['project']))
|
||||||
|
prov = ', '.join(b['providers']) or 'another provider'
|
||||||
|
print(' %d line(s) after the last good message, from %s' % (b['dropLines'], prov))
|
||||||
|
print()
|
||||||
|
ig = d.get('ignored') or []
|
||||||
|
if not d['broken']:
|
||||||
|
print(' %sok %s nothing to repair across %d transcript(s)%s' % (G,X,d['scanned'],
|
||||||
|
' that is not hidden' if ig else ''))
|
||||||
|
else:
|
||||||
|
print(' %swarn%s %d of %d transcript(s) were cut short by a mode switch' % (Y,X,d['count'],d['scanned']))
|
||||||
|
print(' %sclaude-mode repair-session <id> --apply (or --ignore <id> to stop counting it)%s' % (D,X))
|
||||||
|
# Age-hiding must never look like damage vanishing, so whatever was held back
|
||||||
|
# is always named, with the way to see it.
|
||||||
|
if ig:
|
||||||
|
nd = sum(1 for i in ig if i.get('reason') == 'dismissed')
|
||||||
|
ns = len(ig) - nd
|
||||||
|
bits, hints = [], []
|
||||||
|
if nd:
|
||||||
|
bits.append('%d ignored' % nd); hints.append('--ignored lists them')
|
||||||
|
if ns:
|
||||||
|
bits.append('%d older than %d days' % (ns, d.get('maxAgeDays', 0))); hints.append('--max-age 0 shows all')
|
||||||
|
print(' %s%d hidden: %s (%s)%s' % (D, len(ig), ', '.join(bits), '; '.join(hints), X))
|
||||||
|
print(' %ssessions that ran entirely on a gateway are not listed: they carry that%s' % (D,X))
|
||||||
|
print(' %sprovider\'s ids by design and resume fine under it%s' % (D,X))
|
||||||
|
"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Claude Code keys transcripts by the directory the session was started in,
|
||||||
|
# which is rarely the one you are standing in when you come to fix it. Walk
|
||||||
|
# up first, and for a named session fall back to looking through every
|
||||||
|
# project - the id is unique, so there is nothing ambiguous to resolve.
|
||||||
|
local probe="$PWD"
|
||||||
|
while [ -n "$probe" ]; do
|
||||||
|
[ -d "$(cm_project_dir "$probe")" ] && { dir="$(cm_project_dir "$probe")"; break; }
|
||||||
|
[ "$probe" = "/" ] && break
|
||||||
|
probe="$(dirname "$probe")"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$target" ] && [ "$target" != "--list" ]; then
|
||||||
|
if [ -z "$dir" ] || [ ! -f "$dir/${target%.jsonl}.jsonl" ]; then
|
||||||
|
local hit
|
||||||
|
hit="$(ls -1 "$CM_SETTINGS_DIR"/projects/*/"${target%.jsonl}".jsonl 2>/dev/null | head -n1)"
|
||||||
|
[ -n "$hit" ] && dir="$(dirname "$hit")"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$dir" ] || [ ! -d "$dir" ]; then
|
||||||
|
err 'no session transcripts found for this directory'
|
||||||
|
say 'run it from the project the session belongs to, or name the session id'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$target" ] || [ "$target" = "--list" ]; then
|
||||||
|
head_ 'session transcripts here'
|
||||||
|
# This listing classifies each file itself rather than going through
|
||||||
|
# the scan, so it asks the scan which ones are hidden. It still shows
|
||||||
|
# them - listing everything here is its job - but says why the bar
|
||||||
|
# is not counting them.
|
||||||
|
local f v hidden reason row
|
||||||
|
hidden="$("$PY" "$JSON" scan-sessions "$CM_SETTINGS_DIR/projects" "$max_age" "$CM_IGNORED" 2>/dev/null \
|
||||||
|
| "$PY" -c "
|
||||||
|
import json,sys
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
for i in d.get('ignored') or []:
|
||||||
|
print('%s\t%s' % (i['sessionId'], 'ignored' if i.get('reason') == 'dismissed'
|
||||||
|
else 'older than %d days' % d.get('maxAgeDays', 0)))
|
||||||
|
" 2>/dev/null)"
|
||||||
|
for f in $(ls -1t "$dir"/*.jsonl 2>/dev/null); do
|
||||||
|
v="$("$PY" "$JSON" repair-session "$f" 2>/dev/null)" || continue
|
||||||
|
reason=''
|
||||||
|
if row="$(printf '%s\n' "$hidden" | tsv_find "$(basename "$f" .jsonl)")"; then
|
||||||
|
reason="$(tsv_field "$row" 2)"
|
||||||
|
fi
|
||||||
|
printf '%s' "$v" | "$PY" -c "
|
||||||
|
import json,sys,os
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
state = 'ok' if d['healthy'] else ('repairable, would drop %d line(s)' % d['dropLines'] if d['repairable'] else 'no anthropic message found')
|
||||||
|
if sys.argv[1]:
|
||||||
|
state += ' (hidden: %s)' % sys.argv[1]
|
||||||
|
print(' %-40s %s' % (os.path.basename(d['path'])[:-6], state))
|
||||||
|
" "$reason"
|
||||||
|
done
|
||||||
|
printf '\n %sclaude-mode repair-session <session-id> --apply%s\n' "$C_DIM" "$C_RESET"
|
||||||
|
printf ' %s--all checks every project, not just this one%s\n' "$C_DIM" "$C_RESET"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
file="$dir/${target%.jsonl}.jsonl"
|
||||||
|
[ -f "$file" ] || { err "no transcript $file"; return 1; }
|
||||||
|
|
||||||
|
# A transcript that is still being appended to belongs to a session that is
|
||||||
|
# still alive; truncating it underneath a running process helps nobody.
|
||||||
|
age=$(( $(date +%s) - $(cm_file_mtime "$file") ))
|
||||||
|
if [ "$age" -lt 90 ] && [ "$apply" -eq 1 ]; then
|
||||||
|
err "that transcript was written to ${age}s ago - it looks live"
|
||||||
|
say 'close the session that owns it first'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local flags=''
|
||||||
|
[ "$apply" -eq 1 ] && flags="$flags --apply"
|
||||||
|
[ "$reinject" -eq 0 ] && flags="$flags --no-reinject"
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
verdict="$("$PY" "$JSON" repair-session "$file" $flags)" || {
|
||||||
|
err 'could not read that transcript'; return 1; }
|
||||||
|
|
||||||
|
# A repaired session is no longer damage. Left dismissed, it would stay
|
||||||
|
# hidden if the same session broke again later.
|
||||||
|
case "$verdict" in
|
||||||
|
*'"applied": true'*)
|
||||||
|
"$PY" "$JSON" ignore-session "$CM_IGNORED" "$CM_SETTINGS_DIR/projects" \
|
||||||
|
remove "${target%.jsonl}" >/dev/null 2>&1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
printf '%s' "$verdict" | "$PY" -c "
|
||||||
|
import json,sys
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
G,Y,R,D,X = '\033[32m','\033[33m','\033[91m','\033[90m','\033[0m'
|
||||||
|
print()
|
||||||
|
if d['healthy']:
|
||||||
|
print(' %sok %s last message is Anthropic-issued; nothing to repair' % (G,X))
|
||||||
|
raise SystemExit(0)
|
||||||
|
if d['kind'] == 'gateway-native':
|
||||||
|
prov = (d['foreignIds'][0]['model'] or 'a gateway') if d['foreignIds'] else 'a gateway'
|
||||||
|
print(' %sok %s this session ran entirely on %s' % (G,X,prov))
|
||||||
|
print(' %sits ids come from that provider by design; it resumes under it, not%s' % (D,X))
|
||||||
|
print(' %sunder Anthropic. There is nothing here to repair.%s' % (D,X))
|
||||||
|
raise SystemExit(0)
|
||||||
|
if not d['repairable']:
|
||||||
|
print(' %sok %s this transcript has no assistant replies to resume from' % (G,X))
|
||||||
|
raise SystemExit(0)
|
||||||
|
for f in d['foreignIds']:
|
||||||
|
print(' %swarn%s line %d carries a %s id from %s' % (Y,X,f['line'],f['id'].split('-')[0]+'-',f['model'] or 'another provider'))
|
||||||
|
n = sum(1 for s in d['syntheticIds'] if s['apiError'] and s['line'] > d['lastGoodLine'])
|
||||||
|
if n:
|
||||||
|
print(' %swarn%s %d client-side error placeholder(s) after the last good message' % (Y,X,n))
|
||||||
|
if d['applied']:
|
||||||
|
print(' %sok %s truncated to line %d, dropping %d' % (G,X,d['lastGoodLine'],d['dropLines']))
|
||||||
|
print(' %sok %s original saved as %s' % (G,X,d['backup']))
|
||||||
|
if d.get('recovered'):
|
||||||
|
print(' %sok %s dropped turns written to %s' % (G,X,d['recovered']))
|
||||||
|
if d.get('reinjected'):
|
||||||
|
print(' %sok %s and handed back to the session as a context note' % (G,X))
|
||||||
|
print()
|
||||||
|
print(' %sthat session should resume, and will know what it did%s' % (D,X))
|
||||||
|
else:
|
||||||
|
print(' %swarn%s would truncate to line %d, dropping %d line(s)' % (Y,X,d['lastGoodLine'],d['dropLines']))
|
||||||
|
print()
|
||||||
|
print(' %sre-run with --apply: the original is backed up, the dropped turns are%s' % (D,X))
|
||||||
|
print(' %ssaved as markdown, and handed back to the session as a context note%s' % (D,X))
|
||||||
|
print(' %s(--no-reinject writes the file but leaves the session untouched)%s' % (D,X))
|
||||||
|
"
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
# shellcheck shell=bash
|
||||||
|
# linux/lib/sessions.sh - finding running Claude Code sessions, and asking about them before a switch.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Running sessions
|
||||||
|
#
|
||||||
|
# A switch breaks running sessions. Not "leaves them on the old provider" -
|
||||||
|
# breaks them, and it is worth being exact about why, because the two halves of
|
||||||
|
# the config behave differently.
|
||||||
|
#
|
||||||
|
# The static half - base URL, model ids, the env block - really is read once at
|
||||||
|
# startup, and a running session keeps the values it started with.
|
||||||
|
#
|
||||||
|
# The credential is not. It is fetched by running apiKeyHelper, which Claude
|
||||||
|
# Code re-invokes on a timer (CLAUDE_CODE_API_KEY_HELPER_TTL_MS, present in
|
||||||
|
# 2.1.251), and the helper answers for whatever state.json says *now*. So a
|
||||||
|
# switch reaches into a live session through the one thing that was never
|
||||||
|
# cached:
|
||||||
|
#
|
||||||
|
# -> anthropic the helper returns nothing at all, by design, and the
|
||||||
|
# session's next refresh comes back with no credential
|
||||||
|
# -> another provider
|
||||||
|
# the helper hands over the new provider's key while the
|
||||||
|
# session is still pointed at the old base URL, which rejects
|
||||||
|
# it
|
||||||
|
#
|
||||||
|
# Either way the session starts failing its calls, at whatever moment the TTL
|
||||||
|
# happens to expire - mid-turn as easily as between turns. The one case that
|
||||||
|
# does survive is a switch between two presets of the same provider sharing a
|
||||||
|
# keyRef: same key, same endpoint, and the session simply carries on with the
|
||||||
|
# model ids it started with.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
# Session discovery reads /proc, so it is Linux-only. Elsewhere the tool
|
||||||
|
# cannot see running sessions at all - which has to be said rather than
|
||||||
|
# silently reported as "none running", since that is the answer that gets
|
||||||
|
# people to switch out from under a live session.
|
||||||
|
cm_sessions_supported() { [ -d /proc/self ]; }
|
||||||
|
|
||||||
|
cm_session_rows() {
|
||||||
|
local self pid ppid tty cwd busy isself d
|
||||||
|
cm_sessions_supported || return 0
|
||||||
|
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 ! cm_sessions_supported; then
|
||||||
|
head_ 'running sessions'
|
||||||
|
warn 'session control needs /proc, so it is Linux-only'
|
||||||
|
say 'on macOS, restart Claude Code yourself after a switch'
|
||||||
|
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 %stheir credential is re-fetched on a timer, so a switch breaks them%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
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Live sessions: asked before the write, not reported after it
|
||||||
|
#
|
||||||
|
# The damage a switch does to a running session is not limited to it failing
|
||||||
|
# calls. If the session takes even one completion from the new provider before
|
||||||
|
# anything notices, that provider's message-id format lands in its transcript -
|
||||||
|
# OpenRouter issues `gen-<epoch>-<rand>` where Anthropic issues `msg_...` - and
|
||||||
|
# native Anthropic then refuses to resume the session at all:
|
||||||
|
#
|
||||||
|
# API Error: 400 diagnostics.previous_message_id: must be the `id` from a
|
||||||
|
# prior /v1/messages response (starts with `msg_`)
|
||||||
|
#
|
||||||
|
# There is no supported way back from that. The only fix is to truncate the
|
||||||
|
# transcript to the last message Anthropic issued, losing everything after it
|
||||||
|
# (see `claude-mode repair-session`). A confirmation that costs one keystroke is
|
||||||
|
# cheap against a failure that costs an afternoon of conversation.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CM_ASSUME_YES=0
|
||||||
|
CM_SESSION_ACTION=none
|
||||||
|
CM_SESSION_ROWS=''
|
||||||
|
|
||||||
|
cm_confirm_sessions() {
|
||||||
|
local mode="$1" rows n busy reply
|
||||||
|
CM_SESSION_ACTION=none
|
||||||
|
CM_SESSION_ROWS=''
|
||||||
|
|
||||||
|
# Re-applying the active preset after a tier edit: same endpoint, same key,
|
||||||
|
# so running sessions are not at risk - see reapply_if_active.
|
||||||
|
[ "$CM_SAME_ENDPOINT" -eq 1 ] && return 0
|
||||||
|
|
||||||
|
if ! cm_sessions_supported; then
|
||||||
|
printf '\n'
|
||||||
|
warn 'cannot list running sessions here (no /proc) - the switch will not wait for them'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
rows="$(cm_session_rows)"
|
||||||
|
n="$(printf '%s' "$rows" | grep -c . || true)"
|
||||||
|
[ "${n:-0}" -gt 0 ] || return 0
|
||||||
|
CM_SESSION_ROWS="$rows"
|
||||||
|
|
||||||
|
busy="$(printf '%s' "$rows" | cut -f4 | grep -c '^yes$' || true)"
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
warn "$n Claude Code session(s) are running right now"
|
||||||
|
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 one)${C_RESET}"
|
||||||
|
printf ' %-8s %-8s %s%s\n' "$pid" "$tty" "$cwd" "$tag"
|
||||||
|
done <<EOF_ROWS
|
||||||
|
$rows
|
||||||
|
EOF_ROWS
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
say 'Their key is re-fetched on a timer and will resolve to the new mode,'
|
||||||
|
say 'which the endpoint they are still pointed at will not accept. If one'
|
||||||
|
say 'of them takes a reply from the new provider first, that provider'"'"'s'
|
||||||
|
say 'message-id format goes into its transcript and Anthropic will then'
|
||||||
|
say 'refuse to resume that session at all - recoverable only by truncating'
|
||||||
|
say 'it (claude-mode repair-session), which loses the turns after the cut.'
|
||||||
|
[ "${busy:-0}" -gt 0 ] && warn "$busy of them is mid-request and is the most likely to be caught"
|
||||||
|
|
||||||
|
if [ "$CM_ASSUME_YES" -eq 1 ]; then
|
||||||
|
say 'proceeding (--yes)'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! ui_interactive; then
|
||||||
|
printf '\n'
|
||||||
|
err 'refusing to switch while sessions are running'
|
||||||
|
say 'restart or close them first, or pass --yes to switch anyway'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
say 'r switch, then close and reopen them on the new mode (safest)'
|
||||||
|
say 'c switch, then close them'
|
||||||
|
say 's switch and leave them running (risks the above)'
|
||||||
|
say 'a abort'
|
||||||
|
printf '\n [r/c/s/A] '
|
||||||
|
IFS= read -r reply
|
||||||
|
case "$reply" in
|
||||||
|
r|R) CM_SESSION_ACTION=restart; return 0 ;;
|
||||||
|
c|C) CM_SESSION_ACTION=stop; return 0 ;;
|
||||||
|
s|S) CM_SESSION_ACTION=none; return 0 ;;
|
||||||
|
*) say 'aborted; nothing was changed'; return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run after the write, never before: a session reopened first would come back up
|
||||||
|
# on the mode being left behind.
|
||||||
|
cm_apply_session_action() {
|
||||||
|
[ "$CM_SESSION_ACTION" = "none" ] && return 0
|
||||||
|
[ -n "$CM_SESSION_ROWS" ] || return 0
|
||||||
|
printf '\n'
|
||||||
|
cm_session_act "$CM_SESSION_ACTION" "$CM_SESSION_ROWS" 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# shellcheck shell=bash
|
||||||
|
# linux/lib/setup.sh - first-run setup: key, server, models.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# First-run setup
|
||||||
|
#
|
||||||
|
# A shipped preset is a starting point, not a working configuration. OpenRouter
|
||||||
|
# needs a key before it can serve anything; LM Studio needs to be told where the
|
||||||
|
# server is and which of the models it actually has installed to use - and the
|
||||||
|
# ids it ships with are whatever happened to be on the machine this was written
|
||||||
|
# on, which is almost certainly not yours.
|
||||||
|
#
|
||||||
|
# So a preset says whether it has been through setup. `configured: false` is
|
||||||
|
# written into the shipped presets and cleared once setup has run, and preflight
|
||||||
|
# treats it as a blocker: better to be walked through it once than to switch
|
||||||
|
# into something that half-works and produces a confusing failure later.
|
||||||
|
#
|
||||||
|
# Absent means configured. That is deliberate - presets that predate this, and
|
||||||
|
# ones the user built by hand with `preset new`, are their own business and must
|
||||||
|
# not suddenly start demanding a wizard.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
preset_configured() {
|
||||||
|
local v; v="$(jget "$1" configured)"
|
||||||
|
[ "$v" = "false" ] && return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
mark_configured() {
|
||||||
|
"$PY" "$JSON" set-flag "$1" configured true >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# A y/N prompt that defaults to no on anything that is not a clear yes.
|
||||||
|
ask_yes() {
|
||||||
|
local prompt="$1" reply
|
||||||
|
printf ' %s [y/N] ' "$prompt"
|
||||||
|
IFS= read -r reply || return 1
|
||||||
|
case "$reply" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prompt with a default shown in brackets; empty input keeps the default.
|
||||||
|
#
|
||||||
|
# The prompt goes to stderr for the same reason warn/err do: this is called
|
||||||
|
# inside $( ), where anything on stdout is captured as the return value. Printed
|
||||||
|
# to stdout it came back as part of the answer - " server base URL [...]: " with
|
||||||
|
# the typed URL glued on the end, which set-url then rejected.
|
||||||
|
ask_value() {
|
||||||
|
local prompt="$1" default="$2" reply
|
||||||
|
if [ -n "$default" ]; then printf ' %s [%s]: ' "$prompt" "$default" >&2
|
||||||
|
else printf ' %s: ' "$prompt" >&2; fi
|
||||||
|
IFS= read -r reply || return 1
|
||||||
|
[ -n "$reply" ] && printf '%s' "$reply" || printf '%s' "$default"
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_key() {
|
||||||
|
local ref="$1" label="$2"
|
||||||
|
if cm_vault_has "$ref"; then
|
||||||
|
ok "a key is already stored for '$ref' ($(cm_vault_backend_label))"
|
||||||
|
ask_yes "replace it?" || return 0
|
||||||
|
else
|
||||||
|
say "$label needs an API key. It goes into $(cm_vault_backend_label),"
|
||||||
|
say 'not into settings.json.'
|
||||||
|
fi
|
||||||
|
cmd_set_key "$ref"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Offer the provider's own catalogue rather than asking someone to type a model
|
||||||
|
# id from memory. Falls back to typing when the catalogue cannot be reached,
|
||||||
|
# because being offline should not block finishing setup.
|
||||||
|
setup_models() {
|
||||||
|
local pf="$1" provider="$2" name="$3"
|
||||||
|
printf '\n'
|
||||||
|
say 'current model map:'
|
||||||
|
local t v
|
||||||
|
for t in "${TIERS[@]}"; do
|
||||||
|
v="$(jget "$pf" "models.$t")"
|
||||||
|
[ -n "$v" ] && printf ' %-8s %s\n' "$t" "$v"
|
||||||
|
done
|
||||||
|
printf '\n'
|
||||||
|
ask_yes 'change which models back these tiers?' || return 0
|
||||||
|
|
||||||
|
if ! ui_interactive; then
|
||||||
|
warn 'model picking needs an interactive terminal'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(prov_field "$provider" 13)" = one-for-all ]; then
|
||||||
|
# One model for every tier is the normal shape for a local server: it
|
||||||
|
# has one loaded at a time, and mapping tiers to different models just
|
||||||
|
# means paying the load cost on every tier change.
|
||||||
|
local base ids=() id st ctx
|
||||||
|
base="$(jget "$pf" baseUrl)"
|
||||||
|
while IFS=$'\t' read -r id st ctx; do
|
||||||
|
[ -n "$id" ] || continue
|
||||||
|
ids+=("$id")
|
||||||
|
done < <(provider_catalogue "$provider" "$base" "$(cm_preset_token "$pf")")
|
||||||
|
|
||||||
|
if [ "${#ids[@]}" -eq 0 ]; then
|
||||||
|
warn 'the server returned no models; type an id by hand instead'
|
||||||
|
local manual; manual="$(ask_value 'model id for every tier' "$(jget "$pf" models.opus)")"
|
||||||
|
[ -n "$manual" ] && "$PY" "$JSON" set-all "$pf" "$manual" >/dev/null && ok "all tiers -> $manual"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ui_reset_items
|
||||||
|
for id in "${ids[@]}"; do ui_add_item "$id" 'use this for every tier'; done
|
||||||
|
if ui_filter_select "model for all tiers of '$name'" 'esc = keep current'; then
|
||||||
|
"$PY" "$JSON" set-all "$pf" "${ids[$UI_SEL]}" >/dev/null
|
||||||
|
ok "all tiers -> ${ids[$UI_SEL]}"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remote gateways map a different model per tier, which is the whole point
|
||||||
|
# of them, so each tier is asked for separately.
|
||||||
|
local cur
|
||||||
|
for t in "${TIERS[@]}"; do
|
||||||
|
cur="$(jget "$pf" "models.$t")"
|
||||||
|
ui_pick_model "$pf" "$t" "$cur" || continue
|
||||||
|
[ -n "$UI_PICKED" ] || continue
|
||||||
|
"$PY" "$JSON" set-tier "$pf" "$t" "$UI_PICKED" >/dev/null && ok "$t -> $UI_PICKED"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Where a server provider (LM Studio, Ollama, a custom endpoint) lives, and
|
||||||
|
# whether it wants a key. None of them has to be on this machine.
|
||||||
|
setup_server() {
|
||||||
|
local pf="$1" name="$2" mode="$3" url probe token title hint start
|
||||||
|
title="$(prov_field "$mode" 3)"; hint="$(prov_field "$mode" 20)"; start="$(prov_field "$mode" 21)"
|
||||||
|
url="$(jget "$pf" baseUrl)"; [ -n "$url" ] || url="$(prov_field "$mode" 19)"
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
[ -n "$hint" ] && say "$hint"
|
||||||
|
url="$(ask_value 'server base URL' "$url")"
|
||||||
|
[ -n "$url" ] || { err 'a server address is needed'; return 1; }
|
||||||
|
"$PY" "$JSON" set-url "$pf" "$url" >/dev/null || return 1
|
||||||
|
ok "baseUrl -> $url"
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
if ask_yes 'does that server require an API key?'; then
|
||||||
|
local ref; ref="$(ask_value 'key name to store it under' "$(prov_field "$mode" 17)")"
|
||||||
|
"$PY" "$JSON" set-auth "$pf" key "$ref" >/dev/null
|
||||||
|
ok "auth -> vault key '$ref'"
|
||||||
|
cm_vault_has "$ref" || cmd_set_key "$ref"
|
||||||
|
token="$(cm_vault_get "$ref" 2>/dev/null || true)"
|
||||||
|
else
|
||||||
|
"$PY" "$JSON" set-auth "$pf" none >/dev/null
|
||||||
|
token="$(prov_field "$mode" 18)"
|
||||||
|
ok "auth -> none (inline placeholder token '$token')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
say "checking $url ..."
|
||||||
|
probe="$(cm_probe_server "$url" "$token" "$(prov_field "$mode" 9)")"
|
||||||
|
case "$probe" in
|
||||||
|
ok) ok 'server answered' ;;
|
||||||
|
auth) err 'the server refused that credential'; return 1 ;;
|
||||||
|
notfound)
|
||||||
|
if [ "$(prov_field "$mode" 8)" = lenient ]; then
|
||||||
|
warn "something answered, but it lists no models - fine for a proxy; model ids are typed by hand"
|
||||||
|
else
|
||||||
|
err "something answered there, but not a $title API"; return 1
|
||||||
|
fi ;;
|
||||||
|
skip) warn 'curl is missing, so the server was not checked' ;;
|
||||||
|
*) err 'nothing answered at that address'
|
||||||
|
say "${start:+$start, then }run: claude-mode setup $mode"
|
||||||
|
return 1 ;;
|
||||||
|
esac
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_setup() {
|
||||||
|
local mode="${1:-}" name pf
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
anthropic)
|
||||||
|
head_ 'setup: anthropic'
|
||||||
|
ok 'nothing to configure - it uses your existing Claude login'
|
||||||
|
return 0 ;;
|
||||||
|
'') err 'usage: claude-mode setup <mode>'; return 1 ;;
|
||||||
|
*) mode="$(provider_resolve "$mode")" || { err "unknown mode '${1:-}'"; return 1; } ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
name="$(resolve_preset "$mode" "${2:-}")" || return 1
|
||||||
|
pf="$(preset_path "$name")"
|
||||||
|
|
||||||
|
if ! ui_interactive; then
|
||||||
|
err 'setup needs an interactive terminal'
|
||||||
|
say "run: claude-mode setup $mode"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
head_ "setup: $mode / preset '$name'"
|
||||||
|
say "$(mode_label "$mode")"
|
||||||
|
|
||||||
|
# A server provider is asked where it is and whether it wants a key; a
|
||||||
|
# hosted one always wants a key, and the entry says where to get one.
|
||||||
|
if [ "$(prov_field "$mode" 7)" = 1 ]; then
|
||||||
|
setup_server "$pf" "$name" "$mode" || return 1
|
||||||
|
else
|
||||||
|
local ref url
|
||||||
|
ref="$(jget "$pf" auth.keyRef)"; [ -n "$ref" ] || ref="$(prov_field "$mode" 17)"
|
||||||
|
url="$(prov_field "$mode" 14)"
|
||||||
|
printf '\n'
|
||||||
|
[ -n "$url" ] && say "get a key from $url"
|
||||||
|
setup_key "$ref" "$(prov_field "$mode" 3)"
|
||||||
|
fi
|
||||||
|
setup_models "$pf" "$mode" "$name"
|
||||||
|
|
||||||
|
mark_configured "$pf"
|
||||||
|
printf '\n'
|
||||||
|
ok "$mode is set up"
|
||||||
|
say "switch to it with: claude-mode $mode"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
# shellcheck shell=bash
|
||||||
|
# linux/lib/switch.sh - the write into settings.json, and what is checked and reported around it.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Switching
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
backup_settings() {
|
||||||
|
[ -f "$CM_SETTINGS" ] || return 0
|
||||||
|
local stamp dest
|
||||||
|
stamp="$(date +%Y%m%d-%H%M%S-%3N 2>/dev/null || date +%Y%m%d-%H%M%S)"
|
||||||
|
dest="$CM_BACKUPS/settings.$stamp.json"
|
||||||
|
cp "$CM_SETTINGS" "$dest"
|
||||||
|
# keep the 20 most recent
|
||||||
|
ls -1t "$CM_BACKUPS"/settings.*.json 2>/dev/null | tail -n +21 | while read -r f; do rm -f "$f"; done
|
||||||
|
printf '%s' "$dest"
|
||||||
|
}
|
||||||
|
|
||||||
|
set_mode() {
|
||||||
|
local mode="$1" preset_name="${2:-}" preset_file="" backup
|
||||||
|
init_root
|
||||||
|
mkdir -p "$CM_SETTINGS_DIR"
|
||||||
|
|
||||||
|
if [ "$mode" != "anthropic" ]; then
|
||||||
|
preset_file="$(preset_path "$preset_name")"
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# Standing in a terminal with the fix one keystroke away, printing
|
||||||
|
# the command to type next is a poor substitute for running it.
|
||||||
|
if ui_interactive && [ "$CM_PF_KIND" = "setup" ]; then
|
||||||
|
printf '\n'
|
||||||
|
if ask_yes "set up $mode now?"; then
|
||||||
|
cmd_setup "$mode" "$preset_name" || return 1
|
||||||
|
cm_preflight "$mode" "$preset_name" || {
|
||||||
|
err "$CM_PF_TITLE"; return 1
|
||||||
|
}
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
[ -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
|
||||||
|
fi
|
||||||
|
[ -f "$preset_file" ] || { err "preset '$preset_name' not found"; return 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
cm_confirm_sessions "$mode" || return 1
|
||||||
|
|
||||||
|
backup="$(backup_settings)"
|
||||||
|
|
||||||
|
if ! "$PY" "$JSON" apply "$CM_SETTINGS" "$CM_STATE" "$mode" "$preset_file" "$CM_HELPER" >/dev/null; then
|
||||||
|
err "failed to update $CM_SETTINGS"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$mode" = "anthropic" ]; then
|
||||||
|
head_ "switched to: anthropic"
|
||||||
|
else
|
||||||
|
head_ "switched to: $mode / preset '$preset_name'"
|
||||||
|
fi
|
||||||
|
[ -n "$backup" ] && ok "settings.json backed up to $backup"
|
||||||
|
|
||||||
|
if [ "$mode" = "anthropic" ]; then
|
||||||
|
ok "all gateway env + apiKeyHelper removed; native Anthropic login is authoritative"
|
||||||
|
else
|
||||||
|
ok "base url $(jget "$preset_file" baseUrl)"
|
||||||
|
local t v
|
||||||
|
for t in "${TIERS[@]}"; do
|
||||||
|
v="$(jget "$preset_file" "models.$t")"
|
||||||
|
[ -n "$v" ] && ok "$(printf '%-7s -> %s' "$t" "$v")"
|
||||||
|
done
|
||||||
|
v="$(jget "$preset_file" contextTokens)"
|
||||||
|
if [ -n "$v" ]; then ok "context -> $v tokens (max + auto-compact window)"
|
||||||
|
else warn "no contextTokens in this preset - Claude Code will guess a small window and compact early"; fi
|
||||||
|
local am; am="$(jget "$preset_file" auth.mode)"; [ -z "$am" ] && am=vault
|
||||||
|
if [ "$am" = "vault" ]; then ok "auth via apiKeyHelper (key never enters settings.json)"
|
||||||
|
else ok "auth inline placeholder token '$(jget "$preset_file" auth.token)' (not a secret)"; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
check_stray_env "$mode"
|
||||||
|
|
||||||
|
if [ "$mode" != "anthropic" ]; then
|
||||||
|
local am2 ref2
|
||||||
|
am2="$(jget "$preset_file" auth.mode)"; [ -z "$am2" ] && am2=vault
|
||||||
|
if [ "$am2" = "vault" ]; then
|
||||||
|
ref2="$(jget "$preset_file" auth.keyRef)"; [ -z "$ref2" ] && ref2=openrouter
|
||||||
|
show_guardrail_status "$mode" "$(cm_vault_get "$ref2" 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
# Auto-repair on the way into a gateway: a [1m] tag on a non-Anthropic id is
|
||||||
|
# meaningless there and breaks compaction. Anthropic ids are left alone, so
|
||||||
|
# switching back to anthropic keeps whatever 1M selection was made.
|
||||||
|
check_stale_models "$mode"
|
||||||
|
write_health "$mode" "$preset_name"
|
||||||
|
|
||||||
|
cm_apply_session_action
|
||||||
|
|
||||||
|
printf '\n %srestart claude (and reload the VS Code window) to pick this up%s\n' "$C_DIM" "$C_RESET"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stray environment variables
|
||||||
|
#
|
||||||
|
# Windows has User/Machine registry scopes; here the equivalent persistence is a
|
||||||
|
# shell rc file, so that is what gets scanned. An export there outranks
|
||||||
|
# settings.json for any shell that sources it.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
managed_keys() { "$PY" "$JSON" managed "$CM_STATE"; }
|
||||||
|
|
||||||
|
# Set by show_guardrail_status when it probes, so health.json can carry the
|
||||||
|
# tri-state result rather than re-probing.
|
||||||
|
CM_GUARDRAIL=''
|
||||||
|
|
||||||
|
cm_version() {
|
||||||
|
local v=''
|
||||||
|
[ -f "$CM_ROOT/VERSION" ] && v="$(tr -d '[:space:]' < "$CM_ROOT/VERSION")"
|
||||||
|
printf '%s' "${v:-0.0.0}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Machine-readable state for a fleet reader. See cm-json.py cmd_health for the
|
||||||
|
# contract; the short version is: no key material, model lists carry an
|
||||||
|
# `anthropic` flag, guardrailStatus is tri-state.
|
||||||
|
write_health() {
|
||||||
|
local mode="$1" preset="$2" backend=''
|
||||||
|
case "$(cm_vault_backend)" in
|
||||||
|
security) backend='keychain' ;;
|
||||||
|
secret-tool) backend='secret-tool' ;;
|
||||||
|
pass) backend='pass' ;;
|
||||||
|
file) backend='file' ;;
|
||||||
|
esac
|
||||||
|
"$PY" "$JSON" health "$CM_ROOT" "$HOME/.claude.json" "$mode" "$preset" \
|
||||||
|
"$CM_GUARDRAIL" "$backend" "$(cm_version)" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ask OpenRouter whether this key can still reach Anthropic models, by trying the
|
||||||
|
# cheapest possible request against one. Free when the guardrail blocks it; a
|
||||||
|
# fraction of a cent when it does not, which is exactly the case worth knowing.
|
||||||
|
#
|
||||||
|
# OpenRouter only: Z.AI and LM Studio have no equivalent control, so there is
|
||||||
|
# nothing actionable to print for them.
|
||||||
|
show_guardrail_status() {
|
||||||
|
local mode="$1" key="$2" code
|
||||||
|
# An OpenRouter feature, flagged per provider rather than by name.
|
||||||
|
[ "$(prov_field "$mode" 15)" = 1 ] || return 0
|
||||||
|
[ -n "$key" ] || return 0
|
||||||
|
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 25 \
|
||||||
|
-X POST 'https://openrouter.ai/api/v1/messages' \
|
||||||
|
-H 'content-type: application/json' -H "x-api-key: $key" \
|
||||||
|
-H "authorization: Bearer $key" -H 'anthropic-version: 2023-06-01' \
|
||||||
|
-d '{"model":"claude-opus-5","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}' 2>/dev/null)"
|
||||||
|
|
||||||
|
# 403/404 is OpenRouter refusing the model, which is what a guardrail looks
|
||||||
|
# like. 401 is the KEY being rejected - that says nothing about the guardrail
|
||||||
|
# and must not read as an all-clear.
|
||||||
|
case "$code" in
|
||||||
|
403|404)
|
||||||
|
CM_GUARDRAIL=active
|
||||||
|
printf ' %sguardrail %s%sactive%s%s - Anthropic models blocked for this key%s\n' \
|
||||||
|
"$C_DIM" "$C_RESET" "$C_GREEN" "$C_RESET" "$C_DIM" "$C_RESET"
|
||||||
|
;;
|
||||||
|
401)
|
||||||
|
CM_GUARDRAIL=unknown
|
||||||
|
printf ' %sguardrail %s%sunknown%s%s - OpenRouter rejected the key, so it could not be checked%s\n' \
|
||||||
|
"$C_DIM" "$C_RESET" "$C_YELLOW" "$C_RESET" "$C_DIM" "$C_RESET"
|
||||||
|
;;
|
||||||
|
200)
|
||||||
|
CM_GUARDRAIL=not_set
|
||||||
|
printf ' %sguardrail %s%sNOT SET%s%s - Anthropic models reachable, billed at list price%s\n' \
|
||||||
|
"$C_DIM" "$C_RESET" "$C_RED" "$C_RESET" "$C_DIM" "$C_RESET"
|
||||||
|
printf ' %sopenrouter.ai -> Guardrails -> new, select this key,%s\n' "$C_DIM" "$C_RESET"
|
||||||
|
printf ' %sthen exclude anthropic models (or allow only the ones you use)%s\n' "$C_DIM" "$C_RESET"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
# Only an explicit rejection proves the guardrail; anything else says
|
||||||
|
# nothing, and a false all-clear on a safety check is worse than none.
|
||||||
|
CM_GUARDRAIL=unknown
|
||||||
|
printf ' %sguardrail %s%sunknown%s%s - could not reach OpenRouter to check%s\n' \
|
||||||
|
"$C_DIM" "$C_RESET" "$C_YELLOW" "$C_RESET" "$C_DIM" "$C_RESET"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Claude Code caches a resolved model per (entrypoint, model, org) in
|
||||||
|
# ~/.claude.json. A session running before a switch, or a model picked from a
|
||||||
|
# gateway's own catalogue, keeps that id - and the gateway then bills it at full
|
||||||
|
# list price. Detect and say so; claude-mode cannot police runtime model choice.
|
||||||
|
check_stale_models() {
|
||||||
|
local mode="$1" cfg="$HOME/.claude.json" found
|
||||||
|
[ "$mode" = "anthropic" ] && return 0
|
||||||
|
[ -f "$cfg" ] || return 0
|
||||||
|
found="$("$PY" "$JSON" stale-models "$cfg" 2>/dev/null)"
|
||||||
|
[ -n "$found" ] || return 0
|
||||||
|
|
||||||
|
local na nt
|
||||||
|
na="$(printf '%s\n' "$found" | grep -c '^anthropic' || true)"
|
||||||
|
nt="$(printf '%s\n' "$found" | grep -c '^tagged' || true)"
|
||||||
|
|
||||||
|
if [ "${na:-0}" -gt 0 ]; then
|
||||||
|
printf ' %ssessions %s%s%s cached Anthropic model ids%s%s - restart running claude sessions%s\n' \
|
||||||
|
"$C_DIM" "$C_RESET" "$C_YELLOW" "$na" "$C_RESET" "$C_DIM" "$C_RESET"
|
||||||
|
fi
|
||||||
|
if [ "${nt:-0}" -gt 0 ]; then
|
||||||
|
printf ' %stagged %s%s%s model id(s) carry a [1m] tag%s%s - breaks compaction on gateways; claude-mode repair%s\n' \
|
||||||
|
"$C_DIM" "$C_RESET" "$C_YELLOW" "$nt" "$C_RESET" "$C_DIM" "$C_RESET"
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Strip extended-context tags from cached model ids. Backed up first; the file is
|
||||||
|
# the user's own Claude Code config, not ours.
|
||||||
|
# $1 = 'all' to include Anthropic ids, '' for gateway ids only.
|
||||||
|
# $2 = 'quiet' for the one-line form used on a mode switch.
|
||||||
|
cmd_repair() {
|
||||||
|
local scope="${1:-}" quiet="${2:-}" cfg="$HOME/.claude.json" bak out ns nk
|
||||||
|
[ -f "$cfg" ] || { [ "$quiet" = "quiet" ] || err 'no ~/.claude.json'; return 0; }
|
||||||
|
init_root
|
||||||
|
bak="$CM_BACKUPS/claude.json.$(date +%Y%m%d-%H%M%S).bak"
|
||||||
|
cp "$cfg" "$bak"
|
||||||
|
|
||||||
|
out="$("$PY" "$JSON" strip-tags "$cfg" "$scope" 2>/dev/null)"
|
||||||
|
ns="$(printf '%s\n' "$out" | grep -c '^strip' || true)"
|
||||||
|
nk="$(printf '%s\n' "$out" | grep -c '^keep' || true)"
|
||||||
|
|
||||||
|
if [ "${ns:-0}" -eq 0 ]; then
|
||||||
|
rm -f "$bak"
|
||||||
|
if [ "$quiet" != "quiet" ]; then
|
||||||
|
if [ "${nk:-0}" -gt 0 ]; then
|
||||||
|
ok "nothing to strip - $nk tagged id(s) are Anthropic models, where the tag is meaningful"
|
||||||
|
printf '%s\n' "$out" | grep '^keep' | cut -f2 | sed 's/^/ keeping /'
|
||||||
|
printf ' %suse --all to strip those too (downgrades them to the 200k variant)%s\n' "$C_DIM" "$C_RESET"
|
||||||
|
else
|
||||||
|
ok 'no tagged model ids in ~/.claude.json'
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$quiet" = "quiet" ]; then
|
||||||
|
printf ' %srepaired %s%s%s gateway model id(s) had a [1m] tag stripped%s\n' \
|
||||||
|
"$C_DIM" "$C_RESET" "$C_GREEN" "$ns" "$C_RESET"
|
||||||
|
else
|
||||||
|
printf '%s\n' "$out" | grep '^strip' | cut -f2 | sed 's/^/ /'
|
||||||
|
printf '%s\n' "$out" | grep '^keep' | cut -f2 | sed 's/^/ keeping /'
|
||||||
|
ok "stripped $ns tag(s); backup at $bak"
|
||||||
|
printf ' %srestart claude for this to take effect%s\n' "$C_DIM" "$C_RESET"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_stray_env() {
|
||||||
|
local mode="$1" problems=0 k f
|
||||||
|
local rcfiles=("$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile" "$HOME/.zshrc" "$HOME/.zshenv" "/etc/environment")
|
||||||
|
|
||||||
|
if [ "$mode" != "anthropic" ] && [ -n "${ANTHROPIC_API_KEY:-}" ]; then
|
||||||
|
warn 'ANTHROPIC_API_KEY is set in THIS shell. The `claude` wrapper strips it; other shells are unaffected.'
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS= read -r k; do
|
||||||
|
[ -n "$k" ] || continue
|
||||||
|
for f in "${rcfiles[@]}"; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
# our own managed block is not a stray export
|
||||||
|
if grep -qE "^[[:space:]]*(export[[:space:]]+)?$k=" "$f" 2>/dev/null; then
|
||||||
|
err "$k is exported in $f - it overrides claude-mode for every new shell"
|
||||||
|
problems=$((problems+1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done < <(managed_keys)
|
||||||
|
|
||||||
|
return $problems
|
||||||
|
}
|
||||||
@@ -79,14 +79,16 @@ $manifest | ConvertTo-Json -Depth 5 |
|
|||||||
$posixStage = Join-Path ([System.IO.Path]::GetTempPath()) ("cm-posix-" + [Guid]::NewGuid().ToString('N').Substring(0, 8))
|
$posixStage = Join-Path ([System.IO.Path]::GetTempPath()) ("cm-posix-" + [Guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||||
New-Item -ItemType Directory -Path (Join-Path $posixStage 'linux') -Force | Out-Null
|
New-Item -ItemType Directory -Path (Join-Path $posixStage 'linux') -Force | Out-Null
|
||||||
New-Item -ItemType Directory -Path (Join-Path $posixStage 'presets') -Force | Out-Null
|
New-Item -ItemType Directory -Path (Join-Path $posixStage 'presets') -Force | Out-Null
|
||||||
Get-ChildItem (Join-Path $root 'linux') -File | ForEach-Object { Copy-Item $_.FullName (Join-Path $posixStage 'linux') -Force }
|
# Recursive: the CLI's modules live in linux/lib/, and a flat copy would ship a
|
||||||
|
# package that installs a claude-mode unable to start.
|
||||||
|
Copy-Item (Join-Path $root 'linux\*') (Join-Path $posixStage 'linux') -Recurse -Force
|
||||||
Get-ChildItem (Join-Path $root 'presets') -File | ForEach-Object { Copy-Item $_.FullName (Join-Path $posixStage 'presets') -Force }
|
Get-ChildItem (Join-Path $root 'presets') -File | ForEach-Object { Copy-Item $_.FullName (Join-Path $posixStage 'presets') -Force }
|
||||||
Copy-Item (Join-Path $root 'VERSION') $posixStage -Force
|
Copy-Item (Join-Path $root 'VERSION') $posixStage -Force
|
||||||
Copy-Item (Join-Path $root 'providers.json') $posixStage -Force
|
Copy-Item (Join-Path $root 'providers.json') $posixStage -Force
|
||||||
|
|
||||||
# Shell scripts authored on Windows carry CRLF, which makes the kernel reject
|
# Shell scripts authored on Windows carry CRLF, which makes the kernel reject
|
||||||
# the "#!/usr/bin/env bash" line. Normalise before packaging.
|
# the "#!/usr/bin/env bash" line. Normalise before packaging.
|
||||||
Get-ChildItem (Join-Path $posixStage 'linux') -File | ForEach-Object {
|
Get-ChildItem (Join-Path $posixStage 'linux') -File -Recurse | ForEach-Object {
|
||||||
$text = [System.IO.File]::ReadAllText($_.FullName) -replace "`r`n", "`n"
|
$text = [System.IO.File]::ReadAllText($_.FullName) -replace "`r`n", "`n"
|
||||||
[System.IO.File]::WriteAllText($_.FullName, $text, (New-Object System.Text.UTF8Encoding($false)))
|
[System.IO.File]::WriteAllText($_.FullName, $text, (New-Object System.Text.UTF8Encoding($false)))
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# linux/install.sh, run for real into a sandbox home: every file lands, the
|
||||||
|
# ~/.local/bin symlink runs (which exercises the CLI finding its lib/ beside
|
||||||
|
# itself through a symlink), re-running is a safe upgrade.
|
||||||
|
. "$(dirname "$0")/../lib.sh"
|
||||||
|
|
||||||
|
export HOME="$SB/ihome"
|
||||||
|
export CM_ROOT="$HOME/.claude-mode"
|
||||||
|
mkdir -p "$HOME"
|
||||||
|
: > "$HOME/.bashrc"
|
||||||
|
|
||||||
|
install_() { OUT="$(bash "$REPO/linux/install.sh" --skip-key-prompt 2>&1)"; RC=$?; OUT="$(printf '%s' "$OUT" | sed "s/${ESC}\[[0-9;]*m//g")"; }
|
||||||
|
|
||||||
|
install_
|
||||||
|
expect_rc 0 'the installer succeeds'
|
||||||
|
for f in bin/claude-mode bin/cm-json.py bin/cm-vault.sh bin/claude-key-helper.sh \
|
||||||
|
bin/lib/core.sh bin/lib/repair.sh providers.json VERSION presets/ollama.json health.json; do
|
||||||
|
expect_file "$CM_ROOT/$f" "installs $f"
|
||||||
|
done
|
||||||
|
expect_eq "$(readlink "$HOME/.local/bin/claude-mode")" "$CM_ROOT/bin/claude-mode" 'links the entry point'
|
||||||
|
expect_eq "$(grep -c '>>> claude-mode >>>' "$HOME/.bashrc")" 1 'adds the shell block'
|
||||||
|
|
||||||
|
OUT="$("$HOME/.local/bin/claude-mode" presets 2>&1)"
|
||||||
|
expect_out 'ollama' 'runs through the symlink, loading its lib/'
|
||||||
|
|
||||||
|
python3 "$CM_ROOT/bin/cm-json.py" set-tier "$CM_ROOT/presets/default.json" opus my/model
|
||||||
|
: > "$CM_ROOT/bin/lib/stale.sh"
|
||||||
|
install_
|
||||||
|
expect_rc 0 're-running the installer succeeds'
|
||||||
|
expect_eq "$(jget "$CM_ROOT/presets/default.json" models.opus)" my/model 'and keeps an edited preset'
|
||||||
|
expect_no_file "$CM_ROOT/bin/lib/stale.sh" 'and drops a module the source no longer has'
|
||||||
|
expect_eq "$(grep -c '>>> claude-mode >>>' "$HOME/.bashrc")" 1 'and does not add the block twice'
|
||||||
|
|
||||||
|
finish
|
||||||
Reference in New Issue
Block a user