From b514e0074597bcc78f674ddce22073b40d124157 Mon Sep 17 00:00:00 2001 From: smoido Date: Tue, 15 Sep 2026 01:52:03 +0300 Subject: [PATCH] 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) --- CONTRIBUTING.md | 18 + README.md | 1081 +++++----------------------------- docs/architecture.md | 130 ++++ docs/bar-widget.md | 117 ++++ docs/design.md | 159 +++++ docs/development.md | 119 ++++ docs/live-sessions.md | 159 +++++ docs/providers.md | 305 ++++++++++ linux/claude-mode | 7 +- tests/cli/test_sessions.sh | 10 +- tests/python/test_cm_json.py | 13 + tests/static.sh | 37 ++ 12 files changed, 1210 insertions(+), 945 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/architecture.md create mode 100644 docs/bar-widget.md create mode 100644 docs/design.md create mode 100644 docs/development.md create mode 100644 docs/live-sessions.md create mode 100644 docs/providers.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d136c56 --- /dev/null +++ b/CONTRIBUTING.md @@ -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). diff --git a/README.md b/README.md index bd19f7c..2f88295 100644 --- a/README.md +++ b/README.md @@ -1,1010 +1,207 @@ # claude-mode -Switch Claude Code system-wide between **Anthropic**, **OpenRouter**, **Z.AI**, a -local **LM Studio** or **Ollama** server, and any **custom** Anthropic-compatible -endpoint — with named per-tier model presets. +Switch Claude Code between **Anthropic**, **OpenRouter**, **Z.AI**, a local +**LM Studio** or **Ollama** server, and any **custom** Anthropic-compatible +endpoint — system-wide, with named per-tier model presets. -One write to `~/.claude/settings.json` covers the CLI, the VS Code extension, and -the desktop app. Restart Claude Code afterwards — nothing else. Windows -(PowerShell 5.1, no external dependencies) and a POSIX port for Linux and macOS -(python3 only), including a theme-aware TUI and an Omarchy bar widget. +One write to `~/.claude/settings.json` covers the CLI, the VS Code extension and +the desktop app: restart Claude Code and it is on the new provider. API keys stay +out of that file. There is a Windows build (PowerShell 5.1, no dependencies) and a +POSIX port for Linux and macOS (bash 3.2+ and python3), with a theme-aware +terminal menu and an Omarchy bar widget. ``` -claude-mode # interactive menu -claude-mode anthropic # subscription login -claude-mode openrouter # remote gateway (preset: default) -claude-mode zai # Z.AI GLM coding plan (preset: zai) -claude-mode lmstudio # local server (preset: lmstudio) -claude-mode ollama # local server (preset: ollama) -claude-mode custom # your own endpoint (preset: custom) +claude-mode # interactive menu +claude-mode status # what the next `claude` launch will use +claude-mode anthropic # back to your subscription login +claude-mode openrouter # remote gateway, pay per token (preset: default) +claude-mode zai # Z.AI GLM coding plan (preset: zai) +claude-mode lmstudio # local LM Studio server (preset: lmstudio) +claude-mode ollama # local Ollama server (preset: ollama) +claude-mode custom # your own Anthropic-compatible URL (preset: custom) ``` -**Contents:** [Install](#install) · [First run](#first-run) · [The interactive -menu](#the-interactive-menu) · [Commands](#commands) · [Presets](#presets-shipped) · -[Context windows](#context-windows-and-early-auto-compaction) · [Z.AI](#zai-mode) · -[LM Studio](#lm-studio-mode) · [Ollama](#ollama-mode) · [Custom -endpoints](#custom-endpoints) · [Adding a provider](#adding-a-provider) · -[Live sessions](#live-sessions) · -[Troubleshooting](#troubleshooting) · [Design decisions](#design-decisions) · -[Layout](#layout) · [Linux / Omarchy](#linux--omarchy) · [The bar -widget](#the-omarchy-bar-widget) · [Uninstall](#uninstall) - ---- - ## Install ### Windows (PowerShell 5.1+) -One line: - ```powershell irm https://git.nebulm.com/smoido/claude-mode/raw/branch/master/install.ps1 | iex ``` -Piped in like that there is no checkout, so the installer fetches the -repository archive to `%TEMP%` and installs from it. To pass switches -(`-Force`, `-SkipKeyPrompt`), download first and run the file: +Piped in like that there is no checkout, so the installer fetches the repository +archive to `%TEMP%` and installs from it. To pass switches (`-Force`, +`-SkipKeyPrompt`), download the file and run it; or clone the repository and run +`.\install.ps1`. -```powershell -irm https://git.nebulm.com/smoido/claude-mode/raw/branch/master/install.ps1 -OutFile install.ps1 -.\install.ps1 -Force -``` - -Or clone it — the normal path if you want to read the code and pull updates: - -```powershell -git clone https://git.nebulm.com/smoido/claude-mode.git -cd claude-mode -.\install.ps1 -``` - -Installs to `~/.claude-mode/` (ACL: you only), drops `claude-mode.cmd` into +It installs to `~/.claude-mode/` (ACL: you only), puts `claude-mode.cmd` in `~/.local/bin` (already on your User PATH, next to `claude.exe`), and adds a -marked block to `~/Documents/WindowsPowerShell/profile.ps1`. - -If the profile doesn't load: `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`. +marked block to `~/Documents/WindowsPowerShell/profile.ps1`. If the profile does +not load: `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`. ### Linux / macOS -One line: - ```bash curl -fsSL https://git.nebulm.com/smoido/claude-mode/raw/branch/master/linux/install.sh | bash ``` -Or clone: +Or clone and run `bash linux/install.sh` (`--skip-key-prompt` to install without +storing a key). It installs to `~/.claude-mode/`, symlinks +`~/.local/bin/claude-mode`, and adds the `claude` wrapper to `~/.bashrc` / +`~/.zshrc` between markers. Keys go to the macOS Keychain, libsecret, `pass`, or a +0600 file that says plainly that it is not encrypted — whichever is available. -```bash -git clone https://git.nebulm.com/smoido/claude-mode.git -cd claude-mode -bash linux/install.sh # --skip-key-prompt to install without storing a key -``` - -Installs to `~/.claude-mode/`, symlinks `~/.local/bin/claude-mode`, and adds the -`claude` wrapper to `~/.bashrc` / `~/.zshrc` between markers. - -Secrets have no DPAPI equivalent here, so the vault picks the best backend -available and says which one it chose: macOS Keychain, libsecret, `pass`, or a -0600 file that is honestly labelled as unencrypted. - -It runs on the **bash 3.2 that macOS ships** — no Homebrew bash needed. The -menu reads arrow keys through the terminal's own timing (`min 0 time 1`) rather -than a fractional `read` timeout, which 3.2 rejects, and the port avoids -`mapfile` and GNU-only `stat`/`find` flags for the same reason. - -Session listing reads `/proc`, so it is **Linux-only**. On macOS a switch says -it cannot check for running sessions instead of claiming there are none — so -restart Claude Code yourself after switching. +It runs on the bash 3.2 that macOS ships. Finding running sessions needs `/proc`, +so on macOS a switch says it cannot check for them instead of claiming there are +none; restart Claude Code yourself after switching there. ### Omarchy bar widget ```bash -bash omarchy/install.sh +bash omarchy/install.sh # from a checkout +omarchy restart shell # after an upgrade - see below ``` -Copies the plugin to `~/.config/omarchy/plugins/smoido.claude-mode/` and adds its -id to the bar layout in `~/.config/omarchy/shell.json` (backed up first). Needs a -checkout — clone first. See [the bar widget](#the-omarchy-bar-widget) for what it -does. +The shell reloads the plugin when its files change, but keeps the old compiled +panel alive through that reload, so an upgrade only shows after a shell restart. +What the widget does: [docs/bar-widget.md](docs/bar-widget.md). -### All three, and upgrades +### Upgrading -The installers never touch `~/.claude/settings.json` — that only happens on an -actual mode switch, which backs it up to `~/.claude-mode/backups/` first -(last 20 kept). - -Re-running any of them is a safe upgrade: scripts are overwritten, existing -presets are kept unless `-Force` / `--force` is passed. +Re-run the installer. Scripts and `providers.json` are replaced; your presets are +kept unless you pass `--force` / `-Force`. The installers never touch +`~/.claude/settings.json` — only a switch does, and it backs the file up to +`~/.claude-mode/backups/` first (the last 20 are kept). ## First run -A shipped preset is a starting point, not a working configuration. OpenRouter -and Z.AI have no key stored. LM Studio's model ids are whatever happened to be -installed on the machine this was packaged on, which is almost certainly not -yours. - -So the shipped presets carry `configured: false`, and preflight treats that as a -blocker with its own remedy: +A shipped preset is a starting point, not a working configuration: no key is +stored, and its model ids are whatever was on the machine it was packaged on. So +the shipped presets are marked `configured: false`, and a switch to one asks for +setup first: ```bash -claude-mode setup openrouter # key, then models from OpenRouter's catalogue -claude-mode setup zai # key, then per-tier GLM models -claude-mode setup lmstudio # server URL, auth, then models from that server -claude-mode setup anthropic # nothing to do; it uses your existing login +claude-mode setup openrouter # key, then a model per tier from OpenRouter's catalogue +claude-mode setup zai # key, then a GLM model per tier +claude-mode setup lmstudio # where the server is, whether it wants a key, one model for all tiers +claude-mode setup ollama # the same, for Ollama +claude-mode setup custom # the endpoint's address and key, then a model per tier ``` -Setup asks only what it cannot work out, shows the current model map before -offering to change it, and picks from the provider's own catalogue rather than -asking anyone to type a model id from memory. LM Studio maps one model across -every tier, since a local server has one loaded at a time and per-tier mapping -would just pay the load cost on every tier change; the remote gateways ask per -tier, which is the point of them. - -In a terminal, a switch that trips this offers to run setup there and then -rather than printing a command to type next. It sets `configured: true` on the -way out. - -**Absent means configured.** Presets that predate this, and any built by hand -with `preset new`, are nobody's business but yours and will not start demanding -a wizard. - -## The interactive menu - -Run `claude-mode` with no arguments. The mode you're already in is omitted — -there's nothing to switch to: - -``` -claude-mode (currently: openrouter / default) - switch mode: - 1) Anthropic - your subscription login, no gateway - 2) Z.AI - GLM coding plan - 3) LM Studio - local server, offline, free - - 4) show full status - 5) edit presets - 6) run doctor - 0) quit -``` - -Pick a provider and it lists that provider's presets with the default marked; -**Enter** accepts it. Option 5 walks preset → tier → new model ID, and if you -edit the preset that's currently live it re-applies immediately. - -When stdin is redirected (scripts, CI) the menu is skipped and `status` prints -instead, so `claude-mode` is still safe in a pipeline. +In a terminal, a switch that needs setup offers to run it there and then. Presets +you build yourself never ask. ## Commands ``` -claude-mode interactive menu -claude-mode status active mode, preset, model map +claude-mode interactive menu +claude-mode status active mode, preset, model map +claude-mode [preset] switch: anthropic, openrouter, zai, lmstudio, ollama, custom +claude-mode --yes ...without asking about running sessions +claude-mode --force ...even when preflight says it would not work -claude-mode anthropic native login (deletes all managed keys) -claude-mode openrouter [preset] default preset: default -claude-mode zai [preset] default preset: zai (alias: z.ai, z-ai) -claude-mode lmstudio [preset] default preset: lmstudio (alias: lm-studio) -claude-mode ollama [preset] default preset: ollama -claude-mode custom [preset] default preset: custom +claude-mode setup first-run setup: key, server, models +claude-mode preflight [preset] would that switch work? (JSON, no switch) +claude-mode doctor check auth, endpoint, model ids, context window, stray env vars +claude-mode health rewrite health.json (machine-readable state) -claude-mode presets list presets (* = active) +claude-mode presets list presets (* = active) claude-mode preset show -claude-mode preset new [from] copy an existing preset (default: `default`) +claude-mode preset new [from] copy a preset (default: `default`) claude-mode preset new --provider

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

`. +| | | +|---|---| +| [docs/providers.md](docs/providers.md) | presets, defaults, context windows, every provider, adding one | +| [docs/live-sessions.md](docs/live-sessions.md) | what a switch does to running sessions, and repairing them | +| [docs/bar-widget.md](docs/bar-widget.md) | the Omarchy bar widget and its panel | +| [docs/design.md](docs/design.md) | why it works the way it does | +| [docs/architecture.md](docs/architecture.md) | how the pieces fit, and where to change what | +| [docs/development.md](docs/development.md) | tests, conventions, releasing | +| [CHANGELOG.md](CHANGELOG.md) | what changed, by version | ## Uninstall -Windows: - -```powershell -claude-mode anthropic # clean settings.json first -Remove-Item ~\.claude-mode -Recurse -Force -Remove-Item ~\.local\bin\claude-mode.cmd -# then delete the block between the >>> claude-mode >>> markers in profile.ps1 -``` - -Linux / macOS: - ```bash -claude-mode anthropic # clean settings.json first +claude-mode anthropic # clean settings.json first rm -rf ~/.claude-mode ~/.local/bin/claude-mode # then delete the block between the >>> claude-mode >>> markers in ~/.bashrc / ~/.zshrc ``` -Bar widget: +On Windows, `claude-mode anthropic`, then remove `~\.claude-mode` and +`~\.local\bin\claude-mode.cmd`, and delete the marked block in `profile.ps1`. For +the bar widget, remove `~/.config/omarchy/plugins/smoido.claude-mode` and its +`{"id": "smoido.claude-mode"}` entry in `~/.config/omarchy/shell.json`. -```bash -rm -rf ~/.config/omarchy/plugins/smoido.claude-mode -# then remove the {"id": "smoido.claude-mode"} entry from ~/.config/omarchy/shell.json -``` +## Licence + +[MIT](LICENSE). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..009f528 --- /dev/null +++ b/docs/architecture.md @@ -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
(bash on Linux/macOS,
PowerShell on Windows)"] + widget["bar widget
(Omarchy, QML)"] -- runs --> cli + cli -- all JSON through --> engine["cm-json.py
(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 [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.` → `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` | — | diff --git a/docs/bar-widget.md b/docs/bar-widget.md new file mode 100644 index 0000000..8620fc2 --- /dev/null +++ b/docs/bar-widget.md @@ -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
` 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 | diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..71bf6ef --- /dev/null +++ b/docs/design.md @@ -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 ` 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 ` | +| 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 ` | +| 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 [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. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..2013316 --- /dev/null +++ b/docs/development.md @@ -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. diff --git a/docs/live-sessions.md b/docs/live-sessions.md new file mode 100644 index 0000000..dc985db --- /dev/null +++ b/docs/live-sessions.md @@ -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--` 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 # show what it would cut +claude-mode repair-session --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 # stop counting it +claude-mode repair-session --ignored # what is dismissed, and whether it is still broken +claude-mode repair-session --unignore # 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 `.jsonl.pre-repair-backup-`. +2. **Writes the dropped turns out** as `.recovered-.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//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. diff --git a/docs/providers.md b/docs/providers.md new file mode 100644 index 0000000..b2708cc --- /dev/null +++ b/docs/providers.md @@ -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 ` 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 ` 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 = +CLAUDE_CODE_AUTO_COMPACT_WINDOW = +``` + +| 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 +; `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 # ...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 ` 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. diff --git a/linux/claude-mode b/linux/claude-mode index dfe46ac..fb8c9c1 100755 --- a/linux/claude-mode +++ b/linux/claude-mode @@ -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) diff --git a/tests/cli/test_sessions.sh b/tests/cli/test_sessions.sh index e2ee60d..4b5739f 100755 --- a/tests/cli/test_sessions.sh +++ b/tests/cli/test_sessions.sh @@ -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() { # [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 diff --git a/tests/python/test_cm_json.py b/tests/python/test_cm_json.py index 409443a..f75f15f 100644 --- a/tests/python/test_cm_json.py +++ b/tests/python/test_cm_json.py @@ -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") diff --git a/tests/static.sh b/tests/static.sh index b33ec4f..fb9bcdc 100755 --- a/tests/static.sh +++ b/tests/static.sh @@ -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