# 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.