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