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:
smoido
2026-09-15 01:52:03 +03:00
co-authored by Claude Opus 5
parent 51e42823d1
commit b514e00745
12 changed files with 1210 additions and 945 deletions
+18
View File
@@ -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).
+130 -933
View File
File diff suppressed because it is too large Load Diff
+130
View File
@@ -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` | — |
+117
View File
@@ -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
View File
@@ -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.
+119
View File
@@ -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.
+159
View File
@@ -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.
+305
View File
@@ -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
View File
@@ -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)
+9 -1
View File
@@ -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
+13
View File
@@ -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")
+37
View File
@@ -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