README down to what a user needs; the rest into docs/
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>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for looking. Everything you need is in [docs/development.md](docs/development.md):
|
||||
how to run the tests (`scripts/test.sh`), the conventions the code follows, how to
|
||||
add a provider, and how a release is cut. [docs/architecture.md](docs/architecture.md)
|
||||
maps the pieces.
|
||||
|
||||
In short:
|
||||
|
||||
- Run `scripts/test.sh` before you send anything; add a test for what you change,
|
||||
and check that it fails without your change.
|
||||
- Never test by switching your own machine — the tests run in a sandbox for a
|
||||
reason.
|
||||
- A new provider is usually a `providers.json` entry and a preset, not code.
|
||||
- Keep the POSIX port bash 3.2 compatible and the PowerShell ASCII-only and 5.1
|
||||
compatible.
|
||||
|
||||
By contributing you agree your work is licensed under the [MIT licence](LICENSE).
|
||||
@@ -0,0 +1,130 @@
|
||||
# Architecture
|
||||
|
||||
How the pieces of claude-mode fit together, what each file on disk is for, and
|
||||
where to go to change something. Why it is shaped this way is in
|
||||
[design.md](design.md); how to work on it, in [development.md](development.md).
|
||||
|
||||
## The pieces
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
user([you]) --> cli["claude-mode<br/>(bash on Linux/macOS,<br/>PowerShell on Windows)"]
|
||||
widget["bar widget<br/>(Omarchy, QML)"] -- runs --> cli
|
||||
cli -- all JSON through --> engine["cm-json.py<br/>(POSIX only)"]
|
||||
engine --> providers[(providers.json)]
|
||||
engine --> presets[(presets/*.json)]
|
||||
cli -- a switch writes --> settings[(~/.claude/settings.json)]
|
||||
cli --> state[(state.json)]
|
||||
cli --> health[(health.json)]
|
||||
claude["Claude Code"] -- reads at startup --> settings
|
||||
claude -- runs on a timer --> helper["key helper"]
|
||||
helper --> state
|
||||
helper --> vault[(vault)]
|
||||
widget -- watches --> health
|
||||
widget -- reads --> cache[(models-cache.json)]
|
||||
```
|
||||
|
||||
- **The CLI** does everything that changes state. The POSIX port is bash for
|
||||
control flow and terminal UI, with every piece of JSON handled by `cm-json.py`
|
||||
— bash only ever sees flat lines of text. The Windows build is one PowerShell
|
||||
script that does the same with `ConvertFrom-Json`.
|
||||
- **A switch** rewrites the managed keys in `~/.claude/settings.json` (an `env`
|
||||
block and `apiKeyHelper`) and records what it wrote in `state.json`. Claude Code
|
||||
reads `settings.json` at startup, from every launch context.
|
||||
- **The key helper** is what `apiKeyHelper` runs. It prints the active preset's key
|
||||
from the vault and nothing else, so no key ever sits in `settings.json`. Claude
|
||||
Code re-runs it on a timer, in every live session.
|
||||
- **The bar widget** never reads config itself. It watches `health.json`, which the
|
||||
CLI rewrites on every change, and runs the CLI for anything it does.
|
||||
- **`providers.json`** describes every gateway provider as data, read by all three
|
||||
(the widget through `health.json`). Behaviour that differs in kind is chosen by
|
||||
name from it — see [providers.md](providers.md#adding-a-provider).
|
||||
|
||||
## Files on disk
|
||||
|
||||
Under `~/.claude-mode/`:
|
||||
|
||||
| file | written by | read by |
|
||||
|---|---|---|
|
||||
| `bin/claude-mode`, `bin/cm-json.py`, `bin/cm-vault.sh`, `bin/claude-key-helper.sh` | the installer | — (Windows: `claude-mode.ps1` at the top, `bin/claude-key-helper.ps1` + `.cmd`) |
|
||||
| `providers.json` | the installer, every time | the CLI, the key helper's engine, the widget (via `health.json`) |
|
||||
| `presets/*.json` | the installer seeds them; `preset`, `setup`, the panel edit them | everything |
|
||||
| `state.json` | a switch; `preset rename` of the active preset | the CLI, the key helper, the widget |
|
||||
| `defaults.json` | `preset default` (and rename/rm keep it right) | the CLI; published in `health.json` |
|
||||
| `health.json` | a switch, `status`, `doctor`, preset edits, `health` | the widget |
|
||||
| `models-cache.json` | every catalogue fetch | the panel's model picker |
|
||||
| `ignored-sessions.json` | `repair-session --ignore` / `--unignore` | the session scan |
|
||||
| `vault/` | `set-key` (file backend; DPAPI on Windows) | the key helper |
|
||||
| `backups/` | a switch, before it writes `settings.json` (last 20 kept) | you, if needed |
|
||||
| `VERSION` | the installer | the CLI, stamped into `health.json` |
|
||||
|
||||
Outside it: `~/.claude/settings.json` (a switch writes the managed keys and never
|
||||
anything else), `~/.claude/projects/*/*.jsonl` (Claude Code's transcripts, which
|
||||
`repair-session` reads and repairs), and on Omarchy
|
||||
`~/.config/omarchy/plugins/smoido.claude-mode/` (the widget).
|
||||
|
||||
## The repository
|
||||
|
||||
```
|
||||
claude-mode.ps1, install.ps1 the Windows build and its installer
|
||||
bin/ the Windows key helper
|
||||
linux/ the POSIX port: claude-mode, cm-json.py, cm-vault.sh,
|
||||
claude-key-helper.sh, install.sh, bootstrap.sh
|
||||
omarchy/ the bar widget and its installer
|
||||
providers.json, presets/ shared by both builds, byte for byte
|
||||
scripts/ test.sh, bump-version.sh, build-package.ps1
|
||||
tests/ static, python, cli and windows suites
|
||||
docs/ this
|
||||
```
|
||||
|
||||
## Contracts between the pieces
|
||||
|
||||
These are the interfaces one piece relies on another to keep. Each is pinned by a
|
||||
test.
|
||||
|
||||
- **`providers.json`** — its shape and the values each field may take
|
||||
([providers.md](providers.md#adding-a-provider)); `tests/static.sh` enforces them.
|
||||
- **`provider-tsv`** — `cm-json.py` hands bash one tab-separated row per provider,
|
||||
and bash addresses the columns by number (bash 3.2 has no associative arrays).
|
||||
The column order is `PROVIDER_TSV` in `cm-json.py`, mirrored in a comment in
|
||||
`linux/claude-mode`: append only. Pinned by `tests/python`.
|
||||
- **The managed env keys** — the keys a switch owns and clears: `BASE_MANAGED` in
|
||||
`cm-json.py` and `$script:BaseManagedEnvKeys` in `claude-mode.ps1` must list the
|
||||
same keys. Pinned by `tests/python`.
|
||||
- **`health.json`** — what the widget reads: `mode`, `preset`, `version`, `models`,
|
||||
`contextTokens`, `keyBackend`, `presets` (the catalogue), `providers` (id, title,
|
||||
blurb, logo, logoScale, serverEditable, perServerCatalogue, defaultKeyRef,
|
||||
defaultBaseUrl, serverHint, keyOptional), `defaultPresetFor`,
|
||||
`defaultPresetChosen`. No key material, ever.
|
||||
- **`claude-mode preflight <mode> [preset]`** — JSON the widget acts on: `ok`,
|
||||
`code` (`ok`, `needs-setup`, `no-models`, `no-url`, `missing-key`,
|
||||
`helper-missing`, `server-auth`, `server-wrong`, `server-unreachable`,
|
||||
`no-preset`, `provider-mismatch`), `title`, `detail`, `remedy` (a command), and
|
||||
`remedyKind`, which picks the panel's button (`setup`, `set-key`, `edit-preset`,
|
||||
`set-url`, `needs-key`, `start-server`).
|
||||
- **`claude-mode repair-session --json`** — the scan: `broken[]` and `ignored[]`
|
||||
(each with `sessionId`, `project`, `path`, `dropLines`, `providers`, `mtime`, and
|
||||
`reason` for the ignored), `count`, `ignoredCount`, `maxAgeDays`.
|
||||
- **`models-cache.json`** — `providers.<id>` → `fetchedAt`, `ok`, `failedAt`,
|
||||
`baseUrl` (per-server providers), `models[]` (`id`, and `contextTokens`,
|
||||
`priceIn`, `priceOut`, `state`, `note` where known). No keys.
|
||||
- **The key helper** — prints the key and nothing else on stdout; every failure
|
||||
explains itself on stderr, because Claude Code shows that under `/status`. It
|
||||
reads the preset in one open and re-reads `state.json` once on a miss, so a
|
||||
rename of the active preset cannot catch it mid-lookup.
|
||||
|
||||
## Where to change what
|
||||
|
||||
| to change | POSIX | Windows |
|
||||
|---|---|---|
|
||||
| a provider's endpoint, auth template, setup, checks or look | `providers.json` | the same file |
|
||||
| a shipped preset | `presets/*.json` | the same files |
|
||||
| what a switch writes to `settings.json` | `cm-json.py` `cmd_apply`, `BASE_MANAGED` | `Set-ClaudeMode`, `$script:BaseManagedEnvKeys` |
|
||||
| what refuses a switch | `linux/claude-mode` `cm_preflight` | `Set-ClaudeMode` guards |
|
||||
| how a model list is fetched | `provider_catalogue` + a parser in `cm-json.py` | `Get-ProviderCatalogue` |
|
||||
| a doctor check | `cmd_doctor`, `doctor_*` | `Invoke-Doctor`, `Test-PresetCatalogue`, `Test-OllamaContext` |
|
||||
| setup | `cmd_setup`, `setup_server`, `setup_models` | — |
|
||||
| session detection and repair | `cmd_sessions`, `cmd_repair_session`; `cm-json.py` `cmd_scan_sessions`, `cmd_repair_session` | — |
|
||||
| key storage | `cm-vault.sh` | the DPAPI functions in `claude-mode.ps1` |
|
||||
| the bar icon and state | `omarchy/smoido.claude-mode/BarWidget.qml` | — |
|
||||
| the panel | `omarchy/smoido.claude-mode/Panel.qml` | — |
|
||||
@@ -0,0 +1,117 @@
|
||||
# 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 — and edits presets — without a terminal.
|
||||
|
||||
```bash
|
||||
bash omarchy/install.sh
|
||||
omarchy restart shell
|
||||
```
|
||||
|
||||
The installer 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). Placement is `right`, before `omarchy.agents`; `CM_BAR_SECTION` and
|
||||
`CM_BAR_BEFORE` override that at install, and `omarchy bar move
|
||||
smoido.claude-mode --section <section>` moves it later.
|
||||
|
||||
**Restart the shell after installing or upgrading.** The shell does reload a
|
||||
plugin whose files change, but it clears Qt's component cache while the old
|
||||
widget is still alive, so the old compiled panel survives the reload —
|
||||
`omarchy-shell shell rescanPlugins` does not shift it either.
|
||||
|
||||
## The icon
|
||||
|
||||
The icon is the mode, drawn as the provider's own mark: the Claude burst, the
|
||||
OpenRouter arrow, the Z.AI Z, the LM Studio mark, the Ollama llama, and `< >` for
|
||||
a custom endpoint. Anything other than Anthropic takes the theme accent, so the
|
||||
bar stays quiet exactly when nothing unusual is configured. A dot on it means a
|
||||
session needs repair ([live sessions](live-sessions.md#the-bar-notices-for-you));
|
||||
it is a dot rather than a colour change, because recolouring the mark would
|
||||
misreport the mode.
|
||||
|
||||
### The marks
|
||||
|
||||
They are vector paths drawn with `QtQuick.Shapes`, not font glyphs — most have
|
||||
no Nerd Font pictograph at all, and paths take the bar's foreground colour and
|
||||
follow the theme. They come from [simple-icons](https://simpleicons.org) (Claude,
|
||||
OpenRouter, LM Studio, Ollama) and [lobe-icons](https://github.com/lobehub/lobe-icons)
|
||||
(Z.AI); the `< >` is drawn here. Trademarks belong to their owners.
|
||||
|
||||
Each provider's mark and its `logoScale` live in `providers.json`, which reaches
|
||||
the widget through `health.json`; `Modes.js` keeps only Anthropic's and a
|
||||
fallback for an older `health.json`. The scale corrects apparent size, because
|
||||
equal nominal size is not equal apparent size: LM Studio's filled container
|
||||
covers 69% of its box against ~38% for most others, and Z.AI and OpenRouter are
|
||||
wide-but-short marks whose ink spans only ~84% of the box height. A scale starts
|
||||
from the geometric mean of the two corrections and is then settled by eye; the
|
||||
Ollama and custom marks were also held to 1.0 or less of their box, since both
|
||||
already fill its height.
|
||||
|
||||
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*. 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.
|
||||
- **An odd `iconSize`.** The marks are radially symmetric, so their arms sit on the
|
||||
centre line — a pixel *centre* at an odd size, the seam between two pixels at an
|
||||
even one, where each arm greys out. Even values are rounded up.
|
||||
|
||||
## The panel
|
||||
|
||||
**Click** the icon: 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. Each provider's list ends in
|
||||
**New … preset…**.
|
||||
|
||||
Choosing a target does not switch immediately. It runs the same two checks the
|
||||
CLI does, and both can stop it:
|
||||
|
||||
1. **Preflight.** If the mode has not been set up, has no key, has no models, has
|
||||
no address, or its server is not answering, the panel says which and offers the
|
||||
fix: *Set up …* opens a terminal and runs the first-run flow there (a bar popup
|
||||
can host neither a hidden key prompt nor a filter-select list), *Store the
|
||||
key…* opens a terminal for just the prompt, *Edit preset…* opens the editor,
|
||||
*Server settings…* opens the server form, and *Check again* re-runs the
|
||||
preflight.
|
||||
2. **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 runs
|
||||
strictly **after** the write — restarting first would bring them back up on
|
||||
the provider being left.
|
||||
|
||||
**The gear** on a preset row opens the preset 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. The list is the [model cache](providers.md#the-model-cache),
|
||||
so the panel never touches the network on its own; *Fetch models* / *Refresh
|
||||
list* runs `models --refresh`. Any id can also be typed by hand. The editor also
|
||||
has **Make default** / **Clear default**, **Duplicate…**, **Rename…** and
|
||||
**Delete…** (greyed out, with the reason, on the preset in use), and — for a
|
||||
server provider — **Server settings…**: the base URL, *Use local default* where
|
||||
the provider has one, and whether that server needs an API key.
|
||||
|
||||
Editing the preset in use applies it at once; [providers.md](providers.md#editing-the-preset-in-use)
|
||||
says 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.
|
||||
|
||||
A failed action — 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.
|
||||
|
||||
## Settings
|
||||
|
||||
Per instance, in the widget's entry in `~/.config/omarchy/shell.json`:
|
||||
|
||||
| key | default | meaning |
|
||||
|---|---|---|
|
||||
| `showLabel` | `false` | show the preset name beside the icon |
|
||||
| `iconSize` | `13` | mark size in px; rounded up to odd |
|
||||
| `root` | `~/.claude-mode` | where claude-mode is installed |
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
# Design decisions
|
||||
|
||||
Why claude-mode works the way it does. How the pieces fit together is in
|
||||
[architecture.md](architecture.md).
|
||||
|
||||
## Why settings.json and not a profile export
|
||||
|
||||
Three ways to make a switch persist. It uses the second.
|
||||
|
||||
**1. Export the variables from the shell profile.** The obvious move, and the
|
||||
wrong one. It only covers processes launched from a shell that loaded the profile
|
||||
— which is exactly *not* how Claude Code is used. The VS Code extension is spawned
|
||||
by VS Code, not by your shell, so it never sees the exports; neither does the
|
||||
desktop app, nor a terminal opened before the switch. And the failure is silent:
|
||||
you switch to `anthropic`, a shell opened five minutes earlier 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
|
||||
one file and one source of truth. `claude-mode anthropic` *deletes* the managed
|
||||
keys rather than blanking them, so nothing lingers to break native auth. The cost
|
||||
is that the config is global rather than per terminal.
|
||||
|
||||
**3. Persistent user-scope environment variables (`setx`).** Also global, and
|
||||
strictly worse: new processes only, values sitting in the registry in plaintext,
|
||||
and a stale entry silently outranks whatever claude-mode writes. They are treated
|
||||
as a fault: `status` and `doctor` flag them and offer removal, backing the old
|
||||
value up first.
|
||||
|
||||
The `claude` wrapper in the shell profile is a **safety net, not the mechanism**.
|
||||
It strips inherited process-level copies of the managed variables before
|
||||
launching `claude`. Everything works without it — including in VS Code, which
|
||||
never loads the profile.
|
||||
|
||||
`state.json` records which env keys the last switch actually wrote, so a
|
||||
preset's own `extraEnv` key (Z.AI's timeouts, LM Studio's attribution header) is
|
||||
removed when you switch away, even though no other preset knows it exists.
|
||||
|
||||
## Why API keys are not in settings.json
|
||||
|
||||
`settings.json` is a file you hand-edit, diff, and paste into bug reports. An
|
||||
`sk-or-` key does not belong in it.
|
||||
|
||||
Claude Code receives the key at runtime through `apiKeyHelper`, a small script
|
||||
that reads it from a vault 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`. A local server's placeholder
|
||||
token (`lmstudio`, `ollama`) is not a secret, so it is written inline and the
|
||||
helper stays out of it.
|
||||
|
||||
The vault depends on the platform:
|
||||
|
||||
| platform | backend |
|
||||
|---|---|
|
||||
| Windows | DPAPI, CurrentUser scope, in `~/.claude-mode/vault/*.cred` (ACL: you only) — useless if copied elsewhere or read as another user |
|
||||
| macOS | the login Keychain |
|
||||
| Linux | libsecret (GNOME Keyring), else `pass`, else a 0600 file |
|
||||
|
||||
The file backend is the honest fallback: no worse than a key in `.bashrc`, but not
|
||||
encrypted, and claude-mode says so rather than implying protection it does not
|
||||
provide. `CLAUDE_MODE_VAULT=file|secret-tool|pass|security` forces a backend.
|
||||
|
||||
### 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. The value is quoted when it needs to be on both ports
|
||||
(`shlex.quote` on POSIX, where `/Users/Firstname Lastname` does the same thing).
|
||||
`doctor` reads the string out of `settings.json` and runs *that* through a shell,
|
||||
so it tests what Claude Code actually sees; re-running the switch rewrites it.
|
||||
|
||||
**A key stored by a different Windows account.** DPAPI `CurrentUser` scope means
|
||||
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.
|
||||
|
||||
## Refusing a switch that would not work
|
||||
|
||||
A switch writes `settings.json` and is picked up by the *next* launch, so a switch
|
||||
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 here. A local server is the sharp case: its token is a placeholder,
|
||||
so nothing about the switch needs the server to exist.
|
||||
|
||||
So the preconditions are checked before the write (POSIX `cm_preflight`):
|
||||
|
||||
| check | when it fails | remedy offered |
|
||||
|---|---|---|
|
||||
| the preset exists and declares the provider being switched to | always an error | — |
|
||||
| it has been through setup (`configured` is not `false`) | shipped presets, first time | `claude-mode setup <mode>` |
|
||||
| at least one tier is mapped | a blank preset — Claude Code would ask the gateway for Anthropic's own models, at full price | edit the preset |
|
||||
| it has a server address | `custom`, until set | `claude-mode preset url` |
|
||||
| a key is stored for its `keyRef`, and the helper is executable | vault auth | `claude-mode set-key <ref>` |
|
||||
| the server answers, and accepts the credential | see below | start it, fix the address, or fix the key |
|
||||
|
||||
Which providers are probed is each provider's `server.probe` in `providers.json`:
|
||||
|
||||
| probe | providers | behaviour |
|
||||
|---|---|---|
|
||||
| `always` | LM Studio, Ollama | probed wherever the server is — a sleeping LAN box is as absent as an empty loopback port |
|
||||
| `lenient` | Custom | probed, but "answered, no model list here" passes: many proxies serve Messages and nothing else |
|
||||
| `local` | OpenRouter, Z.AI | only when pointed at this machine; a public gateway briefly unreachable is the network's problem, where a missing key never fixes itself |
|
||||
|
||||
The probe tells four outcomes apart, because their remedies are opposites:
|
||||
|
||||
| result | means |
|
||||
|---|---|
|
||||
| `ok` | a model-list path answered |
|
||||
| `auth` | the server is up and refused the credential |
|
||||
| `notfound` | something is listening, but not that API at that path |
|
||||
| `refused` | nothing answered at all — down, asleep, DNS, TLS, timeout |
|
||||
|
||||
`claude-mode preflight <mode> [preset]` runs exactly these and prints the verdict
|
||||
as JSON without switching; the bar widget calls it before it offers to do
|
||||
anything. `--force` overrides the lot. The Windows build has no preflight, but its
|
||||
switch refuses the two that would silently misroute: no server address, and no
|
||||
tier mapped.
|
||||
|
||||
## 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 `#101315` that is 3.8:1 and 2.8:1 where the body text is 11.6:1, so an
|
||||
error became the quietest thing on screen.
|
||||
|
||||
So when Omarchy is present, the palette is derived from the theme it publishes at
|
||||
`~/.local/state/omarchy/current/theme/colors.toml`. Every role is measured against
|
||||
the background it will 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 (Solitude: headings 3.8:1 → 9.4:1, `FAIL` 2.8:1 → 5.2:1). Light themes
|
||||
work by the same arithmetic. `CLAUDE_MODE_THEME=/path/to/colors.toml` points it
|
||||
elsewhere and `NO_COLOR` turns it off; without Omarchy it uses the ANSI slots,
|
||||
with bright red for `FAIL` and bold headings.
|
||||
|
||||
## health.json
|
||||
|
||||
`~/.claude-mode/health.json` is the machine-readable mirror of the active
|
||||
configuration: mode, preset, model map, context window, key backend, guardrail
|
||||
state, the preset catalogue, each provider's effective default, and the provider
|
||||
list itself. It never holds key material. A switch, `status` and most preset
|
||||
edits rewrite it; `claude-mode health` forces it. The bar widget reads nothing
|
||||
else, which is why it costs nothing while idle and why a switch made in a
|
||||
terminal shows up in the bar on its own.
|
||||
|
||||
## Checked against the CLI
|
||||
|
||||
Every variable claude-mode writes was verified by scanning the `claude` binary
|
||||
(first against 2.1.221, again since): `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_MAX_CONTEXT_TOKENS`, `CLAUDE_CODE_AUTO_COMPACT_WINDOW`,
|
||||
`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC`, `API_TIMEOUT_MS` and `apiKeyHelper`.
|
||||
The project-directory slug rule used to find transcripts (every non-alphanumeric
|
||||
character becomes a dash; the physical path is used) was verified empirically
|
||||
against 2.1.269.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Development
|
||||
|
||||
How to change claude-mode without breaking it. How the pieces fit is in
|
||||
[architecture.md](architecture.md); why they are shaped that way, in
|
||||
[design.md](design.md).
|
||||
|
||||
## Running the tests
|
||||
|
||||
```bash
|
||||
scripts/test.sh # static checks, Python unit tests, CLI tests (~30s)
|
||||
scripts/test.sh --windows # ...and the Windows suite on winbox over SSH
|
||||
scripts/test.sh --windows host # ...on another Windows host
|
||||
make test # the same, if make is installed
|
||||
```
|
||||
|
||||
Nothing needs installing beyond bash, python3 and curl. `qmllint` and `shellcheck`
|
||||
are used when present and skipped, out loud, when not.
|
||||
|
||||
| suite | what it covers |
|
||||
|---|---|
|
||||
| `tests/static.sh` | every script parses; JSON is valid; `VERSION` matches the widget manifest and has a `CHANGELOG.md` entry; `providers.json` uses only the kinds, probes and checks the code implements, every default preset exists, no id or alias shadows a command; Markdown links resolve; QML has no syntax errors |
|
||||
| `tests/python/` | `linux/cm-json.py`, function by function: providers and the column contract the shell depends on, the original three scaffolds byte for byte, every catalogue parser, the cache, session scan/dismiss/repair, rename, defaults, health, the cost guard |
|
||||
| `tests/cli/` | the POSIX CLI end to end, in a sandbox, against fake servers: providers, preflight, presets, models, doctor, a real switch, sessions, and the key helper under a rename race |
|
||||
| `tests/windows/` | `claude-mode.ps1` on a real Windows PowerShell 5.1, in a sandbox, against the same fake servers |
|
||||
|
||||
**The tests never touch a real install.** Each CLI test gets its own `HOME`,
|
||||
`CM_ROOT` and `CLAUDE_CONFIG_DIR` with the repository's scripts installed into it,
|
||||
and the vault forced to the plain-file backend (`CLAUDE_MODE_VAULT=file`), so your
|
||||
keyring, `~/.claude-mode` and `~/.claude/settings.json` are never read or written;
|
||||
a switch a test performs rewrites the sandbox's `settings.json`. The Windows suite
|
||||
points `USERPROFILE` at a temp folder for its process, which moves every path the
|
||||
script uses. `tests/fake_server.py` stands in for Ollama, LM Studio, a keyed
|
||||
gateway and a listing-less proxy, on ports the OS picks.
|
||||
|
||||
The Windows suite needs key-based SSH to the host and python on the host (for the
|
||||
fake servers). It copies a payload to the host's `%TEMP%`, runs it, and removes it.
|
||||
|
||||
**Do not test by switching your own machine.** A switch rewrites the live
|
||||
`settings.json` that every Claude Code session — including one you may be working
|
||||
in — depends on. Test in the sandbox; `tests/lib.sh` shows how to get one by hand.
|
||||
|
||||
### Writing a test
|
||||
|
||||
A CLI test sources `tests/lib.sh`, which provides the sandbox, `run_cm` (output in
|
||||
`$OUT`, colour stripped; status in `$RC`), `expect_rc` / `expect_out` /
|
||||
`expect_eq` / `expect_file`, `start_fake` (sets `OLLAMA_URL`, `LMSTUDIO_URL`,
|
||||
`KEYED_URL`, `PROXY_URL`), `ready_preset`, `set_state` and `preflight_code`, and
|
||||
ends with `finish`. Assertions count rather than stop, so one run reports every
|
||||
failure. A test earns its place by failing when the thing it describes breaks —
|
||||
check that it does, by breaking it.
|
||||
|
||||
## Conventions
|
||||
|
||||
The code follows these already; they are written down so it keeps doing so.
|
||||
|
||||
**Portability of the POSIX port.** It runs on the bash 3.2 macOS ships: no
|
||||
associative arrays, no `mapfile`, no fractional `read -t`, no GNU-only `stat` or
|
||||
`find` flags. `linux/claude-mode` uses no `awk` (absent from minimal images).
|
||||
python3 is the only dependency, and handles every piece of JSON — bash only ever
|
||||
sees flat lines of text.
|
||||
|
||||
**Never read TSV with `IFS=$'\t' read`** when a column can be empty: tab counts as
|
||||
whitespace to `read`, so empty fields merge and later columns shift. Use `cut`
|
||||
(`tsv_field`), or have the producer print `-` for empty.
|
||||
|
||||
**Windows PowerShell 5.1.** No `?:` or `??`; `"$id:latest"` parses as a
|
||||
drive-qualified variable, so write `"${id}:latest"`. The `.ps1` files are ASCII
|
||||
only, because 5.1 reads a file without a BOM in the ANSI codepage.
|
||||
|
||||
**Files are LF everywhere**, `.ps1` and `.cmd` included (`.gitattributes`,
|
||||
`.editorconfig`).
|
||||
|
||||
**Comments say why**, and name the thing that went wrong when a line exists
|
||||
because of it. What the code does is left to the code.
|
||||
|
||||
**Keys never leave the vault**: not into `settings.json`, `health.json`, the model
|
||||
cache, logs, or test output.
|
||||
|
||||
## Providers
|
||||
|
||||
Adding a provider that reuses existing behaviour is an entry in `providers.json`
|
||||
and a preset in `presets/` — see [providers.md](providers.md#adding-a-provider).
|
||||
The static check enforces the entry's shape; add a CLI test if the provider does
|
||||
anything the others do not. A new *kind* of behaviour needs a parser in
|
||||
`cm-json.py`, a branch in the bash CLI and in `claude-mode.ps1`, and its value
|
||||
added to the check in `tests/static.sh`.
|
||||
|
||||
`cm-json.py`'s `PROVIDER_TSV` is a contract: the shell addresses its columns by
|
||||
number. Append only, and update the column list in `linux/claude-mode` and the
|
||||
unit test that pins it.
|
||||
|
||||
## The bar widget
|
||||
|
||||
- Install with `bash omarchy/install.sh`, then `omarchy restart shell`: an edited
|
||||
QML file does not reliably reload in a running shell, and `Modes.js`, a
|
||||
`.pragma library`, never does.
|
||||
- Anything with a text field must live in a `KeyboardPanel`. A `PopupCard` is an
|
||||
xdg-popup, which never receives the keyboard, so its fields look fine and take
|
||||
no typing.
|
||||
- The panel's height clamps to the screen rather than scrolling: long content
|
||||
needs its own bounded, scrolling area, or it is cut off.
|
||||
- Provider presentation comes from `health.json`, not from `Modes.js` — new
|
||||
providers need no QML change.
|
||||
- There are no automated UI tests. `tests/static.sh` catches QML syntax errors;
|
||||
everything else is checked by eye, on the real bar.
|
||||
|
||||
## Releasing
|
||||
|
||||
1. `scripts/test.sh --windows` — everything green.
|
||||
2. `scripts/bump-version.sh X.Y.Z`, and a `CHANGELOG.md` entry for it (the static
|
||||
check fails without one).
|
||||
3. Commit.
|
||||
4. Install on your own machine: `bash linux/install.sh`, `bash omarchy/install.sh`,
|
||||
`omarchy restart shell`, and look at the bar.
|
||||
5. Push. The Windows package is built on a Windows machine with
|
||||
`scripts/build-package.ps1`, which refuses to package a script that does not
|
||||
parse.
|
||||
|
||||
Semver: a minor version adds behaviour, a patch fixes it.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Live sessions
|
||||
|
||||
What a switch does to the Claude Code sessions already running, and how
|
||||
claude-mode keeps that from costing you work. The incident that led to all of
|
||||
this is written up in [incident-mode-switch-corrupts-live-sessions.md](incident-mode-switch-corrupts-live-sessions.md).
|
||||
|
||||
## Why a switch breaks a running session
|
||||
|
||||
The two halves of the config behave differently.
|
||||
|
||||
The static half — base URL, model ids, the env block — really 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`), 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` | nothing — the helper returns no credential in that mode, by design |
|
||||
| another provider | the new provider's key, sent to the old base URL, which rejects it |
|
||||
| another preset of the *same* provider, same key | same key, same endpoint — this one survives, on the model ids it started with |
|
||||
|
||||
Either of the first two starts failing calls whenever the timer next fires —
|
||||
mid-turn as easily as between turns. So restart the affected sessions, or let
|
||||
`claude-mode sessions --restart` do it:
|
||||
|
||||
- **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, 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:
|
||||
|
||||
```
|
||||
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:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
`--all` reports only what is actually actionable. Of 59 transcripts here it once
|
||||
flagged 16; 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 — they resume fine
|
||||
under the provider they were born on. Neither is damage, so neither is listed.
|
||||
Only a transcript with a genuine `msg_` message *and* another provider's output
|
||||
after it is something this can or should touch.
|
||||
|
||||
### Dismissing a session
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
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` changes the default age. Neither touches the transcript.
|
||||
Hidden sessions are always counted — `--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.
|
||||
|
||||
## The cut turns are not thrown away
|
||||
|
||||
Truncating is the mechanical fix, but the turns being cut are the work itself —
|
||||
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:
|
||||
|
||||
1. **Backs up** the original as `<session>.jsonl.pre-repair-backup-<stamp>`.
|
||||
2. **Writes the dropped turns out** as `<session>.recovered-<stamp>.md` — what was
|
||||
asked, what was answered, what was run. Tool *results* are left out; they are
|
||||
most of a transcript by volume and the least useful part of a summary.
|
||||
3. **Hands them back to the session** as one appended note, so the agent that
|
||||
resumes knows what it just did.
|
||||
|
||||
That note is a `user` entry marked `isMeta` — the marker Claude Code uses for its
|
||||
own local-command caveats, meaning "context, not something to answer". 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
|
||||
belongs to a session still alive.
|
||||
|
||||
## The switch asks first
|
||||
|
||||
While any sessions are running, a switch stops and asks: restart them (the only
|
||||
answer that ends with everything on the new mode), 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/you/Work working (this session - never touched)
|
||||
631644 pts/1 /home/you/Projects/api
|
||||
```
|
||||
|
||||
Sessions are found through `/proc/<pid>/exe`, which resolves to the real `claude`
|
||||
binary — a process-name match would sweep up every shell that merely mentions
|
||||
claude on its command line. Two things are then filtered out:
|
||||
|
||||
- **The calling session.** 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 climbed and fell with load (2, 5, 11 and 40 on the same two
|
||||
sessions). 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. Interactive runs confirm first; `--yes` is for callers that
|
||||
have already asked; `--dry-run` prints the plan and touches nothing. All of this
|
||||
needs `/proc`, so it is Linux-only.
|
||||
|
||||
## 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 bar 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. The panel lists the affected sessions, with **Repair**
|
||||
(after saying what it will drop and keep) and **Ignore** beside each, and the
|
||||
hidden ones under a collapsed `hidden (N)` row with **Restore**.
|
||||
|
||||
The scan is 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. That is the overwhelmingly common case, so a sweep of 59
|
||||
transcripts costs about 60ms, against about 5s for reading every byte.
|
||||
@@ -0,0 +1,305 @@
|
||||
# Providers and presets
|
||||
|
||||
A **provider** is where Claude Code's requests go: OpenRouter, Z.AI, an LM Studio
|
||||
or Ollama server, or a custom endpoint. Anthropic is not one — it is the native
|
||||
login, and switching to it removes every trace of the others. A **preset** is a
|
||||
named mapping of Claude Code's four model tiers (opus, sonnet, haiku, fable) onto
|
||||
one provider's model ids, plus the endpoint and how to authenticate to it.
|
||||
|
||||
- [Presets](#presets)
|
||||
- [Context windows and early auto-compaction](#context-windows-and-early-auto-compaction)
|
||||
- [OpenRouter](#openrouter) · [Z.AI](#zai) · [LM Studio](#lm-studio) · [Ollama](#ollama) · [Custom endpoints](#custom-endpoints)
|
||||
- [Adding a provider](#adding-a-provider)
|
||||
|
||||
## Presets
|
||||
|
||||
One ships per provider, so `claude-mode <provider>` is never ambiguous:
|
||||
|
||||
| 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) | | | |
|
||||
| `ollama` | ollama | `qwen3-coder` until setup picks from your server (all tiers) | | | |
|
||||
| `custom` | custom | empty until setup | | | |
|
||||
|
||||
Presets are plain JSON in `~/.claude-mode/presets/`, shared verbatim between the
|
||||
Windows and POSIX builds. A preset declares its `provider`, and
|
||||
`claude-mode lmstudio default` is rejected rather than silently pointing a local
|
||||
URL at remote model ids. Build more with `preset new` — or from the bar panel.
|
||||
|
||||
To route Anthropic's own models through OpenRouter, copy a preset and repoint its
|
||||
tiers at `anthropic/claude-opus-5` and friends — and add `"allowAnthropicModels":
|
||||
true`, because the [cost guard](#openrouter) refuses them otherwise.
|
||||
|
||||
### Which preset a bare `claude-mode <provider>` picks
|
||||
|
||||
Never "most recently used". Out of the box it is a fixed name — `default`, `zai`,
|
||||
`lmstudio`, `ollama`, `custom` — and it can be changed:
|
||||
|
||||
```bash
|
||||
claude-mode preset default # what each provider picks, and why
|
||||
claude-mode preset default openrouter cheap # `claude-mode openrouter` now means `cheap`
|
||||
claude-mode preset default openrouter --clear # back to the built-in name
|
||||
```
|
||||
|
||||
The choice lives in `~/.claude-mode/defaults.json`. Renaming the chosen preset
|
||||
moves the choice along and deleting it clears it; a choice whose file has gone
|
||||
some other way falls back to the built-in name, and if that is gone too, to the
|
||||
provider's first preset by name. The Windows build does not read this file yet
|
||||
and keeps the built-in names.
|
||||
|
||||
### Editing the preset in use
|
||||
|
||||
It is re-applied straight away. For `preset set` and `preset all` that happens
|
||||
**without** asking about running sessions: what makes a switch dangerous to them
|
||||
is their endpoint or key moving, 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.
|
||||
|
||||
### The model cache
|
||||
|
||||
Every catalogue fetch — `models`, `doctor`, `setup`, the menu's picker — leaves a
|
||||
copy in `~/.claude-mode/models-cache.json`: one entry per provider, each with its
|
||||
own timestamp. A failed fetch keeps the previous list and marks it failed, and a
|
||||
server provider's 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. The bar
|
||||
panel's model picker reads it, so the panel never touches the network itself.
|
||||
|
||||
## Context windows and early auto-compaction
|
||||
|
||||
**Symptom:** on a gateway, the session starts auto-compacting almost immediately,
|
||||
although every model involved has a huge context window.
|
||||
|
||||
**Cause:** behind a custom `ANTHROPIC_BASE_URL`, Claude Code cannot resolve a
|
||||
third-party model id like `deepseek/deepseek-v4-flash` to a context length. It
|
||||
falls back to a conservative default and compacts against *that*. Z.AI's own docs
|
||||
work around the same thing with `CLAUDE_CODE_AUTO_COMPACT_WINDOW=1000000`.
|
||||
|
||||
**Fix:** every preset carries `contextTokens`, which writes both knobs:
|
||||
|
||||
```
|
||||
CLAUDE_CODE_MAX_CONTEXT_TOKENS = <contextTokens>
|
||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW = <contextTokens>
|
||||
```
|
||||
|
||||
| preset | contextTokens |
|
||||
|---|---|
|
||||
| `default`, `zai` | 1,000,000 |
|
||||
| `lmstudio` | 262,144 |
|
||||
| `ollama` | 65,536 — and see [Ollama](#the-context-window-is-set-on-the-server) |
|
||||
| `custom` | 200,000 |
|
||||
|
||||
`doctor` 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.
|
||||
Adjust it in the preset file and run `claude-mode doctor` again.
|
||||
|
||||
## OpenRouter
|
||||
|
||||
A remote gateway to most vendors' models, paid per token. Setup asks for your key
|
||||
(`sk-or-…`) and picks a model per tier from OpenRouter's live catalogue, which
|
||||
shows each model's context length and price.
|
||||
|
||||
**The cost guard.** Gateways resell Anthropic's models at full list price, with no
|
||||
subscription discount, so routing a tier at one is almost never what was meant. A
|
||||
switch refuses any preset that does, naming the tier, unless the preset says
|
||||
`"allowAnthropicModels": true`. The same guard applies to every gateway provider.
|
||||
|
||||
**The guardrail check.** The cost guard only sees what a preset asks for; Claude
|
||||
Code can still request an Anthropic model on its own (a cached choice, a
|
||||
`/model` command). OpenRouter can block that server-side with a *guardrail* on
|
||||
the key, and `doctor` — and every switch to OpenRouter — checks whether one is in
|
||||
place by asking for the cheapest Anthropic model: refused means the guardrail is
|
||||
**active**; answered means it is **NOT SET**, with the steps to set one
|
||||
(openrouter.ai → Guardrails → exclude anthropic models). `doctor` also shows the
|
||||
key's spend and limit.
|
||||
|
||||
## Z.AI
|
||||
|
||||
Replaces `npx @z_ai/coding-helper`, and does something it does not: maps a
|
||||
distinct model to each tier instead of one model everywhere. Per
|
||||
[Z.AI's Claude Code docs](https://docs.z.ai/devpack/tool/claude):
|
||||
|
||||
| setting | value |
|
||||
|---|---|
|
||||
| `ANTHROPIC_BASE_URL` | `https://api.z.ai/api/anthropic` |
|
||||
| auth | your Z.AI key, from the vault through `apiKeyHelper` |
|
||||
| `API_TIMEOUT_MS` | `3000000` |
|
||||
| `CLAUDE_CODE_AUTO_COMPACT_WINDOW` | `1000000` |
|
||||
| `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | `1` |
|
||||
|
||||
Z.AI publishes no model catalogue endpoint, so its list is kept in
|
||||
`providers.json` (from those docs), and a model id missing from it is reported
|
||||
as undocumented rather than wrong. Get a key at
|
||||
<https://z.ai/manage-apikey/apikey-list>; `doctor` sends a 1-token request to
|
||||
prove it works.
|
||||
|
||||
## LM Studio
|
||||
|
||||
Per [LM Studio's docs](https://lmstudio.ai/docs/integrations/claude-code): base
|
||||
URL `http://127.0.0.1:1234` (**base only** — Claude Code appends `/v1/messages`),
|
||||
token `lmstudio`, plus `CLAUDE_CODE_ATTRIBUTION_HEADER=0`. Setup maps one model
|
||||
across every tier, since a local server holds one in memory at a time and
|
||||
per-tier mapping would pay the load cost on every tier change.
|
||||
|
||||
### 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 by 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 work.
|
||||
|
||||
### The `[Server Error] ... Unable to generate parser for this template` spam
|
||||
|
||||
Some GGUF chat templates hard-assert message ordering:
|
||||
|
||||
```jinja
|
||||
{%- if message.role == "system" %}
|
||||
{%- if not loop.first %}
|
||||
{{- raise_exception('System message must be at the beginning.') }}
|
||||
```
|
||||
|
||||
Runtimes that generate a tool-call parser probe the template with synthetic
|
||||
message sequences; the probes trip the assertion and the request dies. It is a
|
||||
model-template bug, reported against several models
|
||||
([LM Studio #1999](https://github.com/lmstudio-ai/lmstudio-bug-tracker/issues/1999),
|
||||
[llama.cpp #20733](https://github.com/ggml-org/llama.cpp/issues/20733)). The
|
||||
Windows build's `doctor` and `models` flag affected models as `TEMPLATE RISK`.
|
||||
**The fix is a model without the flag** — `claude-mode setup lmstudio` and pick
|
||||
another. Whether a flagged template actually trips depends on the parser strategy
|
||||
the runtime picks, so a flagged model may work fine for you.
|
||||
|
||||
Also: use a model with more than 25k of context (`doctor` warns below that), and
|
||||
the model must be **installed** — JIT loading handles "not loaded" fine.
|
||||
|
||||
### LM Studio somewhere other than this machine
|
||||
|
||||
Loopback is a default, not a constraint:
|
||||
|
||||
```bash
|
||||
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 the 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.
|
||||
|
||||
## Ollama
|
||||
|
||||
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. 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.
|
||||
|
||||
```bash
|
||||
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-coder` is `qwen3-coder:latest` to Ollama; both work.
|
||||
- Ollama does not implement `count_tokens` or 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.0` and 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.
|
||||
|
||||
```bash
|
||||
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, and 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`, which keeps it
|
||||
deliberate.
|
||||
|
||||
## Adding a provider
|
||||
|
||||
Every gateway provider is an entry in [`providers.json`](../providers.json),
|
||||
installed next to the presets and read by the POSIX CLI, the Windows build and
|
||||
the bar widget (through `health.json`). An entry holds:
|
||||
|
||||
| field | what it says |
|
||||
|---|---|
|
||||
| `id`, `aliases`, `title`, `label`, `blurb`, `color` | names, and how the CLI, the menu and the 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 points at this machine), the paths probed, setup hints |
|
||||
| `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 `per-tier` or `one-for-all` model picking |
|
||||
| `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 ([bar widget](bar-widget.md#the-marks)) |
|
||||
|
||||
A provider that reuses those kinds is an entry and a default preset in
|
||||
`presets/`, and **no code**. A hosted Anthropic-compatible coding plan is shaped
|
||||
like `zai`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "example", "aliases": [], "title": "Example",
|
||||
"label": "Example - hosted coding plan",
|
||||
"blurb": "Example's coding plan on its Anthropic endpoint.",
|
||||
"color": "gray", "defaultPreset": "example",
|
||||
"preset": {"baseUrl": "https://api.example.com/anthropic",
|
||||
"auth": {"mode": "vault", "keyRef": "example"},
|
||||
"contextTokens": 200000, "gatewayModelDiscovery": false},
|
||||
"server": {"editable": false, "probe": "local"},
|
||||
"catalogue": {"kind": "openai"},
|
||||
"setup": {"key": "required", "models": "per-tier", "keyUrl": "https://example.com/keys"},
|
||||
"doctor": ["catalogue-models", "message-check"],
|
||||
"logo": "M0 0h24v24H0z", "logoScale": 1.0, "glyph": ""
|
||||
}
|
||||
```
|
||||
|
||||
…plus `presets/example.json` with `"provider": "example"` and
|
||||
`"configured": false`. `scripts/test.sh` checks the entry against the kinds the
|
||||
code implements, that its default preset exists, and that its id cannot shadow a
|
||||
command.
|
||||
|
||||
Only a genuinely new *kind* — a catalogue format none of the parsers read, a probe
|
||||
that behaves differently — needs code: a parser in `linux/cm-json.py`, a branch
|
||||
in `provider_catalogue` (bash) and `Get-ProviderCatalogue` (`claude-mode.ps1`),
|
||||
and the new value added to the check in `tests/static.sh`. The bar widget picks
|
||||
new providers up from `health.json`, with no QML change and no shell restart.
|
||||
+5
-2
@@ -2831,7 +2831,7 @@ print(' %sprovider\'s ids by design and resume fine under it%s' % (D,X))
|
||||
# the scan, so it asks the scan which ones are hidden. It still shows
|
||||
# them - listing everything here is its job - but says why the bar
|
||||
# is not counting them.
|
||||
local f v hidden reason
|
||||
local f v hidden reason row
|
||||
hidden="$("$PY" "$JSON" scan-sessions "$CM_SETTINGS_DIR/projects" "$max_age" "$CM_IGNORED" 2>/dev/null \
|
||||
| "$PY" -c "
|
||||
import json,sys
|
||||
@@ -2842,7 +2842,10 @@ for i in d.get('ignored') or []:
|
||||
" 2>/dev/null)"
|
||||
for f in $(ls -1t "$dir"/*.jsonl 2>/dev/null); do
|
||||
v="$("$PY" "$JSON" repair-session "$f" 2>/dev/null)" || continue
|
||||
reason="$(printf '%s\n' "$hidden" | awk -F'\t' -v s="$(basename "$f" .jsonl)" '$1 == s { print $2; exit }')"
|
||||
reason=''
|
||||
if row="$(printf '%s\n' "$hidden" | tsv_find "$(basename "$f" .jsonl)")"; then
|
||||
reason="$(tsv_field "$row" 2)"
|
||||
fi
|
||||
printf '%s' "$v" | "$PY" -c "
|
||||
import json,sys,os
|
||||
d=json.load(sys.stdin)
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
# Broken transcripts: the scan, dismissing and restoring, the age rule, repair.
|
||||
. "$(dirname "$0")/../lib.sh"
|
||||
|
||||
proj="$CLAUDE_CONFIG_DIR/projects/-tmp-fixture"
|
||||
# The fixture project is filed under the slug of a real directory, so the
|
||||
# per-project listing (run from inside it) finds it the way Claude Code would.
|
||||
work="$SB/work"
|
||||
mkdir -p "$work"
|
||||
proj="$CLAUDE_CONFIG_DIR/projects/$(printf '%s' "$work" | sed 's|[^a-zA-Z0-9]|-|g')"
|
||||
mkdir -p "$proj"
|
||||
transcript() { # <session-id> [age-in-hours]
|
||||
printf '%s\n' \
|
||||
@@ -34,6 +38,10 @@ run_cm repair-session --all
|
||||
expect_out '2 hidden: 1 ignored, 1 older than 7 days' 'hidden sessions are always counted'
|
||||
run_cm repair-session --ignored
|
||||
expect_out 'still broken' 'the dismissed list reconciles against the disk'
|
||||
OUT="$(cd "$work" && "$CM" repair-session 2>&1 | sed "s/${ESC}\[[0-9;]*m//g")"
|
||||
expect_out 'cccc-ok' 'the per-project listing shows every transcript here'
|
||||
expect_out '(hidden: ignored)' 'and marks a dismissed one'
|
||||
expect_out '(hidden: older than 7 days)' 'and an age-hidden one'
|
||||
run_cm repair-session --ignore zzzz
|
||||
expect_rc 1 'an unknown session cannot be dismissed'
|
||||
run_cm repair-session --unignore aaaa-new
|
||||
|
||||
@@ -89,6 +89,19 @@ class Providers(unittest.TestCase):
|
||||
with self.assertRaises(SystemExit):
|
||||
call(cm.cmd_provider_resolve, [word])
|
||||
|
||||
def test_managed_keys_match_the_windows_build(self):
|
||||
# A key one build clears and the other does not survives a switch on
|
||||
# that platform - "must stay in lockstep" was only a comment until this.
|
||||
import re
|
||||
with open(os.path.join(REPO, "claude-mode.ps1"), encoding="utf-8") as fh:
|
||||
ps = fh.read()
|
||||
# Up to the line that is only ")", not the first ")": the array's own
|
||||
# comments contain parentheses.
|
||||
block = re.search(r"\$script:BaseManagedEnvKeys\s*=\s*@\((.*?)^\)", ps, re.S | re.M)
|
||||
self.assertIsNotNone(block, "BaseManagedEnvKeys not found in claude-mode.ps1")
|
||||
body = "\n".join(l for l in block.group(1).splitlines() if not l.strip().startswith("#"))
|
||||
self.assertEqual(sorted(re.findall(r"'([A-Z0-9_]+)'", body)), sorted(cm.BASE_MANAGED))
|
||||
|
||||
def test_static_catalogue(self):
|
||||
self.assertEqual(call(cm.cmd_provider_static, ["zai"]).splitlines()[0].split("\t")[0], "glm-5.3")
|
||||
|
||||
|
||||
@@ -32,6 +32,13 @@ bash_syntax() {
|
||||
}
|
||||
check 'shell scripts parse (bash -n)' bash_syntax
|
||||
|
||||
# awk is absent from minimal images (a stock Fedora WSL rootfs, for one), so the
|
||||
# CLI does without it - and one slipped in once, in a listing no test ran.
|
||||
no_awk() {
|
||||
! grep -nw awk linux/claude-mode linux/lib/*.sh 2>/dev/null | grep -vE '^[^:]+:[0-9]+:\s*#'
|
||||
}
|
||||
check 'no awk in the POSIX CLI' no_awk
|
||||
|
||||
check 'python compiles' env PYTHONPYCACHEPREFIX="$tmp" python3 -m py_compile \
|
||||
linux/cm-json.py tests/fake_server.py tests/python/test_cm_json.py
|
||||
|
||||
@@ -118,6 +125,36 @@ if errors:
|
||||
sys.exit("\n".join(errors))
|
||||
PY
|
||||
|
||||
# Relative links, and the #anchors in them, against the headings they point at -
|
||||
# so moving a section between files cannot leave a dead link behind. Anchors are
|
||||
# slugged the way Gitea and GitHub do: lowercase, punctuation dropped, spaces to
|
||||
# dashes.
|
||||
check 'Markdown links resolve' python3 - <<'PY'
|
||||
import glob, os, re, sys
|
||||
def slug(title):
|
||||
title = re.sub(r"`", "", title.strip().lower())
|
||||
return re.sub(r"[^\w\- ]", "", title).replace(" ", "-")
|
||||
def anchors(path):
|
||||
text = open(path, encoding="utf-8").read()
|
||||
text = re.sub(r"```.*?```", "", text, flags=re.S)
|
||||
return {slug(m.group(1)) for m in re.finditer(r"^#{1,6}\s+(.+?)\s*$", text, flags=re.M)}
|
||||
files = ["README.md", "CONTRIBUTING.md", "CHANGELOG.md"] + glob.glob("docs/*.md")
|
||||
errors = []
|
||||
for f in files:
|
||||
text = re.sub(r"```.*?```", "", open(f, encoding="utf-8").read(), flags=re.S)
|
||||
for target in re.findall(r"\]\(([^)\s]+)\)", text):
|
||||
if re.match(r"[a-z]+:", target):
|
||||
continue # http:, https:, mailto:
|
||||
path, _, anchor = target.partition("#")
|
||||
dest = os.path.normpath(os.path.join(os.path.dirname(f), path)) if path else f
|
||||
if not os.path.exists(dest):
|
||||
errors.append("%s: %s - no such file" % (f, target))
|
||||
elif anchor and dest.endswith(".md") and anchor not in anchors(dest):
|
||||
errors.append("%s: %s - no heading for #%s" % (f, target, anchor))
|
||||
if errors:
|
||||
sys.exit("\n".join(errors))
|
||||
PY
|
||||
|
||||
QMLLINT="$(command -v qmllint || true)"
|
||||
[ -z "$QMLLINT" ] && [ -x /usr/lib/qt6/bin/qmllint ] && QMLLINT=/usr/lib/qt6/bin/qmllint
|
||||
if [ -n "$QMLLINT" ]; then
|
||||
|
||||
Reference in New Issue
Block a user