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>
The README had grown to 1,010 lines of user docs and design notes in one file,
and had gone stale: it still showed the old numbered menu, set up only three
providers, and listed preflight checks and a file layout that predate the last
three releases. It now carries install, first run, the full command
reference, the providers at a glance, troubleshooting and a docs index.
docs/:
- providers.md presets, defaults, the model cache, context windows, each
provider (OpenRouter's cost guard and guardrail check are
written up for the first time), adding a provider
- live-sessions.md what a switch does to running sessions, and repair
- design.md why settings.json, why keys stay out of it (and the vault
per platform), the preflight checks as they are now
- bar-widget.md the widget as it is now: providers from health.json, every
server provider's settings, the restart after an upgrade
- architecture.md the pieces, every file on disk and who writes it, the
contracts between them, where to change what
- development.md running the tests, the conventions the code follows,
working on the widget, releasing
CONTRIBUTING.md points at it.
Also:
- The per-project session listing used awk, which the CLI avoids because it
is missing from minimal images; it uses the script's own TSV helpers now,
and tests/static.sh fails on any awk in the CLI.
- tests/static.sh checks every relative Markdown link and #anchor.
- A unit test pins the managed env keys between cm-json.py and
claude-mode.ps1, which only a comment kept in step before.
- test_sessions covers the per-project listing, which nothing ran.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Every gateway provider is now an entry in providers.json - endpoint and auth
template, how its model list is read, how it is probed before a switch, what
setup asks, which doctor checks apply, and its title, colour and logo -
installed next to the presets and read by all three consumers: the bash CLI
(through cm-json.py), the Windows script, and the bar widget (through
health.json). anthropic stays built in; it is the native login, not a gateway.
Behaviour that differs in kind stays in code, chosen by name from the entry:
catalogue parsers (openrouter, lmstudio, ollama, openai, static), probe rules
(always, lenient, local), and named doctor checks. A provider that reuses them
is an entry and a default preset, with no code. The widget draws providers
from health.json, so a new one needs no QML change and no shell restart.
The existing three are unchanged in behaviour: their blank presets come out
byte-identical from the file, and setup, doctor, models and the picker run the
same checks through the generic paths.
Ollama: local server on :11434, placeholder token, one model for every tier,
models from /api/tags. doctor reads the context each loaded model actually runs
with (/api/ps) and its maximum (/api/show), because Ollama defaults to 4096
tokens unless OLLAMA_CONTEXT_LENGTH is set and silently truncates past it. A
bare model name matches its :latest tag.
Custom: any Anthropic-compatible endpoint. Ships with no address and is refused
until it has one; key optional; models from /v1/models when the endpoint has a
list, and a lenient probe so a proxy without one is not blocked.
Also: preflight (and Set-ClaudeMode on Windows) refuses a preset with no
server address; the server form, setup and set-auth use each provider's own
default URL, key name and placeholder token instead of LM Studio's; the
Windows build gains the no-models and no-address guards it never had.
Tested on Linux against fake Ollama/Custom servers, and on Windows 5.1 in a
USERPROFILE sandbox on winbox.
1.12.0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tier editing with the cached model catalogue, preset create / duplicate /
rename / delete, and a choosable default preset per provider.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Omitting the preset used a fixed name per provider (default, zai,
lmstudio). It can now be chosen:
claude-mode preset default what each picks, and why
claude-mode preset default openrouter cheap
claude-mode preset default openrouter --clear
The choice is stored in ~/.claude-mode/defaults.json and read first by
default_preset_for, while its file still exists. A choice whose file is gone
falls back to the built-in name, and if that is gone too, to the first
preset by name, as before. Renaming a chosen preset moves the choice with it,
and deleting it clears the choice. The terminal menu marks what
resolve_preset would actually pick.
health.json publishes defaultPresetFor (what each provider resolves to, in
the CLI's order) and defaultPresetChosen (the explicit choices only). The
panel sorts and tags from it, and the editor gains Make default / Clear
default. preset new, rename and rm now refresh health.json, so the bar
follows edits made in a terminal too.
This changes existing CLI behaviour only once a default is chosen. The
Windows build does not read defaults.json yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
The gear on every preset row now opens an editor: the four tiers with the
model each maps to, and for each one field that filters the provider's
catalogue as you type (every word must match; arrows and Enter work), listed
inline with context length and price, and that also takes any id typed by
hand. LM Studio's server form moves one click inside the editor.
The panel card moves from PopupCard to KeyboardPanel. PopupCard is an
xdg-popup, which only receives keys after focus is routed through its parent
surface, so no text field in it could ever be typed into - the existing
server URL form included. KeyboardPanel primes layer-shell keyboard focus on
open, which is why every shell panel with a text field uses it. Esc now
closes the panel.
The picker reads ~/.claude-mode/models-cache.json, which or_catalogue and
lms_catalogue now write as a side effect, so models, doctor, setup and the
menu's picker all keep it fresh and the panel never hits the network itself.
One node per provider with its own fetchedAt/ok; a failed fetch keeps the
old list, an LM Studio list is tied to its server, and no key or key name is
ever stored. `models` gains --preset, --refresh and --json, and the Z.AI list
now lives in one place.
A tier edit to the active preset re-applies without the running-sessions
prompt: that prompt guards against the endpoint or key moving, and a tier
edit moves neither (preset url/auth still ask). A failed re-apply now says
the edit was saved. `preset set` on an unknown name no longer creates it.
Panel edits run through one chain that stops on the first failure and
refreshes health.json at the end.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A broken session that will never be repaired kept the bar's warning dot lit
forever. repair-session now takes --ignore/--unignore/--unignore-all/--ignored,
recorded in ~/.claude-mode/ignored-sessions.json, and the scan hides broken
transcripts older than --max-age days (default 7, 0 disables,
CM_IGNORE_AGE_DAYS sets it). Hidden sessions move to a separate ignored[]
list with a reason, and --all always names how many it held back.
A repair clears the session's dismissal, and entries whose transcript is gone
are pruned, so a session that breaks again is never silently hidden.
The panel gets an Ignore button beside Repair and a collapsed "hidden (N)"
section with Restore. The dot still counts broken sessions only.
Bumps to 1.10.0 and fixes the widget manifest, which still said 1.8.0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The vault is written with ConvertFrom-SecureString -SecureString, so the
inline branch has to produce a SecureString as well - it only set the
plain text, and under Set-StrictMode the vault write then failed with
'the variable $secure cannot be retrieved'. Caught by running the new
form on the Windows box rather than assuming it.
Co-Authored-By: Claude Code <noreply@anthropic.com>
`claude-mode set-key openrouter sk-or-...` did the wrong thing on the
POSIX port: the dispatcher loop kept the *last* positional, so the key
became the ref name and the prompt asked for "the key for ref
'sk-or-...'". Storing to that ref succeeds, so nothing looks wrong until
the switch later reports no key for 'openrouter' - which is exactly what
happened on macOS.
Both ports now take `set-key [ref] [key]`: the first positional is the
ref unless it is key-shaped (sk-* or longer than a ref name would
plausibly be), in which case it is the key for the default ref, and a
second positional is always the key. An inline key warns that it is now
in the shell history, since the hidden prompt leaves no trace. Extra
arguments are refused instead of ignored. The Windows dispatch already
read the first positional correctly, so it only gains the inline form.
The shipped default preset moves to the models asked for: DeepSeek 4.1
Flash on opus, GLM Flash on sonnet. Existing installs keep their own
presets - the installer never overwrites one without --force - so an
upgrade needs `claude-mode preset set default opus ...` and the same for
sonnet, or a --force install.
Verified in the bash 3.2 container: ref+key stores under the ref, a bare
key stores under openrouter, an empty prompt aborts, a custom ref still
works, the stored value is byte-identical, and four positionals are
refused.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude Code's project slug is not "slashes become dashes". Every
non-alphanumeric character becomes one dash, nothing collapsed - verified
against 2.1.269 by running a session in a directory named `slug._test x`,
which was filed as `-tmp-cmtest-slug--test-x`: the dot, the underscore and
the space each became a dash of their own. Paths with dots are ordinary on
macOS (iCloud Drive lives under `Mobile Documents`), and the old slash-only
rule pointed at a directory that does not exist, so repair-session could
not find a single transcript there.
It is also the physical directory that gets slugged. Claude Code asks the
OS for its working directory, and symlinks come back resolved: 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. Verified the same way -
a session started in /tmp/cmtest/linkdir landed under -tmp-cmtest-realdir.
cm_project_dir now applies the real rule and falls back to the resolved
path, which also covers the walk-up search in repair-session. Both cases
were re-tested end to end afterwards: from the dotted-and-spaced directory
and from the symlinked one, the transcripts are found.
The bar widget still reverses a slug for display ("-home-smoido-Work" ->
"home/smoido/Work"), which is lossy in the same way but is only a label,
and only ever sees Linux paths.
Co-Authored-By: Claude Code <noreply@anthropic.com>
macOS ships bash 3.2, and the port used three things it does not have:
mapfile, fractional `read -t`, and GNU `stat -c`. The first two took the
interactive menu down - `mapfile: command not found` in the preset
picker, and an arrow key that parsed as a bare Escape, leaving `[C` in
the tty for zsh to report as a bad pattern.
read_key now reads the escape tail through the terminal itself: icanon
off with min 0 / time 1, so a plain read returns the moment a byte
arrives and gives up after ~0.1s. bash 3.2's `read -t 0` cannot be used
to poll for this - it reports nothing even with bytes buffered (checked
against 3.2.57). CSI sequences are consumed whole, so modified keys like
Ctrl+Right no longer leak their tail either. Verified interactively
under bash 3.2.57 in a pty: arrows, pgup/pgdn, home/end, enter,
backspace, and a bare Escape all parse, with no stray bytes in the tty.
Session listing reads /proc, which macOS does not have; it now says so
rather than silently reporting no running sessions - the answer that
gets people to switch out from under a live session. install.sh swaps
GNU `find -print -quit` for a glob, and the README notes both.
Co-Authored-By: Claude Code <noreply@anthropic.com>
apiKeyHelper is a shell command line, not a path, so the raw value written
into settings.json was split at the first space. A Windows profile named
"Mohammed Ahmed" produced an attempt to run C:\Users\Mohammed, surfacing as
"your apiKeyHelper script is failing" with nothing to go on. Quote the value
when it contains anything a shell cares about, and leave it bare otherwise so
no existing settings.json churns on the next switch. The POSIX port had the
same bug against a /Users/First Last home; shlex.quote has exactly the wanted
"leave ordinary paths alone" behaviour.
doctor could not see any of this. It quoted the path itself before running it,
so it exercised a command line Claude Code never uses and passed while the
real one failed. It now reads the string out of settings.json, reports it when
it is not what a switch would write, and runs that string through a shell.
The helper itself exited 1 in silence on four distinct faults - no state, no
preset, no key, undecryptable key - collapsing them into one indistinguishable
message. Each now names itself on stderr, which is what /status displays. The
DPAPI case says what it actually means: a key stored by a different Windows
account than the one Claude Code runs as. Success paths stay silent, so stdout
still carries the key and nothing else.
Also make install.ps1 survive a Restricted execution policy: piped through
iex it is not subject to the policy, but invoking the installed script for the
key prompt is, which is where a fresh install died. Set Process scope for the
install, offer to set CurrentUser to RemoteSigned, and clear the
mark-of-the-web that Expand-Archive can leave on the extracted scripts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Piped in via irm | iex there is no checkout next to the script, so the
installer now notices that and fetches the repository archive to %TEMP%
itself. README documents the one-liner, the download-and-run variant for
switches, and the clone path.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Nothing tells you a session is unresumable until you try to resume it, and by
then you have usually forgotten which one it was. The widget now scans every
project on a timer and whenever the panel opens, marks its icon when something
needs fixing, and lists the affected sessions with a repair button that says
what it will drop and what it will keep before doing anything.
The scan had to get roughly eighty times cheaper first. Classifying a transcript
needs two facts - what its last message id is, and whether any Anthropic id
exists at all - and the first settles the common case alone. Reading the tail of
each file and only opening the whole thing when the tail already looks wrong
takes the sweep from ~5s to ~60ms across 59 transcripts, which is the difference
between something that can sit on a timer and something that cannot. `--all`
uses the same path, and `--json` exposes it.
The badge is a dot beside the mark rather than a recolouring of it: this widget's
job is to report which provider is active, and tinting it red to mean something
else entirely would be a lie about that.
Verified by planting a genuinely corrupted transcript, watching the scan find it
and the dot appear, then removing it and watching both clear.
repair-session only ever listed the project you were standing in, which is a
poor place to start from when the thing you cannot resume is a session whose
project you no longer remember. --all drops the working-directory scoping and
checks all of them. (A named session id was already looked up across every
project; only the listing was scoped.)
The first version of that scan reported 16 of 59 transcripts as unresumable,
which was true in the narrowest sense and useless in every other. Checking them:
5 had never received an assistant reply at all, 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. Calling
either of them damage buries the one case that is.
So the verdict now carries a kind - healthy, repairable, gateway-native,
synthetic-only, no-messages - and only `repairable` is reported or acted on: a
transcript with a genuine msg_ message and junk after it. Across the same 59 it
now correctly reports nothing to repair, while still classifying the real
corrupted transcript from the incident as repairable with 108 lines to drop.
A gateway-native session asked about directly now says what it is and that there
is nothing to fix, rather than failing with "no Anthropic-issued message".
Truncating the transcript 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 - it has no idea what it just did or what it was asked.
So --apply now writes the dropped turns out as <session>.recovered-<stamp>.md,
a readable record of what was asked, what was answered and what was run, and
appends the same text back to the truncated transcript as a single note. Tool
results are deliberately excluded: they are most of a transcript by volume and
the least useful part of a summary.
The note is a user entry marked isMeta - the marker Claude Code already uses for
its own local-command caveats, meaning context rather than something to answer -
and carries no message.id, so it cannot recreate the previous_message_id
condition being repaired. Verified on the real corrupted transcript: 1921 lines
in, 1813 kept plus the note, chained to the last good assistant uuid, no id on
the message, and the file reads back as healthy. The generated digest recovers
the actual instruction that was lost in the incident, which is the thing that
made this worth doing.
--no-reinject writes the markdown but leaves the session alone.
Acts on docs/incident-mode-switch-corrupts-live-sessions.md, which is added here
as the record of why.
The report identifies a consequence that was not modelled. A failed call is
recoverable; a *successful* one may not be. If a running session takes even one
completion from the provider being switched to - which happens when that mode
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_...`, and native Anthropic then refuses to resume the session at
all, with a 400 naming previous_message_id. The only way back is to truncate the
transcript, losing every turn after the cut. That happened here, and was fixed
by hand.
Two changes follow.
Sessions are now settled before the write, not reported after it. A switch with
anything running stops, names the sessions, explains what is about to happen to
them, and offers restart (the only answer that ends with everything on the mode
the bar now claims), close, proceed anyway, or abort - defaulting to abort.
Non-interactively it refuses outright unless given --yes. The old after-the-fact
reporter is deleted rather than left as a second, contradictory account.
`claude-mode repair-session` replaces the hand surgery: it finds a project's
transcripts, reports which are resumable, and on --apply backs the file up and
truncates to the last Anthropic-issued message. Verified against the real
corrupted transcript from the incident - it reproduces the manual cut exactly,
1921 lines to 1813, dropping the two `gen-` completions and the error
placeholders after them, leaving a transcript that ends on a genuine msg_ id. It
refuses a transcript written to in the last 90 seconds, since that one belongs
to a session still running.
The panel passes --yes, having already asked in its own card, and that card now
names the transcript risk rather than only the inconvenient one.
Requirement 4 of the report - documenting the mechanism - landed in 9c301e1;
the README now carries the unrecoverable half as well.
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.
Every one of these said a switch leaves running sessions alone - that they keep
talking to the old provider until restarted. That is wrong, and reassuring in
exactly the wrong direction: a switch breaks them.
The static half of the config does behave as described. Base URL, model ids and
the env block are 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 is present in the
2.1.251 binary - and claude-key-helper.sh answers for whatever state.json says at
that moment. So the switch reaches into a live session through the one part that
was never cached. Into anthropic, the helper returns nothing by design and the
next refresh comes back with no credential; into another provider, it hands over
the new key while the session is still pointed at the old base URL, which
rejects it. Either way the session starts failing calls whenever the TTL happens
to expire, mid-turn as easily as between turns.
One case does survive, and is now stated rather than glossed: switching between
two presets of the same provider that share a keyRef keeps the same key and the
same endpoint, so the session carries on with the model ids it started with.
The claim in "Restarting sessions" predates this work - it came in with the
Windows build - but it is wrong for the same reason and is corrected too.
The session-confirmation card lays out four buttons, which come to roughly
440px of content in a card 332px wide (360 less 14px of padding each side). A
Row has no notion of how wide its parent is and no wrap, so it put them in one
line and the last two ran off the right edge of the popup.
Flow bound to the parent width instead, so the buttons take a second line when
they need one. Applied to every button row rather than just the one that
overflowed - the failure card can show four at once for the same reason, and the
server form's rows would do it at a narrower card or a larger font.
Labels are left as they are: "Switch and restart" says what the button does to
the sessions listed above it, and two tidy rows read better than four terse ones
crammed into a single line.
GLM 5.3 supersedes 5.2, so every reference to the older id follows it: the zai
preset's opus, sonnet and fable tiers, and the fable tier of `default`, which
routes to the same model through OpenRouter.
Also the hardcoded Z.AI entries in both builds' model pickers, which are the one
place a model id is not read from a preset - leaving those would have gone on
offering a superseded model from the menu while the presets used the new one.
haiku stays on glm-4.7: that is the fast/cheap tier, not a 5.x.
A Quickshell plugin for the Omarchy shell (v4's bar is Quickshell, not Waybar).
The icon is the mode; clicking it opens a panel that switches.
State comes from watching health.json rather than 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. state.json is the fallback, because it exists from install onward where
health.json only appears once claude-mode has run.
The marks are the providers' real logos, drawn as vector paths through
QtQuick.Shapes rather than set as font glyphs - three of the four have no Nerd
Font pictograph at all, and paths take the bar's foreground colour and follow
the theme. Claude, OpenRouter and LM Studio from simple-icons, Z.AI from
lobe-icons; trademarks belong to their owners. Zhipu's mark was rejected for
Z.AI: it is a dense lattice that is unreadable below ~40px.
Rendering them at 13px took three fixes. A layer rasterises the Shape at its own
size and then scales the texture, so a 24px buffer minified to 13 resampled two
pixels into one and the Claude burst lost rays; without the layer the scale is a
transform on the geometry and rasterisation happens once, at final resolution.
CurveRenderer replaces the tessellating default. And the size is forced odd,
because a radially symmetric mark puts its vertical and horizontal arms on the
centre line, which is a pixel centre at odd sizes and the seam between two
pixels at even ones, where each arm splits its coverage and greys out. Measured
against a cairo render at the same size, the result is now identical.
Sizing is measured rather than guessed. Every stock glyph in this bar paints
11px of ink; the marks fill their box instead of carrying a font's padding, so
the box is the smaller number. Each mark also carries an optical scale from two
measurements of a 200px render - LM Studio's filled container covers 69% of its
box against ~38% for the others, and Z.AI and OpenRouter are wide-but-short
marks spanning ~84% of the box height.
Choosing a target does not switch immediately. It runs the CLI's own preflight,
and if that refuses, the panel names what is missing and offers the fix - a
terminal for the hidden key prompt, a re-check once a server is up, or the
server form. If sessions are running it lists them by terminal and directory,
marks any mid-request, and asks whether to restart them, close them, or leave
them. The session action is applied strictly after the write, since restarting
first would only bring them back up on the provider just left.
The server form edits an LM Studio preset's base URL and whether it needs an API
key, reachable from the gear on any LM Studio preset row - per row, because two
presets can point at two different machines - and from the failure card. The
shipped local default is untouched unless it is changed.
Four changes to the POSIX build, found while getting it working on Omarchy.
Colour follows the desktop theme. The sixteen ANSI 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,
which against #cacccc body text on a #101315 ground is 3.8:1 and 2.8:1 where the
body text is 11.6:1. Headings rendered as fine print and errors as the quietest
thing on screen. The palette is now derived from the theme's own colors.toml,
with each role measured against the background it will actually be drawn on and
lifted toward the foreground when it falls short - hue kept where the theme has
any, weight substituted where it does not. Headings go 3.8:1 -> 9.4:1 and FAIL
2.8:1 -> 5.2:1. Falls back to the ANSI slots off Omarchy, with the two roles the
slots get wrong corrected.
health.json was only ever written by a switch, so a fresh install had none at
all and any reader had to guess. It is now refreshed by `status` and seeded at
install, and `claude-mode health` forces it. The installer also copies VERSION,
which cm_version() has always read and nothing ever wrote - every health.json
until now reported 0.0.0.
Preflight, because a switch that cannot work does not fail loudly: it succeeds,
and every session started afterwards breaks in a way that points at Claude Code
rather than at here. Keys, the helper, the preset's provider and the server are
all checked before the write. LM Studio is the sharp case - its token is an
inline placeholder, so nothing about the switch needs the server to exist.
Session control, because Claude Code reads settings.json once at startup: a
switch leaves running sessions on the old provider until they are restarted, and
one mid-request can lose that turn outright. `sessions` lists them, `--stop` and
`--restart` act on them behind a confirmation, `--dry-run` shows the plan.
Sessions are found through /proc/<pid>/exe rather than by process name, which
would sweep up every shell that merely mentions claude - including the one this
runs from. Two exclusions: the calling session, and forks of a session. A busy
session spawns children off its own binary that inherit the same exe, and
without filtering those the count climbed and fell with load - it read 2, 5, 11
and 40 for the same two sessions before the parent check went in.
LM Studio is no longer assumed to be on this machine. `preset url` and
`preset auth` move it to a LAN box, a tunnel or a proxy and turn authentication
on, and the probe distinguishes ok / auth / notfound / refused, because "start
the server" and "your key is wrong" are opposite remedies. It is probed wherever
it lives - a sleeping LAN box is exactly as absent as an empty loopback port -
while remote gateways are not, since those being briefly unreachable is the
network's problem and a missing key never fixes itself.
Source of truth so far has been c:\Users\smoido\projects\cli on the Windows
box, which has no git history of its own. This is that tree copied verbatim over
SSH, minus dist/ - the PowerShell build, the POSIX port under linux/, and the
presets both share.
Recorded as its own commit so that everything after it is a reviewable diff
rather than an undifferentiated first drop.