diff --git a/README.md b/README.md index f8ff28e..499e63d 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ claude-mode preset new [from] copy an existing preset (default: `defaul claude-mode preset new --provider

[--blank] copy that provider's default, or start empty claude-mode preset rename repoints state.json if it is in use +claude-mode preset default [provider] [name|--clear] which preset `claude-mode ` picks claude-mode preset set claude-mode preset all point every tier at one model claude-mode preset rm @@ -204,8 +205,21 @@ claude-mode sessions --dry-run show what either would do, and do nothing claude-mode --force switch even when preflight says no ``` -Omitting the preset uses a **fixed** per-provider default, not "most recently -used" — so `claude-mode openrouter` always means `default`. +Omitting the preset uses a per-provider default, never "most recently used". +Out of the box that is a fixed name — `claude-mode openrouter` means `default`, +`zai` means `zai`, `lmstudio` means `lmstudio` — and it can be changed: + +```bash +claude-mode preset default # what each provider picks, and why +claude-mode preset default openrouter cheap # `claude-mode openrouter` now means `cheap` +claude-mode preset default openrouter --clear # back to the built-in name +``` + +The choice lives in `~/.claude-mode/defaults.json`. If the chosen preset is +deleted the choice is cleared with it, renaming moves it along, and a choice +whose file has gone some other way simply falls back to the built-in name. If +that is gone too, the provider's first preset by name is used. The Windows build +does not read this file yet, and keeps the built-in names. Editing the active preset re-applies it straight away. For `preset set` and `preset all` that happens **without** asking about running sessions: a switch diff --git a/linux/claude-mode b/linux/claude-mode index 19829a6..440a739 100755 --- a/linux/claude-mode +++ b/linux/claude-mode @@ -18,6 +18,7 @@ CM_BACKUPS="$CM_ROOT/backups" CM_STATE="$CM_ROOT/state.json" CM_IGNORED="$CM_ROOT/ignored-sessions.json" CM_MODELS_CACHE="$CM_ROOT/models-cache.json" +CM_DEFAULTS="$CM_ROOT/defaults.json" CM_SETTINGS_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" CM_SETTINGS="$CM_SETTINGS_DIR/settings.json" CM_HELPER="$CM_BIN/claude-key-helper.sh" @@ -269,6 +270,8 @@ claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio claude-mode preset new --provider

[--blank] copy that provider's default, or start empty claude-mode preset rename + claude-mode preset default [provider] [name|--clear] + which preset 'claude-mode ' picks claude-mode preset set claude-mode preset all claude-mode preset url point a preset at another server @@ -359,7 +362,12 @@ term_cols() { printf '%s' "$c" } -default_preset_for() { +# The preset `claude-mode ` 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() { case "$1" in openrouter) printf 'default' ;; zai) printf 'zai' ;; @@ -367,6 +375,15 @@ default_preset_for() { esac } +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 @@ -1571,6 +1588,7 @@ cmd_preset() { 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 @@ -1588,6 +1606,7 @@ cmd_preset() { 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 @@ -1596,14 +1615,17 @@ cmd_preset() { [ -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" 2>&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' - write_health "$(state_mode)" "$new" ;; + 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 '; return 1; } @@ -1613,12 +1635,62 @@ cmd_preset() { 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 ` # 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 --provider $prov [--blank]" fi + write_health "$(state_mode)" "$(state_preset)" + ;; + default) + # Which preset `claude-mode ` 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 openrouter zai lmstudio; 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 (or --clear)%s\n' "$C_DIM" "$C_RESET" + return 0 + fi + case "$prov" in + openrouter|zai|lmstudio) ;; + *) err "unknown provider '$prov' - openrouter, zai or lmstudio"; return 1 ;; + esac + 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:-}" @@ -1913,7 +1985,9 @@ UI_PICKED='' ui_pick_preset() { local provider="$1" def names name UI_PICKED='' - def="$(default_preset_for "$provider")" + # resolve_preset, not default_preset_for: the mark should land on whatever + # `claude-mode ` 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; } diff --git a/linux/cm-json.py b/linux/cm-json.py index e1fb7c7..b5405e1 100644 --- a/linux/cm-json.py +++ b/linux/cm-json.py @@ -17,8 +17,9 @@ Subcommands: scan-sessions [max-age-days] [ignored] broken transcripts, as JSON ignore-session add|remove|clear|list [id] cache-models <1|0> [baseUrl] store a TSV catalogue (stdin) - preset-rename rename, repointing state.json + preset-rename

[defaults] rename, repointing state + default auth-of "modekeyRef"; fails if missing + set-default [name] choose (or clear) a provider default """ import glob @@ -62,6 +63,10 @@ BASE_MANAGED = [ TIERS = ["opus", "sonnet", "haiku", "fable"] +# Mirrors builtin_default_preset() in the CLI: what `claude-mode ` +# picks when no preset is named and none has been chosen with `preset default`. +BUILTIN_DEFAULT_PRESET = {"openrouter": "default", "zai": "zai", "lmstudio": "lmstudio"} + def load(path, default=None): if not os.path.exists(path): @@ -269,15 +274,36 @@ def cmd_auth_of(argv): print("%s\t%s" % (auth.get("mode") or "vault", auth.get("keyRef") or "openrouter")) +def cmd_set_default(argv): + """set-default [name] - an empty name clears it.""" + path, prov = argv[0], argv[1] + name = argv[2] if len(argv) > 2 else "" + try: + d = load(path, {}) + except (OSError, ValueError): + d = {} + if not isinstance(d, dict): + d = {} + if name: + d[prov] = name + else: + d.pop(prov, None) + save(path, d) + print(json.dumps(d)) + + def cmd_preset_rename(argv): - """preset-rename + """preset-rename [defaults.json] Every running session's key helper resolves the active preset through state.json on each key fetch, so at no instant may state.json name a file that is not there. The new name is linked in first, state.json repointed, - and only then does the old name go. Prints {"renamed", "active"}. + and only then does the old name go. A provider default chosen with + `preset default` follows the rename too. Prints {"renamed", "active", + "default"}. """ d, old, new, state_path = argv[:4] + defaults_path = argv[4] if len(argv) > 4 else "" src = os.path.join(d, old + ".json") dst = os.path.join(d, new + ".json") if not os.path.isfile(src): @@ -295,8 +321,21 @@ def cmd_preset_rename(argv): state["preset"] = new save(state_path, state) + moved = [] + if defaults_path: + try: + defaults = load(defaults_path, {}) + except (OSError, ValueError): + defaults = {} + if isinstance(defaults, dict): + moved = [p for p, n in defaults.items() if n == old] + if moved: + for p in moved: + defaults[p] = new + save(defaults_path, defaults) + os.unlink(src) - print(json.dumps({"renamed": True, "active": active})) + print(json.dumps({"renamed": True, "active": active, "default": bool(moved)})) def cmd_get(argv): @@ -610,6 +649,31 @@ def cmd_health(argv): }) h["presets"] = catalogue + # Which preset `claude-mode ` picks when none is named, in the + # CLI's own order (default_preset_for, then resolve_preset): the chosen one + # while its file exists, else the built-in name if that exists, else the + # first by name. `defaultPresetChosen` holds only the explicit choices, so a + # reader can tell a choice it could clear from the built-in fallback. + names = {c["name"] for c in catalogue} + try: + chosen_raw = load(os.path.join(root, "defaults.json"), {}) + except (OSError, ValueError): + chosen_raw = {} + if not isinstance(chosen_raw, dict): + chosen_raw = {} + effective, chosen = {}, {} + for prov, builtin in BUILTIN_DEFAULT_PRESET.items(): + pick = chosen_raw.get(prov) + if pick and pick in names: + effective[prov] = chosen[prov] = pick + elif builtin in names: + effective[prov] = builtin + else: + mine = sorted(c["name"] for c in catalogue if c["provider"] == prov) + effective[prov] = mine[0] if mine else "" + h["defaultPresetFor"] = effective + h["defaultPresetChosen"] = chosen + save(os.path.join(root, "health.json"), h) @@ -1227,6 +1291,7 @@ COMMANDS = { "scaffold": cmd_scaffold, "preset-rename": cmd_preset_rename, "auth-of": cmd_auth_of, + "set-default": cmd_set_default, "get": cmd_get, "presets": cmd_presets, "managed": cmd_managed, diff --git a/omarchy/smoido.claude-mode/Panel.qml b/omarchy/smoido.claude-mode/Panel.qml index f0f45dc..372988d 100644 --- a/omarchy/smoido.claude-mode/Panel.qml +++ b/omarchy/smoido.claude-mode/Panel.qml @@ -317,7 +317,27 @@ Panel { property string editTier: "" property string editModel: "" + // Which preset `claude-mode ` picks when none is named. The CLI + // publishes it now that it can be chosen; the built-in names in Modes.js + // are only the fallback for a health.json written by an older CLI. + readonly property var defaultPresets: { + var out = {} + var builtin = Modes.DEFAULT_PRESET + for (var b in builtin) out[b] = builtin[b] + var pub = health && health.defaultPresetFor ? health.defaultPresetFor : null + if (pub) for (var p in pub) if (pub[p]) out[p] = pub[p] + return out + } + readonly property var chosenDefaults: health && health.defaultPresetChosen ? health.defaultPresetChosen : ({}) + readonly property var editEntry: root.editPreset !== "" ? root.presetEntry(root.editPreset) : null + readonly property bool editIsDefault: root.editPreset !== "" && root.defaultPresets[root.editProvider] === root.editPreset + readonly property bool editIsChosenDefault: root.editPreset !== "" && root.chosenDefaults[root.editProvider] === root.editPreset + + function setDefault(on) { + root.runOps("saving", [on ? ["preset", "default", root.editProvider, root.editPreset] + : ["preset", "default", root.editProvider, "--clear"]], "preset") + } readonly property string editProvider: editEntry ? String(editEntry.provider) : "" readonly property bool editIsActive: root.mode !== "anthropic" && root.editPreset !== "" && root.editPreset === root.preset @@ -847,7 +867,7 @@ Panel { readonly property string thisMode: String(modelData) readonly property bool isCurrent: thisMode === root.mode readonly property bool isExpanded: root.expandedMode === thisMode - readonly property var presetList: Modes.presetsFor(root.health, thisMode, Modes.DEFAULT_PRESET) + readonly property var presetList: Modes.presetsFor(root.health, thisMode, root.defaultPresets) Item { width: parent.width @@ -968,10 +988,24 @@ Panel { font.bold: presetRow.isActive } + // Only worth saying when there is a choice to tell apart. + Text { + id: defaultTag + visible: modeEntry.presetList.length > 1 + && String(presetRow.modelData.name) === root.defaultPresets[modeEntry.thisMode] + anchors.left: presetName.right + anchors.leftMargin: Style.space(6) + anchors.verticalCenter: parent.verticalCenter + text: "default" + color: Color.muted + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(9) + } + // The opus mapping is the one that tells you what you are // about to be talking to; the rest is in the tooltip. Text { - anchors.left: presetName.right + anchors.left: defaultTag.visible ? defaultTag.right : presetName.right anchors.leftMargin: Style.space(8) anchors.right: gearButton.visible ? gearButton.left : parent.right anchors.rightMargin: Style.space(4) @@ -1311,6 +1345,17 @@ Panel { font.pixelSize: Style.space(10) } + Text { + width: parent.width + visible: root.editIsDefault + text: "claude-mode " + root.editProvider + " picks this one when no preset is named" + + (root.editIsChosenDefault ? "." : " (the built-in choice).") + color: Color.muted + wrapMode: Text.WordWrap + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(10) + } + Column { width: parent.width spacing: Style.space(1) @@ -1392,6 +1437,8 @@ Panel { visible: root.editProvider === "lmstudio" onTriggered: root.openServerSettings(root.editPreset, "preset") } + PillButton { label: "Make default"; visible: !root.editIsDefault; onTriggered: root.setDefault(true) } + PillButton { label: "Clear default"; visible: root.editIsChosenDefault; onTriggered: root.setDefault(false) } PillButton { label: "Duplicate…"; onTriggered: root.openNewPreset(root.editProvider, root.editPreset) } PillButton { label: "Rename…"; onTriggered: root.openRename() } // The CLI refuses to delete the preset in use. A button that always