Create, duplicate, rename and delete presets from the panel

Each provider's preset list ends in "New ... preset...", and the preset
editor gains Duplicate, Rename and Delete. New asks for a name and what to
start from: a copy of one of that provider's presets, or a blank template.
Delete is greyed out, with the reason, on the preset in use, and warns when
it would leave a provider with no preset at all.

CLI:
- preset rename <name> <new>: the new name is hard-linked in, state.json is
  repointed, and only then does the old name go.
- preset new <name> --provider <p> [--blank]: with a provider and no source
  it copies that provider's own default instead of the OpenRouter-only
  `default`; --blank starts from the scaffold.
- valid_preset_name on every preset subcommand: no slash, no leading dot.
  `preset show ../../etc/passwd` used to print the file.
- preset rm warns when it removes a provider's last preset.
- preflight refuses a preset with every tier empty. Otherwise a blank preset
  would switch cleanly and Claude Code would ask the gateway for its default
  Anthropic models, billed at full price on OpenRouter. The panel's blocked
  card offers "Edit preset..." for it.

The key helper now reads the active preset in a single open (new cm-json
auth-of) and re-reads state.json once on a miss. Renaming the active preset
could otherwise catch a running session between reading the old name and
opening the file: 1 failure in 51 key fetches in a race test before, 0 in
118 across 60 renames after. It could also briefly hand out the openrouter
key for a preset that uses another.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
smoido
2026-09-15 00:25:39 +03:00
co-authored by Claude Opus 5
parent 83b59f34a1
commit de9f10a8c2
5 changed files with 527 additions and 22 deletions
+17 -10
View File
@@ -28,17 +28,24 @@ mode="$("$PY" "$JSON" get "$state" mode 2>/dev/null)"
[ "$mode" = "anthropic" ] && exit 0
[ -n "$mode" ] || exit 0
preset_name="$("$PY" "$JSON" get "$state" preset 2>/dev/null)"
[ -n "$preset_name" ] || exit 0
# `preset rename` on the active preset moves the file while this may be running
# in any live session. The name read from state.json can be the old one by the
# time the file is opened, so the preset is read in a single open (a removal
# after that cannot change what was read), and a miss re-reads state.json once:
# the rename repoints it before removing the old name, so the second read finds
# the new one. A second miss is a real fault.
auth=''
for _attempt in 1 2; do
preset_name="$("$PY" "$JSON" get "$state" preset 2>/dev/null)"
[ -n "$preset_name" ] || exit 0
preset="$CM_ROOT/presets/$preset_name.json"
auth="$("$PY" "$JSON" auth-of "$preset" 2>/dev/null)" && break
auth=''
done
[ -n "$auth" ] || fail "state.json names preset '$preset_name' but $preset does not exist. Run: claude-mode presets"
preset="$CM_ROOT/presets/$preset_name.json"
[ -f "$preset" ] || fail "state.json names preset '$preset_name' but $preset does not exist. Run: claude-mode presets"
auth_mode="$("$PY" "$JSON" get "$preset" auth.mode 2>/dev/null)"
[ -z "$auth_mode" ] && auth_mode="vault"
auth_mode="${auth%%$'\t'*}"
key_ref="${auth#*$'\t'}"
[ "$auth_mode" = "vault" ] || exit 0 # inline token: nothing for us to emit
key_ref="$("$PY" "$JSON" get "$preset" auth.keyRef 2>/dev/null)"
[ -n "$key_ref" ] || key_ref="openrouter"
cm_vault_get "$key_ref" || fail "no key readable for ref '$key_ref' from $(cm_vault_backend_label 2>/dev/null || echo 'the vault'). Run: claude-mode set-key $key_ref"
+95 -6
View File
@@ -265,7 +265,10 @@ claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio
claude-mode presets list presets
claude-mode preset show <name>
claude-mode preset new <name> [from] create a preset (copies 'from')
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 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
@@ -310,6 +313,16 @@ 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
@@ -476,6 +489,18 @@ cm_preflight() {
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
@@ -1508,26 +1533,92 @@ else: print(' ok spend %.2f of %.2f limit (%s), %.2f remaining' % (use, lim,
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> [copy-from]'; return 1; }
[ -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="${3:-default}"
local from='' provider='' blank=0 a
shift 2
while [ $# -gt 0 ]; do
a="$1"; shift
case "$a" in
--provider)
[ $# -gt 0 ] || { err '--provider needs openrouter, zai or lmstudio'; return 1; }
provider="$1"; shift ;;
--blank) blank=1 ;;
-*) err "unknown option '$a'"; return 1 ;;
*) from="$a" ;;
esac
done
case "$provider" in
''|openrouter|zai|lmstudio) ;;
*) err "unknown provider '$provider' - openrouter, zai or lmstudio"; return 1 ;;
esac
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'
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'"
;;
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" 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" ;;
esac
;;
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'"
# 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
;;
set)
local tier="${3:-}" model="${4:-}"
@@ -1935,9 +2026,7 @@ ui_new_preset() {
printf ' name (letters, digits, dash; blank = cancel): '
local name; IFS= read -r name
[ -n "$name" ] || return
case "$name" in
*[!A-Za-z0-9._-]*) err "invalid name '$name'"; return ;;
esac
valid_preset_name "$name" || { err "invalid name '$name'"; return; }
[ -f "$(preset_path "$name")" ] && { err "preset '$name' already exists"; return; }
if [ "$choice" -eq 0 ]; then
+51
View File
@@ -17,6 +17,8 @@ Subcommands:
scan-sessions <projects> [max-age-days] [ignored] broken transcripts, as JSON
ignore-session <ignored> <projects> add|remove|clear|list [id]
cache-models <cache> <provider> <1|0> [baseUrl] store a TSV catalogue (stdin)
preset-rename <presets-dir> <old> <new> <state> rename, repointing state.json
auth-of <preset> "mode<TAB>keyRef"; fails if missing
"""
import glob
@@ -250,6 +252,53 @@ def cmd_scaffold(argv):
print(json.dumps(base, indent=2))
def cmd_auth_of(argv):
"""auth-of <preset> - 'mode<TAB>keyRef' for the key helper, defaults filled in.
Exits non-zero when the file is missing, which load() would otherwise
hide as an empty preset - and an empty preset reads as vault/openrouter,
so a preset renamed mid-read would quietly hand out the wrong key.
"""
try:
with open(argv[0], encoding="utf-8") as fh:
text = fh.read().strip()
except OSError:
sys.exit(1)
p = json.loads(text) if text else {}
auth = p.get("auth") or {}
print("%s\t%s" % (auth.get("mode") or "vault", auth.get("keyRef") or "openrouter"))
def cmd_preset_rename(argv):
"""preset-rename <presets-dir> <old> <new> <state.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"}.
"""
d, old, new, state_path = argv[:4]
src = os.path.join(d, old + ".json")
dst = os.path.join(d, new + ".json")
if not os.path.isfile(src):
raise SystemExit("preset '%s' not found" % old)
if os.path.exists(dst):
raise SystemExit("preset '%s' already exists" % new)
try:
os.link(src, dst)
except OSError:
__import__("shutil").copy2(src, dst) # filesystems without hard links
state = load(state_path, {})
active = isinstance(state, dict) and state.get("preset") == old
if active:
state["preset"] = new
save(state_path, state)
os.unlink(src)
print(json.dumps({"renamed": True, "active": active}))
def cmd_get(argv):
data = load(argv[0])
cur = data
@@ -1176,6 +1225,8 @@ COMMANDS = {
"set-all": cmd_set_all,
"set-auth": cmd_set_auth,
"scaffold": cmd_scaffold,
"preset-rename": cmd_preset_rename,
"auth-of": cmd_auth_of,
"get": cmd_get,
"presets": cmd_presets,
"managed": cmd_managed,