setup · diff
git:20260904.fad3ae3 to git:20260907.d0ba09c
61 added, 30 removed. Audit C to C.
---
name: setup
- description: One-time setup for the cc-proxy plugin. Writes API keys (all optional — GLM_API_KEY, OPENROUTER_API_KEY, DEEPSEEK_API_KEY, DASHSCOPE_API_KEY, LMSTUDIO_BASE_URL, LMSTUDIO_API_KEY) to ~/.env, and configures ANTHROPIC_BASE_URL and the glm-5.3[1m] custom model option in ~/.claude/settings.json so the SessionStart hook can auto-start the proxy and /model can route to GLM. Invoke via /cc-proxy:setup.
+ description: One-time setup for the cc-proxy plugin. Writes API keys (all optional — GLM_API_KEY, OPENROUTER_API_KEY, DEEPSEEK_API_KEY, DASHSCOPE_API_KEY, LMSTUDIO_BASE_URL, LMSTUDIO_API_KEY) to ~/.env, and configures ANTHROPIC_BASE_URL plus a /model picker row per routable model (each carrying its real context window) in ~/.claude/settings.json, so the SessionStart hook can auto-start the proxy and /model can route to every configured backend. Invoke via /cc-proxy:setup.
---
# cc-proxy setup
One-time configuration of `~/.claude/settings.json` so the proxy runs automatically on every Claude Code session.
The proxy binary needs **no configuration**: the SessionStart hook and `scripts/start-proxy.js` resolve `bin/cc-proxy.js` from their own plugin tree, which is always the currently-installed version. Do **not** write `PROXY_PATH` into settings.json — a version-pinned path there is exactly how users used to get stuck on stale proxies after plugin updates. If a `PROXY_PATH` already exists in settings.json `env`, **remove it** during step 3 (it is a legacy pin; the tree's own bin outranks it anyway).
## What to do
Follow these steps **exactly**. Do not skip any.
### 1. Locate the plugin tree (for the statusline path only)
Check these locations in order and use the first one that exists:
1. `~/.claude/plugins/cache/betmoar/cc-proxy/*/` (marketplace install — the normal case; glob the `*` version segment and take the newest if there is more than one)
2. `~/dev/cc-proxy-plugin/` (dev-repo fallback, if the user cloned source)
This concrete path is needed **only** for the optional statusline command in step 4 (which runs outside plugin context). It is *not* written as `PROXY_PATH`.
### 2. Collect provider API keys (written to `~/.env`)
API keys live in `~/.env` — the single source of truth the proxy reads at startup. Do **not** put keys in `~/.claude/settings.json` `env`; it keeps only non-secret plumbing (step 3).
Read `~/.env` first (create the file if absent). For each key, reuse a value already present rather than re-asking.
- **Z.ai / GLM — optional, but the one this plugin is built around.** It is the model wired into the `/model` picker, so without it that picker entry routes nowhere useful. If `GLM_API_KEY` is missing or empty in `~/.env`, **ask explicitly**:
+ **Z.ai / GLM — optional, but the one this plugin is built around.** Its nine models are the bulk of what step 3b publishes to the `/model` picker; without the key none of them appear there. If `GLM_API_KEY` is missing or empty in `~/.env`, **ask explicitly**:
> "Enter your Z.ai API key (https://z.ai → Dashboard → API Keys), or press Enter to skip. It will be stored in ~/.env:"
If the user skips, continue setup — do not stop and do not re-ask. The proxy
starts without it and routes to Claude; a backend with no key is simply not
- registered. Say once that `glm-5.3[1m]` will appear in `/model` but won't route
- until a key is added, then move on.
+ registered. Say once that no GLM models will appear in `/model` until a key is
+ added, then move on.
**OpenRouter — optional.** Ask the user whether they also want OpenRouter routing. If yes and `OPENROUTER_API_KEY` is missing or empty in `~/.env`, ask:
> "Enter your OpenRouter API key (https://openrouter.ai/settings/keys). It will be stored in ~/.env:"
**DeepSeek — optional.** Ask the user whether they also want DeepSeek routing. If yes and `DEEPSEEK_API_KEY` is missing or empty in `~/.env`, ask:
> "Enter your DeepSeek API key (https://platform.deepseek.com/api_keys). It will be stored in ~/.env:"
**Qwen — optional.** Ask the user whether they also want Qwen (QwenCloud Token Plan) routing. If yes and `DASHSCOPE_API_KEY` is missing or empty in `~/.env`, ask:
> "Enter your QwenCloud Token Plan API key (ANTHROPIC_AUTH_TOKEN from the QwenCloud console). It will be stored in ~/.env:"
**LM Studio — optional (self-hosted).** Ask the user whether they also run an LM Studio server they want reachable. If yes and `LMSTUDIO_BASE_URL` is missing or empty in `~/.env`, ask:
> "Enter your LM Studio server base URL, INCLUDING the http:// scheme (e.g. http://192.168.1.50:1234 — LM Studio's own UI shows the address without it, and a scheme-less value is refused. The server must also have 'Serve on Local Network' enabled to be reachable from another machine). It will be stored in ~/.env:"
If the user's server has "Require Authentication" enabled and `LMSTUDIO_API_KEY` is missing or empty, also ask:
> "Enter your LM Studio API key. It will be stored in ~/.env:"
(Auth-off servers need no key; a dummy token is sent and ignored. `LMSTUDIO_BASE_URL` is the opt-in — the key alone registers nothing.)
Write each collected key to `~/.env` as a `KEY=value` line, one per line (e.g. `GLM_API_KEY=<value>`). If `~/.env` already exists, **merge** — update only the key lines you collected and preserve every other line unchanged. If it does not exist, create it with just the key line(s).
- The proxy only registers OpenRouter when `OPENROUTER_API_KEY` is set, and routes any model id containing a slash to it (e.g. `z-ai/glm-4.7`, `deepseek/deepseek-v4-pro` — never an `anthropic/…` id: that routes Claude traffic through a metered reseller, which invariants 3 and 4 exist to prevent, and discovery deliberately omits those ids). It only registers DeepSeek when `DEEPSEEK_API_KEY` is set, and routes any bare `deepseek-*` id to it (e.g. `deepseek-v4-pro`, `deepseek-v4-flash`). It only registers Qwen when `DASHSCOPE_API_KEY` is set, and routes any bare `qwen`-prefixed id to it (e.g. `qwen3.7-max`, `qwen3.6-flash`). It only registers LM Studio when `LMSTUDIO_BASE_URL` is set, and routes **only** explicit `lmstudio:<model-id>` selectors to it — no bare id routes there by shape (local model names would collide with the GLM/Qwen/OpenRouter predicates above); `DEFAULT_BACKEND=lmstudio` additionally makes it the unmatched-id fallback, which is an explicit user choice like `=openrouter`. **Tell the user this constraint:** Claude Code allows only **one** custom `/model` picker entry, and GLM uses it — so OpenRouter, DeepSeek, Qwen, and LM Studio models do **not** appear in the `/model` picker. They are reached only by (a) setting `DEFAULT_BACKEND=openrouter` (or `deepseek`, `qwen`, `lmstudio`) so unmatched requests fall through to it, (b) a subagent/slash-command whose frontmatter pins the model id (which the proxy then routes verbatim), or — for LM Studio only — (c) any `/model lmstudio:<model-id>`-style selector, since bare local ids cannot route by shape anyway.
+ The proxy only registers OpenRouter when `OPENROUTER_API_KEY` is set, and routes any model id containing a slash to it (e.g. `z-ai/glm-4.7`, `deepseek/deepseek-v4-pro` — never an `anthropic/…` id: that routes Claude traffic through a metered reseller, which invariants 3 and 4 exist to prevent, and discovery deliberately omits those ids). It only registers DeepSeek when `DEEPSEEK_API_KEY` is set, and routes any bare `deepseek-*` id to it (e.g. `deepseek-v4-pro`, `deepseek-v4-flash`). It only registers Qwen when `DASHSCOPE_API_KEY` is set, and routes any bare `qwen`-prefixed id to it (e.g. `qwen3.7-max`, `qwen3.6-flash`). It only registers LM Studio when `LMSTUDIO_BASE_URL` is set, and routes **only** explicit `lmstudio:<model-id>` selectors to it — no bare id routes there by shape (local model names would collide with the GLM/Qwen/OpenRouter predicates above); `DEFAULT_BACKEND=lmstudio` additionally makes it the unmatched-id fallback, which is an explicit user choice like `=openrouter`. **Tell the user what does and does not reach the `/model` picker:** step 3b publishes a picker row for every model with a curated context window whose provider has a key — GLM, DeepSeek and Qwen ids all appear. Two kinds do **not**: OpenRouter's `vendor/model` ids (~400 of them, no curated window each) and LM Studio's, whose names are per-machine and arbitrary. Those are reached by (a) `DEFAULT_BACKEND=openrouter` (or `lmstudio`) so unmatched requests fall through, (b) a subagent/slash-command whose frontmatter pins the id verbatim, or — for LM Studio — (c) a `/model lmstudio:<model-id>` selector, since bare local ids cannot route by shape anyway.
**Migrate existing keys (one source of truth).** Read `~/.claude/settings.json`. If its `env` block contains `GLM_API_KEY`, `OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY`, `DASHSCOPE_API_KEY`, `LMSTUDIO_BASE_URL`, or `LMSTUDIO_API_KEY` (legacy setups), move them to `~/.env`: if `~/.env` already has the key, keep the `~/.env` value and just drop the settings.json copy; otherwise copy the value over then **remove** the key from settings.json `env`. After setup, keys must exist **only** in `~/.env`.
### 3. Update `~/.claude/settings.json` (plumbing only — no keys)
Read the current file, then merge the following into the `env` object (create `env` if missing). Preserve every other existing key unchanged, **except `PROXY_PATH`: delete it if present** (legacy version-pinned path; the hook resolves the binary from its own tree now). **Do not add `GLM_API_KEY`, `OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY`, `DASHSCOPE_API_KEY`, `LMSTUDIO_BASE_URL`, or `LMSTUDIO_API_KEY` here** — they go in `~/.env` (step 2).
```json
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:4000"
}
}
```
- **Then, ONLY if a `GLM_API_KEY` was collected in step 2**, also merge the picker
- entry:
-
- ```json
- {
- "env": {
- "ANTHROPIC_CUSTOM_MODEL_OPTION": "glm-5.3[1m]",
- "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME": "GLM-5.3 (1M)",
- "ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION": "Z.ai GLM-5.3 1M-context (routed via cc-proxy)"
- }
- }
- ```
-
- This registers `glm-5.3[1m]` in the `/model` picker (Claude Code allows exactly
- one custom model option). If `ANTHROPIC_CUSTOM_MODEL_OPTION` is already set to a
- different value, ask the user before overwriting it.
-
- **Why the condition.** The GLM key is skippable (issue #20), and the picker slot
- holds exactly one entry. Writing `glm-5.3[1m]` for a user who skipped it puts a
- model in their picker that cannot route — it resolves to the default backend
- instead, and the only warning was spoken once during setup and never persisted,
- so weeks later the entry fails with nothing on disk explaining why. If the user
- skipped GLM, say plainly that the picker entry was skipped too, and that adding
- `GLM_API_KEY` to `~/.env` and re-running `/cc-proxy:setup` will add it.
-
Write the file back with 2-space indentation, matching the existing formatting.
+ Do **not** write `ANTHROPIC_CUSTOM_MODEL_OPTION` here — step 3b replaces it with
+ a full row set. Claude Code dedupes the picker by model id and that env WINS, so
+ leaving it in place replaces the generated row for that model with a bare one —
+ losing its `behavesAs` (the catalog warning returns for that id) and showing
+ "Custom model" instead of its context window.
+
**PROXY_AUTH_TOKEN — only if the user says the proxy must be reachable off this
machine** (e.g. another device on the LAN, `PROXY_HOST=0.0.0.0`). Ask:
> "You want the proxy reachable from other machines. It injects API keys, so it needs a shared token. Enter one (any long random string), or press Enter to skip and keep loopback-only:"
If given: write `PROXY_AUTH_TOKEN=<value>` to `~/.env` **and** merge
`"ANTHROPIC_AUTH_TOKEN": "<same value>"` into settings.json `env` (that is how
Claude Code presents it to the proxy). Also warn, once, in plain words: with the
token in place, Claude Code's single credential slot is the proxy token, so
`claude-*` model requests no longer carry OAuth — this mode is for GLM/OpenRouter/
DeepSeek/Qwen/LM Studio routing. If the user did not ask for off-host access, do
NOT configure any of this; the loopback default needs no token.
+
+ ### 3b. Publish the `/model` picker rows
+
+ Run, verbatim:
+
+ ```
+ node "$CLAUDE_PLUGIN_ROOT/scripts/render-model-picker.js"
+ ```
+
+ **Do not write the `modelPicker` block yourself.** Claude Code does no merging
+ across settings sources — the highest-precedence file that defines `modelPicker`
+ wins outright — so a hand-written block silently discards any rows the user
+ added themselves, with no diagnostic anywhere. The script merges: it replaces
+ only the rows cc-proxy previously generated, keeps foreign rows in their exact
+ positions, backs the file up to `settings.json.bak` first, and removes the
+ superseded `ANTHROPIC_CUSTOM_MODEL_OPTION*` keys. The write is atomic (temp file
+ renamed over the target), so an interrupted run cannot leave a half-written
+ settings.json.
+
+ **Why this exists.** Claude Code assumes a **200K** context window for any model
+ id its built-in catalog does not describe — which is every id cc-proxy routes —
+ and auto-compacts there no matter what the backend actually serves. The rows
+ carry each model's real window (via a `[1m]` suffix on the ids that have one) and
+ a `behavesAs` mapping that stops the "isn't described by this version's model
+ catalog" warning.
+
+ Interpret the script's output:
+
+ - `wrote N modelPicker rows to …` → success. It also prints which env keys it
+ removed. Continue.
+ - `no provider keys are registered` → the user skipped every key in step 2, so
+ there is nothing to publish and nothing was written. Say so plainly: the
+ `/model` picker keeps only Claude's built-in entries, and re-running
+ `/cc-proxy:setup` after adding a key to `~/.env` will add the rows.
+ - `could not be read as JSON` → their settings.json is malformed. Nothing was
+ written. Tell them the parse error and stop; do not attempt a repair.
+
+ **If the script reports `CLAUDE_CODE_MAX_CONTEXT_TOKENS is still set`, ask the
+ user** — do not remove it yourself:
+
+ > "Your settings pin `CLAUDE_CODE_MAX_CONTEXT_TOKENS` to `<value>` globally. Each model now carries its own window, so that pin no longer affects them — it only applies to ids with **no** row (a typo, a model pinned in a subagent's frontmatter, an OpenRouter `vendor/model` id), where a large value tells Claude Code not to compact until far past what those models actually accept. Remove it?"
+
+ If they say yes, delete just that key from settings.json `env` and leave
+ everything else untouched. If they say no or don't answer, leave it — it is
+ harmless for every model that has a row.
+
+ **Tell the user this limitation once:** models whose real window is *below* 200K
+ (`glm-4.5`, `glm-4.5-air` at 128K) are still budgeted at 200K. Claude Code has no
+ per-model channel below 200K, and the global one is disabled for any model
+ carrying `behavesAs`.
### 4. Optional: enable the statusline
Ask the user whether they want the quota/credits statusline. It shows Claude 5-hour usage, GLM coding quota, OpenRouter credits (when `OPENROUTER_API_KEY` is set), DeepSeek balance (when `DEEPSEEK_API_KEY` is set), Qwen presence (when `DASHSCOPE_API_KEY` is set), and a bold-red `proxy down` when the local proxy is unreachable.
If yes, merge this **top-level** key into `~/.claude/settings.json` (it is *not* under `env`):
```json
{
"statusLine": {
"type": "command",
"command": "node <PROXY_DIR>/scripts/statusline.js"
}
}
```
`<PROXY_DIR>` is the plugin tree located in step 1 (e.g. `~/.claude/plugins/cache/betmoar/cc-proxy/<version>`). The statusline command runs outside plugin context, so `${CLAUDE_PLUGIN_ROOT}` is unavailable — an absolute path is required here, and it *is* version-pinned (a statusline pointing at an older cache dir still renders; it does not affect which proxy runs). If the user already has a `statusLine` configured, show them the command and let them decide rather than overwriting it.
### 5. Start the proxy now
Spawn the proxy so it is already up when `ANTHROPIC_BASE_URL` takes effect, eliminating the first-run `ECONNREFUSED`. Run, verbatim:
```
node "$CLAUDE_PLUGIN_ROOT/scripts/start-proxy.js"
```
`scripts/start-proxy.js` reuses the SessionStart hook's `ensureProxyRunning()`: it probes `PROXY_PORT` first (idempotent — a same-version proxy is left running; a stale-version one is gracefully replaced), then spawns its own tree's `bin/cc-proxy.js` detached + `unref`'d so it survives this turn. It reads the `env` block you just wrote to `~/.claude/settings.json` and passes it to the spawn, because the proxy reads config from env (not settings.json) and nothing has injected those vars into this process yet on a first-run setup.
Interpret the script's stdout/stderr:
- `cc-proxy already up`, `cc-proxy started`, or `cc-proxy restarted` → success. Proceed to step 6.
- `PROXY_PATH is unset` → the plugin tree has no `bin/cc-proxy.js` (hand-rolled install) and no legacy `PROXY_PATH` exists. Ask the user where `cc-proxy.js` is and put that absolute path in settings.json `env` as `PROXY_PATH` — the one case where it is still legitimate.
- `did not become reachable in time` → spawn fired but readiness timed out. Treat as a fallback: keep `/exit` + `/resume` as the path to recovery (step 6 covers this). Show the user the `~/.claude/cc-proxy/cc-proxy.log` tail if they ask.
### 6. Inform the user
Tell the user, verbatim:
> Setup complete. The proxy is running (step 5 started it). Claude Code re-applies `ANTHROPIC_BASE_URL` to running sessions immediately, so any open `claude` may still fail until it re-reads env — `/exit` and `/resume` any open session if you hit an error.
>
> To confirm, check `~/.claude/cc-proxy/cc-proxy.log` after your next prompt — you should see routing lines like `claude-sonnet-4-6 -> claude` or `glm-5.3 -> glm`.
## Important constraints
- **Do not** overwrite unrelated keys in `settings.json` or unrelated lines in `~/.env`. Use a merge strategy for both, not a full rewrite from template.
- **Do not** commit the user's API key anywhere. API keys stay only in `~/.env` (it is gitignored). They must **not** appear in `~/.claude/settings.json`.
- **Do not** start the proxy by hand with `node bin/cc-proxy.js` or similar — use `scripts/start-proxy.js` (step 5), which is idempotent and passes settings.json's plumbing env to the spawn. Raw starts risk duplicate proxies on the port.
- If `~/.claude/settings.json` does not exist, create it with just the `env` block above (and valid JSON structure).