One preset per mode, and a first-run setup that fills it in

The openrouter default moves its hot tiers: opus to z-ai/glm-5.3-flash and
sonnet to deepseek/deepseek-v4-flash-0731. haiku and fable are unchanged.

`cheap` and `lmstudio-qwen` are gone, leaving exactly one preset per mode so
`claude-mode <mode>` is never ambiguous and there is no menu to read before the
thing you asked for happens. The surviving lmstudio preset keeps the Qwen3.6
model rather than KAT-Coder: the two differed mainly in that KAT's chat template
carries the message-order assertion this README already warns about, so between
two presets that had to become one, the one that is known to work won.

More presets are still a `preset new` away; the shipped set is a starting point,
not a ceiling.

Which is the other half of this. A shipped preset was never a working
configuration - OpenRouter and Z.AI have no key stored, and lmstudio's model ids
were whatever happened to be installed on the machine this was packaged on. That
was left for the user to discover through a failure. Now the shipped presets
carry `configured: false`, preflight blocks on it, and `claude-mode setup <mode>`
walks through what is actually needed: key, server URL and auth for LM Studio,
then models chosen from the provider's own catalogue rather than typed from
memory. A switch that trips this in a terminal offers to run setup there and
then instead of printing a command to type next.

Absent means configured, deliberately: presets that predate this and any built
by hand with `preset new` do not suddenly start demanding a wizard.

The panel gets a "Set up <mode>…" button that hands the whole flow to a terminal,
since a bar popup can host neither a hidden key prompt nor a filter-select list.

Two bugs found while testing it, both real:

ask_value printed its prompt to stdout while being called inside $( ), so the
prompt text came back glued to the front of the answer and set-url rejected the
result. Moved to stderr, which is why warn and err already go there.

lms_catalogue never sent the API key. On a server with authentication switched
on - the case just added support for - /api/v0/models answers 401 like anything
else, so the catalogue came back empty and every caller silently concluded the
server had no models installed. It now sends the preset's credential, as do the
three other call sites that read it.
This commit is contained in:
smoido
2026-08-31 01:48:17 +03:00
parent 9c301e10a1
commit 9ff9b82c25
9 changed files with 394 additions and 77 deletions
+280 -8
View File
@@ -274,6 +274,7 @@ claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio
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
@@ -453,6 +454,13 @@ cm_preflight() {
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
base="$(jget "$pf" baseUrl)"; CM_PF_BASEURL="$base"
auth_mode="$(jget "$pf" auth.mode)"; [ -z "$auth_mode" ] && auth_mode=vault
@@ -868,9 +876,24 @@ set_mode() {
if [ "$CM_FORCE" -eq 0 ] && ! cm_preflight "$mode" "$preset_name"; then
err "$CM_PF_TITLE"
[ -n "$CM_PF_DETAIL" ] && say "$CM_PF_DETAIL"
[ -n "$CM_PF_REMEDY" ] && printf ' %sfix:%s %s\n' "$C_DIM" "$C_RESET" "$CM_PF_REMEDY"
printf ' %s--force switches anyway%s\n' "$C_DIM" "$C_RESET"
return 1
# 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
@@ -1107,9 +1130,26 @@ or_catalogue() {
curl -fsS --max-time 30 https://openrouter.ai/api/v1/models 2>/dev/null | "$PY" "$JSON" or-models 2>/dev/null
}
# 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 is not optional decoration. An LM Studio server with authentication
# switched on answers /api/v0/models with 401 like anything else, so without it
# the catalogue comes back empty and every caller silently believes the server
# has no models installed - on exactly the setups that need the list most.
lms_catalogue() {
local base="${1%/}"
curl -fsS --max-time 10 "$base/api/v0/models" 2>/dev/null | "$PY" "$JSON" lms-models 2>/dev/null
local base="${1%/}" token="${2:-}"
curl -fsS --max-time 10 ${token:+-H "Authorization: Bearer $token"} \
"$base/api/v0/models" 2>/dev/null | "$PY" "$JSON" lms-models 2>/dev/null
}
# ---------------------------------------------------------------------------
@@ -1181,7 +1221,7 @@ cmd_models() {
case "$mode" in
lmstudio)
head_ "models installed in LM Studio at $(jget "$pf" baseUrl)"
lms_catalogue "$(jget "$pf" baseUrl)" | while IFS=$'\t' read -r id st ctx; do
lms_catalogue "$(jget "$pf" baseUrl)" "$(cm_preset_token "$pf")" | while IFS=$'\t' read -r id st ctx; do
[ -z "$filter" ] || case "$id" in *"$filter"*) ;; *) continue ;; esac
printf ' %-58s %-11s %s\n' "$id" "$st" "$ctx"
done
@@ -1295,7 +1335,7 @@ else: print(' ok spend %.2f of %.2f limit (%s), %.2f remaining' % (use, lim,
fi
if [ "$mode" = "lmstudio" ]; then
local cat; cat="$(lms_catalogue "$base")"
local cat; cat="$(lms_catalogue "$base" "$(cm_preset_token "$pf")")"
if [ -n "$cat" ]; then
ok "LM Studio reachable at $base ($(printf '%s\n' "$cat" | wc -l | tr -d ' ') models installed)"
local t id row st ctx
@@ -1636,7 +1676,7 @@ ui_pick_model() {
while IFS=$'\t' read -r id st ctx; do
[ -n "$id" ] || continue
ids+=("$id"); ui_add_item "$id" "state: $st max context: $ctx"
done < <(lms_catalogue "$base")
done < <(lms_catalogue "$base" "$(cm_preset_token "$pf")")
;;
zai)
ids+=('glm-5.3'); ui_add_item 'glm-5.3' 'flagship coding model - opus/sonnet tier'
@@ -1783,6 +1823,217 @@ ui_menu() {
done
}
# ---------------------------------------------------------------------------
# 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 [ "$provider" = "lmstudio" ]; 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 < <(lms_catalogue "$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
}
setup_lmstudio_server() {
local pf="$1" name="$2" url probe token
url="$(jget "$pf" baseUrl)"; [ -n "$url" ] || url='http://127.0.0.1:1234'
printf '\n'
say 'LM Studio does not have to be on this machine - a LAN address or'
say 'anything reachable through a tunnel or proxy works just as well.'
url="$(ask_value 'server base URL' "$url")"
"$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' 'lmstudio')"
"$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
ok 'auth -> none (inline placeholder token)'
token='lmstudio'
fi
printf '\n'
say "checking $url ..."
probe="$(cm_probe_server "$url" "$token")"
case "$probe" in
ok) ok 'server answered' ;;
auth) err 'the server refused that credential'; return 1 ;;
notfound) err 'something answered there, but not an LM Studio API' ; return 1 ;;
skip) warn 'curl is missing, so the server was not checked' ;;
*) err 'nothing answered at that address'
say 'start the server and run: claude-mode setup lmstudio'
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 ;;
openrouter|zai|lmstudio) ;;
z.ai|z-ai) mode=zai ;;
'') err 'usage: claude-mode setup <mode>'; return 1 ;;
*) err "unknown mode '$mode'"; 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")"
case "$mode" in
openrouter)
printf '\n'
setup_key openrouter 'OpenRouter'
setup_models "$pf" openrouter "$name"
;;
zai)
printf '\n'
say 'get a key from https://z.ai/manage-apikey/apikey-list'
setup_key zai 'Z.AI'
setup_models "$pf" zai "$name"
;;
lmstudio)
setup_lmstudio_server "$pf" "$name" || return 1
setup_models "$pf" lmstudio "$name"
;;
esac
mark_configured "$pf"
printf '\n'
ok "$mode is set up"
say "switch to it with: claude-mode $mode"
return 0
}
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
@@ -1836,6 +2087,27 @@ case "$cmd" in
doctor) cmd_doctor ;;
health) write_health "$(state_mode)" "$(state_preset)" ;;
preflight) cmd_preflight "${1:-}" "${2:-}" ;;
setup)
# --terminal for callers with no stdin to offer. The bar widget
# cannot host a hidden key prompt or a filter-select list, so it
# asks a terminal to host the whole flow instead.
_mode="${1:-}"; _term=0; _rest=''
for _a in "$@"; do
case "$_a" in
--terminal) _term=1 ;;
"$_mode") ;;
*) _rest="$_a" ;;
esac
done
if [ "$_term" -eq 1 ]; then
_t="$(cm_terminal_cmd)"
setsid nohup "$_t" -e bash -lc \
"'$0' setup '$_mode' $_rest; printf '\n press enter to close '; read -r _" \
>/dev/null 2>&1 &
ok "opened $_t to set up $_mode"
else
cmd_setup "$_mode" "$_rest"
fi ;;
sessions) cmd_sessions "$@" ;;
repair)
scope=''
+26
View File
@@ -579,6 +579,30 @@ def cmd_set_auth(argv):
save(path, p)
print(json.dumps(p["auth"]))
def cmd_set_flag(argv):
"""set-flag <preset> <key> true|false
Only used for `configured` so far. Kept generic because a preset-level
boolean written by hand is exactly the kind of thing that ends up as a
string "false", which is truthy everywhere that matters.
"""
path, key, val = argv[0], argv[1], argv[2]
p = load(path)
p[key] = (val == "true")
save(path, p)
def cmd_set_all(argv):
"""set-all <preset> <model-id> - point every tier and the subagent at one id."""
path, model = argv[0], argv[1]
p = load(path)
p["models"] = {t: model for t in TIERS}
if p.get("subagentModel") and p["subagentModel"] != "inherit":
p["subagentModel"] = model
save(path, p)
print(model)
COMMANDS = {
"health": cmd_health,
"preflight-json": cmd_preflight_json,
@@ -592,6 +616,8 @@ COMMANDS = {
"models": cmd_models,
"set-tier": cmd_set_tier,
"set-url": cmd_set_url,
"set-flag": cmd_set_flag,
"set-all": cmd_set_all,
"set-auth": cmd_set_auth,
"scaffold": cmd_scaffold,
"get": cmd_get,