Files
claude-mode/docs/design.md
T
smoidoandClaude Opus 5 b514e00745 README down to what a user needs; the rest into docs/
The README had grown to 1,010 lines of user docs and design notes in one file,
and had gone stale: it still showed the old numbered menu, set up only three
providers, and listed preflight checks and a file layout that predate the last
three releases. It now carries install, first run, the full command
reference, the providers at a glance, troubleshooting and a docs index.

docs/:
- providers.md    presets, defaults, the model cache, context windows, each
                  provider (OpenRouter's cost guard and guardrail check are
                  written up for the first time), adding a provider
- live-sessions.md  what a switch does to running sessions, and repair
- design.md       why settings.json, why keys stay out of it (and the vault
                  per platform), the preflight checks as they are now
- bar-widget.md   the widget as it is now: providers from health.json, every
                  server provider's settings, the restart after an upgrade
- architecture.md the pieces, every file on disk and who writes it, the
                  contracts between them, where to change what
- development.md  running the tests, the conventions the code follows,
                  working on the widget, releasing
CONTRIBUTING.md points at it.

Also:
- The per-project session listing used awk, which the CLI avoids because it
  is missing from minimal images; it uses the script's own TSV helpers now,
  and tests/static.sh fails on any awk in the CLI.
- tests/static.sh checks every relative Markdown link and #anchor.
- A unit test pins the managed env keys between cm-json.py and
  claude-mode.ps1, which only a comment kept in step before.
- test_sessions covers the per-project listing, which nothing ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 01:52:03 +03:00

160 lines
8.6 KiB
Markdown

# Design decisions
Why claude-mode works the way it does. How the pieces fit together is in
[architecture.md](architecture.md).
## Why settings.json and not a profile export
Three ways to make a switch persist. It uses the second.
**1. Export the variables from the shell profile.** The obvious move, and the
wrong one. It only covers processes launched from a shell that loaded the profile
— which is exactly *not* how Claude Code is used. The VS Code extension is spawned
by VS Code, not by your shell, so it never sees the exports; neither does the
desktop app, nor a terminal opened before the switch. And the failure is silent:
you switch to `anthropic`, a shell opened five minutes earlier still has
`ANTHROPIC_BASE_URL` set, and that session quietly keeps billing OpenRouter.
**2. Rewrite the `env` block in `~/.claude/settings.json`.** ← chosen.
Claude Code reads this file on every startup, from every launch context. One
write, and the next `claude` — CLI, extension, desktop — picks it up. A switch is
one file and one source of truth. `claude-mode anthropic` *deletes* the managed
keys rather than blanking them, so nothing lingers to break native auth. The cost
is that the config is global rather than per terminal.
**3. Persistent user-scope environment variables (`setx`).** Also global, and
strictly worse: new processes only, values sitting in the registry in plaintext,
and a stale entry silently outranks whatever claude-mode writes. They are treated
as a fault: `status` and `doctor` flag them and offer removal, backing the old
value up first.
The `claude` wrapper in the shell profile is a **safety net, not the mechanism**.
It strips inherited process-level copies of the managed variables before
launching `claude`. Everything works without it — including in VS Code, which
never loads the profile.
`state.json` records which env keys the last switch actually wrote, so a
preset's own `extraEnv` key (Z.AI's timeouts, LM Studio's attribution header) is
removed when you switch away, even though no other preset knows it exists.
## Why API keys are not in settings.json
`settings.json` is a file you hand-edit, diff, and paste into bug reports. An
`sk-or-` key does not belong in it.
Claude Code receives the key at runtime through `apiKeyHelper`, a small script
that reads it from a vault and prints it; `settings.json` holds only the base URL
and model ids. In `anthropic` mode the helper is removed from `settings.json`
*and* returns nothing when state says `anthropic`. A local server's placeholder
token (`lmstudio`, `ollama`) is not a secret, so it is written inline and the
helper stays out of it.
The vault depends on the platform:
| platform | backend |
|---|---|
| Windows | DPAPI, CurrentUser scope, in `~/.claude-mode/vault/*.cred` (ACL: you only) — useless if copied elsewhere or read as another user |
| macOS | the login Keychain |
| Linux | libsecret (GNOME Keyring), else `pass`, else a 0600 file |
The file backend is the honest fallback: no worse than a key in `.bashrc`, but not
encrypted, and claude-mode says so rather than implying protection it does not
provide. `CLAUDE_MODE_VAULT=file|secret-tool|pass|security` forces a backend.
### When the helper "is failing"
Claude Code reports a broken helper as *your apiKeyHelper script is failing* and
shows its stderr under `/status`. Two causes account for nearly all of it.
**A space in your home directory.** `apiKeyHelper` is a shell *command line*, not
a path, so `C:\Users\Firstname Lastname\.claude-mode\bin\claude-key-helper.cmd` is
split at the space. The value is quoted when it needs to be on both ports
(`shlex.quote` on POSIX, where `/Users/Firstname Lastname` does the same thing).
`doctor` reads the string out of `settings.json` and runs *that* through a shell,
so it tests what Claude Code actually sees; re-running the switch rewrites it.
**A key stored by a different Windows account.** DPAPI `CurrentUser` scope means
a key stored from an elevated or *run as* shell cannot be decrypted by the account
Claude Code runs as. Re-run `claude-mode set-key <ref>` unelevated, as yourself.
## Refusing a switch that would not work
A switch writes `settings.json` and is picked up by the *next* launch, so a switch
into a mode that cannot serve requests does not fail loudly — it succeeds, and
every session started afterwards is broken in a way that points at Claude Code
rather than here. A local server is the sharp case: its token is a placeholder,
so nothing about the switch needs the server to exist.
So the preconditions are checked before the write (POSIX `cm_preflight`):
| check | when it fails | remedy offered |
|---|---|---|
| the preset exists and declares the provider being switched to | always an error | — |
| it has been through setup (`configured` is not `false`) | shipped presets, first time | `claude-mode setup <mode>` |
| at least one tier is mapped | a blank preset — Claude Code would ask the gateway for Anthropic's own models, at full price | edit the preset |
| it has a server address | `custom`, until set | `claude-mode preset url` |
| a key is stored for its `keyRef`, and the helper is executable | vault auth | `claude-mode set-key <ref>` |
| the server answers, and accepts the credential | see below | start it, fix the address, or fix the key |
Which providers are probed is each provider's `server.probe` in `providers.json`:
| probe | providers | behaviour |
|---|---|---|
| `always` | LM Studio, Ollama | probed wherever the server is — a sleeping LAN box is as absent as an empty loopback port |
| `lenient` | Custom | probed, but "answered, no model list here" passes: many proxies serve Messages and nothing else |
| `local` | OpenRouter, Z.AI | only when pointed at this machine; a public gateway briefly unreachable is the network's problem, where a missing key never fixes itself |
The probe tells four outcomes apart, because their remedies are opposites:
| result | means |
|---|---|
| `ok` | a model-list path answered |
| `auth` | the server is up and refused the credential |
| `notfound` | something is listening, but not that API at that path |
| `refused` | nothing answered at all — down, asleep, DNS, TLS, timeout |
`claude-mode preflight <mode> [preset]` runs exactly these and prints the verdict
as JSON without switching; the bar widget calls it before it offers to do
anything. `--force` overrides the lot. The Windows build has no preflight, but its
switch refuses the two that would silently misroute: no server address, and no
tier mapped.
## The menu follows your desktop theme
The sixteen ANSI colour slots carry no guarantee about relative brightness, and
monochrome themes exploit that. Under Omarchy's Solitude, slot 36 — headings —
resolves to `#707070` and slot 31 — `FAIL` — to `#565d60`; against `#cacccc` body
text on `#101315` that is 3.8:1 and 2.8:1 where the body text is 11.6:1, so an
error became the quietest thing on screen.
So when Omarchy is present, the palette is derived from the theme it publishes at
`~/.local/state/omarchy/current/theme/colors.toml`. Every role is measured against
the background it will be drawn on and lifted toward the foreground when it falls
short, which keeps hue where the theme has any and falls back to weight where it
does not (Solitude: headings 3.8:1 → 9.4:1, `FAIL` 2.8:1 → 5.2:1). Light themes
work by the same arithmetic. `CLAUDE_MODE_THEME=/path/to/colors.toml` points it
elsewhere and `NO_COLOR` turns it off; without Omarchy it uses the ANSI slots,
with bright red for `FAIL` and bold headings.
## health.json
`~/.claude-mode/health.json` is the machine-readable mirror of the active
configuration: mode, preset, model map, context window, key backend, guardrail
state, the preset catalogue, each provider's effective default, and the provider
list itself. It never holds key material. A switch, `status` and most preset
edits rewrite it; `claude-mode health` forces it. The bar widget reads nothing
else, which is why it costs nothing while idle and why a switch made in a
terminal shows up in the bar on its own.
## Checked against the CLI
Every variable claude-mode writes was verified by scanning the `claude` binary
(first against 2.1.221, again since): `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`,
`ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL`,
`CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY`, `CLAUDE_CODE_ATTRIBUTION_HEADER`,
`CLAUDE_CODE_MAX_CONTEXT_TOKENS`, `CLAUDE_CODE_AUTO_COMPACT_WINDOW`,
`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC`, `API_TIMEOUT_MS` and `apiKeyHelper`.
The project-directory slug rule used to find transcripts (every non-alphanumeric
character becomes a dash; the physical path is used) was verified empirically
against 2.1.269.