A test suite with no dependencies beyond bash and the Python standard library, run by one command: scripts/test.sh (or: make test) scripts/test.sh --windows [host] adds the PowerShell suite over SSH - tests/static.sh: script syntax, JSON validity, VERSION against the widget manifest and CHANGELOG, providers.json against the kinds, probes and checks the code implements (and ids that would shadow a command), qmllint and shellcheck when installed. - tests/python: unit tests for cm-json.py - providers and the column contract bash depends on, the original three scaffolds byte for byte, every catalogue parser and the cache, session scan/dismiss/repair, rename, defaults, health, the cost guard. - tests/cli: the CLI in a sandbox with its own HOME, CM_ROOT, CLAUDE_CONFIG_DIR and a file-only vault, against fake Ollama, LM Studio, keyed-gateway and proxy servers - preflight, presets, models, doctor, a real switch, sessions, and the key helper under a rename race. - tests/windows: the same idea on a Windows host, with USERPROFILE pointed at a temp folder so the real install is never touched. Also: MIT licence, CHANGELOG.md reconstructed from history, .editorconfig and .gitattributes pinning LF everywhere (what the tree already is), and scripts/bump-version.sh, since the version lives in two files and drifted once before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-mode
Switch Claude Code system-wide between Anthropic, OpenRouter, Z.AI, a local LM Studio or Ollama server, and any custom Anthropic-compatible endpoint — with named per-tier model presets.
One write to ~/.claude/settings.json covers the CLI, the VS Code extension, and
the desktop app. Restart Claude Code afterwards — nothing else. Windows
(PowerShell 5.1, no external dependencies) and a POSIX port for Linux and macOS
(python3 only), including a theme-aware TUI and an Omarchy bar widget.
claude-mode # interactive menu
claude-mode anthropic # subscription login
claude-mode openrouter # remote gateway (preset: default)
claude-mode zai # Z.AI GLM coding plan (preset: zai)
claude-mode lmstudio # local server (preset: lmstudio)
claude-mode ollama # local server (preset: ollama)
claude-mode custom # your own endpoint (preset: custom)
Contents: Install · First run · The interactive menu · Commands · Presets · Context windows · Z.AI · LM Studio · Ollama · Custom endpoints · Adding a provider · Live sessions · Troubleshooting · Design decisions · Layout · Linux / Omarchy · The bar widget · Uninstall
Install
Windows (PowerShell 5.1+)
One line:
irm https://git.nebulm.com/smoido/claude-mode/raw/branch/master/install.ps1 | iex
Piped in like that there is no checkout, so the installer fetches the
repository archive to %TEMP% and installs from it. To pass switches
(-Force, -SkipKeyPrompt), download first and run the file:
irm https://git.nebulm.com/smoido/claude-mode/raw/branch/master/install.ps1 -OutFile install.ps1
.\install.ps1 -Force
Or clone it — the normal path if you want to read the code and pull updates:
git clone https://git.nebulm.com/smoido/claude-mode.git
cd claude-mode
.\install.ps1
Installs to ~/.claude-mode/ (ACL: you only), drops claude-mode.cmd into
~/.local/bin (already on your User PATH, next to claude.exe), and adds a
marked block to ~/Documents/WindowsPowerShell/profile.ps1.
If the profile doesn't load: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned.
Linux / macOS
One line:
curl -fsSL https://git.nebulm.com/smoido/claude-mode/raw/branch/master/linux/install.sh | bash
Or clone:
git clone https://git.nebulm.com/smoido/claude-mode.git
cd claude-mode
bash linux/install.sh # --skip-key-prompt to install without storing a key
Installs to ~/.claude-mode/, symlinks ~/.local/bin/claude-mode, and adds the
claude wrapper to ~/.bashrc / ~/.zshrc between markers.
Secrets have no DPAPI equivalent here, so the vault picks the best backend
available and says which one it chose: macOS Keychain, libsecret, pass, or a
0600 file that is honestly labelled as unencrypted.
It runs on the bash 3.2 that macOS ships — no Homebrew bash needed. The
menu reads arrow keys through the terminal's own timing (min 0 time 1) rather
than a fractional read timeout, which 3.2 rejects, and the port avoids
mapfile and GNU-only stat/find flags for the same reason.
Session listing reads /proc, so it is Linux-only. On macOS a switch says
it cannot check for running sessions instead of claiming there are none — so
restart Claude Code yourself after switching.
Omarchy bar widget
bash omarchy/install.sh
Copies the plugin to ~/.config/omarchy/plugins/smoido.claude-mode/ and adds its
id to the bar layout in ~/.config/omarchy/shell.json (backed up first). Needs a
checkout — clone first. See the bar widget for what it
does.
All three, and upgrades
The installers never touch ~/.claude/settings.json — that only happens on an
actual mode switch, which backs it up to ~/.claude-mode/backups/ first
(last 20 kept).
Re-running any of them is a safe upgrade: scripts are overwritten, existing
presets are kept unless -Force / --force is passed.
First run
A shipped preset is a starting point, not a working configuration. OpenRouter and Z.AI have no key stored. LM Studio's model ids are whatever happened to be installed on the machine this was packaged on, which is almost certainly not yours.
So the shipped presets carry configured: false, and preflight treats that as a
blocker with its own remedy:
claude-mode setup openrouter # key, then models from OpenRouter's catalogue
claude-mode setup zai # key, then per-tier GLM models
claude-mode setup lmstudio # server URL, auth, then models from that server
claude-mode setup anthropic # nothing to do; it uses your existing login
Setup asks only what it cannot work out, shows the current model map before offering to change it, and picks from the provider's own catalogue rather than asking anyone to type a model id from memory. LM Studio maps one model across every tier, since a local server has one loaded at a time and per-tier mapping would just pay the load cost on every tier change; the remote gateways ask per tier, which is the point of them.
In a terminal, a switch that trips this offers to run setup there and then
rather than printing a command to type next. It sets configured: true on the
way out.
Absent means configured. Presets that predate this, and any built by hand
with preset new, are nobody's business but yours and will not start demanding
a wizard.
The interactive menu
Run claude-mode with no arguments. The mode you're already in is omitted —
there's nothing to switch to:
claude-mode (currently: openrouter / default)
switch mode:
1) Anthropic - your subscription login, no gateway
2) Z.AI - GLM coding plan
3) LM Studio - local server, offline, free
4) show full status
5) edit presets
6) run doctor
0) quit
Pick a provider and it lists that provider's presets with the default marked; Enter accepts it. Option 5 walks preset → tier → new model ID, and if you edit the preset that's currently live it re-applies immediately.
When stdin is redirected (scripts, CI) the menu is skipped and status prints
instead, so claude-mode is still safe in a pipeline.
Commands
claude-mode interactive menu
claude-mode status active mode, preset, model map
claude-mode anthropic native login (deletes all managed keys)
claude-mode openrouter [preset] default preset: default
claude-mode zai [preset] default preset: zai (alias: z.ai, z-ai)
claude-mode lmstudio [preset] default preset: lmstudio (alias: lm-studio)
claude-mode ollama [preset] default preset: ollama
claude-mode custom [preset] default preset: custom
claude-mode presets list presets (* = active)
claude-mode preset show <name>
claude-mode preset new <name> [from] copy an existing preset (default: `default`)
claude-mode preset new <name> --provider <p> [--blank]
copy that provider's default, or start empty
claude-mode preset rename <name> <new-name> repoints state.json if it is in use
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> point every tier at one model
claude-mode preset rm <name>
claude-mode set-key [ref] [key] store a key (hidden prompt; [key] for scripts)
claude-mode models [filter] models available from the active provider
claude-mode models --preset <name> ...from that preset's provider (and server) instead
claude-mode models --preset <name> --refresh only update the cached list the panel uses
claude-mode models --preset <name> --json the cached list for that provider, as JSON
claude-mode doctor verify auth, endpoint, model ids, stray env vars
claude-mode preflight <mode> [preset] can this mode actually serve? (no switch)
claude-mode sessions running sessions, and which are mid-request
claude-mode sessions --stop close them (asks first)
claude-mode sessions --restart close and reopen each in its own directory
claude-mode sessions --dry-run show what either would do, and do nothing
claude-mode <mode> --force switch even when preflight says no
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:
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
endangers them by moving their endpoint or key, and a tier edit moves neither.
preset url and preset auth do move them, so those still ask. If the re-apply
fails (a preflight refusal, say), the edit is still saved, and the last line says
so and names the command that finishes the job.
Every catalogue fetch — models, doctor, setup, the menu's picker — leaves
a copy in ~/.claude-mode/models-cache.json, one entry per provider with its own
timestamp. A failed fetch keeps the previous list and marks it failed, and an LM
Studio list is tied to the server it came from. Model ids, context lengths and
prices only; no key or key name is ever written there.
Presets shipped
One per mode, so claude-mode <mode> is never ambiguous and there is no
menu to read before the thing you asked for happens. Build more with
preset new whenever one stops being enough.
| preset | provider | opus | sonnet | haiku | fable |
|---|---|---|---|---|---|
default |
openrouter | deepseek/deepseek-v4.1-flash |
z-ai/glm-5.3-flash |
openrouter/free |
z-ai/glm-5.3 |
zai |
zai | glm-5.3 |
glm-5.3 |
glm-4.7 |
glm-5.3 |
lmstudio |
lmstudio | whatever setup finds on your server (all tiers) |
Presets are plain JSON in ~/.claude-mode/presets/. A preset declares its
provider; claude-mode lmstudio default is rejected rather than silently
pointing a local URL at remote model IDs.
To route the real Anthropic models through OpenRouter, copy one and repoint the
tiers - claude-mode preset new claude-via-or default, then preset set each
tier to anthropic/claude-opus-5 and friends.
Context windows and early auto-compaction
Symptom: switch to a gateway and the session starts auto-compacting almost immediately, even though every model involved has a huge context window.
Cause: behind a custom ANTHROPIC_BASE_URL, Claude Code has no way to
resolve a third-party model ID like deepseek/deepseek-v4-flash to a context
length. It falls back to a conservative default and starts compacting against
that, not against the model's real 1M window. Z.AI's own docs work around this
by setting CLAUDE_CODE_AUTO_COMPACT_WINDOW=1000000 — they hit the same thing.
Fix: every preset carries a contextTokens field, which writes both knobs
(confirmed present in CLI 2.1.221):
CLAUDE_CODE_MAX_CONTEXT_TOKENS = <contextTokens>
CLAUDE_CODE_AUTO_COMPACT_WINDOW = <contextTokens>
| preset | contextTokens |
|---|---|
default, zai |
1,000,000 |
lmstudio |
262,144 |
doctor cross-checks the declared window against each tier's actual model
window and names any tier that falls short — default maps haiku to
openrouter/free (200k), which it flags as harmless since haiku only runs short
background tasks. Switching without contextTokens prints a warning.
Adjust per preset:
# edit ~/.claude-mode/presets/<name>.json -> "contextTokens": 262144
claude-mode doctor # re-checks declared vs actual
Z.AI mode
Replaces npx @z_ai/coding-helper — and does something it doesn't: maps a
distinct model to each Anthropic tier instead of forcing one model everywhere.
| setting | value |
|---|---|
ANTHROPIC_BASE_URL |
https://api.z.ai/api/anthropic |
| auth | your Z.AI API key — kept in the DPAPI vault, delivered via apiKeyHelper |
API_TIMEOUT_MS |
3000000 |
CLAUDE_CODE_AUTO_COMPACT_WINDOW |
1000000 |
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC |
1 |
All three extra variables are confirmed present in CLI 2.1.221. Setup:
claude-mode set-key zai # paste your key from https://z.ai/manage-apikey/apikey-list
claude-mode zai
claude-mode doctor # sends a 1-token request to prove the key works
LM Studio mode
Per LM Studio's docs: base
URL http://127.0.0.1:1234 (base only — Claude Code appends /v1/messages),
token lmstudio, plus CLAUDE_CODE_ATTRIBUTION_HEADER=0. Gateway discovery
stays off; the Anthropic surface is /v1/messages only.
Model IDs are not what the UI shows
LM Studio's /v1/models lists only loaded instances under their display
alias. kat-coder-v2.5 is such an alias — once the model idle-unloads it
vanishes, and a request using that name returns 400 No models loaded. The
JIT-loadable ID is the model key, kwaipilot_kat-coder-v2.5-dev. claude-mode
reads /api/v0/models instead, which lists every installed model with its load
state, so models and doctor show IDs that actually work.
The [Server Error] ... Unable to generate parser for this template spam
Cause: some GGUF chat templates hard-assert message ordering —
{%- if message.role == "system" %}
{%- if not loop.first %}
{{- raise_exception('System message must be at the beginning.') }}
Runtimes that auto-generate a tool-call parser probe the template with synthetic message sequences; those probes trip the assertion and the request dies. It's a model-template bug, not a Claude Code or claude-mode bug — it's been reported against several models (LM Studio #1999, llama.cpp #20733).
Scanning your installed models' templates:
| model | template |
|---|---|
qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved |
clean |
qwen3.6-35b-a3b |
clean |
qwen2.5-coder-7b-instruct, google/gemma-4-12b-qat |
clean |
kwaipilot_kat-coder-v2.5-dev |
asserts |
qwen/qwen3.5-9b, prism-ml/bonsai-27b |
assert |
doctor now reports this per model, and models flags affected entries with
TEMPLATE RISK. The fix is to use a model without the flag. The Qwen3.6
line above is what the single lmstudio preset ships pointed at, verified
end-to-end with a cold JIT load, streaming, and tool calls.
Honest caveat: KAT-Coder's template does contain the assertion, but I could not
reproduce the failure against it here — cold JIT, streaming, tools, system
blocks, and multi-turn tool_result all succeeded. Whether it trips seems to
depend on which parser strategy the runtime picks. If the model you land on
does spam, run setup again and pick another:
claude-mode setup lmstudio
Other notes: use a model with >25k context (doctor warns below that), and
the model must be installed — JIT loading handles "not loaded" fine.
If you enable authentication in LM Studio, move that preset to a vault key:
claude-mode set-key lmstudio
# then in ~/.claude-mode/presets/lmstudio.json:
# "auth": { "mode": "vault", "keyRef": "lmstudio" }
LM Studio somewhere other than this machine
It ships on loopback, but that is a default, not a constraint. A preset is just a base URL and an auth block, so all of these are the same two fields:
claude-mode preset url lmstudio http://192.168.1.40:1234 # another box on the LAN
claude-mode preset url lmstudio https://lms.example.net # through a tunnel or proxy
claude-mode preset auth lmstudio key lmstudio # that server wants an API key
claude-mode set-key lmstudio # store it (vault, not settings.json)
claude-mode preset auth lmstudio none # back to the open-server default
auth none writes LM Studio's inline placeholder token, which is not a secret
and is what an unauthenticated server expects. auth key moves it to the vault
like every other credential — a real key on a public address is a real key.
Nothing here changes the shipped presets unless you ask it to; the local default
stays exactly as it was.
Ollama mode
Ollama serves Anthropic's Messages API itself, at /v1/messages, so nothing sits
in between. claude-mode setup ollama asks where the server is (it ships on
http://127.0.0.1:11434), whether it needs a key, and picks one model for every
tier from what the server has pulled. One model for all four is the right shape
for a local server: it holds one in memory at a time, and mapping tiers to
different models just means paying the load cost on every tier change. The token
is a placeholder, ollama, which Ollama requires but does not check.
The context window is set on the server
This is the part that bites. Ollama sizes the context on the server, not per
request: 4096 tokens unless ollama serve runs with OLLAMA_CONTEXT_LENGTH,
and anything past it is cut off without an error. Claude Code's system prompt
alone is most of that. Ollama recommends 64k or more for Claude Code, and the
shipped preset declares 65,536 — but contextTokens only tells Claude Code what
to expect. It cannot change what the server does.
OLLAMA_CONTEXT_LENGTH=65536 ollama serve
# or, for the systemd service: systemctl edit ollama
# [Service]
# Environment=OLLAMA_CONTEXT_LENGTH=65536
claude-mode doctor reads what each loaded model is actually running with
(/api/ps) and the model's own maximum (/api/show), and says so when either is
below the preset. When nothing is loaded it cannot see the server's setting, and
says that instead of guessing.
Also worth knowing:
- A bare name like
qwen3-coderisqwen3-coder:latestto Ollama; both work. - Ollama does not implement
count_tokensor prompt caching, so long sessions redo more work than they would on a hosted provider. Nothing breaks. - To reach it from another machine, serve it with
OLLAMA_HOST=0.0.0.0and point the preset there:claude-mode preset url ollama http://192.168.1.40:11434.
Custom endpoints
For anything else that speaks Anthropic's Messages API: a LiteLLM or Vercel
gateway, vLLM, llama.cpp's server, a company proxy. The custom preset ships
with no address — there is no sensible one to guess — and a switch to it is
refused until it has one.
claude-mode setup custom # asks for all of the below
# or by hand:
claude-mode preset url custom https://llm.example.com
claude-mode preset auth custom key custom # the key lives in the vault as 'custom'
claude-mode set-key custom
claude-mode preset set custom opus <model-id> # ...and each other tier
Several endpoints are several presets, each with its own address and key name:
claude-mode preset new work --provider custom --blank, then
claude-mode preset auth work key work.
The model list comes from /v1/models when the endpoint has one (OpenAI's shape
or Anthropic's). Plenty of proxies serve Messages and nothing else. That is fine:
the check before a switch only wants something to answer, and model ids can be
typed by hand. The cost guard still applies. A custom endpoint in front of
Anthropic's own models needs "allowAnthropicModels": true in the preset, which
keeps it deliberate.
Adding a provider
Every gateway provider is an entry in providers.json, installed
next to the presets and read by the CLI, the bar widget (through health.json)
and the Windows build. Anthropic is not in it: it is the native login, not a
gateway. An entry holds:
| field | what it says |
|---|---|
id, aliases, title, label, blurb, color |
names and how the CLI, menu and panel show it |
defaultPreset, preset |
the preset claude-mode <id> picks, and the template for a blank one (URL, auth, context, extra env) |
server |
whether the address is editable, how it is probed before a switch (always, lenient for proxies, local only when it is on this machine), setup hint |
catalogue.kind |
how its model list is read: openrouter, lmstudio, ollama, openai (/v1/models) or static (a list kept in the entry) |
setup |
whether a key is required or optional, where to get one, and one model for every tier or one per tier |
doctor |
which checks apply: catalogue-models, openrouter-key, guardrail, message-check, ollama-context, lmstudio-templates (Windows) |
logo, logoScale |
a single-path 24×24 SVG mark, and its optical size correction |
A provider that reuses those kinds is an entry plus a default preset in
presets/, and no code. A hosted Anthropic-compatible coding plan — Kimi,
MiniMax, DeepSeek — is an entry shaped like zai, with its own URL and a
static or openai catalogue. Only a genuinely new kind of behaviour, such as
a catalogue format none of the parsers read, needs code: a parser in
cm-json.py, and a branch in provider_catalogue (bash) and
Get-ProviderCatalogue (Windows). The bar widget picks new providers up from
health.json — no QML change, and no shell restart.
Live sessions
Why a switch breaks a running session
A switch breaks running sessions. The two halves of the config behave differently, which is why.
The static half — base URL, model ids, the env block — genuinely is read once at startup, and a running session keeps what it started with.
The credential is not. It comes from 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 at that moment. So a
switch reaches into a live session through the one thing that was never cached:
| switching to | what the running session gets |
|---|---|
anthropic |
the helper returns nothing, by design — no credential at all |
| another provider | the new provider's key, sent to the old base URL, which rejects it |
another preset of the same provider, same keyRef |
same key, same endpoint — this one survives, on the model ids it started with |
Either of the first two starts failing calls whenever the TTL happens to expire:
mid-turn as easily as between turns. So restart affected sessions — and
claude-mode sessions --restart will do it for you:
- CLI — exit and relaunch
claude - VS Code —
Ctrl+Shift+P→ Developer: Reload Window - Desktop app — quit and reopen
claude-mode status shows what the next launch will use.
The part that is not just an inconvenience
A failed call is recoverable. A successful one may not be.
If a running session takes even one completion from the new provider before
anything notices — which happens when the mode it is switched to matches the
base URL it already had cached — that provider's message-id format lands in its
transcript. OpenRouter issues gen-<epoch>-<rand> where Anthropic issues
msg_…. 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 transcript has to be rolled back to the last message Anthropic issued:
claude-mode repair-session # transcripts for this project
claude-mode repair-session --all # every project, problems only
claude-mode repair-session <session-id> # show what it would cut
claude-mode repair-session <session-id> --apply
Scope, since it is not obvious: a bare listing covers only the project you are
standing in (walking up from the current directory to find it), while a named
session id is looked up across every project — you rarely remember which
project a session you cannot resume belonged to. --all drops the scoping
entirely.
Not every broken session is worth repairing — a throwaway, or one whose work was finished some other way — and one that can never be cleared keeps the bar's warning dot lit for good. So a session can be dismissed, and one untouched for more than 7 days is hidden on its own:
claude-mode repair-session --ignore <session-id> # stop counting it
claude-mode repair-session --ignored # what is dismissed, and whether it is still broken
claude-mode repair-session --unignore <session-id> # or --unignore-all
claude-mode repair-session --all --max-age 0 # include the age-hidden ones (CM_IGNORE_AGE_DAYS sets the default)
Neither touches the transcript. Hidden sessions are always named — --all
ends with a line like 2 hidden: 1 ignored, 1 older than 7 days — so age-hiding
never looks like damage disappearing. Dismissals live in
~/.claude-mode/ignored-sessions.json; an entry is dropped when its transcript
is deleted, and when the session is repaired, so a session that breaks again
later is not silently hidden. In the bar panel each broken session has an
Ignore button beside Repair, and the hidden ones sit under a collapsed
hidden (N) row with Restore.
--all reports only what is actually actionable, which matters more than it
sounds. Of 59 transcripts here it initially flagged 16; on inspection 5 had
simply never received a reply, and 10 had run start-to-finish on a gateway, so
every id in them is that provider's by design. Those resume perfectly well under
the provider they were born on, have nothing to truncate back to, and are only a
problem if you try to resume them as Anthropic. Neither is damage, so neither is
listed. Only a transcript that has a genuine msg_ message and junk after it
is something this can or should touch.
The cut turns are not thrown away
Truncating is the mechanical fix, but the turns being cut are the work itself —
losing the conversation that produced a morning's changes is most of the damage,
and a session that resumes with a hole in its memory is barely resumed at all.
So --apply does three things before it deletes anything:
- Backs up the original as
<session>.jsonl.pre-repair-backup-<stamp>. - Writes the dropped turns out as
<session>.recovered-<stamp>.md— a readable record of what was asked, what was answered, and what was run. Tool results are left out; they are most of a transcript by volume and the least useful part of a summary. - Hands them back to the session as a single appended note, so the agent that resumes knows what it just did.
That note is a user entry marked isMeta — the same marker Claude Code uses
for its own local-command caveats, meaning "context, not something to answer".
Critically it carries no message.id, so it cannot re-create the very
condition being repaired. --no-reinject writes the Markdown but leaves the
session untouched.
It refuses to touch a transcript written to in the last 90 seconds, since that one belongs to a session still alive.
The switch asks first
A switch stops and asks while any sessions are running, offering to restart them
(the only answer that ends with everything on the mode the bar now claims), close
them, proceed anyway, or abort — and abort is the default. Non-interactively it
refuses outright unless given --yes.
claude-mode sessions
running sessions (2)
562250 pts/4 /home/smoido/Work working (this session - never touched)
631644 pts/1 /home/smoido/Projects/api
Sessions are found through /proc/<pid>/exe, which on Linux resolves to the
real claude binary — a process-name match would sweep up every shell that
merely mentions claude on its command line, including the one this is running
from. Two things are then filtered out:
- The calling session. Killing the session that asked for the kill is not a thing anyone means, so it is listed and never signalled.
- Forks of a session. A busy session spawns children off its own binary,
and they inherit the same
exe. Without excluding anything whose parent is itself claude, the count climbs and falls with how hard the machine is thinking — it read 2, 5, 11 and 40 on the same two sessions before this. A real session's parent is a terminal.
working is a sampled-CPU heuristic — two reads of utime + stime 300ms apart
— so it is a good guess about which session is mid-turn, not a promise.
--stop sends SIGTERM (never SIGKILL; Claude Code writes out its transcript on
the way down). --restart stops each session and reopens it — re-running the
parent terminal's own command line where there is one, so the same terminal,
flags and directory come back, and falling back to a fresh terminal in the
session's directory otherwise. Interactive runs confirm first; --yes is for
callers that have already asked, and --dry-run prints the plan and touches
nothing.
The bar notices for you
Nothing tells you a session is unresumable until you try to resume it, by which point you have usually forgotten which one it was. So the widget scans every project on a timer (and whenever the panel opens) and puts a dot on its icon when there is something to fix. Clicking through lists the affected sessions and offers to repair each one, after saying what it will drop and what it will keep.
The dot is a dot rather than a colour change, because recolouring the mark would misreport the active mode — which is the widget's actual job.
That scan is only affordable because it reads the tail of each transcript first: if the last message is Anthropic's, the transcript is healthy and the rest of the file is never opened. Since that is the overwhelmingly common case, the whole sweep costs ~60ms for 59 transcripts, against ~5s for the obvious version that reads every byte of every one.
This is why a switch asks before it writes rather than reporting afterwards.
Troubleshooting
When the helper "is failing"
Claude Code reports a broken helper as your apiKeyHelper script is failing and
shows its stderr under /status. Two causes account for nearly all of it.
A space in your home directory. apiKeyHelper is a shell command line, not
a path, so C:\Users\Firstname Lastname\.claude-mode\bin\claude-key-helper.cmd
is split at the space and cmd tries to run C:\Users\Firstname. The value is now
quoted when it needs to be, on both ports — shlex.quote on POSIX, where a
/Users/Firstname Lastname home does the same thing. Paths that need no quoting
are written bare exactly as before, so no existing settings.json churns.
doctor used to miss this, because it quoted the path itself before running it
and so tested something Claude Code never sees. It now reads the string out of
settings.json, says so when that string is not what a switch would write, and
executes that string through a shell. Re-running the switch rewrites it:
claude-mode openrouter default
claude-mode doctor
A key stored by a different Windows account. The vault is DPAPI CurrentUser
scope, so a key stored from an elevated or run as shell cannot be decrypted by
the account Claude Code runs as. Re-run claude-mode set-key <ref> unelevated,
as yourself.
Other known issues with their own sections: context windows and early compaction, the LM Studio template parser spam, and refusing a switch that would not work.
Design decisions
Why settings.json and not a profile export
Three ways to make this persist. I picked the second.
1. Export the variables from $PROFILE.
The obvious move, and the wrong one here. It only covers processes launched from
a PowerShell session that loaded the profile — which is exactly not how you use
Claude Code. The VS Code extension is spawned by VS Code, not by your shell, so
it would never see the exports. Same for the desktop app, cmd.exe, and any
terminal opened before the switch. Worse, the failure is silent: you switch to
anthropic, a shell opened five minutes ago still has ANTHROPIC_BASE_URL set,
and that session quietly keeps billing OpenRouter.
2. Rewrite the env block in ~/.claude/settings.json. ← chosen
Claude Code reads this file on every startup, from every launch context. One
write, and the next claude — CLI, extension, desktop — picks it up. A switch is
atomic: one file, one source of truth. claude-mode anthropic deletes the
managed keys rather than blanking them, so nothing can linger and break native
auth. The cost is that config is global rather than per-terminal.
3. Persistent User-scope environment variables (setx).
Also global and reboot-proof, but strictly worse: new processes only, values sit
in the registry in plaintext, and a stale entry silently outranks whatever
claude-mode writes. This tool treats them as a fault condition — status and
doctor flag them and offer removal, backing the old value up first.
The claude wrapper in the profile is a safety net, not the mechanism. It
strips inherited process-level copies of all thirteen managed variables before
launching claude.exe. Everything still works without it — including in VS Code,
which never loads the profile.
Why API keys are not in settings.json
settings.json is a config file you'll hand-edit, diff, and possibly paste into
a bug report. A sk-or- or Z.AI token does not belong there.
Keys are stored DPAPI-encrypted in ~/.claude-mode/vault/*.cred — encrypted
against your Windows account on this machine, so copying the file elsewhere or
reading it as another user yields nothing — with the file ACL restricted to you.
Claude Code receives the key at runtime through apiKeyHelper, which decrypts
and prints it. settings.json holds only the base URL and model IDs.
In anthropic mode the helper is removed from settings.json and returns
nothing when state says anthropic — belt and braces. LM Studio's lmstudio
token is a placeholder, not a secret, so it's written inline and the helper stays
out of it.
Refusing a switch that would not work
A switch writes settings.json and is picked up by the next claude launch, so
switching into a mode that cannot serve requests does not fail loudly — it
succeeds, and every session started afterwards is broken in a way that points at
Claude Code rather than at here.
LM Studio is the sharp case. Its token is an inline placeholder, so nothing about the switch needs the server to exist; point at a server that is not running and you get a config that looks perfectly healthy and answers nothing.
So the preconditions are checked before the write, not after:
| mode | checked | when it fails |
|---|---|---|
| any gateway | the preset has been through setup at least once | claude-mode setup <mode> |
anthropic |
nothing to check | — |
openrouter, zai |
a key exists for the preset's keyRef, and the helper is executable |
claude-mode set-key <ref> |
lmstudio (anywhere) |
the server answers, and accepts the credential the preset would send | see below |
| any preset on a local base URL | same probe | start the server |
| all | the preset exists and declares the provider being switched to | — |
The probe distinguishes four outcomes, because their remedies are opposites:
| result | means | remedy offered |
|---|---|---|
ok |
answered /api/v0/models or /v1/models |
— |
auth |
the server is up and refused the credential | store or fix the key, or turn auth on for the preset |
notfound |
something is listening, but the API is not at that path | fix the base URL |
refused |
nothing answered at all — down, asleep, DNS, TLS, timeout | start the server, or fix the address |
claude-mode preflight <mode> [preset] runs exactly these and prints the
verdict as JSON without switching; it is what the bar widget calls before it
offers to do anything. --force overrides the lot.
Remote gateways are not probed — OpenRouter or Z.AI being briefly unreachable is the network's problem and not worth blocking a config change over, where a missing key never fixes itself. LM Studio is probed wherever it lives, because an instance on a sleeping LAN box is exactly as absent as a loopback port with nothing behind it, and produces the identical silent breakage. Off-machine addresses get a longer timeout, not a pass.
CLI version
Verified against claude.exe 2.1.221 by scanning the binary — every variable
this tool writes is referenced by it:
ANTHROPIC_BASE_URL · ANTHROPIC_AUTH_TOKEN · ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL ·
CLAUDE_CODE_SUBAGENT_MODEL · CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY ·
CLAUDE_CODE_ATTRIBUTION_HEADER · CLAUDE_CODE_AUTO_COMPACT_WINDOW ·
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC · API_TIMEOUT_MS · apiKeyHelper
(On 2.1.89 the fable and gateway-discovery vars did not exist; the update to 2.1.221 added both.)
Layout
Windows:
~/.claude-mode/
claude-mode.ps1 main script
providers.json what each gateway provider is (replaced on every install)
state.json mode, active preset, and the exact env keys last written
presets/*.json provider + model maps
vault/*.cred DPAPI-encrypted keys (openrouter, zai, ...)
backups/ settings.json snapshots + removed env-var values
bin/claude-key-helper.cmd apiKeyHelper shim
~/.local/bin/claude-mode.cmd PATH entry point (works from any shell)
~/Documents/WindowsPowerShell/profile.ps1
claude-mode + claude functions, between markers
Linux / macOS: the same tree, with bin/claude-mode + bin/claude-key-helper.sh,
bin/cm-json.py, bin/cm-vault.sh, the symlink ~/.local/bin/claude-mode, and
the wrapper block in ~/.bashrc / ~/.zshrc.
state.json records which env keys the last switch actually wrote, so a custom
extraEnv key (Z.AI's timeouts, LM Studio's attribution header) is removed when
you switch away — even though no other preset knows that key exists.
Linux / Omarchy
The linux/ tree is a POSIX port of the same design: one write to
~/.claude/settings.json, secrets kept out of it, presets shared verbatim with
the Windows build. Install is up top.
The menu follows your desktop theme
The sixteen ANSI colour slots carry no guarantee about relative brightness, and
monochrome themes exploit that. Under Omarchy's Solitude, slot 36 — headings —
resolves to #707070 and slot 31 — FAIL — to #565d60. Against #cacccc
body text on a #101315 ground that is 3.8:1 and 2.8:1 where the body text is
11.6:1, so headings render as fine print and an error becomes the quietest
thing on screen. Exactly backwards.
So when Omarchy is present, the palette is derived from the theme it publishes
at ~/.local/state/omarchy/current/theme/colors.toml instead. Every role is
measured against the background it will actually be drawn on and lifted toward
the foreground when it falls short, which keeps hue where the theme has any and
falls back to weight where it does not:
| role | before (Solitude) | after |
|---|---|---|
| heading | 3.8:1 | 9.4:1 |
FAIL |
2.8:1 | 5.2:1 |
| help text | 2.2:1 | 2.2:1 (recessive on purpose, floored) |
Light themes are handled by the same arithmetic — mode in colors.toml is
authoritative, so catppuccin-latte and flexoki-light keep their accents
rather than washing out.
Overrides: CLAUDE_MODE_THEME=/path/to/colors.toml points it elsewhere,
NO_COLOR turns it off. Without Omarchy, or on a terminal that cannot do
truecolor, it falls back to the ANSI slots with the two roles the slots get
wrong corrected — bright red for FAIL, bold on headings.
claude-mode health
Rewrites ~/.claude-mode/health.json, the machine-readable mirror of the
active configuration: mode, preset, model map, context window, key backend, and
the switchable preset catalogue. No key material. A switch and a status both
refresh it; the command exists for anything that wants to force it.
The Omarchy bar widget
An icon in the Omarchy top bar showing which provider the next claude launch
will use, and a panel that switches it without a terminal. Install is
up top. The shell notices the new files and reloads the
plugin, but on an upgrade that is not enough: it clears Qt's component cache
while the old widget is still alive, so the old compiled panel survives, and
omarchy-shell shell rescanPlugins does not shift it either. Run
omarchy restart shell after upgrading. The same goes for any change to
Modes.js, which the QML engine caches for the life of the process as a
.pragma library.
The icon is the mode, and it is the provider's own logo: the Claude burst, the
OpenRouter arrow, the Z.AI Z, the LM Studio mark. They are drawn as vector paths
with QtQuick.Shapes rather than set as font glyphs — three of the four have no
Nerd Font pictograph at all, and being paths means they take the bar's
foreground colour and follow the theme like everything else. Anything other than
Anthropic takes the theme accent, so the bar stays quiet exactly when nothing
unusual is configured.
Marks come from simple-icons (Claude, OpenRouter, LM
Studio) and lobe-icons (Z.AI);
trademarks belong to their owners. Each carries an optical scale factor, because
equal nominal size is not equal apparent size — LM Studio's filled container
covers 69% of its box against ~38% for the other three, and Z.AI and OpenRouter
are wide-but-short marks whose ink spans only ~84% of the box height. The
factors in Modes.js are the geometric mean of both corrections.
Three details keep them from looking ragged in a 13px slot, which is what every stock glyph in this bar measures:
-
No layer. A layer rasterises the Shape at its own size and then scales the texture, so a 24px buffer minified to 13 resamples ~2 pixels into 1. Without one the scale is a transform on the geometry and rasterisation happens once, at final resolution.
-
Shape.CurveRenderer(Qt 6.6+) rasterises curves analytically instead of tessellating them into antialiased triangles. Measured against a cairo render of the same mark at the same size, the two now come out identical. -
An odd
iconSize. These marks are radially symmetric, so their vertical and horizontal arms sit on the centre line — which is a pixel centre at an odd size and the seam between two pixels at an even one, where each arm splits its coverage and greys out. Even values are rounded up. -
Click the icon for the panel: current mode, every mode with the active one ticked, and the model map behind a gateway. Picking a gateway unfolds its presets rather than switching blind; picking a preset starts the switch.
Choosing a target does not switch immediately — it runs the same two checks the CLI does, and both can stop it:
-
Preflight. If the mode has not been set up, has no key stored, or its server is not answering, the panel says which and offers the fix: Set up … opens a terminal and runs the whole first-run flow there (a bar popup can host neither a hidden key prompt nor a filter-select model list), Store the key… opens a terminal for just the prompt, Check again re-runs the preflight, and Server settings… opens the form below. For an LM Studio preset the same form is also one click inside the preset editor (below), without waiting for a failure.
The form holds the base URL, a Use local default reset, and a switch for whether that server needs an API key. Saving rewrites the preset and drops straight back into the switch that was blocked.
-
Running sessions. If any are running, the panel lists them by terminal and directory, marks any that are mid-request, and asks: Switch and restart, Switch and close, Switch only, or Cancel. The session action is applied strictly after the write — restarting first would only bring them back up on the provider you just left.
- The gear on any preset row opens that preset in an editor: its four tiers,
each with the model it maps to. Clicking a tier opens one field that filters
the provider's models as you type (every word must match; ↓/↑ and Enter work),
listed with context length and price. That list comes from
the cache the CLI leaves behind, so the panel never touches the network on
its own. Fetch models / Refresh list runs
models --refreshwhen it is empty or old. Any id can also be typed by hand. The gear sits on each row rather than each provider, because two presets of one provider map tiers differently, and two LM Studio presets can point at two different machines. Editing the preset in use applies it at once; see Commands for why that does not ask about running sessions. - Esc closes the panel. While it is open the panel holds the keyboard, as every other shell panel does, so its text fields can be typed into.
- Right-click switches straight back to Anthropic.
- Middle-click re-reads state.
- Hover for mode, preset, and the opus/sonnet mapping.
State comes from watching health.json, not from polling the CLI, so the
widget costs nothing while idle and a switch made in a terminal shows up in the
bar on its own. A failed switch — a preset whose key was never stored is the
common one — surfaces the CLI's own error in the panel rather than looking
like a click that did nothing.
Per-instance settings in the shell.json layout entry:
| key | default | meaning |
|---|---|---|
showLabel |
false |
show the preset name beside the icon as well |
iconSize |
13 |
mark size in px; rounded up to odd (see below) |
root |
~/.claude-mode |
where claude-mode is installed |
Placement is right, before omarchy.agents; override with CM_BAR_SECTION
and CM_BAR_BEFORE when installing. Moving it later is a normal
omarchy bar move smoido.claude-mode --section <section>.
Uninstall
Windows:
claude-mode anthropic # clean settings.json first
Remove-Item ~\.claude-mode -Recurse -Force
Remove-Item ~\.local\bin\claude-mode.cmd
# then delete the block between the >>> claude-mode >>> markers in profile.ps1
Linux / macOS:
claude-mode anthropic # clean settings.json first
rm -rf ~/.claude-mode ~/.local/bin/claude-mode
# then delete the block between the >>> claude-mode >>> markers in ~/.bashrc / ~/.zshrc
Bar widget:
rm -rf ~/.config/omarchy/plugins/smoido.claude-mode
# then remove the {"id": "smoido.claude-mode"} entry from ~/.config/omarchy/shell.json