Compare commits
19
Commits
7dfc428d63
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a3c6f86eb | ||
|
|
bb01418ce0 | ||
|
|
4091746d4e | ||
|
|
441166d003 | ||
|
|
b514e00745 | ||
|
|
51e42823d1 | ||
|
|
b5824c6611 | ||
|
|
a54f7bcb55 | ||
|
|
c5c3fc57d9 | ||
|
|
de9f10a8c2 | ||
|
|
83b59f34a1 | ||
|
|
cbd3b1d8a9 | ||
|
|
f776ce099e | ||
|
|
5bf5a6d36e | ||
|
|
d86a2ffe77 | ||
|
|
5a1af9f7e6 | ||
|
|
69c7810345 | ||
|
|
b60c00450c | ||
|
|
b32651fe00 |
@@ -0,0 +1,19 @@
|
||||
# Matches what is already in the tree. Everything is LF, the PowerShell and
|
||||
# .cmd files included: PowerShell 5.1 and cmd.exe both read LF, while a CRLF
|
||||
# shell script fails its #! line. The PowerShell files are also kept ASCII-only,
|
||||
# because 5.1 reads a .ps1 without a BOM in the ANSI codepage.
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{qml,js,json}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,8 @@
|
||||
# LF in every working tree, Windows checkouts included: a CRLF shell script
|
||||
# fails its #! line, and a Windows checkout is exactly where the repository's
|
||||
# shell scripts would otherwise pick CRLF up.
|
||||
* text=auto eol=lf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.ico binary
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
dist/
|
||||
*.bak
|
||||
*.bak.*
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to claude-mode. Versions follow [semver](https://semver.org):
|
||||
a minor version adds behaviour, a patch fixes it. The version lives in `VERSION`
|
||||
and in the bar widget's `omarchy/smoido.claude-mode/manifest.json`, and
|
||||
`scripts/bump-version.sh` changes both.
|
||||
|
||||
## 1.13.0 — 2026-09-15
|
||||
|
||||
Maintainability; no change in what the tool does.
|
||||
|
||||
- **Tests.** `scripts/test.sh` (or `make test`) runs static checks, a Python
|
||||
unit suite for `cm-json.py`, and sandboxed CLI suites against fake Ollama,
|
||||
LM Studio, keyed-gateway and proxy servers. `--windows [host]` adds the
|
||||
Windows suite on a remote host, in a `USERPROFILE` sandbox.
|
||||
- **Docs.** A short README; the detail lives in `docs/` (providers, live
|
||||
sessions, design, bar widget, architecture, development) and
|
||||
`CONTRIBUTING.md`. MIT licence.
|
||||
- **Modules.** The POSIX CLI is split into `linux/lib/*.sh`, the Windows script
|
||||
into `lib\*.ps1`, and the bar panel into one QML file per stage. The
|
||||
installers copy the modules and clear stale ones.
|
||||
- `scripts/bump-version.sh` keeps `VERSION` and the widget manifest in step.
|
||||
- Removed: the Arkylx Index packaging (`linux/bootstrap.sh`,
|
||||
`scripts/build-package.ps1`).
|
||||
- Fixed: the per-project session listing used `awk`, which minimal images lack.
|
||||
|
||||
## 1.12.0 — 2026-09-15
|
||||
|
||||
- **Providers are data.** Every gateway provider is an entry in `providers.json`:
|
||||
its endpoint, auth, model-list format, pre-switch probe, setup, doctor checks
|
||||
and presentation. The bash CLI, the Windows build and the bar widget all read
|
||||
it, and a provider that reuses existing behaviour needs no code.
|
||||
- **Ollama** mode: local server, one model for every tier, `doctor` reads the
|
||||
context each loaded model actually runs with (Ollama defaults to 4096 tokens
|
||||
and truncates silently).
|
||||
- **Custom** mode: any Anthropic-compatible endpoint; refused until it has an
|
||||
address; a proxy with no model list is not treated as broken.
|
||||
- Preflight refuses a preset with no server address. The Windows build gains
|
||||
the no-address and no-models guards it never had.
|
||||
|
||||
## 1.11.0 — 2026-09-15
|
||||
|
||||
- Preset editor in the bar panel: edit a tier from a searchable, cached model
|
||||
list; create, duplicate, rename and delete presets.
|
||||
- `claude-mode preset default <provider> <name>` chooses which preset
|
||||
`claude-mode <provider>` picks (`defaults.json`).
|
||||
- `models --preset/--refresh/--json`, and a model cache every fetch keeps fresh.
|
||||
- A tier edit to the active preset re-applies without the running-sessions
|
||||
prompt (it moves neither endpoint nor key).
|
||||
- Preset names are validated (`preset show ../../etc/passwd` used to print it).
|
||||
- The key helper reads the active preset in one open, so renaming it cannot
|
||||
catch a running session mid-lookup.
|
||||
- The panel moved to `KeyboardPanel`: no text field in the old popup could
|
||||
ever receive typing.
|
||||
|
||||
## 1.10.0 — 2026-09-14
|
||||
|
||||
- Dismiss a broken session (`repair-session --ignore`), and hide broken ones
|
||||
untouched for 7 days; hidden ones are always counted, never silently dropped.
|
||||
- The panel offers Ignore beside Repair, with a collapsed hidden section.
|
||||
|
||||
## 1.9.3 — 2026-09-13
|
||||
|
||||
- `set-key [ref] [key]` takes the ref it was given, and a key inline; the
|
||||
Windows build builds the SecureString for an inline key too.
|
||||
|
||||
## 1.9.2 — 2026-09-12
|
||||
|
||||
- Transcript lookup uses the project directory Claude Code actually uses: every
|
||||
non-alphanumeric character becomes a dash, and the physical path is slugged.
|
||||
|
||||
## 1.9.1 — 2026-09-12
|
||||
|
||||
- Runs on macOS: bash 3.2, arrow keys, honest session reporting without `/proc`.
|
||||
- The `apiKeyHelper` path is quoted, and the helper says why it failed.
|
||||
|
||||
## 1.9.0 — 2026-09-01
|
||||
|
||||
- One-line installs from the repository (`irm | iex`, `curl | bash`).
|
||||
|
||||
## 1.8.x and earlier — 2026-08-30 to 2026-08-31
|
||||
|
||||
- Linux port with a theme-aware TUI, switch preflight and session control.
|
||||
- Omarchy bar widget: shows the active provider and switches from the bar.
|
||||
- Asks before switching while sessions are live; repairs sessions a switch
|
||||
broke, keeping the cut turns as Markdown and handing them back.
|
||||
- One preset per mode, with a first-run setup that fills it in.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 smoido
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,18 @@
|
||||
# A thin wrapper. The real commands live in scripts/ and the installers, so
|
||||
# everything also works where make is not installed.
|
||||
.PHONY: test test-windows install install-widget version
|
||||
|
||||
test:
|
||||
scripts/test.sh
|
||||
|
||||
test-windows:
|
||||
scripts/test.sh --windows
|
||||
|
||||
install:
|
||||
bash linux/install.sh
|
||||
|
||||
install-widget:
|
||||
bash omarchy/install.sh
|
||||
|
||||
version:
|
||||
@cat VERSION
|
||||
@@ -1,757 +1,207 @@
|
||||
# claude-mode
|
||||
|
||||
Switch Claude Code system-wide between **Anthropic**, **OpenRouter**, **Z.AI**, and a
|
||||
local **LM Studio** server — 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.
|
||||
|
||||
Windows / PowerShell 5.1 with no external dependencies, and a POSIX port for
|
||||
Linux and macOS that needs only python3 — including a theme-aware TUI and an
|
||||
Omarchy bar widget. See [Linux / Omarchy](#linux--omarchy).
|
||||
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 # 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)
|
||||
```
|
||||
|
||||
Works for the CLI, the VS Code extension, and the desktop app from a single
|
||||
switch. Restart Claude Code afterwards — nothing else.
|
||||
## Install
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
claude-mode interactive menu
|
||||
claude-mode status active mode, preset, model map
|
||||
|
||||
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
|
||||
|
||||
claude-mode presets list presets (* = active)
|
||||
claude-mode preset show <name>
|
||||
claude-mode preset new <name> [from] copy an existing preset
|
||||
claude-mode preset set <name> <tier> <model-id>
|
||||
claude-mode preset all <name> <model-id> point every tier at one model
|
||||
claude-mode preset rm <name>
|
||||
|
||||
claude-mode set-key [ref] store a key (hidden prompt, DPAPI)
|
||||
claude-mode models [filter] models available from the active provider
|
||||
claude-mode doctor verify auth, endpoint, model ids, stray env vars
|
||||
|
||||
claude-mode preflight <mode> [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 <mode> --force switch even when preflight says no
|
||||
```
|
||||
|
||||
Omitting the preset uses a **fixed** per-provider default, not "most recently
|
||||
used" — so `claude-mode openrouter` always means `default`.
|
||||
|
||||
## Presets shipped
|
||||
|
||||
One per mode, so `claude-mode <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 | `z-ai/glm-5.3-flash` | `deepseek/deepseek-v4-flash-0731` | `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 = <contextTokens>
|
||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW = <contextTokens>
|
||||
```
|
||||
|
||||
| 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:
|
||||
|
||||
```powershell
|
||||
# edit ~/.claude-mode/presets/<name>.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:
|
||||
|
||||
```powershell
|
||||
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 setup lands 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:
|
||||
|
||||
```powershell
|
||||
claude-mode set-key lmstudio
|
||||
# then in ~/.claude-mode/presets/lmstudio.json:
|
||||
# "auth": { "mode": "vault", "keyRef": "lmstudio" }
|
||||
```
|
||||
|
||||
## 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.)
|
||||
|
||||
## Setup
|
||||
|
||||
One line, on any machine that can reach the repo (PowerShell 5.1 is fine):
|
||||
### Windows (PowerShell 5.1+)
|
||||
|
||||
```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
|
||||
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 does
|
||||
not load: `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`.
|
||||
|
||||
### Linux / macOS
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.nebulm.com/smoido/claude-mode/raw/branch/master/linux/install.sh | bash
|
||||
```
|
||||
|
||||
Or clone it — the normal path if you want to read the code and pull updates:
|
||||
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.
|
||||
|
||||
```powershell
|
||||
git clone https://git.nebulm.com/smoido/claude-mode.git
|
||||
cd claude-mode
|
||||
.\install.ps1
|
||||
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 # from a checkout
|
||||
omarchy restart shell # after an upgrade - see below
|
||||
```
|
||||
|
||||
All three install the same way: to `~/.claude-mode/` (ACL: you only), dropping
|
||||
`claude-mode.cmd` into `~/.local/bin` (already on your User PATH, next to
|
||||
`claude.exe`), and adding a marked block to
|
||||
`~/Documents/WindowsPowerShell/profile.ps1`.
|
||||
`~/.claude/settings.json` is **not** touched by the installer — only by an actual
|
||||
mode switch, which backs it up to `~/.claude-mode/backups/` first (last 20 kept).
|
||||
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).
|
||||
|
||||
Re-running any of them is a safe upgrade: scripts are overwritten, existing
|
||||
presets are kept unless `-Force` is passed.
|
||||
### Upgrading
|
||||
|
||||
If the profile doesn't load: `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`.
|
||||
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).
|
||||
|
||||
## Restarting sessions
|
||||
## First run
|
||||
|
||||
A switch **breaks** running sessions. This originally said it did not affect
|
||||
them; that was wrong, and the difference matters, because the two halves of the
|
||||
config behave differently.
|
||||
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:
|
||||
|
||||
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.
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
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:
|
||||
In a terminal, a switch that needs setup offers to run it there and then. Presets
|
||||
you build yourself never ask.
|
||||
|
||||
| switching to | what the running session gets |
|
||||
## Commands
|
||||
|
||||
```
|
||||
claude-mode interactive menu
|
||||
claude-mode status active mode, preset, model map
|
||||
claude-mode <mode> [preset] switch: anthropic, openrouter, zai, lmstudio, ollama, custom
|
||||
claude-mode <mode> --yes ...without asking about running sessions
|
||||
claude-mode <mode> --force ...even when preflight says it would not work
|
||||
|
||||
claude-mode setup <mode> first-run setup: key, server, models
|
||||
claude-mode preflight <mode> [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 preset show <name>
|
||||
claude-mode preset new <name> [from] copy a preset (default: `default`)
|
||||
claude-mode preset new <name> --provider <p> [--blank]
|
||||
copy that provider's default, or start empty
|
||||
claude-mode preset set <name> <tier> <model-id>
|
||||
claude-mode preset all <name> <model-id> point every tier at one model
|
||||
claude-mode preset url <name> <base-url> point a preset at another server
|
||||
claude-mode preset auth <name> none|key [ref]
|
||||
claude-mode preset rename <name> <new-name>
|
||||
claude-mode preset default [provider] [name|--clear]
|
||||
which preset `claude-mode <provider>` picks
|
||||
claude-mode preset rm <name>
|
||||
|
||||
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 <name> [--refresh|--json]
|
||||
|
||||
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 <id> | --unignore <id> | --ignored | --all [--max-age <days>]
|
||||
claude-mode repair [--all] strip [1m] tags from cached model ids
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
- **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 <ref>` 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 <mode>` says why, and what
|
||||
fixes it; the checks are listed in [docs/design.md](docs/design.md#refusing-a-switch-that-would-not-work).
|
||||
|
||||
## Documentation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `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.
|
||||
|
||||
### 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-<epoch>-<rand>` 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 <session-id> # show what it would cut
|
||||
claude-mode repair-session <session-id> --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.
|
||||
|
||||
`--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 `<session>.jsonl.pre-repair-backup-<stamp>`.
|
||||
2. **Writes the dropped turns out** as `<session>.recovered-<stamp>.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 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 now asks before it writes rather than reporting afterwards.
|
||||
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.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
~/.claude-mode/
|
||||
claude-mode.ps1 main script
|
||||
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
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
```bash
|
||||
cd claude-code-switcher
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
## First-run setup
|
||||
|
||||
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:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## 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 <mode>` |
|
||||
| `anthropic` | nothing to check | — |
|
||||
| `openrouter`, `zai` | a key exists for the preset's `keyRef`, and the helper is executable | `claude-mode set-key <ref>` |
|
||||
| `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 <mode> [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.
|
||||
|
||||
### 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.
|
||||
|
||||
## Sessions still on the old provider
|
||||
|
||||
A switch does not leave running sessions on the old provider — it breaks them.
|
||||
See [Restarting sessions](#restarting-sessions) for the mechanism: their
|
||||
endpoint is fixed at startup but their credential is re-fetched on a timer, so
|
||||
it switches under them and the endpoint they are still pointed at refuses it.
|
||||
Which is why this is a decision rather than a notification.
|
||||
|
||||
A switch stops and asks while any 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/<pid>/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 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.
|
||||
|
||||
```bash
|
||||
bash omarchy/install.sh
|
||||
```
|
||||
|
||||
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).
|
||||
Both hot-reload, so nothing needs restarting — except after a change to
|
||||
`Modes.js`, which the QML engine caches for the life of the process as a
|
||||
`.pragma library` (`omarchy restart shell`).
|
||||
|
||||
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
|
||||
<mode>…* 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. The gear on any LM Studio preset row opens the same form without
|
||||
waiting for a failure — per row, because two LM Studio presets can point at
|
||||
two different machines.
|
||||
|
||||
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.
|
||||
- **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 <section>`.
|
||||
|
||||
Uninstall:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.config/omarchy/plugins/smoido.claude-mode
|
||||
# then remove the {"id": "smoido.claude-mode"} entry from ~/.config/omarchy/shell.json
|
||||
```
|
||||
| [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
|
||||
|
||||
```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
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
## Licence
|
||||
|
||||
[MIT](LICENSE).
|
||||
|
||||
@@ -13,15 +13,31 @@
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Claude Code surfaces a failing helper as "your apiKeyHelper script is failing"
|
||||
# and shows this stream under /status. Exiting 1 in silence turns four different
|
||||
# faults into one indistinguishable message, so every failure path says which it
|
||||
# was. Success paths stay silent - stdout carries the key and nothing else.
|
||||
function Fail {
|
||||
param([string] $Message)
|
||||
[Console]::Error.WriteLine("claude-key-helper: $Message")
|
||||
exit 1
|
||||
}
|
||||
|
||||
try {
|
||||
$root = Join-Path $env:USERPROFILE '.claude-mode'
|
||||
$state = Get-Content -LiteralPath (Join-Path $root 'state.json') -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$root = Join-Path $env:USERPROFILE '.claude-mode'
|
||||
$statePath = Join-Path $root 'state.json'
|
||||
if (-not (Test-Path -LiteralPath $statePath)) {
|
||||
Fail "no state.json at $statePath - claude-mode is not installed for this Windows account. Run install.ps1"
|
||||
}
|
||||
$state = Get-Content -LiteralPath $statePath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
|
||||
if ($state.mode -eq 'anthropic') { exit 0 }
|
||||
if (-not $state.preset) { exit 0 }
|
||||
|
||||
$presetPath = Join-Path $root ('presets\' + $state.preset + '.json')
|
||||
if (-not (Test-Path -LiteralPath $presetPath)) { exit 1 }
|
||||
if (-not (Test-Path -LiteralPath $presetPath)) {
|
||||
Fail "state.json names preset '$($state.preset)' but $presetPath does not exist. Run: claude-mode presets"
|
||||
}
|
||||
$preset = Get-Content -LiteralPath $presetPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
|
||||
$authMode = 'vault'
|
||||
@@ -34,15 +50,24 @@ try {
|
||||
if ($authMode -ne 'vault') { exit 0 } # inline token: nothing for us to emit
|
||||
|
||||
$credPath = Join-Path $root ('vault\' + $keyRef + '.cred')
|
||||
if (-not (Test-Path -LiteralPath $credPath)) { exit 1 }
|
||||
if (-not (Test-Path -LiteralPath $credPath)) {
|
||||
Fail "no key stored for ref '$keyRef'. Run: claude-mode set-key $keyRef"
|
||||
}
|
||||
|
||||
$secure = ConvertTo-SecureString (Get-Content -LiteralPath $credPath -Raw).Trim()
|
||||
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
|
||||
try {
|
||||
$secure = ConvertTo-SecureString (Get-Content -LiteralPath $credPath -Raw).Trim()
|
||||
} catch {
|
||||
# DPAPI is CurrentUser scope, so this is almost always a key stored by a
|
||||
# different Windows account than the one Claude Code is running as -
|
||||
# typically set-key run from an elevated or "run as" shell.
|
||||
Fail "cannot decrypt $credPath as $env:USERDOMAIN\$env:USERNAME. The key is DPAPI-encrypted for whichever account stored it; re-run 'claude-mode set-key $keyRef' as this user, unelevated."
|
||||
}
|
||||
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
|
||||
try {
|
||||
[Console]::Out.Write([Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr))
|
||||
} finally {
|
||||
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
|
||||
}
|
||||
} catch {
|
||||
exit 1
|
||||
Fail $_.Exception.Message
|
||||
}
|
||||
|
||||
+153
-2171
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,203 @@
|
||||
# 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<br/>(bash on Linux/macOS,<br/>PowerShell on Windows)"]
|
||||
widget["bar widget<br/>(Omarchy, QML)"] -- runs --> cli
|
||||
cli -- all JSON through --> engine["cm-json.py<br/>(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/lib/*.sh`, `bin/cm-json.py`, `bin/cm-vault.sh`, `bin/claude-key-helper.sh` | the installer | — (Windows: `claude-mode.ps1` and `lib\*.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, lib/*.ps1 the Windows build: settings + dispatch, and its modules
|
||||
install.ps1, profile-snippet.ps1 its installer, and the block it adds to the PowerShell profile
|
||||
bin/ the Windows key helper
|
||||
linux/ the POSIX port: claude-mode (settings + dispatch),
|
||||
lib/*.sh (its modules), cm-json.py, cm-vault.sh,
|
||||
claude-key-helper.sh, install.sh
|
||||
omarchy/ the bar widget and its installer
|
||||
providers.json, presets/ shared by both builds, byte for byte
|
||||
scripts/ test.sh, bump-version.sh
|
||||
tests/ static, python, cli and windows suites
|
||||
docs/ this
|
||||
```
|
||||
|
||||
### The POSIX CLI's modules
|
||||
|
||||
`linux/claude-mode` holds the paths and flags, loads these in order, and
|
||||
dispatches the command line. It finds them — and `cm-json.py` and `cm-vault.sh`
|
||||
— beside itself, following the `~/.local/bin` symlink, so a checkout runs its own
|
||||
code rather than the installed version's.
|
||||
|
||||
| `linux/lib/` | what is in it |
|
||||
|---|---|
|
||||
| `output.sh` | the theme-derived colour palette, `ok`/`warn`/`err`, the banner, `usage` |
|
||||
| `core.sh` | state and preset helpers, the provider table (`prov_field`, `provider_resolve`), `resolve_preset` |
|
||||
| `preflight.sh` | `cm_preflight`, the server probe, `claude-mode preflight` |
|
||||
| `sessions.sh` | finding running sessions, `claude-mode sessions`, and the question a switch asks about them |
|
||||
| `switch.sh` | `set_mode`, settings backups, stray env vars, `health.json`, the guardrail check, `claude-mode repair` |
|
||||
| `catalogue.sh` | `provider_catalogue` and the model cache |
|
||||
| `commands.sh` | `status`, `presets`, `models`, `set-key` |
|
||||
| `doctor.sh` | `doctor` and its per-provider checks |
|
||||
| `presets.sh` | `claude-mode preset …` and re-applying the preset in use |
|
||||
| `menu.sh` | the interactive menu and its pickers |
|
||||
| `setup.sh` | `claude-mode setup` |
|
||||
| `repair.sh` | `claude-mode repair-session` and dismissals |
|
||||
|
||||
### The Windows script's modules
|
||||
|
||||
`claude-mode.ps1` holds the help block, the parameters, the paths and flags and
|
||||
the managed-key list, then dot-sources these from `lib\` beside it, in order, and
|
||||
dispatches. Inside a dot-sourced file `$PSScriptRoot` is `lib\`, so paths beside
|
||||
the main script go through `$script:Here`.
|
||||
|
||||
| `lib/` | what is in it |
|
||||
|---|---|
|
||||
| `providers.ps1` | reading `providers.json`; `Get-Provider`, `Resolve-ProviderId`, `Test-ProviderDoctor` |
|
||||
| `output.ps1` | `Write-Ok`/`Write-Warn2`/`Write-Err2`, mode colours, the banner, `Show-Usage` |
|
||||
| `files.ps1` | JSON read/write (5.1 has no `-AsHashtable`), `Protect-FileAcl` |
|
||||
| `core.ps1` | `state.json`, presets, `Resolve-PresetForProvider` |
|
||||
| `vault.ps1` | the DPAPI vault, `Get-PresetAuth` |
|
||||
| `switch.ps1` | `Set-ClaudeMode`, settings backups, the `apiKeyHelper` command line |
|
||||
| `guards.ps1` | the cost guard, the OpenRouter guardrail check, stale cached model ids, `repair` |
|
||||
| `health.ps1` | `health.json`; persistent environment variables that would override a switch |
|
||||
| `catalogue.ps1` | `Get-ProviderCatalogue`, LM Studio's model list and template check, the doctor model and context checks |
|
||||
| `commands.ps1` | `status`, `presets`, `preset`, `models`, `doctor` |
|
||||
| `menu.ps1` | the interactive menu, its pickers, interactive preset editing, `New-PresetScaffold` |
|
||||
|
||||
## 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 <mode> [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.<id>` → `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` | `lib/switch.ps1` `Set-ClaudeMode`; `$script:BaseManagedEnvKeys` in `claude-mode.ps1` |
|
||||
| a command's arguments | the dispatch at the end of `linux/claude-mode` | the dispatch at the end of `claude-mode.ps1` |
|
||||
| what refuses a switch | `linux/lib/preflight.sh` `cm_preflight` | the guards in `Set-ClaudeMode` |
|
||||
| how a model list is fetched | `linux/lib/catalogue.sh` `provider_catalogue` + a parser in `cm-json.py` | `lib/catalogue.ps1` `Get-ProviderCatalogue` |
|
||||
| a doctor check | `linux/lib/doctor.sh` | `lib/commands.ps1` `Invoke-Doctor`, `lib/catalogue.ps1` |
|
||||
| setup | `linux/lib/setup.sh` | — |
|
||||
| session detection | `linux/lib/sessions.sh` | — |
|
||||
| session repair | `linux/lib/repair.sh`; `cm-json.py` `cmd_scan_sessions`, `cmd_repair_session` | — |
|
||||
| key storage | `linux/cm-vault.sh` | `lib/vault.ps1` |
|
||||
| the bar icon and state | `omarchy/smoido.claude-mode/BarWidget.qml` | — |
|
||||
| what the panel does | `Panel.qml` (state, actions, the processes it runs) | — |
|
||||
| how one panel stage looks | its own file, below | — |
|
||||
|
||||
### The bar widget's files
|
||||
|
||||
All in `omarchy/smoido.claude-mode/`. The panel owns every piece of state and
|
||||
every action; each stage file only draws, from the `panel` it is handed.
|
||||
|
||||
| file | what is in it |
|
||||
|---|---|
|
||||
| `BarWidget.qml` | the icon, the state it watches (`health.json`, `state.json`, the model cache), the session scan, the provider lookup |
|
||||
| `Panel.qml` | the panel's state and actions, the CLI processes they run, the hero and the footer, and the stages in stacking order |
|
||||
| `ModeList.qml` | the modes to switch to, each unfolding its presets |
|
||||
| `BlockedStage.qml` | a refused switch, with the fix |
|
||||
| `ServerStage.qml` | a server provider's address and key |
|
||||
| `PresetStage.qml` | the preset editor |
|
||||
| `TierStage.qml` | picking one tier's model |
|
||||
| `NewPresetStage.qml`, `RenameStage.qml`, `DeleteStage.qml` | a preset's lifecycle |
|
||||
| `SessionsStage.qml` | the running-sessions question before a switch |
|
||||
| `BrokenSessions.qml`, `RepairStage.qml` | sessions a switch broke, and repairing one |
|
||||
| `ModelMap.qml` | the active tier-to-model map |
|
||||
| `PillButton.qml`, `BrandIcon.qml` | the panel's button, and a provider's mark |
|
||||
| `Modes.js` | fallback presentation for anthropic and an older `health.json` |
|
||||
|
||||
A stage cannot reach into another file by id, so where an action has to touch a
|
||||
stage's field — clearing the model search, focusing a name field — `Panel.qml`
|
||||
emits a signal and the stage's `Connections` block does it.
|
||||
@@ -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 <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 |
|
||||
+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.
|
||||
@@ -0,0 +1,121 @@
|
||||
# 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. In the
|
||||
`lib/*.ps1` modules `$PSScriptRoot` is `lib\`: reach anything beside
|
||||
`claude-mode.ps1` through `$script:Here`.
|
||||
|
||||
**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. Both installers fetch the repository's own archive (`install.ps1` piped
|
||||
through `iex`, `linux/install.sh` through `curl | bash`), so a push to `master`
|
||||
is the release.
|
||||
|
||||
Semver: a minor version adds behaviour, a patch fixes it.
|
||||
@@ -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-<epoch>-<rand>` 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 <session-id> # show what it would cut
|
||||
claude-mode repair-session <session-id> --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 <session-id> # stop counting it
|
||||
claude-mode repair-session --ignored # what is dismissed, and whether it is still broken
|
||||
claude-mode repair-session --unignore <session-id> # 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 `<session>.jsonl.pre-repair-backup-<stamp>`.
|
||||
2. **Writes the dropped turns out** as `<session>.recovered-<stamp>.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/<pid>/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.
|
||||
@@ -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 <provider>` 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 <provider>` 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 = <contextTokens>
|
||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW = <contextTokens>
|
||||
```
|
||||
|
||||
| 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
|
||||
<https://z.ai/manage-apikey/apikey-list>; `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 <model-id> # ...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 <id>` 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.
|
||||
+46
-4
@@ -18,6 +18,12 @@ param(
|
||||
Set-StrictMode -Version 1.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Piped in as `irm ... | iex` the installer itself is never subject to the
|
||||
# execution policy - but invoking the installed claude-mode.ps1 below is, and
|
||||
# on a stock Restricted machine that fails. Process scope lasts only for this
|
||||
# powershell.exe and does not weaken the machine or user policy.
|
||||
try { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force -ErrorAction Stop } catch { }
|
||||
|
||||
$repoUrl = 'https://git.nebulm.com/smoido/claude-mode'
|
||||
|
||||
# Running from a checkout, the source is the files next to this script. Piped
|
||||
@@ -79,13 +85,25 @@ try {
|
||||
|
||||
# --- 2. scripts -------------------------------------------------------------
|
||||
Copy-Item -LiteralPath (Join-Path $src 'claude-mode.ps1') -Destination $root -Force
|
||||
# The script's modules, dot-sourced from lib\ beside it. Cleared first, so a
|
||||
# module removed from the source does not linger and get loaded.
|
||||
$libDest = Join-Path $root 'lib'
|
||||
New-Item -ItemType Directory -Path $libDest -Force | Out-Null
|
||||
Remove-Item -Path (Join-Path $libDest '*.ps1') -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item -Path (Join-Path $src 'lib\*.ps1') -Destination $libDest -Force
|
||||
# claude-mode reads VERSION from its own directory to stamp health.json.
|
||||
if (Test-Path -LiteralPath (Join-Path $src 'VERSION')) {
|
||||
Copy-Item -LiteralPath (Join-Path $src 'VERSION') -Destination $root -Force
|
||||
}
|
||||
Copy-Item -LiteralPath (Join-Path $src 'bin\claude-key-helper.ps1') -Destination "$root\bin" -Force
|
||||
Copy-Item -LiteralPath (Join-Path $src 'bin\claude-key-helper.cmd') -Destination "$root\bin" -Force
|
||||
Write-Host ' ok copied claude-mode.ps1 + key helper' -ForegroundColor Green
|
||||
# The table of providers claude-mode can switch to. Always replaced, unlike
|
||||
# presets: it is the tool's own definition of each provider, not user data.
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $src 'providers.json'))) {
|
||||
throw 'providers.json missing from the payload - claude-mode cannot run without it'
|
||||
}
|
||||
Copy-Item -LiteralPath (Join-Path $src 'providers.json') -Destination $root -Force
|
||||
Write-Host ' ok copied claude-mode.ps1 + key helper + providers.json' -ForegroundColor Green
|
||||
|
||||
# --- 3. presets -------------------------------------------------------------
|
||||
foreach ($p in Get-ChildItem -LiteralPath (Join-Path $src 'presets') -Filter '*.json') {
|
||||
@@ -144,13 +162,37 @@ if ($current -match [regex]::Escape($startMark)) {
|
||||
Write-Host " ok appended claude-mode block to $profilePath" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# --- 7. execution policy check ---------------------------------------------
|
||||
# --- 7. execution policy ----------------------------------------------------
|
||||
# Without this the profile block above never loads, so `claude-mode` and the
|
||||
# `claude` wrapper simply do not exist in PowerShell. Offer to fix it rather
|
||||
# than printing a warning the user has to act on later.
|
||||
$pol = Get-ExecutionPolicy -Scope CurrentUser
|
||||
if ($pol -in @('Restricted', 'Undefined', 'AllSigned')) {
|
||||
Write-Host " warn CurrentUser execution policy is '$pol'; the profile will not load." -ForegroundColor Yellow
|
||||
Write-Host " Fix: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned" -ForegroundColor Yellow
|
||||
$effective = Get-ExecutionPolicy
|
||||
if ($effective -in @('Restricted', 'AllSigned')) {
|
||||
Write-Host ''
|
||||
Write-Host " warn execution policy is '$effective'; the profile block will not load," -ForegroundColor Yellow
|
||||
Write-Host " so 'claude-mode' will not be a command in PowerShell." -ForegroundColor Yellow
|
||||
$ans = Read-Host ' Set CurrentUser policy to RemoteSigned now? [Y/n]'
|
||||
if ($ans -eq '' -or $ans -match '^(y|yes)$') {
|
||||
try {
|
||||
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned -Force -ErrorAction Stop
|
||||
Write-Host ' ok CurrentUser execution policy set to RemoteSigned' -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host " warn could not set policy: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
Write-Host ' Run manually: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned' -ForegroundColor Yellow
|
||||
}
|
||||
} else {
|
||||
Write-Host ' Skipped. Run manually: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned' -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Expand-Archive can carry the mark-of-the-web onto extracted files, which
|
||||
# RemoteSigned then refuses. Clear it on what was just installed.
|
||||
Get-ChildItem -LiteralPath $root -Recurse -Filter '*.ps1' -ErrorAction SilentlyContinue |
|
||||
Unblock-File -ErrorAction SilentlyContinue
|
||||
|
||||
# --- 8. key ----------------------------------------------------------------
|
||||
if (-not $SkipKeyPrompt) {
|
||||
$vault = Join-Path $root 'vault\openrouter.cred'
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
# lib/catalogue.ps1 - provider model lists, and the checks doctor runs against them.
|
||||
#
|
||||
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
|
||||
# the settings at its top. Not meant to run on its own. ASCII only: Windows
|
||||
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
|
||||
# lib\, so paths beside the main script go through $script:Here.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider probes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# LM Studio's /v1/models lists only *loaded* instances, so an installed model
|
||||
# that has idle-unloaded disappears from it. /api/v0/models lists everything
|
||||
# with a load state, which is what we want: LM Studio JIT-loads on first
|
||||
# request, so "installed but not loaded" is fine - "not installed" is not.
|
||||
function Get-LmStudioModels {
|
||||
param([string] $BaseUrl)
|
||||
$base = $BaseUrl.TrimEnd('/')
|
||||
try {
|
||||
return @((Invoke-RestMethod -Uri "$base/api/v0/models" -TimeoutSec 10).data |
|
||||
ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
Id = $_.id
|
||||
State = $_.state
|
||||
Ctx = $(if ($_.PSObject.Properties.Name -contains 'max_context_length') { $_.max_context_length } else { $null })
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return @((Invoke-RestMethod -Uri "$base/v1/models" -TimeoutSec 10).data |
|
||||
ForEach-Object { [pscustomobject]@{ Id = $_.id; State = 'unknown'; Ctx = $null } })
|
||||
}
|
||||
}
|
||||
|
||||
# Some GGUF chat templates hard-assert message ordering, e.g.
|
||||
# {%- 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 with "Unable to generate parser for this template". Detect it up front
|
||||
# instead of letting the user hit a wall of [Server Error] spam.
|
||||
$script:TemplateAssertions = @(
|
||||
'System message must be at the beginning',
|
||||
'No user query found in messages'
|
||||
)
|
||||
|
||||
function Get-LmStudioTemplateReport {
|
||||
$cache = Join-Path $script:LmStudioDir '.internal\gguf-metadata-cache.json'
|
||||
if (-not (Test-Path -LiteralPath $cache)) { return @() }
|
||||
|
||||
$out = @()
|
||||
try {
|
||||
$j = Get-Content -LiteralPath $cache -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
foreach ($entry in $j.json.map) {
|
||||
$path = [string]$entry[0]
|
||||
$meta = $entry[1]
|
||||
if (-not $meta -or -not $meta.metadata) { continue }
|
||||
if ($meta.metadata.PSObject.Properties.Name -notcontains 'chatTemplate') { continue }
|
||||
$tpl = [string]$meta.metadata.chatTemplate
|
||||
if (-not $tpl) { continue }
|
||||
|
||||
$hits = @()
|
||||
foreach ($a in $script:TemplateAssertions) { if ($tpl.Contains($a)) { $hits += $a } }
|
||||
|
||||
# Derive the id LM Studio serves this file under: the repo folder
|
||||
# name, lowercased, minus the -GGUF suffix.
|
||||
$folder = Split-Path -Leaf (Split-Path -Parent ($path -replace '/', '\'))
|
||||
$key = ($folder -replace '(?i)-GGUF$', '').ToLower()
|
||||
|
||||
$out += [pscustomobject]@{ Key = $key; Path = $path; Assertions = $hits }
|
||||
}
|
||||
} catch { return @() }
|
||||
return $out
|
||||
}
|
||||
|
||||
function Test-LmStudioTemplate {
|
||||
param([string] $ModelId)
|
||||
$short = ($ModelId -split '/')[-1].ToLower()
|
||||
$rep = Get-LmStudioTemplateReport | Where-Object { $_.Key -eq $short } | Select-Object -First 1
|
||||
if (-not $rep) { return $null }
|
||||
return $rep
|
||||
}
|
||||
|
||||
# The credential a preset would send: its vault key, or its inline token.
|
||||
function Get-PresetToken {
|
||||
param($Preset)
|
||||
$auth = Get-PresetAuth $Preset
|
||||
if ([string]$auth['mode'] -eq 'vault') {
|
||||
$ref = $(if ($auth.Contains('keyRef') -and $auth['keyRef']) { [string]$auth['keyRef'] } else { 'openrouter' })
|
||||
return (Get-VaultKey $ref)
|
||||
}
|
||||
return [string]$auth['token']
|
||||
}
|
||||
|
||||
# One catalogue fetch for any provider, by its catalogue.kind in providers.json.
|
||||
# Every row comes back in one shape - Id, Ctx, State, InM, OutM, Note - so the
|
||||
# callers (models, the picker, doctor) never branch on the provider itself.
|
||||
function Get-ProviderCatalogue {
|
||||
param($Preset)
|
||||
$prov = Get-Provider ([string]$Preset['provider'])
|
||||
if (-not $prov) { return @() }
|
||||
$base = ([string]$Preset['baseUrl']).TrimEnd('/')
|
||||
switch ([string]$prov.catalogue.kind) {
|
||||
'openrouter' {
|
||||
return @((Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/models' -TimeoutSec 30).data | ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
Id = $_.id; Ctx = $_.context_length; State = $null; Note = ''
|
||||
InM = $(if ($_.pricing -and $_.pricing.prompt) { [math]::Round([double]$_.pricing.prompt * 1e6, 3) } else { $null })
|
||||
OutM = $(if ($_.pricing -and $_.pricing.completion) { [math]::Round([double]$_.pricing.completion * 1e6, 3) } else { $null })
|
||||
}
|
||||
})
|
||||
}
|
||||
'lmstudio' {
|
||||
return @(Get-LmStudioModels $base | ForEach-Object {
|
||||
[pscustomobject]@{ Id = $_.Id; Ctx = $_.Ctx; State = $_.State; InM = $null; OutM = $null; Note = '' }
|
||||
})
|
||||
}
|
||||
'ollama' {
|
||||
$h = @{}
|
||||
$tok = Get-PresetToken $Preset
|
||||
if ($tok) { $h['Authorization'] = "Bearer $tok" }
|
||||
return @((Invoke-RestMethod -Uri "$base/api/tags" -Headers $h -TimeoutSec 10).models | ForEach-Object {
|
||||
$d = $_.details
|
||||
$note = $(if ($d) { (@($d.parameter_size, $d.quantization_level) | Where-Object { $_ }) -join ' ' } else { '' })
|
||||
[pscustomobject]@{ Id = $_.name; Ctx = $null; State = $null; InM = $null; OutM = $null; Note = $note }
|
||||
})
|
||||
}
|
||||
'openai' {
|
||||
# Both header styles: a proxy in front of Anthropic wants x-api-key,
|
||||
# one in front of anything else wants Bearer.
|
||||
$h = @{ 'anthropic-version' = '2023-06-01' }
|
||||
$tok = Get-PresetToken $Preset
|
||||
if ($tok) { $h['Authorization'] = "Bearer $tok"; $h['x-api-key'] = $tok }
|
||||
return @((Invoke-RestMethod -Uri "$base/v1/models" -Headers $h -TimeoutSec 15).data | ForEach-Object {
|
||||
[pscustomobject]@{ Id = $_.id; Ctx = $null; State = $null; InM = $null; OutM = $null; Note = '' }
|
||||
})
|
||||
}
|
||||
'static' {
|
||||
return @($prov.catalogue.static | ForEach-Object {
|
||||
[pscustomobject]@{ Id = $_.id; Ctx = $null; State = $null; InM = $null; OutM = $null; Note = [string]$_.note }
|
||||
})
|
||||
}
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
# The preset's model ids against what the provider actually offers. A server
|
||||
# provider that does not answer is a failure; a hosted catalogue that cannot be
|
||||
# fetched is only a warning, since the endpoint may be fine regardless.
|
||||
function Test-PresetCatalogue {
|
||||
param([string] $Mode, $Preset)
|
||||
$prov = Get-Provider $Mode
|
||||
$kind = [string]$prov.catalogue.kind
|
||||
$title = [string]$prov.title
|
||||
$base = ([string]$Preset['baseUrl']).TrimEnd('/')
|
||||
$server = [bool]$prov.server.editable
|
||||
|
||||
$cat = @()
|
||||
try { $cat = @(Get-ProviderCatalogue $Preset) } catch { $cat = @() }
|
||||
if ($cat.Count -eq 0) {
|
||||
if ($server -and [string]$prov.server.probe -eq 'lenient') {
|
||||
Write-Warn2 "$title at $base lists no models - fine for a proxy, but the ids below cannot be checked"
|
||||
} elseif ($server) {
|
||||
$start = [string]$prov.server.start
|
||||
Write-Err2 ("$title not reachable at $base" + $(if ($start) { " - $start" } else { '' }))
|
||||
} else {
|
||||
Write-Warn2 "could not fetch the $title model list"
|
||||
}
|
||||
return
|
||||
}
|
||||
if ($server) { Write-Ok "$title reachable at $base ($($cat.Count) models)" }
|
||||
|
||||
$declared = $(if ($Preset.Contains('contextTokens') -and $Preset['contextTokens']) { [int]$Preset['contextTokens'] } else { 0 })
|
||||
$tpl = $(if (Test-ProviderDoctor $Mode 'lmstudio-templates') { Get-LmStudioTemplateReport } else { @() })
|
||||
$warned = @{} # a one-for-all preset names one model four times; say things about it once
|
||||
|
||||
foreach ($t in $script:Tiers) {
|
||||
if (-not ($Preset['models'].Contains($t) -and $Preset['models'][$t])) { continue }
|
||||
$id = [string]$Preset['models'][$t]
|
||||
$m = $cat | Where-Object { $_.Id -eq $id } | Select-Object -First 1
|
||||
# Ollama lists every model with its tag; a bare name means :latest.
|
||||
if (-not $m -and $kind -eq 'ollama' -and $id -notlike '*:*') {
|
||||
$m = $cat | Where-Object { $_.Id -eq "${id}:latest" } | Select-Object -First 1
|
||||
}
|
||||
if (-not $m) {
|
||||
# A fixed list is documentation, not the provider's word.
|
||||
if ($kind -eq 'static') { Write-Ok ("{0,-6} {1} (not in the documented list)" -f $t, $id) }
|
||||
else { Write-Err2 "$t model NOT available from ${title}: $id" }
|
||||
continue
|
||||
}
|
||||
switch ($kind) {
|
||||
'lmstudio' {
|
||||
if ($m.State -eq 'loaded') { Write-Ok ("{0,-6} {1} [loaded, ctx {2}]" -f $t, $id, $m.Ctx) }
|
||||
else { Write-Ok ("{0,-6} {1} [{2} - LM Studio will JIT-load it on first request, ctx {3}]" -f $t, $id, $m.State, $m.Ctx) }
|
||||
if (-not $warned.ContainsKey($id)) {
|
||||
$warned[$id] = $true
|
||||
if ($m.Ctx -and [int]$m.Ctx -lt 25000) { Write-Warn2 "$id context is $($m.Ctx); LM Studio recommends >25k for Claude Code." }
|
||||
if ($declared -and $m.Ctx -and [int]$m.Ctx -lt $declared) {
|
||||
Write-Warn2 ("declared contextTokens {0:N0} exceeds {1}'s {2:N0} - lower it." -f $declared, $id, [int]$m.Ctx)
|
||||
}
|
||||
$short = ($id -split '/')[-1].ToLower()
|
||||
$risk = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1
|
||||
if ($risk -and $risk.Assertions.Count -gt 0) {
|
||||
Write-Warn2 "$id chat template hard-asserts message order ($($risk.Assertions -join '; '))."
|
||||
Write-Warn2 " This can surface as: [Server Error] 'Unable to generate parser for this template'."
|
||||
Write-Warn2 " If you hit that, switch to a model without this flag - see 'claude-mode models'."
|
||||
}
|
||||
}
|
||||
}
|
||||
'openrouter' { Write-Ok ("{0,-6} {1} [ctx {2:N0}]" -f $t, $id, [int]$m.Ctx) }
|
||||
'ollama' { Write-Ok ("{0,-6} {1} [{2}]" -f $t, $id, $m.Note) }
|
||||
default { Write-Ok ("{0,-6} {1}" -f $t, $id) }
|
||||
}
|
||||
}
|
||||
|
||||
if ($kind -eq 'openrouter') { Test-ContextWindow -Preset $Preset -Catalogue $cat }
|
||||
elseif ($declared) { Write-Ok ("declared context window: {0:N0} tokens" -f $declared) }
|
||||
else { Write-Warn2 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early.' }
|
||||
}
|
||||
|
||||
# Ollama sets the context window on the server, not per request from Claude
|
||||
# Code: 4096 tokens unless `ollama serve` runs with OLLAMA_CONTEXT_LENGTH, and
|
||||
# anything past it is cut off without an error. contextTokens only tells Claude
|
||||
# Code what to expect, so say what the server is actually running where that
|
||||
# can be seen, and what to set where it cannot.
|
||||
function Test-OllamaContext {
|
||||
param($Preset)
|
||||
if (-not ($Preset.Contains('contextTokens') -and $Preset['contextTokens'])) { return }
|
||||
$declared = [int]$Preset['contextTokens']
|
||||
$base = ([string]$Preset['baseUrl']).TrimEnd('/')
|
||||
$ids = @()
|
||||
foreach ($t in $script:Tiers) {
|
||||
$v = [string]$Preset['models'][$t]
|
||||
if ($v -and $ids -notcontains $v) { $ids += $v }
|
||||
}
|
||||
$ps = $null
|
||||
try { $ps = Invoke-RestMethod -Uri "$base/api/ps" -TimeoutSec 5 } catch { }
|
||||
|
||||
$seen = $false; $short = $false
|
||||
foreach ($id in $ids) {
|
||||
try {
|
||||
$show = Invoke-RestMethod -Uri "$base/api/show" -Method Post -ContentType 'application/json' `
|
||||
-Body (@{ model = $id } | ConvertTo-Json) -TimeoutSec 8
|
||||
$max = $null
|
||||
if ($show.model_info) {
|
||||
foreach ($prop in $show.model_info.PSObject.Properties) {
|
||||
if ($prop.Name -like '*.context_length') { $max = [int]$prop.Value; break }
|
||||
}
|
||||
}
|
||||
if ($max -and $max -lt $declared) { Write-Warn2 "$id supports at most $max tokens, below the declared $declared - lower contextTokens" }
|
||||
} catch { }
|
||||
|
||||
$want = @($id)
|
||||
if ($id -notlike '*:*') { $want += "${id}:latest" }
|
||||
$live = $null
|
||||
if ($ps -and $ps.models) {
|
||||
foreach ($m in $ps.models) {
|
||||
if ((($want -contains [string]$m.name) -or ($want -contains [string]$m.model)) -and $m.context_length) {
|
||||
$live = [int]$m.context_length; break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($null -eq $live) { continue }
|
||||
$seen = $true
|
||||
if ($live -lt $declared) {
|
||||
$short = $true
|
||||
Write-Warn2 "$id is loaded with a $live-token context, below the declared $declared - requests past it are cut off"
|
||||
} else {
|
||||
Write-Ok "$id is loaded with a $live-token context"
|
||||
}
|
||||
}
|
||||
if (-not $seen) {
|
||||
Write-Warn2 "none of these models is loaded, so the server's context window cannot be checked"
|
||||
Write-Host " Ollama defaults to 4096 tokens; run it with OLLAMA_CONTEXT_LENGTH=$declared or requests past that are cut off silently"
|
||||
} elseif ($short) {
|
||||
Write-Host " restart it with OLLAMA_CONTEXT_LENGTH=$declared (or lower contextTokens to match)"
|
||||
}
|
||||
}
|
||||
|
||||
# CLAUDE_CODE_MAX_CONTEXT_TOKENS is a single global value, but each tier can
|
||||
# point at a model with a different window. Declaring more context than a tier's
|
||||
# model actually has means requests on that tier can overflow, so name the
|
||||
# offenders rather than silently trusting the preset.
|
||||
function Test-ContextWindow {
|
||||
param($Preset, $Catalogue)
|
||||
|
||||
if (-not ($Preset.Contains('contextTokens') -and $Preset['contextTokens'])) {
|
||||
Write-Warn2 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early.'
|
||||
Write-Warn2 " Fix: add \"contextTokens\": 1000000 to $((Get-PresetPath ([string](Get-State)['preset']))) "
|
||||
return
|
||||
}
|
||||
|
||||
$declared = [int]$Preset['contextTokens']
|
||||
Write-Ok ("declared context window: {0:N0} tokens" -f $declared)
|
||||
|
||||
foreach ($t in $script:Tiers) {
|
||||
if (-not $Preset['models'].Contains($t)) { continue }
|
||||
$id = [string]$Preset['models'][$t]
|
||||
$m = $Catalogue | Where-Object { $_.Id -eq $id } | Select-Object -First 1
|
||||
if (-not $m -or -not $m.Ctx) { continue }
|
||||
if ([int]$m.Ctx -lt $declared) {
|
||||
Write-Warn2 ("{0} model {1} only has {2:N0} ctx, below the declared {3:N0}." -f $t, $id, [int]$m.Ctx, $declared)
|
||||
if ($t -eq 'haiku') {
|
||||
Write-Warn2 ' haiku only runs short background tasks, so this is usually harmless.'
|
||||
} else {
|
||||
Write-Warn2 ' This tier can overflow. Lower contextTokens or pick a bigger model.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
# lib/commands.ps1 - status, presets, preset, models and doctor.
|
||||
#
|
||||
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
|
||||
# the settings at its top. Not meant to run on its own. ASCII only: Windows
|
||||
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
|
||||
# lib\, so paths beside the main script go through $script:Here.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Invoke-Status {
|
||||
$state = Get-State
|
||||
$mode = [string]$state['mode']
|
||||
|
||||
Write-Head "claude-mode: $mode"
|
||||
|
||||
if ($mode -eq 'anthropic') {
|
||||
Write-Host ' native Anthropic login/subscription; no gateway env, no apiKeyHelper'
|
||||
} else {
|
||||
$name = [string]$state['preset']
|
||||
Write-Host " preset: $name"
|
||||
try {
|
||||
$preset = Get-Preset $name
|
||||
Write-Host " baseUrl: $($preset['baseUrl'])"
|
||||
foreach ($tier in $script:Tiers) {
|
||||
if ($preset['models'].Contains($tier)) {
|
||||
Write-Host (" {0,-9} {1}" -f ($tier + ':'), $preset['models'][$tier])
|
||||
}
|
||||
}
|
||||
if ($preset.Contains('subagentModel')) { Write-Host " subagent: $($preset['subagentModel'])" }
|
||||
if ($preset.Contains('contextTokens') -and $preset['contextTokens']) {
|
||||
Write-Host (" context: {0:N0} tokens" -f [int]$preset['contextTokens'])
|
||||
}
|
||||
$auth = Get-PresetAuth $preset
|
||||
if ([string]$auth['mode'] -eq 'vault') {
|
||||
$ref = [string]$auth['keyRef']
|
||||
Write-Host " key: $ref -> $(Format-KeyMask (Get-VaultKey $ref))"
|
||||
} else {
|
||||
Write-Host " token: $($auth['token']) (inline, not a secret)"
|
||||
}
|
||||
} catch {
|
||||
Write-Err2 $_.Exception.Message
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ' settings.json managed keys:'
|
||||
$settings = Read-JsonFile $script:Settings
|
||||
$found = 0
|
||||
if ($settings) {
|
||||
if ($settings.Contains('apiKeyHelper')) { Write-Host " apiKeyHelper = $($settings['apiKeyHelper'])"; $found++ }
|
||||
if ($settings.Contains('env') -and $settings['env'] -is [System.Collections.IDictionary]) {
|
||||
$keys = @($script:BaseManagedEnvKeys) + @($state['writtenEnvKeys']) | Sort-Object -Unique
|
||||
foreach ($k in $keys) {
|
||||
if ($k -and $settings['env'].Contains($k)) {
|
||||
Write-Host " $k = $($settings['env'][$k])"
|
||||
$found++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($found -eq 0) { Write-Host ' (none - clean)' }
|
||||
|
||||
Write-Host ''
|
||||
[void](Test-StrayEnvVars -Mode $mode)
|
||||
}
|
||||
|
||||
function Invoke-Presets {
|
||||
Write-Head 'presets'
|
||||
$state = Get-State
|
||||
foreach ($n in Get-PresetNames) {
|
||||
$mark = ' '
|
||||
if ($n -eq [string]$state['preset'] -and [string]$state['mode'] -ne 'anthropic') { $mark = '*' }
|
||||
try {
|
||||
$p = Get-Preset $n
|
||||
$bits = @()
|
||||
foreach ($t in $script:Tiers) {
|
||||
if ($p['models'].Contains($t)) { $bits += "$t=$($p['models'][$t])" }
|
||||
}
|
||||
Write-Host (" {0} {1,-18} [{2,-10}] {3}" -f $mark, $n, $p['provider'], ($bits -join ' '))
|
||||
} catch {
|
||||
Write-Host (" {0} {1,-18} <unreadable>" -f $mark, $n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PresetCmd {
|
||||
# NOT named $Args - that collides with PowerShell's automatic variable.
|
||||
param([string[]] $Argv)
|
||||
if (-not $Argv -or $Argv.Count -lt 1) { Show-Usage; return }
|
||||
$sub = $Argv[0]
|
||||
$name = if ($Argv.Count -ge 2) { $Argv[1] } else { $null }
|
||||
|
||||
switch ($sub) {
|
||||
'show' {
|
||||
if (-not $name) { throw 'usage: claude-mode preset show <name>' }
|
||||
(Get-Preset $name) | ConvertTo-Json -Depth 20 | Write-Host
|
||||
}
|
||||
'new' {
|
||||
if (-not $name) { throw 'usage: claude-mode preset new <name> [copy-from]' }
|
||||
$path = Get-PresetPath $name
|
||||
if (Test-Path -LiteralPath $path) { throw "preset '$name' already exists" }
|
||||
$from = if ($Argv.Count -ge 3) { $Argv[2] } else { 'default' }
|
||||
$base = Get-Preset $from
|
||||
$base['description'] = "copy of '$from'"
|
||||
Write-JsonFile $path $base
|
||||
Write-Ok "created $path from '$from' - edit with: claude-mode preset set $name <tier> <model-id>"
|
||||
}
|
||||
'rm' {
|
||||
if (-not $name) { throw 'usage: claude-mode preset rm <name>' }
|
||||
$path = Get-PresetPath $name
|
||||
if (-not (Test-Path -LiteralPath $path)) { throw "preset '$name' not found" }
|
||||
$state = Get-State
|
||||
if ([string]$state['preset'] -eq $name -and [string]$state['mode'] -ne 'anthropic') {
|
||||
throw "preset '$name' is active. Switch away first (claude-mode anthropic)."
|
||||
}
|
||||
Remove-Item -LiteralPath $path -Force
|
||||
Write-Ok "deleted preset '$name'"
|
||||
}
|
||||
'set' {
|
||||
if ($Argv.Count -lt 4) { throw 'usage: claude-mode preset set <name> <tier> <model-id>' }
|
||||
Set-PresetTier -Name $name -Tier $Argv[2].ToLower() -Model $Argv[3]
|
||||
}
|
||||
'all' {
|
||||
if ($Argv.Count -lt 3) { throw 'usage: claude-mode preset all <name> <model-id>' }
|
||||
$model = $Argv[2]
|
||||
$preset = Get-Preset $name
|
||||
if (-not $preset.Contains('models')) { $preset['models'] = [ordered]@{} }
|
||||
foreach ($t in $script:Tiers) { $preset['models'][$t] = $model }
|
||||
$preset['subagentModel'] = $model
|
||||
Write-JsonFile (Get-PresetPath $name) $preset
|
||||
Write-Ok "$name : all tiers + subagent -> $model"
|
||||
Update-ActivePreset $name
|
||||
}
|
||||
default { Show-Usage }
|
||||
}
|
||||
}
|
||||
|
||||
function Set-PresetTier {
|
||||
param([string] $Name, [string] $Tier, [string] $Model)
|
||||
$preset = Get-Preset $Name
|
||||
|
||||
if ($Tier -eq 'subagent') {
|
||||
$preset['subagentModel'] = $Model
|
||||
} elseif ($script:Tiers -contains $Tier) {
|
||||
if (-not $preset.Contains('models')) { $preset['models'] = [ordered]@{} }
|
||||
$preset['models'][$Tier] = $Model
|
||||
} else {
|
||||
throw "unknown tier '$Tier' (use: opus | sonnet | haiku | fable | subagent)"
|
||||
}
|
||||
|
||||
Write-JsonFile (Get-PresetPath $Name) $preset
|
||||
Write-Ok "$Name : $Tier -> $Model"
|
||||
Update-ActivePreset $Name
|
||||
}
|
||||
|
||||
# Editing the live preset re-applies it, so there is no second command to run.
|
||||
function Update-ActivePreset {
|
||||
param([string] $Name)
|
||||
$state = Get-State
|
||||
if ([string]$state['mode'] -ne 'anthropic' -and [string]$state['preset'] -eq $Name) {
|
||||
Write-Host ' re-applying active preset...'
|
||||
Set-ClaudeMode -Mode ([string]$state['mode']) -PresetName $Name
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Models {
|
||||
param([string] $Filter)
|
||||
$state = Get-State
|
||||
$mode = [string]$state['mode']
|
||||
|
||||
# On anthropic, OpenRouter's catalogue is the one worth browsing, as before.
|
||||
$preset = $(if ($mode -ne 'anthropic') { Get-Preset ([string]$state['preset']) } else { [ordered]@{ provider = 'openrouter'; baseUrl = '' } })
|
||||
$prov = Get-Provider ([string]$preset['provider'])
|
||||
$kind = [string]$prov.catalogue.kind
|
||||
switch ($kind) {
|
||||
'static' { Write-Head "$($prov.title) models (from its docs - no public catalogue endpoint)" }
|
||||
'openrouter' { Write-Head 'fetching https://openrouter.ai/api/v1/models ...' }
|
||||
default { Write-Head "models on the $($prov.title) server at $($preset['baseUrl'])" }
|
||||
}
|
||||
|
||||
$tpl = $(if ($kind -eq 'lmstudio') { Get-LmStudioTemplateReport } else { @() })
|
||||
$rows = foreach ($m in (Get-ProviderCatalogue $preset | Sort-Object Id)) {
|
||||
if ($Filter -and $m.Id -notlike "*$Filter*") { continue }
|
||||
if ($kind -eq 'openrouter') {
|
||||
[pscustomobject]@{ Id = $m.Id; Ctx = $m.Ctx; 'In/M$' = $m.InM; 'Out/M$' = $m.OutM }
|
||||
} elseif ($kind -eq 'lmstudio') {
|
||||
$short = ($m.Id -split '/')[-1].ToLower()
|
||||
$t = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1
|
||||
$flag = $(if ($t -and $t.Assertions.Count -gt 0) { 'TEMPLATE RISK' } else { '' })
|
||||
[pscustomobject]@{ Id = $m.Id; State = $m.State; Ctx = $m.Ctx; Note = $flag }
|
||||
} else {
|
||||
[pscustomobject]@{ Id = $m.Id; Note = $m.Note }
|
||||
}
|
||||
}
|
||||
$rows | Format-Table -AutoSize
|
||||
|
||||
if ($kind -eq 'lmstudio') {
|
||||
Write-Host " 'TEMPLATE RISK' = the model's chat template hard-asserts message order,"
|
||||
Write-Host " which can break tool-call parser generation. Prefer an unflagged model."
|
||||
}
|
||||
if ($kind -eq 'static' -and $prov.catalogue.docs) { Write-Host " Full list: $($prov.catalogue.docs)" }
|
||||
}
|
||||
|
||||
function Invoke-Doctor {
|
||||
$state = Get-State
|
||||
$mode = [string]$state['mode']
|
||||
Write-Head "doctor - mode '$mode'"
|
||||
|
||||
try {
|
||||
$liveSettings = Read-JsonFile $script:Settings
|
||||
Write-Ok 'settings.json parses'
|
||||
} catch {
|
||||
Write-Err2 "settings.json does not parse: $($_.Exception.Message)"
|
||||
return
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $script:HelperCmd) { Write-Ok "key helper present: $($script:HelperCmd)" }
|
||||
else { Write-Err2 "key helper missing: $($script:HelperCmd)" }
|
||||
|
||||
if ($mode -ne 'anthropic') {
|
||||
$preset = Get-Preset ([string]$state['preset'])
|
||||
$auth = Get-PresetAuth $preset
|
||||
$base = ([string]$preset['baseUrl']).TrimEnd('/')
|
||||
|
||||
if ([string]$auth['mode'] -eq 'vault') {
|
||||
$keyRef = [string]$auth['keyRef']
|
||||
$key = Get-VaultKey $keyRef
|
||||
if ($key) {
|
||||
Write-Ok "vault '$keyRef' decrypts -> $(Format-KeyMask $key)"
|
||||
if ($key -match '\s' -or $key -like 'claude-mode*') {
|
||||
Write-Err2 "the stored '$keyRef' value looks like a pasted command, not a key. Re-run: claude-mode set-key $keyRef"
|
||||
}
|
||||
}
|
||||
else { Write-Err2 "vault '$keyRef' missing or undecryptable. Run: claude-mode set-key $keyRef" }
|
||||
|
||||
# The stored string is what Claude Code hands to a shell, and a
|
||||
# path this process can quote correctly is not evidence that the
|
||||
# recorded one parses. Check the value, then run that value.
|
||||
$stored = ''
|
||||
if ($liveSettings.Contains('apiKeyHelper')) { $stored = [string]$liveSettings['apiKeyHelper'] }
|
||||
$expected = Get-HelperCommandLine
|
||||
$reSwitch = "claude-mode $mode $([string]$state['preset'])"
|
||||
|
||||
if (-not $stored) {
|
||||
Write-Err2 "settings.json has no apiKeyHelper. Run: $reSwitch"
|
||||
} elseif ($stored -ne $expected) {
|
||||
Write-Err2 "apiKeyHelper reads $stored"
|
||||
Write-Err2 " but should read $expected - run: $reSwitch"
|
||||
} else {
|
||||
Write-Ok "apiKeyHelper wired as $stored"
|
||||
}
|
||||
|
||||
if ($stored) {
|
||||
# Run it through cmd the way a shell would, via a batch file, so
|
||||
# PowerShell's own native-argument quoting cannot paper over a
|
||||
# value that a real shell would split.
|
||||
$probe = Join-Path $env:TEMP ('cm-helper-probe-' + [IO.Path]::GetRandomFileName().Replace('.', '') + '.cmd')
|
||||
try {
|
||||
Set-Content -LiteralPath $probe -Value ("@echo off`r`n" + $stored) -Encoding ASCII
|
||||
$out = (& cmd.exe /c "`"$probe`"" 2>&1 | Out-String).Trim()
|
||||
if ($out -and $key -and $out -eq $key) { Write-Ok 'apiKeyHelper emits the correct key' }
|
||||
elseif ($out -match '^\S+$') {
|
||||
# One unbroken token: a credential, just the wrong one. Never echo it.
|
||||
Write-Err2 "apiKeyHelper output does not match vault key (got: $(Format-KeyMask $out))"
|
||||
}
|
||||
elseif ($out) { Write-Err2 "apiKeyHelper failed: $out" }
|
||||
else { Write-Err2 'apiKeyHelper produced no output' }
|
||||
} catch {
|
||||
Write-Err2 "apiKeyHelper failed to run: $($_.Exception.Message)"
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
if ($key -and (Test-ProviderDoctor $mode 'openrouter-key')) {
|
||||
try {
|
||||
$r = Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/key' -Headers @{ Authorization = "Bearer $key" } -TimeoutSec 20
|
||||
Write-Ok "OpenRouter key valid (label: $($r.data.label))"
|
||||
if ($null -ne $r.data.limit) {
|
||||
Write-Ok ("spend {0:N2} of {1:N2} limit ({2}), {3:N2} remaining" -f `
|
||||
[double]$r.data.usage, [double]$r.data.limit, $r.data.limit_reset, [double]$r.data.limit_remaining)
|
||||
} else {
|
||||
Write-Ok ("spend {0:N2} this month (no key limit set)" -f [double]$r.data.usage)
|
||||
}
|
||||
} catch {
|
||||
Write-Err2 "OpenRouter rejected the key: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# The guardrail is not exposed by /api/v1/key, so it can only be
|
||||
# established by trying a blocked model.
|
||||
Show-GuardrailStatus -Mode $mode -Key $key
|
||||
}
|
||||
|
||||
if ($key -and (Test-ProviderDoctor $mode 'message-check')) {
|
||||
# No key-info endpoint; the cheapest real check is a 1-token
|
||||
# message against the Anthropic-compatible surface.
|
||||
try {
|
||||
$body = @{
|
||||
model = [string]$preset['models']['haiku']
|
||||
max_tokens = 1
|
||||
messages = @(@{ role = 'user'; content = 'hi' })
|
||||
} | ConvertTo-Json -Depth 6
|
||||
[void](Invoke-RestMethod -Uri "$base/v1/messages" -Method Post -Body $body `
|
||||
-ContentType 'application/json' -TimeoutSec 45 `
|
||||
-Headers @{ 'x-api-key' = $key; 'Authorization' = "Bearer $key"; 'anthropic-version' = '2023-06-01' })
|
||||
Write-Ok "$((Get-Provider $mode).title) endpoint accepted the key ($base/v1/messages)"
|
||||
} catch {
|
||||
$detail = ''
|
||||
if ($_.ErrorDetails) { $detail = ($_.ErrorDetails.Message -replace '\s+', ' ') }
|
||||
Write-Err2 "$((Get-Provider $mode).title) request failed: $($_.Exception.Message) $detail"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Ok "inline token '$($auth['token'])' (no secret in settings.json)"
|
||||
}
|
||||
|
||||
# Which checks run is the provider's call, by name, in providers.json.
|
||||
if (Test-ProviderDoctor $mode 'catalogue-models') { Test-PresetCatalogue -Mode $mode -Preset $preset }
|
||||
if (Test-ProviderDoctor $mode 'ollama-context') { Test-OllamaContext -Preset $preset }
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
if ((Test-StaleModelSelections -Mode $mode) -eq 0 -and $mode -ne 'anthropic') {
|
||||
Write-Ok 'no cached Anthropic model ids'
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
$bad = Test-StrayEnvVars -Mode $mode
|
||||
if ($bad -gt 0) {
|
||||
Write-Host ''
|
||||
$ans = Read-Host 'Remove the User-scope overrides listed above? [y/N]'
|
||||
if ($ans -match '^[Yy]') { Repair-StrayEnvVars }
|
||||
} else {
|
||||
Write-Ok 'no persistent env-var overrides'
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
$exe = Get-Command claude.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($exe) {
|
||||
Write-Host " claude: $((& $exe.Source --version 2>&1 | Out-String).Trim()) [$($exe.Source)]"
|
||||
} else {
|
||||
Write-Warn2 'claude.exe not found on PATH'
|
||||
}
|
||||
|
||||
# Refresh the machine-readable state so a fleet reader sees doctor's findings
|
||||
# (notably guardrailStatus, which only a probe can establish).
|
||||
Write-HealthFile -Mode $mode -PresetName ([string]$state['preset'])
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
# lib/core.ps1 - state.json, presets, and which preset a mode resolves to.
|
||||
#
|
||||
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
|
||||
# the settings at its top. Not meant to run on its own. ASCII only: Windows
|
||||
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
|
||||
# lib\, so paths beside the main script go through $script:Here.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State / presets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Initialize-Root {
|
||||
foreach ($d in @($script:Root, $script:PresetDir, $script:VaultDir, $script:BackupDir, $script:BinDir)) {
|
||||
if (-not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-State {
|
||||
$s = Read-JsonFile $script:StatePath
|
||||
if ($null -eq $s) { $s = [ordered]@{} }
|
||||
if (-not $s.Contains('mode')) { $s['mode'] = 'anthropic' }
|
||||
if (-not $s.Contains('preset')) { $s['preset'] = '' }
|
||||
if (-not $s.Contains('writtenEnvKeys')) { $s['writtenEnvKeys'] = @() }
|
||||
return $s
|
||||
}
|
||||
|
||||
function Set-State {
|
||||
param([string] $Mode, [string] $PresetName, [string[]] $WrittenKeys)
|
||||
$s = Get-State
|
||||
$s['mode'] = $Mode
|
||||
$s['preset'] = $PresetName
|
||||
$s['writtenEnvKeys'] = @($WrittenKeys)
|
||||
$s['updated'] = (Get-Date).ToString('o')
|
||||
# Written by older builds that picked "most recently used" presets; the
|
||||
# per-provider default is fixed now, so nothing maintains this.
|
||||
if ($s.Contains('lastByProvider')) { $s.Remove('lastByProvider') }
|
||||
Write-JsonFile $script:StatePath $s
|
||||
}
|
||||
|
||||
function Get-PresetPath { param([string] $Name) return (Join-Path $script:PresetDir "$Name.json") }
|
||||
|
||||
function Get-Preset {
|
||||
param([string] $Name)
|
||||
$p = Read-JsonFile (Get-PresetPath $Name)
|
||||
if ($null -eq $p) { throw "preset '$Name' not found. Run: claude-mode presets" }
|
||||
if (-not $p.Contains('provider')) { $p['provider'] = 'openrouter' }
|
||||
return $p
|
||||
}
|
||||
|
||||
function Get-PresetNames {
|
||||
if (-not (Test-Path -LiteralPath $script:PresetDir)) { return @() }
|
||||
return @(Get-ChildItem -LiteralPath $script:PresetDir -Filter '*.json' |
|
||||
ForEach-Object { $_.BaseName } | Sort-Object)
|
||||
}
|
||||
|
||||
function Get-PresetNamesForProvider {
|
||||
param([string] $Provider)
|
||||
$out = @()
|
||||
foreach ($n in Get-PresetNames) {
|
||||
try { if ([string](Get-Preset $n)['provider'] -eq $Provider) { $out += $n } } catch { }
|
||||
}
|
||||
return $out
|
||||
}
|
||||
|
||||
function Resolve-PresetForProvider {
|
||||
param([string] $Provider, [string] $Requested)
|
||||
|
||||
if ($Requested) {
|
||||
$p = Get-Preset $Requested
|
||||
if ([string]$p['provider'] -ne $Provider) {
|
||||
throw "preset '$Requested' is a '$($p['provider'])' preset, not '$Provider'"
|
||||
}
|
||||
return $Requested
|
||||
}
|
||||
|
||||
# Fixed per-provider default, so `claude-mode openrouter` is predictable
|
||||
# rather than depending on what you last used or on alphabetical order.
|
||||
$fallback = $script:ProviderDefaultPreset[$Provider]
|
||||
if ($fallback -and (Test-Path -LiteralPath (Get-PresetPath $fallback))) {
|
||||
if ([string](Get-Preset $fallback)['provider'] -eq $Provider) { return $fallback }
|
||||
}
|
||||
|
||||
$names = Get-PresetNamesForProvider $Provider
|
||||
if ($names.Count -gt 0) { return $names[0] }
|
||||
throw "no preset found for provider '$Provider'"
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# lib/files.ps1 - JSON read/write for PowerShell 5.1, and restricting a file to its owner.
|
||||
#
|
||||
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
|
||||
# the settings at its top. Not meant to run on its own. ASCII only: Windows
|
||||
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
|
||||
# lib\, so paths beside the main script go through $script:Here.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON helpers (PS 5.1 has no ConvertFrom-Json -AsHashtable)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function ConvertTo-DeepHashtable {
|
||||
param($InputObject)
|
||||
|
||||
if ($null -eq $InputObject) { return $null }
|
||||
|
||||
if ($InputObject -is [System.Collections.IDictionary]) {
|
||||
$h = [ordered]@{}
|
||||
foreach ($k in $InputObject.Keys) { $h[[string]$k] = ConvertTo-DeepHashtable $InputObject[$k] }
|
||||
return $h
|
||||
}
|
||||
|
||||
if ($InputObject -is [System.Management.Automation.PSCustomObject]) {
|
||||
$h = [ordered]@{}
|
||||
foreach ($p in $InputObject.PSObject.Properties) { $h[$p.Name] = ConvertTo-DeepHashtable $p.Value }
|
||||
return $h
|
||||
}
|
||||
|
||||
if ($InputObject -is [string]) { return $InputObject }
|
||||
|
||||
if ($InputObject -is [System.Collections.IEnumerable]) {
|
||||
$list = New-Object System.Collections.ArrayList
|
||||
foreach ($item in $InputObject) { [void]$list.Add((ConvertTo-DeepHashtable $item)) }
|
||||
return , $list.ToArray()
|
||||
}
|
||||
|
||||
return $InputObject
|
||||
}
|
||||
|
||||
function Read-JsonFile {
|
||||
param([string] $Path)
|
||||
if (-not (Test-Path -LiteralPath $Path)) { return $null }
|
||||
$raw = Get-Content -LiteralPath $Path -Raw -Encoding UTF8
|
||||
if ([string]::IsNullOrWhiteSpace($raw)) { return $null }
|
||||
return ConvertTo-DeepHashtable (ConvertFrom-Json $raw)
|
||||
}
|
||||
|
||||
function Write-JsonFile {
|
||||
param([string] $Path, $Data)
|
||||
$dir = Split-Path -Parent $Path
|
||||
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
||||
$json = Format-JsonPretty ($Data | ConvertTo-Json -Depth 100)
|
||||
# UTF-8 without BOM; some JSON readers choke on a BOM.
|
||||
[System.IO.File]::WriteAllText($Path, $json, (New-Object System.Text.UTF8Encoding($false)))
|
||||
}
|
||||
|
||||
# PowerShell 5.1's ConvertTo-Json indents by aligning values into a column,
|
||||
# which is valid but painful to hand-edit. Re-indent with node when available.
|
||||
function Format-JsonPretty {
|
||||
param([string] $Json)
|
||||
if ($null -eq $script:NodeExe) {
|
||||
$c = Get-Command node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($c) { $script:NodeExe = $c.Source } else { $script:NodeExe = '' }
|
||||
}
|
||||
if (-not $script:NodeExe) { return $Json }
|
||||
|
||||
$tmp = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
[System.IO.File]::WriteAllText($tmp, $Json, (New-Object System.Text.UTF8Encoding($false)))
|
||||
$out = & $script:NodeExe -e "const fs=require('fs');process.stdout.write(JSON.stringify(JSON.parse(fs.readFileSync(process.argv[1],'utf8')),null,2)+'\n')" $tmp
|
||||
if ($out) { return (($out -join "`n") + "`n") }
|
||||
return $Json
|
||||
} catch {
|
||||
return $Json
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ACL hardening - restrict a file to the current user only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Protect-FileAcl {
|
||||
param([string] $Path)
|
||||
try {
|
||||
$acl = Get-Acl -LiteralPath $Path
|
||||
if ($acl.AreAccessRulesProtected) { return }
|
||||
$acl.SetAccessRuleProtection($true, $false)
|
||||
foreach ($rule in @($acl.Access)) { [void]$acl.RemoveAccessRule($rule) }
|
||||
$me = New-Object System.Security.Principal.NTAccount($env:USERDOMAIN, $env:USERNAME)
|
||||
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule(
|
||||
$me, 'FullControl', 'None', 'None', 'Allow')))
|
||||
Set-Acl -LiteralPath $Path -AclObject $acl
|
||||
} catch {
|
||||
Write-Warn2 "could not harden ACL on $Path : $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
# lib/guards.ps1 - the Anthropic-model cost guard, the OpenRouter guardrail, stale cached model ids.
|
||||
#
|
||||
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
|
||||
# the settings at its top. Not meant to run on its own. ASCII only: Windows
|
||||
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
|
||||
# lib\, so paths beside the main script go through $script:Here.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Anthropic-model cost guard
|
||||
#
|
||||
# Matches both the qualified gateway id (anthropic/claude-opus-5) and the bare
|
||||
# internal id Claude Code persists (claude-opus-5, claude-opus-4-8,
|
||||
# claude-haiku-4-5-20251001). Both have been observed in the wild.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ask OpenRouter whether this key can still reach Anthropic models, by trying the
|
||||
# cheapest possible request against one. Free when the guardrail blocks it; a
|
||||
# fraction of a cent when it does not, which is exactly the case worth knowing.
|
||||
# Returns 'active' | 'open' | 'unknown'.
|
||||
function Test-OpenRouterGuardrail {
|
||||
param([string] $Key)
|
||||
$body = '{"model":"claude-opus-5","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}'
|
||||
try {
|
||||
[void](Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/messages' -Method Post -Body $body `
|
||||
-ContentType 'application/json' -TimeoutSec 25 `
|
||||
-Headers @{ 'x-api-key' = $Key; 'Authorization' = "Bearer $Key"; 'anthropic-version' = '2023-06-01' })
|
||||
return 'open'
|
||||
} catch {
|
||||
$c = 0
|
||||
if ($_.Exception.Response) { $c = [int]$_.Exception.Response.StatusCode }
|
||||
# 403/404 is OpenRouter refusing the model, which is what a guardrail
|
||||
# looks like. 401 is the KEY being rejected - that says nothing about the
|
||||
# guardrail and must not read as an all-clear. Anything else (timeout,
|
||||
# 5xx) is equally uninformative.
|
||||
if ($c -eq 403 -or $c -eq 404) { return 'active' }
|
||||
if ($c -eq 401) { return 'badkey' }
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
# OpenRouter only. Z.AI and LM Studio have no equivalent control, so there is
|
||||
# nothing actionable to print for them.
|
||||
function Show-GuardrailStatus {
|
||||
param([string] $Mode, [string] $Key)
|
||||
# An OpenRouter feature, flagged per provider rather than by name.
|
||||
$pv = Get-Provider $Mode
|
||||
if (-not ($pv -and $pv.guardrail) -or -not $Key) { return }
|
||||
|
||||
$label = ' guardrail '
|
||||
$state = Test-OpenRouterGuardrail -Key $Key
|
||||
$script:HealthGuardrail = switch ($state) { 'active' { 'active' } 'open' { 'not_set' } default { 'unknown' } }
|
||||
switch ($state) {
|
||||
'active' {
|
||||
Write-Host $label -NoNewline -ForegroundColor DarkGray
|
||||
Write-Host 'active' -NoNewline -ForegroundColor Green
|
||||
Write-Host ' - Anthropic models blocked for this key' -ForegroundColor DarkGray
|
||||
}
|
||||
'open' {
|
||||
Write-Host $label -NoNewline -ForegroundColor DarkGray
|
||||
Write-Host 'NOT SET' -NoNewline -ForegroundColor Red
|
||||
Write-Host ' - Anthropic models reachable, billed at list price' -ForegroundColor DarkGray
|
||||
Write-Host ' openrouter.ai -> Guardrails -> new, select this key,' -ForegroundColor DarkGray
|
||||
Write-Host ' then exclude anthropic models (or allow only the ones you use)' -ForegroundColor DarkGray
|
||||
}
|
||||
'badkey' {
|
||||
Write-Host $label -NoNewline -ForegroundColor DarkGray
|
||||
Write-Host 'unknown' -NoNewline -ForegroundColor Yellow
|
||||
Write-Host ' - OpenRouter rejected the key, so it could not be checked' -ForegroundColor DarkGray
|
||||
}
|
||||
default {
|
||||
Write-Host $label -NoNewline -ForegroundColor DarkGray
|
||||
Write-Host 'unknown' -NoNewline -ForegroundColor Yellow
|
||||
Write-Host ' - could not reach OpenRouter to check' -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Test-AnthropicModelId {
|
||||
param([string] $Id)
|
||||
if (-not $Id) { return $false }
|
||||
return ($Id -match '(^|/)claude[-.]' -or $Id -like 'anthropic/*')
|
||||
}
|
||||
|
||||
# Claude Code caches a resolved model per (entrypoint, model, org) in
|
||||
# ~/.claude.json. A session that was running - or a picker selection made -
|
||||
# before the switch keeps its old model id, and that id is then sent to whatever
|
||||
# endpoint is now configured. That is how an Anthropic model ends up billed
|
||||
# through a gateway. Detect it; we cannot prevent it from here.
|
||||
# Walk every string in ~/.claude.json and collect model-ish values. Claude Code
|
||||
# caches resolved models under more than one key - clientDataCacheSlots and
|
||||
# additionalModelOptionsCache have both been observed - so scan rather than
|
||||
# reach for a fixed path.
|
||||
function Get-CachedModelIds {
|
||||
$cfg = Join-Path $env:USERPROFILE '.claude.json'
|
||||
if (-not (Test-Path -LiteralPath $cfg)) { return @() }
|
||||
try { $j = Get-Content -LiteralPath $cfg -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return @() }
|
||||
|
||||
$out = New-Object System.Collections.ArrayList
|
||||
$walk = {
|
||||
param($o)
|
||||
if ($null -eq $o) { return }
|
||||
if ($o -is [string]) { return }
|
||||
foreach ($p in $o.PSObject.Properties) {
|
||||
$v = $p.Value
|
||||
if ($v -is [string]) {
|
||||
if ($p.Name -match 'model|value' -and $v -match '^[~a-zA-Z0-9]') { [void]$out.Add($v) }
|
||||
} elseif ($v -is [System.Collections.IEnumerable]) {
|
||||
foreach ($i in $v) { if ($i -isnot [string]) { & $walk $i } }
|
||||
} elseif ($null -ne $v) { & $walk $v }
|
||||
}
|
||||
}
|
||||
& $walk $j
|
||||
|
||||
# The structural walk only reaches keys named model/value. Tagged ids have
|
||||
# been found under other keys too, and `repair` matches them by raw text, so
|
||||
# union that in - otherwise health.json under-reports what repair would act
|
||||
# on, which is worse than either alone.
|
||||
try {
|
||||
$raw = Get-Content -LiteralPath $cfg -Raw -Encoding UTF8
|
||||
foreach ($m in [regex]::Matches($raw, '"([^"]*\[[0-9]+[a-zA-Z]\])"')) {
|
||||
[void]$out.Add($m.Groups[1].Value)
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return @($out | Sort-Object -Unique)
|
||||
}
|
||||
|
||||
# A model id carrying a bracket suffix - claude-mode has seen `claude-fable-5[1m]`
|
||||
# - is Claude Code's extended-context marker. It belongs to Anthropic's 1M models
|
||||
# and no gateway recognises it. When a session that had it switches to a gateway,
|
||||
# the tag can survive onto the new id, producing something like
|
||||
# `~deepseek/deepseek-v4-flash-latest[1m]` that only fails at compaction time,
|
||||
# because compaction re-resolves the model from session state.
|
||||
function Test-TaggedModelId {
|
||||
param([string] $Id)
|
||||
return ($Id -match '\[[0-9]+[a-zA-Z]\]$')
|
||||
}
|
||||
|
||||
function Test-StaleModelSelections {
|
||||
param([string] $Mode)
|
||||
if ($Mode -eq 'anthropic') { return 0 }
|
||||
|
||||
$ids = Get-CachedModelIds
|
||||
$anth = @($ids | Where-Object { Test-AnthropicModelId $_ })
|
||||
$tagged = @($ids | Where-Object { Test-TaggedModelId $_ })
|
||||
$n = $anth.Count + $tagged.Count
|
||||
if ($n -eq 0) { return 0 }
|
||||
|
||||
if ($anth.Count -gt 0) {
|
||||
Write-Host ' sessions ' -NoNewline -ForegroundColor DarkGray
|
||||
Write-Host "$($anth.Count) cached Anthropic model ids" -NoNewline -ForegroundColor Yellow
|
||||
Write-Host ' - restart running claude sessions' -ForegroundColor DarkGray
|
||||
}
|
||||
if ($tagged.Count -gt 0) {
|
||||
Write-Host ' tagged ' -NoNewline -ForegroundColor DarkGray
|
||||
Write-Host "$($tagged.Count) model id(s) carry a [1m] tag" -NoNewline -ForegroundColor Yellow
|
||||
Write-Host ' - breaks compaction on gateways; claude-mode repair' -ForegroundColor DarkGray
|
||||
}
|
||||
return $n
|
||||
}
|
||||
|
||||
# Strip extended-context tags from cached model ids. Backed up first; the file is
|
||||
# the user's own Claude Code config, not ours.
|
||||
# Strip extended-context tags from cached model ids.
|
||||
#
|
||||
# The tag is NOT junk everywhere. On an Anthropic id it is how Claude Code
|
||||
# selects the 1M variant (`qS()` in the CLI literally tests the id string for
|
||||
# `[1m]`), so stripping `claude-fable-5[1m]` silently downgrades that choice to
|
||||
# the 200k variant and the user has to re-pick it in /model. On a gateway id the
|
||||
# same tag is meaningless and breaks compaction.
|
||||
#
|
||||
# So the default - and the only thing done automatically - is to strip tags from
|
||||
# NON-Anthropic ids only. -All includes Anthropic ids and is a deliberate,
|
||||
# manual choice.
|
||||
function Invoke-Repair {
|
||||
param([switch] $All, [switch] $Quiet)
|
||||
|
||||
$cfg = Join-Path $env:USERPROFILE '.claude.json'
|
||||
if (-not (Test-Path -LiteralPath $cfg)) { if (-not $Quiet) { Write-Err2 'no ~/.claude.json' }; return 0 }
|
||||
|
||||
$raw = Get-Content -LiteralPath $cfg -Raw -Encoding UTF8
|
||||
$tagged = @([regex]::Matches($raw, '"([^"]*\[[0-9]+[a-zA-Z]\])"') |
|
||||
ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
|
||||
if ($tagged.Count -eq 0) { if (-not $Quiet) { Write-Ok 'no tagged model ids in ~/.claude.json' }; return 0 }
|
||||
|
||||
$target = @($tagged | Where-Object { $All -or -not (Test-AnthropicModelId $_) })
|
||||
$kept = @($tagged | Where-Object { $_ -notin $target })
|
||||
|
||||
if ($target.Count -eq 0) {
|
||||
if (-not $Quiet) {
|
||||
Write-Ok "nothing to strip - $($kept.Count) tagged id(s) are Anthropic models, where the tag is meaningful"
|
||||
foreach ($k in $kept) { Write-Host " keeping $k" -ForegroundColor DarkGray }
|
||||
Write-Host ' use --all to strip those too (downgrades them to the 200k variant)' -ForegroundColor DarkGray
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
Initialize-Root
|
||||
$bak = Join-Path $script:BackupDir ('claude.json.' + (Get-Date).ToString('yyyyMMdd-HHmmss') + '.bak')
|
||||
Copy-Item -LiteralPath $cfg -Destination $bak -Force
|
||||
|
||||
$fixed = $raw
|
||||
foreach ($t in $target) {
|
||||
$clean = $t -replace '\[[0-9]+[a-zA-Z]\]$', ''
|
||||
$fixed = $fixed.Replace('"' + $t + '"', '"' + $clean + '"')
|
||||
}
|
||||
try { [void](ConvertFrom-Json $fixed) } catch { Write-Err2 'repair would produce invalid JSON - aborted'; return 0 }
|
||||
[System.IO.File]::WriteAllText($cfg, $fixed, (New-Object System.Text.UTF8Encoding($false)))
|
||||
|
||||
if ($Quiet) {
|
||||
Write-Host ' repaired ' -NoNewline -ForegroundColor DarkGray
|
||||
Write-Host "$($target.Count) gateway model id(s) had a [1m] tag stripped" -ForegroundColor Green
|
||||
} else {
|
||||
foreach ($t in $target) { Write-Host " $t" -ForegroundColor DarkGray }
|
||||
foreach ($k in $kept) { Write-Host " keeping $k (Anthropic - tag is meaningful)" -ForegroundColor DarkGray }
|
||||
Write-Ok "stripped $($target.Count) tag(s); backup at $bak"
|
||||
Write-Host ' restart claude for this to take effect' -ForegroundColor DarkGray
|
||||
}
|
||||
return $target.Count
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
# lib/health.ps1 - health.json, and persistent environment variables that would override a switch.
|
||||
#
|
||||
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
|
||||
# the settings at its top. Not meant to run on its own. ASCII only: Windows
|
||||
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
|
||||
# lib\, so paths beside the main script go through $script:Here.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stray environment variable detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# health.json - the machine-readable state a fleet reader (or anything else)
|
||||
# reads. Written on every switch and on every `doctor`.
|
||||
#
|
||||
# Contract, deliberately narrow:
|
||||
# * NO key material, ever. `keysConfigured` is names only; `keyBackend` says
|
||||
# how they are stored, never what they are.
|
||||
# * Model-id lists are [{id, anthropic}] rather than bare strings, so a reader
|
||||
# never has to re-derive the Anthropic matcher. A tagged *Anthropic* id is
|
||||
# normal (that is how the 1M variant is selected) and must not render as a
|
||||
# fault; a tagged gateway id is the breakage.
|
||||
# * `guardrailStatus` is tri-state (active|not_set|unknown) or null when not
|
||||
# probed. Never collapse to a boolean - "unknown" must not read as safe.
|
||||
# ---------------------------------------------------------------------------
|
||||
$script:HealthPath = Join-Path $script:Root 'health.json'
|
||||
$script:HealthGuardrail = $null # set by Show-GuardrailStatus when it probes
|
||||
|
||||
# Must serialise as a JSON array for 0, 1 and many entries. PS 5.1 unrolls a
|
||||
# single-element array on return (rendering it as a bare object) and renders a
|
||||
# comma-wrapped empty array as [[]]. A generic List survives both: `,` stops the
|
||||
# unroll, and ConvertTo-Json always emits a List as an array.
|
||||
function ConvertTo-ModelEntry {
|
||||
param([string[]] $Ids)
|
||||
$out = New-Object 'System.Collections.Generic.List[object]'
|
||||
foreach ($i in @($Ids)) {
|
||||
$out.Add([ordered]@{ id = $i; anthropic = [bool](Test-AnthropicModelId $i) })
|
||||
}
|
||||
return , $out
|
||||
}
|
||||
|
||||
function Write-HealthFile {
|
||||
param([string] $Mode, [string] $PresetName)
|
||||
|
||||
$h = [ordered]@{
|
||||
schema = 1
|
||||
tool = 'claude-mode'
|
||||
version = $script:Version
|
||||
updatedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
|
||||
os = 'windows'
|
||||
mode = $Mode
|
||||
preset = ''
|
||||
}
|
||||
|
||||
$ids = Get-CachedModelIds
|
||||
$stale = @($ids | Where-Object { Test-AnthropicModelId $_ })
|
||||
$tagged = @($ids | Where-Object { Test-TaggedModelId $_ })
|
||||
|
||||
if ($Mode -eq 'anthropic') {
|
||||
# Nothing gateway-shaped is meaningful here, and a cached Anthropic id is
|
||||
# simply the model in use - not a finding.
|
||||
$h['staleModelIds'] = (ConvertTo-ModelEntry @())
|
||||
$h['taggedModelIds'] = ConvertTo-ModelEntry $tagged
|
||||
} else {
|
||||
$h['preset'] = $PresetName
|
||||
try {
|
||||
$p = Get-Preset $PresetName
|
||||
$h['provider'] = [string]$p['provider']
|
||||
$h['baseUrl'] = [string]$p['baseUrl']
|
||||
$models = [ordered]@{}
|
||||
foreach ($t in $script:Tiers) { if ($p['models'].Contains($t)) { $models[$t] = [string]$p['models'][$t] } }
|
||||
$h['models'] = $models
|
||||
$h['subagentModel'] = [string]$p['subagentModel']
|
||||
$h['contextTokens'] = if ($p['contextTokens']) { [int]$p['contextTokens'] } else { $null }
|
||||
$h['gatewayDiscovery'] = [bool]$p['gatewayModelDiscovery']
|
||||
|
||||
$auth = Get-PresetAuth $p
|
||||
if ([string]$auth['mode'] -eq 'vault') {
|
||||
$ref = [string]$auth['keyRef']
|
||||
$h['keyBackend'] = 'dpapi'
|
||||
$kc = New-Object 'System.Collections.Generic.List[object]'
|
||||
if (Get-VaultKey $ref) { $kc.Add($ref) }
|
||||
$h['keysConfigured'] = $kc
|
||||
} else {
|
||||
$h['keyBackend'] = 'inline'
|
||||
$h['keysConfigured'] = (New-Object 'System.Collections.Generic.List[object]')
|
||||
}
|
||||
# It passed the guard, or Set-ClaudeMode would have thrown.
|
||||
$h['costGuardPassed'] = $true
|
||||
} catch {
|
||||
$h['costGuardPassed'] = $null
|
||||
}
|
||||
$h['guardrailStatus'] = $script:HealthGuardrail
|
||||
$h['staleModelIds'] = ConvertTo-ModelEntry $stale
|
||||
$h['taggedModelIds'] = ConvertTo-ModelEntry $tagged
|
||||
}
|
||||
|
||||
try { Write-JsonFile $script:HealthPath $h } catch { }
|
||||
}
|
||||
|
||||
function Get-PersistentEnv {
|
||||
param([string] $Name)
|
||||
return [pscustomobject]@{
|
||||
Name = $Name
|
||||
User = [Environment]::GetEnvironmentVariable($Name, 'User')
|
||||
Machine = [Environment]::GetEnvironmentVariable($Name, 'Machine')
|
||||
}
|
||||
}
|
||||
|
||||
function Test-StrayEnvVars {
|
||||
param([string] $Mode)
|
||||
|
||||
$problems = 0
|
||||
|
||||
$ak = Get-PersistentEnv 'ANTHROPIC_API_KEY'
|
||||
if ($Mode -ne 'anthropic') {
|
||||
if ($ak.User) { Write-Err2 'ANTHROPIC_API_KEY is set at User scope - it will bypass the gateway. Fix: claude-mode doctor'; $problems++ }
|
||||
if ($ak.Machine) { Write-Err2 'ANTHROPIC_API_KEY is set at Machine scope - it will bypass the gateway. Remove it (admin required).'; $problems++ }
|
||||
if ($env:ANTHROPIC_API_KEY -and -not $ak.User -and -not $ak.Machine) {
|
||||
Write-Warn2 'ANTHROPIC_API_KEY is set in THIS shell only. The `claude` wrapper strips it; other shells are unaffected.'
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($name in $script:BaseManagedEnvKeys) {
|
||||
if ($name -eq 'ANTHROPIC_API_KEY') { continue }
|
||||
$v = Get-PersistentEnv $name
|
||||
if ($v.User) { Write-Err2 "$name is set at User scope - it overrides claude-mode. Fix: claude-mode doctor"; $problems++ }
|
||||
if ($v.Machine) { Write-Err2 "$name is set at Machine scope - remove it (admin required)."; $problems++ }
|
||||
}
|
||||
|
||||
return $problems
|
||||
}
|
||||
|
||||
function Repair-StrayEnvVars {
|
||||
$fixed = 0
|
||||
foreach ($name in $script:BaseManagedEnvKeys) {
|
||||
$v = [Environment]::GetEnvironmentVariable($name, 'User')
|
||||
if ($v) {
|
||||
$bak = Join-Path $script:BackupDir ("userenv-$name-" + (Get-Date).ToString('yyyyMMdd-HHmmss') + '.txt')
|
||||
Set-Content -LiteralPath $bak -Value $v -Encoding UTF8
|
||||
Protect-FileAcl $bak
|
||||
[Environment]::SetEnvironmentVariable($name, $null, 'User')
|
||||
Write-Ok "removed User-scope $name (old value saved to $bak)"
|
||||
$fixed++
|
||||
}
|
||||
}
|
||||
if ($fixed -eq 0) { Write-Ok 'no User-scope overrides to remove' }
|
||||
}
|
||||
+669
@@ -0,0 +1,669 @@
|
||||
# lib/menu.ps1 - the interactive menu, its pickers, and interactive preset editing.
|
||||
#
|
||||
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
|
||||
# the settings at its top. Not meant to run on its own. ASCII only: Windows
|
||||
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
|
||||
# lib\, so paths beside the main script go through $script:Here.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive menu (claude-mode with no arguments)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Test-Interactive {
|
||||
try {
|
||||
if ([Console]::IsInputRedirected) { return $false }
|
||||
if ([Console]::IsOutputRedirected) { return $false }
|
||||
[void][Console]::WindowWidth
|
||||
return $true
|
||||
} catch { return $false }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UI frame - the screen region the interactive menus own.
|
||||
#
|
||||
# Successive selectors (mode -> preset, preset -> tier) must REPLACE each other
|
||||
# rather than stack, otherwise the earlier list stays on screen looking frozen
|
||||
# and interactive. The frame remembers where the current run of menus began;
|
||||
# each new selector wipes back to that line and redraws from it.
|
||||
#
|
||||
# Output that must persist (a switch summary, doctor results) calls Stop-UiFrame
|
||||
# first: that erases the menu, then lets the output print in the freed space and
|
||||
# stay there, with the next menu opening a fresh frame below it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$script:UiActive = $false
|
||||
$script:UiTop = 0
|
||||
$script:UiQuit = $false # set by the submenu to unwind the whole menu stack
|
||||
|
||||
function Start-UiFrame {
|
||||
if ($script:UiActive) { return }
|
||||
try { $script:UiTop = [Console]::CursorTop } catch { $script:UiTop = 0 }
|
||||
$script:UiActive = $true
|
||||
}
|
||||
|
||||
function Clear-UiFrame {
|
||||
if (-not $script:UiActive) { return }
|
||||
try {
|
||||
$w = [Math]::Max(1, [Console]::WindowWidth - 1)
|
||||
$blank = ' ' * $w
|
||||
$bottom = [Console]::CursorTop
|
||||
for ($y = $script:UiTop; $y -le $bottom; $y++) {
|
||||
[Console]::SetCursorPosition(0, $y)
|
||||
[Console]::Write($blank)
|
||||
}
|
||||
[Console]::SetCursorPosition(0, $script:UiTop)
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function Stop-UiFrame {
|
||||
Clear-UiFrame
|
||||
$script:UiActive = $false
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Show-Select - arrow-key list picker.
|
||||
#
|
||||
# Draws the list once, then repaints it in place on every keypress by parking
|
||||
# the cursor back at the top of the region. Each row is padded to the console
|
||||
# width so a repaint erases whatever the previous, longer row left behind.
|
||||
# A fixed-height detail pane under the list shows the highlighted row's info,
|
||||
# which keeps the geometry constant - variable-height rows would make the
|
||||
# in-place repaint arithmetic fragile.
|
||||
#
|
||||
# Returns the selected index, or -1 if the user cancelled / the console cannot
|
||||
# support this (callers fall back to Read-Choice).
|
||||
# ---------------------------------------------------------------------------
|
||||
function Show-Select {
|
||||
param(
|
||||
[string] $Title,
|
||||
[string] $Status,
|
||||
[object[]] $Items, # each: Label (string), Detail (string[])
|
||||
[int] $Default = 0,
|
||||
[int] $DetailLines = 3
|
||||
)
|
||||
|
||||
if (-not (Test-Interactive)) { return -1 }
|
||||
$n = @($Items).Count
|
||||
if ($n -eq 0) { return -1 }
|
||||
|
||||
$idx = [Math]::Max(0, [Math]::Min($Default, $n - 1))
|
||||
$w = [Math]::Max(40, [Console]::WindowWidth - 1)
|
||||
|
||||
# Take over the frame: a menu already on screen is wiped so this one lands
|
||||
# in its place instead of below it.
|
||||
if ($script:UiActive) { Clear-UiFrame } else { Start-UiFrame }
|
||||
|
||||
$headerH = 3
|
||||
Write-Host ''
|
||||
if ($Title) {
|
||||
$headerH = 4
|
||||
$t = " $Title"
|
||||
if ($Status) { $t = $t.PadRight(34) + $Status }
|
||||
Write-Host $t -ForegroundColor Cyan
|
||||
}
|
||||
Write-Host ' up/down move enter select esc cancel' -ForegroundColor DarkGray
|
||||
Write-Host ''
|
||||
|
||||
# Reserve the region first so the cursor never has to scroll mid-repaint,
|
||||
# then rewind to its top.
|
||||
$regionH = $n + 1 + $DetailLines
|
||||
for ($i = 0; $i -lt $regionH; $i++) { Write-Host '' }
|
||||
$top = [Console]::CursorTop - $regionH
|
||||
|
||||
# Reserving may have scrolled the buffer, which moves everything up and
|
||||
# invalidates the remembered frame top. Re-anchor it from where the list
|
||||
# actually landed.
|
||||
$script:UiTop = [Math]::Max(0, $top - $headerH)
|
||||
|
||||
# Truncate-or-pad a row to exactly the console width, so repainting a short
|
||||
# row fully erases a longer one underneath it.
|
||||
$pad = {
|
||||
param([string] $s)
|
||||
if ($s.Length -gt $w) { $s = $s.Substring(0, $w) }
|
||||
return $s.PadRight($w)
|
||||
}
|
||||
|
||||
$cursorWasVisible = $true
|
||||
try { $cursorWasVisible = [Console]::CursorVisible; [Console]::CursorVisible = $false } catch { }
|
||||
|
||||
try {
|
||||
while ($true) {
|
||||
[Console]::SetCursorPosition(0, $top)
|
||||
|
||||
for ($i = 0; $i -lt $n; $i++) {
|
||||
$sel = ($i -eq $idx)
|
||||
$mark = if ($sel) { ' > ' } else { ' ' }
|
||||
$row = & $pad ($mark + $Items[$i].Label)
|
||||
if ($sel) {
|
||||
# An item may carry its own accent (mode rows do), so the
|
||||
# highlight itself tells you which provider you are on.
|
||||
$bg = 'Cyan'
|
||||
if ($Items[$i].PSObject.Properties.Name -contains 'Accent' -and $Items[$i].Accent) {
|
||||
$bg = [string]$Items[$i].Accent
|
||||
}
|
||||
Write-Host $row -ForegroundColor Black -BackgroundColor $bg
|
||||
}
|
||||
else { Write-Host $row -ForegroundColor Gray }
|
||||
}
|
||||
|
||||
Write-Host (& $pad '')
|
||||
|
||||
$detail = @($Items[$idx].Detail)
|
||||
for ($k = 0; $k -lt $DetailLines; $k++) {
|
||||
$line = if ($k -lt $detail.Count) { ' ' + $detail[$k] } else { '' }
|
||||
Write-Host (& $pad $line) -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# if/elseif rather than switch: `break`/`continue` inside a switch
|
||||
# nested in a loop is ambiguous in PowerShell, and this is the input
|
||||
# loop - it has to be unambiguous.
|
||||
$key = [Console]::ReadKey($true)
|
||||
$k = [string]$key.Key
|
||||
$ch = $key.KeyChar
|
||||
|
||||
if ($k -eq 'UpArrow' -or $k -eq 'K') { $idx = ($idx - 1 + $n) % $n }
|
||||
elseif ($k -eq 'DownArrow' -or $k -eq 'J') { $idx = ($idx + 1) % $n }
|
||||
elseif ($k -eq 'Home') { $idx = 0 }
|
||||
elseif ($k -eq 'End') { $idx = $n - 1 }
|
||||
elseif ($k -eq 'Enter' -or $k -eq 'Spacebar') { return $idx }
|
||||
elseif ($k -eq 'Escape') { return -1 }
|
||||
elseif ($ch -eq 'q' -or $ch -eq 'Q') { return -1 }
|
||||
elseif ($ch -match '^[1-9]$' -and [int][string]$ch -le $n) {
|
||||
# Number keys still work as direct shortcuts.
|
||||
return ([int][string]$ch - 1)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
[Console]::SetCursorPosition(0, $top + $regionH)
|
||||
[Console]::CursorVisible = $cursorWasVisible
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
# Numeric fallback, used when the console cannot host Show-Select.
|
||||
function Read-Choice {
|
||||
param([string] $Prompt, [int] $Max, [string] $Default = '')
|
||||
while ($true) {
|
||||
$raw = Read-Host $Prompt
|
||||
if ([string]::IsNullOrWhiteSpace($raw)) {
|
||||
if ($Default) { return $Default }
|
||||
continue
|
||||
}
|
||||
$raw = $raw.Trim()
|
||||
if ($raw -eq 'q' -or $raw -eq '0') { return 'q' }
|
||||
$n = 0
|
||||
if ([int]::TryParse($raw, [ref]$n) -and $n -ge 1 -and $n -le $Max) { return [string]$n }
|
||||
Write-Host ' (invalid choice)' -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PresetSummaryLines {
|
||||
param($Preset)
|
||||
$out = @()
|
||||
if ($Preset.Contains('description')) { $out += [string]$Preset['description'] }
|
||||
$bits = @()
|
||||
foreach ($t in $script:Tiers) {
|
||||
if ($Preset['models'].Contains($t)) { $bits += "$t=$($Preset['models'][$t])" }
|
||||
}
|
||||
if ($bits.Count) { $out += ($bits -join ' ') }
|
||||
if ($Preset.Contains('contextTokens') -and $Preset['contextTokens']) {
|
||||
$out += ("context {0:N0} tokens base {1}" -f [int]$Preset['contextTokens'], $Preset['baseUrl'])
|
||||
} else {
|
||||
$out += "base $($Preset['baseUrl'])"
|
||||
}
|
||||
return $out
|
||||
}
|
||||
|
||||
function Invoke-PresetPicker {
|
||||
param([string] $Provider)
|
||||
|
||||
$names = @(Get-PresetNamesForProvider $Provider)
|
||||
if ($names.Count -eq 0) { throw "no presets defined for provider '$Provider'" }
|
||||
|
||||
$default = $script:ProviderDefaultPreset[$Provider]
|
||||
if (-not ($names -contains $default)) { $default = $names[0] }
|
||||
$defaultIdx = [Math]::Max(0, [array]::IndexOf($names, $default))
|
||||
|
||||
$items = @()
|
||||
foreach ($n in $names) {
|
||||
$p = Get-Preset $n
|
||||
$label = $n
|
||||
if ($n -eq $default) { $label = "$n (default)" }
|
||||
$items += [pscustomobject]@{ Label = $label; Detail = (Get-PresetSummaryLines $p) }
|
||||
}
|
||||
|
||||
$sel = Show-Select -Title "preset for $Provider" -Items $items -Default $defaultIdx
|
||||
if ($sel -ge 0) { return $names[$sel] }
|
||||
if ($sel -eq -1 -and (Test-Interactive)) { return $null } # cancelled
|
||||
|
||||
# console too limited for the picker - fall back to numbers
|
||||
for ($i = 0; $i -lt $names.Count; $i++) { Write-Host (" {0}) {1}" -f ($i + 1), $names[$i]) }
|
||||
$c = Read-Choice -Prompt " preset [Enter = $default, q = cancel]" -Max $names.Count -Default 'D'
|
||||
if ($c -eq 'q') { return $null }
|
||||
if ($c -eq 'D') { return $default }
|
||||
return $names[[int]$c - 1]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Show-SearchSelect - arrow-key picker with a live type-to-filter box.
|
||||
#
|
||||
# Show-Select is fine for a handful of rows, but OpenRouter lists 300+ models,
|
||||
# which is unusable as a flat list. This keeps the same in-place repaint but adds
|
||||
# a filter line and a scrolling window over the matches.
|
||||
#
|
||||
# Returns the index into $Items, or -1 to cancel.
|
||||
# ---------------------------------------------------------------------------
|
||||
function Show-SearchSelect {
|
||||
param(
|
||||
[string] $Title,
|
||||
[string] $Status,
|
||||
[object[]] $Items, # each: Label (string), Detail (string[]), Key (string, searched)
|
||||
[string] $Query = '',
|
||||
[int] $MaxRows = 12,
|
||||
[int] $DetailLines = 2
|
||||
)
|
||||
|
||||
if (-not (Test-Interactive)) { return -1 }
|
||||
$all = @($Items)
|
||||
if ($all.Count -eq 0) { return -1 }
|
||||
|
||||
$w = [Math]::Max(40, [Console]::WindowWidth - 1)
|
||||
$MaxRows = [Math]::Max(3, [Math]::Min($MaxRows, [Console]::WindowHeight - 12))
|
||||
|
||||
if ($script:UiActive) { Clear-UiFrame } else { Start-UiFrame }
|
||||
|
||||
$headerH = 3
|
||||
Write-Host ''
|
||||
if ($Title) {
|
||||
$headerH = 4
|
||||
$t = " $Title"
|
||||
if ($Status) { $t = $t.PadRight(34) + $Status }
|
||||
Write-Host $t -ForegroundColor Cyan
|
||||
}
|
||||
Write-Host ' type to filter up/down move enter select esc cancel' -ForegroundColor DarkGray
|
||||
Write-Host ''
|
||||
|
||||
# filter line + blank + rows + count + blank + detail
|
||||
$regionH = 1 + 1 + $MaxRows + 1 + 1 + $DetailLines
|
||||
for ($i = 0; $i -lt $regionH; $i++) { Write-Host '' }
|
||||
$top = [Console]::CursorTop - $regionH
|
||||
$script:UiTop = [Math]::Max(0, $top - $headerH)
|
||||
|
||||
$pad = {
|
||||
param([string] $s)
|
||||
if ($s.Length -gt $w) { $s = $s.Substring(0, $w) }
|
||||
return $s.PadRight($w)
|
||||
}
|
||||
|
||||
$idx = 0
|
||||
$off = 0
|
||||
|
||||
$cursorWasVisible = $true
|
||||
try { $cursorWasVisible = [Console]::CursorVisible; [Console]::CursorVisible = $false } catch { }
|
||||
|
||||
try {
|
||||
while ($true) {
|
||||
# Filter on every keystroke. Match against Key when present so a row
|
||||
# can display extra decoration without breaking search.
|
||||
$q = $Query.Trim().ToLower()
|
||||
if ($q) {
|
||||
$matches = @($all | Where-Object {
|
||||
$hay = if ($_.PSObject.Properties.Name -contains 'Key' -and $_.Key) { [string]$_.Key } else { [string]$_.Label }
|
||||
$hay.ToLower().Contains($q)
|
||||
})
|
||||
} else {
|
||||
$matches = $all
|
||||
}
|
||||
|
||||
$m = $matches.Count
|
||||
if ($idx -ge $m) { $idx = [Math]::Max(0, $m - 1) }
|
||||
if ($idx -lt $off) { $off = $idx }
|
||||
if ($idx -ge $off + $MaxRows) { $off = $idx - $MaxRows + 1 }
|
||||
if ($off -gt [Math]::Max(0, $m - $MaxRows)) { $off = [Math]::Max(0, $m - $MaxRows) }
|
||||
|
||||
[Console]::SetCursorPosition(0, $top)
|
||||
|
||||
Write-Host (& $pad (" filter: " + $Query + "_")) -ForegroundColor White
|
||||
Write-Host (& $pad '')
|
||||
|
||||
for ($r = 0; $r -lt $MaxRows; $r++) {
|
||||
$i = $off + $r
|
||||
if ($i -ge $m) { Write-Host (& $pad ''); continue }
|
||||
$sel = ($i -eq $idx)
|
||||
$mark = if ($sel) { ' > ' } else { ' ' }
|
||||
$row = & $pad ($mark + $matches[$i].Label)
|
||||
if ($sel) { Write-Host $row -ForegroundColor Black -BackgroundColor Cyan }
|
||||
else { Write-Host $row -ForegroundColor Gray }
|
||||
}
|
||||
|
||||
$count = if ($m -eq 0) { ' (no match)' } else { " $($idx + 1) of $m" + $(if ($all.Count -ne $m) { " (filtered from $($all.Count))" } else { '' }) }
|
||||
Write-Host (& $pad $count) -ForegroundColor DarkGray
|
||||
Write-Host (& $pad '')
|
||||
|
||||
$detail = if ($m -gt 0) { @($matches[$idx].Detail) } else { @() }
|
||||
for ($k = 0; $k -lt $DetailLines; $k++) {
|
||||
$line = if ($k -lt $detail.Count) { ' ' + $detail[$k] } else { '' }
|
||||
Write-Host (& $pad $line) -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
$key = [Console]::ReadKey($true)
|
||||
$k = [string]$key.Key
|
||||
$ch = $key.KeyChar
|
||||
|
||||
if ($k -eq 'UpArrow') { if ($m) { $idx = ($idx - 1 + $m) % $m } }
|
||||
elseif ($k -eq 'DownArrow') { if ($m) { $idx = ($idx + 1) % $m } }
|
||||
elseif ($k -eq 'PageUp') { $idx = [Math]::Max(0, $idx - $MaxRows) }
|
||||
elseif ($k -eq 'PageDown') { $idx = [Math]::Min([Math]::Max(0, $m - 1), $idx + $MaxRows) }
|
||||
elseif ($k -eq 'Home') { $idx = 0 }
|
||||
elseif ($k -eq 'End') { $idx = [Math]::Max(0, $m - 1) }
|
||||
elseif ($k -eq 'Enter') { if ($m -gt 0) { return [array]::IndexOf($all, $matches[$idx]) } }
|
||||
elseif ($k -eq 'Escape') { return -1 }
|
||||
elseif ($k -eq 'Backspace') {
|
||||
if ($Query.Length -gt 0) { $Query = $Query.Substring(0, $Query.Length - 1); $idx = 0; $off = 0 }
|
||||
}
|
||||
elseif ($ch -and [int][char]$ch -ge 32 -and [int][char]$ch -lt 127) {
|
||||
$Query += $ch; $idx = 0; $off = 0
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
[Console]::SetCursorPosition(0, $top + $regionH)
|
||||
[Console]::CursorVisible = $cursorWasVisible
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
# Catalogue per provider, shaped for the picker. Cached per process so opening
|
||||
# the picker repeatedly in one session does not refetch OpenRouter's 300+ models.
|
||||
$script:ModelCatalogueCache = @{}
|
||||
|
||||
function Get-ModelChoices {
|
||||
param($Preset)
|
||||
|
||||
$provider = [string]$Preset['provider']
|
||||
$cacheKey = $provider + '|' + [string]$Preset['baseUrl']
|
||||
if ($script:ModelCatalogueCache.ContainsKey($cacheKey)) { return $script:ModelCatalogueCache[$cacheKey] }
|
||||
|
||||
$out = @()
|
||||
$kind = [string](Get-Provider $provider).catalogue.kind
|
||||
$tpl = $(if ($kind -eq 'lmstudio') { Get-LmStudioTemplateReport } else { @() })
|
||||
|
||||
foreach ($m in (Get-ProviderCatalogue $Preset | Sort-Object Id)) {
|
||||
$flag = ''
|
||||
if ($kind -eq 'openrouter') {
|
||||
$det = @(("context {0:N0} `$$($m.InM) in / `$$($m.OutM) out per 1M tokens" -f [int]$m.Ctx))
|
||||
} elseif ($kind -eq 'lmstudio') {
|
||||
$det = @("state: $($m.State) max context: $($m.Ctx)")
|
||||
$short = ($m.Id -split '/')[-1].ToLower()
|
||||
$risk = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1
|
||||
if ($risk -and $risk.Assertions.Count -gt 0) {
|
||||
$flag = ' [TEMPLATE RISK]'
|
||||
$det += 'chat template asserts message order - can break tool calls'
|
||||
}
|
||||
} else {
|
||||
$det = @([string]$m.Note)
|
||||
}
|
||||
$out += [pscustomobject]@{ Id = $m.Id; Key = $m.Id; Label = ($m.Id + $flag); Detail = $det }
|
||||
}
|
||||
|
||||
$script:ModelCatalogueCache[$cacheKey] = $out
|
||||
return $out
|
||||
}
|
||||
|
||||
function Read-ModelId {
|
||||
param($Preset, [string] $Tier, [string] $Current)
|
||||
|
||||
$choices = @()
|
||||
$err = $null
|
||||
try { $choices = @(Get-ModelChoices $Preset) } catch { $err = $_.Exception.Message }
|
||||
|
||||
if ($choices.Count -gt 0) {
|
||||
$items = @()
|
||||
# First row is always the manual escape hatch - a catalogue can lag
|
||||
# behind what the provider actually accepts.
|
||||
$items += [pscustomobject]@{ Id = $null; Key = 'type manually custom'; Label = '<type an id manually>'; Detail = @('enter any model id by hand') }
|
||||
$items += $choices
|
||||
|
||||
$sel = Show-SearchSelect -Title "model for '$Tier'" -Status "current: $Current" -Items $items
|
||||
if ($sel -lt 0) { return $null }
|
||||
if ($sel -gt 0) { return $items[$sel].Id }
|
||||
# fall through to manual entry
|
||||
}
|
||||
|
||||
# A typed prompt cannot live inside the repainting frame - the redraw would
|
||||
# erase what is being typed. Close the frame first.
|
||||
Stop-UiFrame
|
||||
Write-Host ''
|
||||
if ($err) { Write-Warn2 "could not load the model list: $err" }
|
||||
Write-Host " current $Tier : $Current" -ForegroundColor DarkGray
|
||||
$val = Read-Host " new model id for '$Tier' (blank = cancel)"
|
||||
if ([string]::IsNullOrWhiteSpace($val)) { return $null }
|
||||
return $val.Trim()
|
||||
}
|
||||
|
||||
# A blank preset per provider, with the endpoint/auth/context bits already right
|
||||
# so only the model choices are left to make.
|
||||
function New-PresetScaffold {
|
||||
param([string] $Provider)
|
||||
|
||||
# Built from the provider's own template in providers.json; the POSIX
|
||||
# build's `cm-json.py scaffold` produces the same thing from the same file.
|
||||
$p = Get-Provider $Provider
|
||||
if (-not $p) { throw "unknown provider '$Provider'" }
|
||||
$tpl = $p.preset
|
||||
|
||||
$base = [ordered]@{
|
||||
provider = $Provider
|
||||
description = 'new preset'
|
||||
}
|
||||
$base['baseUrl'] = [string]$tpl.baseUrl
|
||||
$auth = [ordered]@{}
|
||||
if ($tpl.auth) { foreach ($prop in $tpl.auth.PSObject.Properties) { $auth[$prop.Name] = $prop.Value } }
|
||||
else { $auth['mode'] = 'vault'; $auth['keyRef'] = $Provider }
|
||||
$base['auth'] = $auth
|
||||
|
||||
$base['models'] = [ordered]@{ opus = ''; sonnet = ''; haiku = ''; fable = '' }
|
||||
$base['subagentModel'] = 'inherit'
|
||||
$base['gatewayModelDiscovery'] = [bool]$tpl.gatewayModelDiscovery
|
||||
$base['contextTokens'] = $(if ($tpl.contextTokens) { [int]$tpl.contextTokens } else { 200000 })
|
||||
if ($tpl.extraEnv) {
|
||||
$extra = [ordered]@{}
|
||||
foreach ($prop in $tpl.extraEnv.PSObject.Properties) { $extra[$prop.Name] = [string]$prop.Value }
|
||||
$base['extraEnv'] = $extra
|
||||
}
|
||||
return $base
|
||||
}
|
||||
|
||||
function New-PresetInteractive {
|
||||
# 1. provider
|
||||
$provs = @($script:Modes | Where-Object { $_ -ne 'anthropic' })
|
||||
$items = @()
|
||||
foreach ($p in $provs) { $items += [pscustomobject]@{ Label = $p; Detail = @($script:ModeLabel[$p]) } }
|
||||
$sel = Show-Select -Title 'new preset - which provider' -Items $items
|
||||
if ($sel -lt 0) { return }
|
||||
$provider = $provs[$sel]
|
||||
|
||||
# 2. start from an existing preset of that provider, or blank
|
||||
$siblings = @(Get-PresetNamesForProvider $provider)
|
||||
$items = @([pscustomobject]@{ Label = '<blank>'; Detail = @("empty $provider preset - pick every model yourself") })
|
||||
foreach ($n in $siblings) {
|
||||
$items += [pscustomobject]@{ Label = "copy of $n"; Detail = (Get-PresetSummaryLines (Get-Preset $n)) }
|
||||
}
|
||||
$sel = Show-Select -Title 'start from' -Items $items
|
||||
if ($sel -lt 0) { return }
|
||||
|
||||
$preset = if ($sel -eq 0) { New-PresetScaffold $provider } else { Get-Preset $siblings[$sel - 1] }
|
||||
|
||||
# 3. name it
|
||||
Stop-UiFrame
|
||||
Write-Host ''
|
||||
Write-Host " new $provider preset" -ForegroundColor Cyan
|
||||
$name = Read-Host ' name (letters, digits, dash; blank = cancel)'
|
||||
if ([string]::IsNullOrWhiteSpace($name)) { return }
|
||||
$name = $name.Trim()
|
||||
if ($name -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$') {
|
||||
Write-Err2 "invalid name '$name' - use letters, digits, dash, dot, underscore"
|
||||
return
|
||||
}
|
||||
if (Test-Path -LiteralPath (Get-PresetPath $name)) {
|
||||
Write-Err2 "preset '$name' already exists"
|
||||
return
|
||||
}
|
||||
|
||||
$preset['description'] = if ($sel -eq 0) { "custom $provider preset" } else { "copy of $($siblings[$sel - 1])" }
|
||||
Write-JsonFile (Get-PresetPath $name) $preset
|
||||
Write-Ok "created preset '$name' ($provider)"
|
||||
|
||||
# 4. straight into editing it
|
||||
Invoke-PresetEditor -Name $name
|
||||
}
|
||||
|
||||
function Invoke-PresetEditor {
|
||||
param([string] $Name)
|
||||
|
||||
if (-not $Name) {
|
||||
$names = @(Get-PresetNames)
|
||||
if ($names.Count -eq 0) { Write-Warn2 'no presets'; return }
|
||||
|
||||
$items = @()
|
||||
foreach ($n in $names) {
|
||||
$p = Get-Preset $n
|
||||
$items += [pscustomobject]@{
|
||||
Label = ("{0,-18} [{1}]" -f $n, $p['provider'])
|
||||
Detail = (Get-PresetSummaryLines $p)
|
||||
}
|
||||
}
|
||||
|
||||
$sel = Show-Select -Title 'edit which preset' -Items $items
|
||||
if ($sel -lt 0) { return }
|
||||
$Name = $names[$sel]
|
||||
}
|
||||
|
||||
$name = $Name
|
||||
$preset = Get-Preset $name
|
||||
|
||||
while ($true) {
|
||||
$rows = @()
|
||||
foreach ($t in $script:Tiers) {
|
||||
$cur = ''
|
||||
if ($preset['models'].Contains($t)) { $cur = [string]$preset['models'][$t] }
|
||||
$rows += [pscustomobject]@{ Tier = $t; Current = $cur }
|
||||
}
|
||||
$sub = ''
|
||||
if ($preset.Contains('subagentModel')) { $sub = [string]$preset['subagentModel'] }
|
||||
$rows += [pscustomobject]@{ Tier = 'subagent'; Current = $sub }
|
||||
|
||||
$items = @()
|
||||
foreach ($r in $rows) {
|
||||
$items += [pscustomobject]@{
|
||||
Label = ("{0,-9} {1}" -f $r.Tier, $r.Current)
|
||||
Detail = @("change which model backs the '$($r.Tier)' tier", "current: $($r.Current)")
|
||||
}
|
||||
}
|
||||
|
||||
$c = Show-Select -Title "$name [$($preset['provider'])]" -Status 'esc = done' -Items $items
|
||||
if ($c -lt 0) { return }
|
||||
|
||||
$tier = $rows[$c].Tier
|
||||
$val = Read-ModelId -Preset $preset -Tier $tier -Current $rows[$c].Current
|
||||
if (-not $val) { continue }
|
||||
|
||||
# Set-PresetTier may re-apply the live preset, which prints a full switch
|
||||
# summary. Drop the frame so that output survives instead of being wiped
|
||||
# by the next redraw of the tier list.
|
||||
Stop-UiFrame
|
||||
Set-PresetTier -Name $name -Tier $tier -Model $val
|
||||
$preset = Get-Preset $name
|
||||
}
|
||||
}
|
||||
|
||||
# The second level: everything that is not switching mode. Loops until the user
|
||||
# backs out, so running doctor then editing a preset costs one trip in.
|
||||
function Invoke-MoreMenu {
|
||||
while ($true) {
|
||||
$state = Get-State
|
||||
$current = [string]$state['mode']
|
||||
$curDesc = $current
|
||||
if ($current -ne 'anthropic' -and $state['preset']) { $curDesc = "$current / $($state['preset'])" }
|
||||
|
||||
$items = @(
|
||||
[pscustomobject]@{ Label = 'status'; Detail = @('show the full active configuration') }
|
||||
[pscustomobject]@{ Label = 'edit presets'; Detail = @('pick models per tier from the provider catalogue') }
|
||||
[pscustomobject]@{ Label = 'new preset'; Detail = @('create a preset - blank or copied from an existing one') }
|
||||
[pscustomobject]@{ Label = 'doctor'; Detail = @('verify auth, endpoint, model ids, context window') }
|
||||
[pscustomobject]@{ Label = 'back'; Detail = @('return to the mode menu') }
|
||||
[pscustomobject]@{ Label = 'quit'; Detail = @() }
|
||||
)
|
||||
|
||||
$sel = Show-Select -Title 'claude-mode - more' -Status "currently: $curDesc" -Items $items
|
||||
if ($sel -lt 0) { return } # esc = back
|
||||
|
||||
switch ($sel) {
|
||||
0 { Stop-UiFrame; Invoke-Status } # output worth keeping
|
||||
1 { Invoke-PresetEditor } # more menus - keep the frame
|
||||
2 { New-PresetInteractive }
|
||||
3 { Stop-UiFrame; Invoke-Doctor }
|
||||
4 { return }
|
||||
5 { Stop-UiFrame; $script:UiQuit = $true; return }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Menu {
|
||||
if (-not (Test-Interactive)) { Invoke-Status; return }
|
||||
$script:UiQuit = $false
|
||||
|
||||
# Drawn once, above the frame anchor, so the menus repaint underneath it and
|
||||
# the banner stays put instead of flickering on every keypress.
|
||||
$st0 = Get-State
|
||||
Show-Banner -Mode ([string]$st0['mode']) -Preset $(if ([string]$st0['mode'] -ne 'anthropic') { [string]$st0['preset'] } else { '' })
|
||||
|
||||
while ($true) {
|
||||
$state = Get-State
|
||||
$current = [string]$state['mode']
|
||||
$curDesc = $current
|
||||
if ($current -ne 'anthropic' -and $state['preset']) { $curDesc = "$current / $($state['preset'])" }
|
||||
|
||||
# The mode you are already in is not offered - nothing to switch to.
|
||||
$choices = @($script:Modes | Where-Object { $_ -ne $current })
|
||||
|
||||
$items = @()
|
||||
foreach ($m in $choices) {
|
||||
$detail = @($script:ModeLabel[$m])
|
||||
try {
|
||||
$pn = $script:ProviderDefaultPreset[$m]
|
||||
if ($pn -and (Test-Path -LiteralPath (Get-PresetPath $pn))) {
|
||||
$detail += (Get-PresetSummaryLines (Get-Preset $pn))[1]
|
||||
}
|
||||
} catch { }
|
||||
$items += [pscustomobject]@{
|
||||
Label = ("switch to " + $m)
|
||||
Detail = $detail
|
||||
Accent = $script:ModeColor[$m]
|
||||
}
|
||||
}
|
||||
# Everything that is not "switch mode" lives one level down, so the three
|
||||
# things this tool exists to do are the whole first screen.
|
||||
$items += [pscustomobject]@{ Label = 'more ...'; Detail = @('status, presets, doctor') }
|
||||
|
||||
$sel = Show-Select -Title 'claude-mode' -Status "currently: $curDesc" -Items $items
|
||||
if ($sel -lt 0) { Stop-UiFrame; return }
|
||||
|
||||
$nChoices = $choices.Count
|
||||
if ($sel -eq $nChoices) {
|
||||
Invoke-MoreMenu
|
||||
if ($script:UiQuit) { return }
|
||||
continue
|
||||
}
|
||||
|
||||
$mode = $choices[$sel]
|
||||
if ($mode -eq 'anthropic') { Stop-UiFrame; Set-ClaudeMode -Mode 'anthropic' -PresetName ''; return }
|
||||
|
||||
$preset = Invoke-PresetPicker -Provider $mode
|
||||
if (-not $preset) { continue }
|
||||
Stop-UiFrame
|
||||
Set-ClaudeMode -Mode $mode -PresetName $preset
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
# lib/output.ps1 - output helpers, mode colours, the banner and usage.
|
||||
#
|
||||
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
|
||||
# the settings at its top. Not meant to run on its own. ASCII only: Windows
|
||||
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
|
||||
# lib\, so paths beside the main script go through $script:Here.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Write-Ok ($m) { Write-Host " ok $m" -ForegroundColor Green }
|
||||
function Write-Warn2 ($m) { Write-Host " warn $m" -ForegroundColor Yellow }
|
||||
function Write-Err2 ($m) { Write-Host " FAIL $m" -ForegroundColor Red }
|
||||
function Write-Head ($m) { Write-Host ""; Write-Host $m -ForegroundColor Cyan }
|
||||
|
||||
# Each mode gets an identity colour, reused for its menu row, its banner tagline
|
||||
# and its status line - so "which mode am I in" is answerable at a glance.
|
||||
$script:ModeColor = @{ 'anthropic' = 'Magenta' }
|
||||
$__colors = @{ cyan = 'Cyan'; green = 'Green'; yellow = 'Yellow'; magenta = 'Magenta'
|
||||
white = 'White'; gray = 'Gray'; dkcyan = 'DarkCyan'; red = 'Red' }
|
||||
foreach ($__p in $script:Providers) {
|
||||
$__c = $__colors[[string]$__p.color]
|
||||
$script:ModeColor[[string]$__p.id] = $(if ($__c) { $__c } else { 'Gray' })
|
||||
}
|
||||
|
||||
# Deliberately ASCII-only. This file is read by Windows PowerShell 5.1, which
|
||||
# assumes the ANSI codepage for a .ps1 without a BOM - box-drawing characters
|
||||
# would arrive mangled on some machines. Plain ASCII always renders.
|
||||
$script:Banner = @(
|
||||
' ____ _ _ __ __ _ ',
|
||||
' / ___| | __ _ _ _ __| | ___ | \/ | ___ __| | ___ ',
|
||||
' | | | |/ _` | | | |/ _` |/ _ \ | |\/| |/ _ \ / _` |/ _ \',
|
||||
' | |___| | (_| | |_| | (_| | __/ | | | | (_) | (_| | __/',
|
||||
' \____|_|\__,_|\__,_|\__,_|\___| |_| |_|\___/ \__,_|\___|'
|
||||
)
|
||||
|
||||
function Show-Banner {
|
||||
param([string] $Mode, [string] $Preset)
|
||||
|
||||
$shades = @('DarkCyan', 'Cyan', 'Cyan', 'Cyan', 'DarkCyan')
|
||||
Write-Host ''
|
||||
for ($i = 0; $i -lt $script:Banner.Count; $i++) {
|
||||
Write-Host $script:Banner[$i] -ForegroundColor $shades[$i]
|
||||
}
|
||||
|
||||
$accent = $script:ModeColor[$Mode]
|
||||
if (-not $accent) { $accent = 'Gray' }
|
||||
$tag = if ($Preset) { "$Mode / $Preset" } else { $Mode }
|
||||
|
||||
Write-Host ' ' -NoNewline
|
||||
Write-Host ('-' * 59) -ForegroundColor DarkGray
|
||||
Write-Host ' now ' -NoNewline -ForegroundColor DarkGray
|
||||
Write-Host $tag -NoNewline -ForegroundColor $accent
|
||||
Write-Host ' ' -NoNewline
|
||||
Write-Host 'switch Claude Code between providers' -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
function Show-Usage {
|
||||
@'
|
||||
claude-mode - switch Claude Code between Anthropic and gateway providers
|
||||
|
||||
claude-mode interactive menu
|
||||
claude-mode status active mode, preset, model map
|
||||
|
||||
claude-mode anthropic native login/subscription (clears all gateway config)
|
||||
'@ | Write-Host
|
||||
# One line per provider in providers.json, so a new one documents itself.
|
||||
foreach ($p in $script:Providers) {
|
||||
$desc = ([string]$p.label) -replace '^[^-]*-\s*', ''
|
||||
Write-Host (" claude-mode {0,-26} {1} (default preset: {2})" -f "$($p.id) [preset]", $desc, $script:ProviderDefaultPreset[[string]$p.id])
|
||||
}
|
||||
@'
|
||||
|
||||
claude-mode presets list presets
|
||||
claude-mode preset show <name>
|
||||
claude-mode preset new <name> [from] create a preset (copies 'from')
|
||||
claude-mode preset set <name> <tier> <model-id>
|
||||
tier = opus | sonnet | haiku | fable | subagent
|
||||
claude-mode preset all <name> <model-id>
|
||||
point every tier at one model
|
||||
claude-mode preset rm <name>
|
||||
|
||||
claude-mode set-key [ref] [key] store an API key (hidden prompt, DPAPI-encrypted)
|
||||
claude-mode models [filter] models available from the active provider
|
||||
claude-mode doctor verify auth, endpoint, model ids, stray env vars
|
||||
claude-mode repair [--all] strip [1m] tags from cached model ids
|
||||
(default: gateway ids only; --all includes Anthropic)
|
||||
'@ | Write-Host
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# lib/providers.ps1 - every gateway provider, read from providers.json, and lookups into them.
|
||||
#
|
||||
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
|
||||
# the settings at its top. Not meant to run on its own. ASCII only: Windows
|
||||
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
|
||||
# lib\, so paths beside the main script go through $script:Here.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Providers
|
||||
#
|
||||
# Every gateway provider is an entry in providers.json, next to this script and
|
||||
# shared with the POSIX build: endpoint, auth, how its model list is fetched,
|
||||
# which doctor checks apply. This script only knows the *kinds* of behaviour
|
||||
# (Get-ProviderCatalogue, Test-PresetCatalogue) and picks one by name from the
|
||||
# entry, so a provider that reuses them needs no change here. anthropic is
|
||||
# built in: it is the native login, not a gateway.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$script:ProvidersPath = Join-Path $script:Here 'providers.json'
|
||||
$script:Providers = @()
|
||||
try {
|
||||
$__pj = Get-Content -LiteralPath $script:ProvidersPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$script:Providers = @($__pj.providers | Where-Object { $_.id })
|
||||
} catch { }
|
||||
if ($script:Providers.Count -eq 0) {
|
||||
Write-Host " FAIL providers.json missing or unreadable at $($script:ProvidersPath) - re-run install.ps1" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$script:Modes = @('anthropic') + @($script:Providers | ForEach-Object { [string]$_.id })
|
||||
$script:ModeLabel = @{ 'anthropic' = 'Anthropic - your subscription login, no gateway' }
|
||||
|
||||
# `claude-mode <provider>` with no preset named uses this one. Deliberately a
|
||||
# fixed choice rather than "most recently used", so the command is predictable.
|
||||
$script:ProviderDefaultPreset = @{}
|
||||
foreach ($__p in $script:Providers) {
|
||||
$script:ModeLabel[[string]$__p.id] = [string]$__p.label
|
||||
$script:ProviderDefaultPreset[[string]$__p.id] = $(if ($__p.defaultPreset) { [string]$__p.defaultPreset } else { [string]$__p.id })
|
||||
}
|
||||
|
||||
function Get-Provider {
|
||||
param([string] $Id)
|
||||
return ($script:Providers | Where-Object { $_.id -eq $Id } | Select-Object -First 1)
|
||||
}
|
||||
|
||||
# An id or an alias (z.ai, z-ai) to the provider id; $null if neither.
|
||||
function Resolve-ProviderId {
|
||||
param([string] $Word)
|
||||
$w = ([string]$Word).ToLower()
|
||||
foreach ($p in $script:Providers) {
|
||||
if ([string]$p.id -eq $w -or (@($p.aliases) -contains $w)) { return [string]$p.id }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Test-ProviderDoctor {
|
||||
param([string] $Id, [string] $Check)
|
||||
$p = Get-Provider $Id
|
||||
return [bool]($p -and (@($p.doctor) -contains $Check))
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
# lib/switch.ps1 - the write into settings.json, and the apiKeyHelper command line.
|
||||
#
|
||||
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
|
||||
# the settings at its top. Not meant to run on its own. ASCII only: Windows
|
||||
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
|
||||
# lib\, so paths beside the main script go through $script:Here.
|
||||
|
||||
# Claude Code runs apiKeyHelper as a shell command line, not as a bare argv[0],
|
||||
# so the value in settings.json is parsed by cmd before anything is executed. A
|
||||
# profile path containing a space therefore has to arrive already quoted:
|
||||
# C:\Users\Firstname Lastname\... otherwise splits and cmd tries to run
|
||||
# C:\Users\Firstname. Paths with nothing cmd cares about are written bare,
|
||||
# exactly as before, so no existing settings.json churns on the next switch.
|
||||
function Get-HelperCommandLine {
|
||||
param([string] $Path = $script:HelperCmd)
|
||||
if ($Path -match '[\s&()^;,]') { return '"' + $Path + '"' }
|
||||
return $Path
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# settings.json rewriting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Backup-Settings {
|
||||
if (-not (Test-Path -LiteralPath $script:Settings)) { return $null }
|
||||
Initialize-Root
|
||||
$stamp = (Get-Date).ToString('yyyyMMdd-HHmmss-fff')
|
||||
$dest = Join-Path $script:BackupDir "settings.$stamp.json"
|
||||
Copy-Item -LiteralPath $script:Settings -Destination $dest -Force
|
||||
$old = @(Get-ChildItem -LiteralPath $script:BackupDir -Filter 'settings.*.json' |
|
||||
Sort-Object Name -Descending | Select-Object -Skip 20)
|
||||
foreach ($f in $old) { Remove-Item -LiteralPath $f.FullName -Force }
|
||||
return $dest
|
||||
}
|
||||
|
||||
function Clear-ManagedSettings {
|
||||
param($Settings)
|
||||
# Baseline keys plus whatever the previous switch actually wrote, so a
|
||||
# preset's custom extraEnv key cannot outlive the preset that added it.
|
||||
$keys = @($script:BaseManagedEnvKeys) + @((Get-State)['writtenEnvKeys']) | Sort-Object -Unique
|
||||
|
||||
if ($Settings.Contains('env') -and $Settings['env'] -is [System.Collections.IDictionary]) {
|
||||
foreach ($k in $keys) {
|
||||
if ($k -and $Settings['env'].Contains($k)) { $Settings['env'].Remove($k) }
|
||||
}
|
||||
if ($Settings['env'].Count -eq 0) { $Settings.Remove('env') }
|
||||
}
|
||||
if ($Settings.Contains('apiKeyHelper')) { $Settings.Remove('apiKeyHelper') }
|
||||
return $Settings
|
||||
}
|
||||
|
||||
function Set-ClaudeMode {
|
||||
param(
|
||||
[string] $Mode,
|
||||
[string] $PresetName
|
||||
)
|
||||
|
||||
Initialize-Root
|
||||
if (-not (Test-Path -LiteralPath $script:SettingsDir)) {
|
||||
New-Item -ItemType Directory -Path $script:SettingsDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# Any provider in providers.json, rather than a fixed ValidateSet.
|
||||
if ($Mode -ne 'anthropic' -and -not (Get-Provider $Mode)) { throw "unknown mode '$Mode'" }
|
||||
|
||||
$settings = Read-JsonFile $script:Settings
|
||||
if ($null -eq $settings) { $settings = [ordered]@{} }
|
||||
|
||||
$backup = Backup-Settings
|
||||
$settings = Clear-ManagedSettings $settings
|
||||
$written = @()
|
||||
$preset = $null
|
||||
|
||||
if ($Mode -ne 'anthropic') {
|
||||
$preset = Get-Preset $PresetName
|
||||
if ([string]$preset['provider'] -ne $Mode) {
|
||||
throw "preset '$PresetName' declares provider '$($preset['provider'])', not '$Mode'"
|
||||
}
|
||||
|
||||
$models = $preset['models']
|
||||
if ($null -eq $models) { throw "preset '$PresetName' has no 'models' block" }
|
||||
|
||||
# A custom endpoint ships with no address, since there is no sensible
|
||||
# one to guess. Switching to it would point every session at nothing.
|
||||
if ([string]::IsNullOrWhiteSpace([string]$preset['baseUrl'])) {
|
||||
throw "preset '$PresetName' has no server address - set baseUrl in $(Get-PresetPath $PresetName)"
|
||||
}
|
||||
|
||||
# Every tier empty - a fresh blank preset - would switch cleanly and
|
||||
# leave Claude Code asking for its own default Anthropic models: billed
|
||||
# at full price through OpenRouter, refused by the other providers.
|
||||
$anyTier = $false
|
||||
foreach ($tier in $script:Tiers) { if (-not [string]::IsNullOrWhiteSpace([string]$models[$tier])) { $anyTier = $true } }
|
||||
if (-not $anyTier) {
|
||||
throw "preset '$PresetName' has no models set - set a tier first: claude-mode preset set $PresetName <tier> <model-id>"
|
||||
}
|
||||
|
||||
# Cost guard. Gateways resell Anthropic models at full list price, with no
|
||||
# subscription discount - routing a tier there is almost never intended
|
||||
# and is expensive enough to be worth blocking outright. Opt in per
|
||||
# preset with "allowAnthropicModels": true.
|
||||
if (-not ($preset.Contains('allowAnthropicModels') -and $preset['allowAnthropicModels'])) {
|
||||
$offenders = @()
|
||||
foreach ($tier in $script:Tiers) {
|
||||
$id = [string]$models[$tier]
|
||||
if ($id -and (Test-AnthropicModelId $id)) { $offenders += "$tier -> $id" }
|
||||
}
|
||||
$sub = [string]$preset['subagentModel']
|
||||
if ($sub -and (Test-AnthropicModelId $sub)) { $offenders += "subagent -> $sub" }
|
||||
if ($offenders.Count -gt 0) {
|
||||
Write-Err2 "preset '$PresetName' routes a tier at an Anthropic model through '$Mode':"
|
||||
foreach ($o in $offenders) { Write-Err2 " $o" }
|
||||
throw "refusing to switch - gateways bill Anthropic models at full price. Add `"allowAnthropicModels`": true to the preset if this is deliberate."
|
||||
}
|
||||
}
|
||||
|
||||
$envBlock = [ordered]@{}
|
||||
$envBlock['ANTHROPIC_BASE_URL'] = [string]$preset['baseUrl']
|
||||
# Explicitly empty, not absent: a cached Anthropic login can otherwise
|
||||
# override the gateway config and surface as a model-not-found error.
|
||||
# Removed entirely when switching back to anthropic.
|
||||
$envBlock['ANTHROPIC_API_KEY'] = ''
|
||||
|
||||
foreach ($tier in $script:Tiers) {
|
||||
if ($models.Contains($tier) -and -not [string]::IsNullOrWhiteSpace([string]$models[$tier])) {
|
||||
$envBlock["ANTHROPIC_DEFAULT_$($tier.ToUpper())_MODEL"] = [string]$models[$tier]
|
||||
}
|
||||
}
|
||||
if ($preset.Contains('subagentModel') -and -not [string]::IsNullOrWhiteSpace([string]$preset['subagentModel'])) {
|
||||
$envBlock['CLAUDE_CODE_SUBAGENT_MODEL'] = [string]$preset['subagentModel']
|
||||
}
|
||||
if ($preset.Contains('gatewayModelDiscovery') -and $preset['gatewayModelDiscovery']) {
|
||||
$envBlock['CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY'] = '1'
|
||||
}
|
||||
|
||||
# Behind a custom base URL, Claude Code cannot resolve a third-party
|
||||
# model id to a context length, so it falls back to a conservative
|
||||
# default and starts auto-compacting long before the model is actually
|
||||
# full. State the real window explicitly.
|
||||
if ($preset.Contains('contextTokens') -and $preset['contextTokens']) {
|
||||
$ctx = [string][int]$preset['contextTokens']
|
||||
$envBlock['CLAUDE_CODE_MAX_CONTEXT_TOKENS'] = $ctx
|
||||
$envBlock['CLAUDE_CODE_AUTO_COMPACT_WINDOW'] = $ctx
|
||||
}
|
||||
if ($preset.Contains('extraEnv') -and $preset['extraEnv'] -is [System.Collections.IDictionary]) {
|
||||
foreach ($k in $preset['extraEnv'].Keys) { $envBlock[$k] = [string]$preset['extraEnv'][$k] }
|
||||
}
|
||||
|
||||
# Auth. 'vault' keeps the secret out of settings.json entirely and hands
|
||||
# it over at runtime; 'literal' is for non-secrets like LM Studio's
|
||||
# placeholder token.
|
||||
$auth = Get-PresetAuth $preset
|
||||
if ([string]$auth['mode'] -eq 'vault') {
|
||||
$keyRef = 'openrouter'
|
||||
if ($auth.Contains('keyRef') -and $auth['keyRef']) { $keyRef = [string]$auth['keyRef'] }
|
||||
if (-not (Get-VaultKey $keyRef)) {
|
||||
throw "no key stored for ref '$keyRef'. Run: claude-mode set-key $keyRef"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $script:HelperCmd)) {
|
||||
throw "key helper missing at $($script:HelperCmd). Re-run install.ps1"
|
||||
}
|
||||
$settings['apiKeyHelper'] = Get-HelperCommandLine
|
||||
} else {
|
||||
# The provider's own placeholder (lmstudio, ollama), not LM Studio's.
|
||||
$pv = Get-Provider ([string]$preset['provider'])
|
||||
$tok = $(if ($pv -and $pv.preset.auth.token) { [string]$pv.preset.auth.token } else { 'lmstudio' })
|
||||
if ($auth.Contains('token') -and $auth['token']) { $tok = [string]$auth['token'] }
|
||||
$envBlock['ANTHROPIC_AUTH_TOKEN'] = $tok
|
||||
}
|
||||
|
||||
if ($settings.Contains('env') -and $settings['env'] -is [System.Collections.IDictionary]) {
|
||||
foreach ($k in $envBlock.Keys) { $settings['env'][$k] = $envBlock[$k] }
|
||||
} else {
|
||||
$settings['env'] = $envBlock
|
||||
}
|
||||
$written = @($envBlock.Keys)
|
||||
}
|
||||
|
||||
# settings.json deliberately keeps its default ACL: it never holds a real
|
||||
# secret (apiKeyHelper supplies those), and other tools read it.
|
||||
Write-JsonFile $script:Settings $settings
|
||||
Set-State -Mode $Mode -PresetName $PresetName -WrittenKeys $written
|
||||
|
||||
if ($Mode -eq 'anthropic') { Write-Head 'switched to: anthropic' }
|
||||
else { Write-Head "switched to: $Mode / preset '$PresetName'" }
|
||||
if ($backup) { Write-Ok "settings.json backed up to $backup" }
|
||||
|
||||
if ($Mode -eq 'anthropic') {
|
||||
Write-Ok 'all gateway env + apiKeyHelper removed; native Anthropic login is authoritative'
|
||||
} else {
|
||||
Write-Ok "base url $($preset['baseUrl'])"
|
||||
foreach ($tier in $script:Tiers) {
|
||||
if ($preset['models'].Contains($tier)) {
|
||||
Write-Ok ("{0,-7} -> {1}" -f $tier, $preset['models'][$tier])
|
||||
}
|
||||
}
|
||||
if ($preset.Contains('contextTokens') -and $preset['contextTokens']) {
|
||||
Write-Ok ("context -> {0:N0} tokens (max + auto-compact window)" -f [int]$preset['contextTokens'])
|
||||
} else {
|
||||
Write-Warn2 'no contextTokens in this preset - Claude Code will guess a small window and compact early'
|
||||
}
|
||||
$auth = Get-PresetAuth $preset
|
||||
if ([string]$auth['mode'] -eq 'vault') {
|
||||
Write-Ok 'auth via apiKeyHelper (key stays DPAPI-encrypted on disk)'
|
||||
} else {
|
||||
Write-Ok "auth inline placeholder token '$($auth['token'])' (not a secret)"
|
||||
}
|
||||
}
|
||||
|
||||
[void](Test-StrayEnvVars -Mode $Mode)
|
||||
|
||||
if ($Mode -ne 'anthropic') {
|
||||
$auth = Get-PresetAuth $preset
|
||||
if ([string]$auth['mode'] -eq 'vault') {
|
||||
Show-GuardrailStatus -Mode $Mode -Key (Get-VaultKey ([string]$auth['keyRef']))
|
||||
}
|
||||
}
|
||||
[void](Test-StaleModelSelections -Mode $Mode)
|
||||
|
||||
Write-HealthFile -Mode $Mode -PresetName $PresetName
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ' restart claude (and reload the VS Code window) to pick this up' -ForegroundColor DarkGray
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# lib/vault.ps1 - the DPAPI key vault, and a preset's auth.
|
||||
#
|
||||
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
|
||||
# the settings at its top. Not meant to run on its own. ASCII only: Windows
|
||||
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
|
||||
# lib\, so paths beside the main script go through $script:Here.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vault (DPAPI: CurrentUser scope)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Get-VaultPath { param([string] $Ref) return (Join-Path $script:VaultDir "$Ref.cred") }
|
||||
|
||||
function ConvertFrom-SecureStringPlain {
|
||||
param([System.Security.SecureString] $Secure)
|
||||
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure)
|
||||
try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
|
||||
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
|
||||
}
|
||||
|
||||
function Set-VaultKey {
|
||||
param([string] $Ref, [string] $Key = '')
|
||||
Initialize-Root
|
||||
if ($Key) {
|
||||
Write-Warn2 'the key was given on the command line, so it is in this shell history - the hidden prompt leaves no trace'
|
||||
$plain = $Key
|
||||
# The vault is written from a SecureString (DPAPI), so the inline form
|
||||
# has to produce one too.
|
||||
$secure = ConvertTo-SecureString -String $Key -AsPlainText -Force
|
||||
} else {
|
||||
Write-Host "Paste the API key for ref '$Ref' (input hidden):"
|
||||
$secure = Read-Host -AsSecureString
|
||||
$plain = ConvertFrom-SecureStringPlain $secure
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($plain)) { throw 'empty key, aborted' }
|
||||
|
||||
# A hidden prompt will happily swallow a mis-paste. Guard the two shapes that
|
||||
# are never a real key, because the failure is otherwise invisible until the
|
||||
# provider answers 401 and the UI just spins.
|
||||
if ($plain -match '\s') {
|
||||
throw "that value contains whitespace, so it is not an API key (a pasted command line?). Nothing was stored."
|
||||
}
|
||||
if ($plain -like 'claude-mode*') {
|
||||
throw "that value is a claude-mode command, not an API key. Nothing was stored."
|
||||
}
|
||||
if ($plain.Length -lt 16) {
|
||||
$unit = if ($plain.Length -eq 1) { 'character' } else { 'characters' }
|
||||
Write-Warn2 "that key is only $($plain.Length) $unit - unusually short. Storing anyway."
|
||||
}
|
||||
if ($Ref -eq 'openrouter' -and $plain -notlike 'sk-or-*') {
|
||||
Write-Warn2 "key does not start with 'sk-or-' - storing anyway"
|
||||
}
|
||||
|
||||
$path = Get-VaultPath $Ref
|
||||
# ConvertFrom-SecureString with no -Key uses DPAPI, CurrentUser scope.
|
||||
ConvertFrom-SecureString -SecureString $secure |
|
||||
Set-Content -LiteralPath $path -Encoding ASCII -NoNewline
|
||||
Protect-FileAcl $path
|
||||
Write-Ok "stored DPAPI-encrypted key at $path"
|
||||
}
|
||||
|
||||
function Get-VaultKey {
|
||||
param([string] $Ref)
|
||||
$path = Get-VaultPath $Ref
|
||||
if (-not (Test-Path -LiteralPath $path)) { return $null }
|
||||
$blob = (Get-Content -LiteralPath $path -Raw).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($blob)) { return $null }
|
||||
try { return ConvertFrom-SecureStringPlain (ConvertTo-SecureString $blob) }
|
||||
catch { return $null }
|
||||
}
|
||||
|
||||
function Format-KeyMask {
|
||||
param([string] $Key)
|
||||
if ([string]::IsNullOrEmpty($Key)) { return '(none)' }
|
||||
if ($Key.Length -le 12) { return '****' }
|
||||
return ($Key.Substring(0, 8) + '...' + $Key.Substring($Key.Length - 4))
|
||||
}
|
||||
|
||||
function Get-PresetAuth {
|
||||
param($Preset)
|
||||
$auth = $Preset['auth']
|
||||
if ($null -eq $auth) { $auth = [ordered]@{ mode = 'vault'; keyRef = 'openrouter' } }
|
||||
if (-not $auth.Contains('mode')) { $auth['mode'] = 'vault' }
|
||||
return $auth
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# claude-mode bootstrap installer for Linux and macOS.
|
||||
#
|
||||
# Served by the Arkylx Index and run as a one-liner:
|
||||
#
|
||||
# curl -fsSL https://index.arkylx.com/tools/claude-mode/install.sh | bash
|
||||
#
|
||||
# Downloads the payload, verifies its SHA-256 against the manifest, unpacks it to
|
||||
# a temp directory and runs the bundled install.sh. Nothing is written outside
|
||||
# ~/.claude-mode, ~/.local/bin and your shell rc.
|
||||
#
|
||||
# Environment overrides:
|
||||
# ARKYLX_CLAUDE_MODE_BASE base URL (default: index.arkylx.com)
|
||||
# ARKYLX_CLAUDE_MODE_VERSION pin a version instead of "latest"
|
||||
# ARKYLX_CODE enrolment code; reports the install to the Index
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${ARKYLX_CLAUDE_MODE_BASE:-https://index.arkylx.com/tools/claude-mode}"
|
||||
BASE="${BASE%/}"
|
||||
VERSION="${ARKYLX_CLAUDE_MODE_VERSION:-latest}"
|
||||
|
||||
green() { printf ' \033[32mok \033[0m %s\n' "$*"; }
|
||||
warn() { printf ' \033[33mwarn\033[0m %s\n' "$*"; }
|
||||
fail() { printf ' \033[31mFAIL\033[0m %s\n' "$*" >&2; }
|
||||
|
||||
printf '\n \033[36mclaude-mode installer\033[0m\n'
|
||||
printf ' \033[90msource: %s (%s)\033[0m\n\n' "$BASE" "$VERSION"
|
||||
|
||||
# --- preflight -------------------------------------------------------------
|
||||
need() { command -v "$1" >/dev/null 2>&1 || { fail "$1 is required but not installed"; exit 1; }; }
|
||||
need curl
|
||||
need tar
|
||||
PY="${CLAUDE_MODE_PYTHON:-python3}"
|
||||
command -v "$PY" >/dev/null 2>&1 || {
|
||||
fail "python3 is required (claude-mode uses it for JSON handling)"
|
||||
fail " Debian/Ubuntu: sudo apt install python3"
|
||||
fail " Fedora/RHEL: sudo dnf install python3"
|
||||
fail " macOS: xcode-select --install (or brew install python)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
sha256_of() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1
|
||||
elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1
|
||||
else fail 'no sha256sum or shasum available - cannot verify the download'; exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
WORK="$(mktemp -d 2>/dev/null || mktemp -d -t claude-mode)"
|
||||
cleanup() { rm -rf "$WORK"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# --- manifest --------------------------------------------------------------
|
||||
curl -fsSL --max-time 30 "$BASE/$VERSION/manifest.posix.json" -o "$WORK/manifest.json" || {
|
||||
fail "could not fetch $BASE/$VERSION/manifest.posix.json"; exit 1; }
|
||||
|
||||
read_field() { "$PY" -c "import json,sys;print(json.load(open(sys.argv[1])).get(sys.argv[2],''))" "$WORK/manifest.json" "$1"; }
|
||||
PKG="$(read_field package)"
|
||||
EXPECTED="$(read_field sha256)"
|
||||
PKGVER="$(read_field version)"
|
||||
[ -n "$PKG" ] && [ -n "$EXPECTED" ] || { fail 'manifest is missing package/sha256'; exit 1; }
|
||||
|
||||
# --- payload ---------------------------------------------------------------
|
||||
printf ' downloading %s (%s)\n' "$PKG" "$PKGVER"
|
||||
curl -fsSL --max-time 120 "$BASE/$VERSION/$PKG" -o "$WORK/$PKG" || { fail 'download failed'; exit 1; }
|
||||
|
||||
ACTUAL="$(sha256_of "$WORK/$PKG")"
|
||||
if [ "$ACTUAL" != "$EXPECTED" ]; then
|
||||
fail 'checksum mismatch - refusing to install'
|
||||
fail "expected $EXPECTED"
|
||||
fail "actual $ACTUAL"
|
||||
exit 1
|
||||
fi
|
||||
green 'checksum verified'
|
||||
|
||||
mkdir -p "$WORK/src"
|
||||
tar -xzf "$WORK/$PKG" -C "$WORK/src"
|
||||
[ -f "$WORK/src/linux/install.sh" ] || { fail 'package does not contain linux/install.sh'; exit 1; }
|
||||
chmod +x "$WORK/src/linux/install.sh"
|
||||
|
||||
printf '\n'
|
||||
"$WORK/src/linux/install.sh" "$@"
|
||||
|
||||
# --- optional report -------------------------------------------------------
|
||||
# Best-effort: a failure here must never make a good install look broken.
|
||||
if [ -n "${ARKYLX_CODE:-}" ] && [ "${ARKYLX_CLAUDE_MODE_REPORT:-1}" != "0" ]; then
|
||||
if curl -fsS --max-time 10 -X POST "$BASE/report" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"code\":\"$ARKYLX_CODE\",\"tool\":\"claude-mode\",\"version\":\"$PKGVER\",\"hostname\":\"$(hostname 2>/dev/null || echo unknown)\",\"os\":\"$(uname -sr 2>/dev/null || echo unknown)\"}" \
|
||||
>/dev/null 2>&1; then
|
||||
green 'reported install to the Arkylx Index'
|
||||
else
|
||||
warn 'could not report to the Index (install is fine)'
|
||||
fi
|
||||
fi
|
||||
+24
-11
@@ -8,6 +8,12 @@
|
||||
|
||||
set -u
|
||||
|
||||
# Claude Code surfaces a failing helper as "your apiKeyHelper script is failing"
|
||||
# and shows this stream under /status. Exiting 1 in silence turns several
|
||||
# distinct faults into one indistinguishable message, so every failure path says
|
||||
# which it was. Success paths stay silent - stdout carries the key and nothing else.
|
||||
fail() { printf 'claude-key-helper: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
CM_ROOT="${CM_ROOT:-$HOME/.claude-mode}"
|
||||
# shellcheck source=/dev/null
|
||||
. "$CM_ROOT/bin/cm-vault.sh"
|
||||
@@ -22,17 +28,24 @@ mode="$("$PY" "$JSON" get "$state" mode 2>/dev/null)"
|
||||
[ "$mode" = "anthropic" ] && exit 0
|
||||
[ -n "$mode" ] || exit 0
|
||||
|
||||
preset_name="$("$PY" "$JSON" get "$state" preset 2>/dev/null)"
|
||||
[ -n "$preset_name" ] || exit 0
|
||||
# `preset rename` on the active preset moves the file while this may be running
|
||||
# in any live session. The name read from state.json can be the old one by the
|
||||
# time the file is opened, so the preset is read in a single open (a removal
|
||||
# after that cannot change what was read), and a miss re-reads state.json once:
|
||||
# the rename repoints it before removing the old name, so the second read finds
|
||||
# the new one. A second miss is a real fault.
|
||||
auth=''
|
||||
for _attempt in 1 2; do
|
||||
preset_name="$("$PY" "$JSON" get "$state" preset 2>/dev/null)"
|
||||
[ -n "$preset_name" ] || exit 0
|
||||
preset="$CM_ROOT/presets/$preset_name.json"
|
||||
auth="$("$PY" "$JSON" auth-of "$preset" 2>/dev/null)" && break
|
||||
auth=''
|
||||
done
|
||||
[ -n "$auth" ] || fail "state.json names preset '$preset_name' but $preset does not exist. Run: claude-mode presets"
|
||||
|
||||
preset="$CM_ROOT/presets/$preset_name.json"
|
||||
[ -f "$preset" ] || exit 1
|
||||
|
||||
auth_mode="$("$PY" "$JSON" get "$preset" auth.mode 2>/dev/null)"
|
||||
[ -z "$auth_mode" ] && auth_mode="vault"
|
||||
auth_mode="${auth%%$'\t'*}"
|
||||
key_ref="${auth#*$'\t'}"
|
||||
[ "$auth_mode" = "vault" ] || exit 0 # inline token: nothing for us to emit
|
||||
|
||||
key_ref="$("$PY" "$JSON" get "$preset" auth.keyRef 2>/dev/null)"
|
||||
[ -n "$key_ref" ] || key_ref="openrouter"
|
||||
|
||||
cm_vault_get "$key_ref" || exit 1
|
||||
cm_vault_get "$key_ref" || fail "no key readable for ref '$key_ref' from $(cm_vault_backend_label 2>/dev/null || echo 'the vault'). Run: claude-mode set-key $key_ref"
|
||||
|
||||
+74
-2254
File diff suppressed because it is too large
Load Diff
+625
-79
@@ -14,12 +14,23 @@ Subcommands:
|
||||
scaffold <provider> print a blank preset
|
||||
get <file> <dotted.path> print one value
|
||||
presets <dir> "name<TAB>provider<TAB>desc"
|
||||
scan-sessions <projects> [max-age-days] [ignored] broken transcripts, as JSON
|
||||
ignore-session <ignored> <projects> add|remove|clear|list [id]
|
||||
cache-models <cache> <provider> <1|0> [baseUrl] store a TSV catalogue (stdin)
|
||||
preset-rename <dir> <old> <new> <state> [defaults] rename, repointing state + default
|
||||
auth-of <preset> "mode<TAB>keyRef"; fails if missing
|
||||
set-default <defaults> <provider> [name] choose (or clear) a provider default
|
||||
provider-tsv | provider-resolve <word> | provider-get <id> <path> | provider-static <id>
|
||||
read providers.json
|
||||
ollama-models | openai-models catalogue parsers (JSON on stdin)
|
||||
ollama-ctx show | ps <model> context lengths from Ollama (stdin)
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
# Matches both the qualified gateway id (anthropic/claude-opus-5) and the bare
|
||||
@@ -56,6 +67,163 @@ BASE_MANAGED = [
|
||||
|
||||
TIERS = ["opus", "sonnet", "haiku", "fable"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Providers
|
||||
#
|
||||
# Every gateway provider is described in providers.json - endpoint, auth, how
|
||||
# its model list is fetched, how setup runs, which doctor checks apply, how it
|
||||
# is drawn - so adding one that reuses an existing behaviour is an entry there
|
||||
# rather than code in four places. What differs in *kind* (parsing a catalogue
|
||||
# format, probing a server) stays in code, picked by name from the entry.
|
||||
#
|
||||
# anthropic is not in it: it is the native login, not a gateway.
|
||||
#
|
||||
# The file sits one level above this script in both layouts - the repository
|
||||
# (linux/cm-json.py) and an install (~/.claude-mode/bin/cm-json.py) - so no
|
||||
# path has to be passed around. CM_PROVIDERS overrides it, for tests.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROVIDERS_PATH = os.environ.get("CM_PROVIDERS") or os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "providers.json")
|
||||
_PROVIDERS = None
|
||||
|
||||
|
||||
def providers():
|
||||
global _PROVIDERS
|
||||
if _PROVIDERS is None:
|
||||
if not os.path.exists(PROVIDERS_PATH):
|
||||
raise SystemExit("providers.json not found at %s - re-run the installer" % PROVIDERS_PATH)
|
||||
data = load(PROVIDERS_PATH, {})
|
||||
items = data.get("providers") if isinstance(data, dict) else None
|
||||
_PROVIDERS = [p for p in (items or []) if isinstance(p, dict) and p.get("id")]
|
||||
return _PROVIDERS
|
||||
|
||||
|
||||
def provider(pid):
|
||||
for p in providers():
|
||||
if p["id"] == pid:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _dig(node, dotted, default=None):
|
||||
for part in dotted.split("."):
|
||||
if isinstance(node, dict) and part in node:
|
||||
node = node[part]
|
||||
else:
|
||||
return default
|
||||
return node
|
||||
|
||||
|
||||
def catalogue_kind(pid):
|
||||
return _dig(provider(pid) or {}, "catalogue.kind", "")
|
||||
|
||||
|
||||
def per_server_catalogue(pid):
|
||||
"""A catalogue that belongs to one server rather than to the provider -
|
||||
two LM Studio presets can point at two machines with different models."""
|
||||
return bool(_dig(provider(pid) or {}, "catalogue.perServer", False))
|
||||
|
||||
|
||||
def builtin_default_preset():
|
||||
"""What `claude-mode <provider>` picks when no preset is named and none has
|
||||
been chosen. Mirrored by builtin_default_preset() in the CLI."""
|
||||
return {p["id"]: p.get("defaultPreset") or p["id"] for p in providers()}
|
||||
|
||||
|
||||
# Column order of `provider-tsv`, which the shell reads once per run and looks
|
||||
# rows up in (bash 3.2 on macOS has no associative arrays). Append only: the
|
||||
# shell addresses these by number.
|
||||
PROVIDER_TSV = [
|
||||
("id", lambda p: p["id"]),
|
||||
("aliases", lambda p: ",".join(p.get("aliases") or [])),
|
||||
("title", lambda p: p.get("title") or p["id"]),
|
||||
("label", lambda p: p.get("label") or p.get("title") or p["id"]),
|
||||
("color", lambda p: p.get("color") or "gray"),
|
||||
("defaultPreset", lambda p: p.get("defaultPreset") or p["id"]),
|
||||
("serverEditable", lambda p: "1" if _dig(p, "server.editable") else "0"),
|
||||
("probe", lambda p: _dig(p, "server.probe", "none")),
|
||||
("probePaths", lambda p: ",".join(_dig(p, "server.paths", []) or [])),
|
||||
("catalogueKind", lambda p: _dig(p, "catalogue.kind", "")),
|
||||
("perServer", lambda p: "1" if _dig(p, "catalogue.perServer") else "0"),
|
||||
("setupKey", lambda p: _dig(p, "setup.key", "required")),
|
||||
("setupModels", lambda p: _dig(p, "setup.models", "per-tier")),
|
||||
("keyUrl", lambda p: _dig(p, "setup.keyUrl", "")),
|
||||
("guardrail", lambda p: "1" if p.get("guardrail") else "0"),
|
||||
("doctor", lambda p: ",".join(p.get("doctor") or [])),
|
||||
("defaultKeyRef", lambda p: _dig(p, "preset.auth.keyRef") or p["id"]),
|
||||
("literalToken", lambda p: _dig(p, "preset.auth.token") or p["id"]),
|
||||
("defaultBaseUrl", lambda p: _dig(p, "preset.baseUrl", "")),
|
||||
("serverHint", lambda p: _dig(p, "server.hint", "")),
|
||||
("serverStart", lambda p: _dig(p, "server.start", "")),
|
||||
]
|
||||
|
||||
|
||||
def cmd_provider_static(argv):
|
||||
"""provider-static <id> - a fixed catalogue from providers.json as id<TAB>note."""
|
||||
for m in _dig(provider(argv[0]) or {}, "catalogue.static", []) or []:
|
||||
if isinstance(m, dict) and m.get("id"):
|
||||
print("%s\t%s" % (m["id"], m.get("note", "")))
|
||||
|
||||
|
||||
def cmd_ollama_ctx(argv):
|
||||
"""ollama-ctx show | ps <model> (the matching Ollama response on stdin)
|
||||
|
||||
show: the model's own maximum, from /api/show's model_info
|
||||
"<architecture>.context_length".
|
||||
ps: the context a loaded model is actually running with, from /api/ps -
|
||||
which is the number that matters, because Ollama sets it on the
|
||||
server and cuts anything longer off without saying so.
|
||||
Prints nothing when the answer is not there to read.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(sys.stdin.read() or "{}")
|
||||
except ValueError:
|
||||
return
|
||||
if argv[0] == "show":
|
||||
info = data.get("model_info") or {}
|
||||
for k, v in info.items():
|
||||
if k.endswith(".context_length") and isinstance(v, int):
|
||||
print(v)
|
||||
return
|
||||
elif argv[0] == "ps" and len(argv) > 1:
|
||||
# A bare name is `:latest` to Ollama, and /api/ps reports the tag.
|
||||
want = {argv[1]} | ({argv[1] + ":latest"} if ":" not in argv[1] else set())
|
||||
for m in data.get("models") or []:
|
||||
if want & {m.get("name"), m.get("model")} and isinstance(m.get("context_length"), int):
|
||||
print(m["context_length"])
|
||||
return
|
||||
|
||||
|
||||
def cmd_provider_tsv(argv):
|
||||
"""provider-tsv - one tab-separated row per provider, columns as PROVIDER_TSV."""
|
||||
for p in providers():
|
||||
print("\t".join(str(fn(p)).replace("\t", " ") for _, fn in PROVIDER_TSV))
|
||||
|
||||
|
||||
def cmd_provider_resolve(argv):
|
||||
"""provider-resolve <word> - the provider id for an id or alias, else exit 1."""
|
||||
word = (argv[0] if argv else "").strip().lower()
|
||||
for p in providers():
|
||||
if word == p["id"] or word in [a.lower() for a in (p.get("aliases") or [])]:
|
||||
print(p["id"])
|
||||
return
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_provider_get(argv):
|
||||
"""provider-get <id> <dotted.path> - one value from a provider entry."""
|
||||
p = provider(argv[0])
|
||||
if p is None:
|
||||
sys.exit(1)
|
||||
val = _dig(p, argv[1])
|
||||
if isinstance(val, (dict, list)):
|
||||
print(json.dumps(val))
|
||||
elif isinstance(val, bool):
|
||||
print("true" if val else "false")
|
||||
elif val is not None:
|
||||
print(val)
|
||||
|
||||
|
||||
def load(path, default=None):
|
||||
if not os.path.exists(path):
|
||||
@@ -156,7 +324,11 @@ def cmd_apply(argv):
|
||||
if auth.get("mode") == "vault":
|
||||
if not helper:
|
||||
raise SystemExit("apply: vault auth needs the key-helper path")
|
||||
settings["apiKeyHelper"] = helper
|
||||
# Claude Code runs this value as a shell command line, so a $HOME
|
||||
# containing a space (common on macOS) has to arrive quoted or the
|
||||
# shell splits it and tries to execute the first word. shlex.quote
|
||||
# leaves an ordinary path untouched, so nothing churns.
|
||||
settings["apiKeyHelper"] = shlex.quote(helper)
|
||||
else:
|
||||
block["ANTHROPIC_AUTH_TOKEN"] = str(auth.get("token") or "lmstudio")
|
||||
|
||||
@@ -214,34 +386,104 @@ def cmd_set_tier(argv):
|
||||
|
||||
|
||||
def cmd_scaffold(argv):
|
||||
provider = argv[0]
|
||||
base = {"provider": provider, "description": "new preset"}
|
||||
if provider == "openrouter":
|
||||
base["baseUrl"] = "https://openrouter.ai/api"
|
||||
base["auth"] = {"mode": "vault", "keyRef": "openrouter"}
|
||||
elif provider == "zai":
|
||||
base["baseUrl"] = "https://api.z.ai/api/anthropic"
|
||||
base["auth"] = {"mode": "vault", "keyRef": "zai"}
|
||||
elif provider == "lmstudio":
|
||||
base["baseUrl"] = "http://127.0.0.1:1234"
|
||||
base["auth"] = {"mode": "literal", "token": "lmstudio"}
|
||||
else:
|
||||
raise SystemExit("unknown provider '%s'" % provider)
|
||||
|
||||
"""scaffold <provider> - a blank preset built from the provider's template."""
|
||||
p = provider(argv[0])
|
||||
if p is None:
|
||||
raise SystemExit("unknown provider '%s'" % argv[0])
|
||||
tpl = p.get("preset") or {}
|
||||
base = {"provider": p["id"], "description": "new preset"}
|
||||
base["baseUrl"] = tpl.get("baseUrl", "")
|
||||
base["auth"] = dict(tpl.get("auth") or {"mode": "vault", "keyRef": p["id"]})
|
||||
base["models"] = {t: "" for t in TIERS}
|
||||
base["subagentModel"] = "inherit"
|
||||
base["gatewayModelDiscovery"] = provider == "openrouter"
|
||||
base["contextTokens"] = 262144 if provider == "lmstudio" else 1000000
|
||||
if provider == "lmstudio":
|
||||
base["extraEnv"] = {"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"}
|
||||
if provider == "zai":
|
||||
base["extraEnv"] = {
|
||||
"API_TIMEOUT_MS": "3000000",
|
||||
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
||||
}
|
||||
base["gatewayModelDiscovery"] = bool(tpl.get("gatewayModelDiscovery"))
|
||||
base["contextTokens"] = int(tpl.get("contextTokens") or 200000)
|
||||
if tpl.get("extraEnv"):
|
||||
base["extraEnv"] = dict(tpl["extraEnv"])
|
||||
print(json.dumps(base, indent=2))
|
||||
|
||||
|
||||
def cmd_auth_of(argv):
|
||||
"""auth-of <preset> - 'mode<TAB>keyRef' for the key helper, defaults filled in.
|
||||
|
||||
Exits non-zero when the file is missing, which load() would otherwise
|
||||
hide as an empty preset - and an empty preset reads as vault/openrouter,
|
||||
so a preset renamed mid-read would quietly hand out the wrong key.
|
||||
"""
|
||||
try:
|
||||
with open(argv[0], encoding="utf-8") as fh:
|
||||
text = fh.read().strip()
|
||||
except OSError:
|
||||
sys.exit(1)
|
||||
p = json.loads(text) if text else {}
|
||||
auth = p.get("auth") or {}
|
||||
print("%s\t%s" % (auth.get("mode") or "vault", auth.get("keyRef") or "openrouter"))
|
||||
|
||||
|
||||
def cmd_set_default(argv):
|
||||
"""set-default <defaults.json> <provider> [name] - an empty name clears it."""
|
||||
path, prov = argv[0], argv[1]
|
||||
name = argv[2] if len(argv) > 2 else ""
|
||||
try:
|
||||
d = load(path, {})
|
||||
except (OSError, ValueError):
|
||||
d = {}
|
||||
if not isinstance(d, dict):
|
||||
d = {}
|
||||
if name:
|
||||
d[prov] = name
|
||||
else:
|
||||
d.pop(prov, None)
|
||||
save(path, d)
|
||||
print(json.dumps(d))
|
||||
|
||||
|
||||
def cmd_preset_rename(argv):
|
||||
"""preset-rename <presets-dir> <old> <new> <state.json> [defaults.json]
|
||||
|
||||
Every running session's key helper resolves the active preset through
|
||||
state.json on each key fetch, so at no instant may state.json name a file
|
||||
that is not there. The new name is linked in first, state.json repointed,
|
||||
and only then does the old name go. A provider default chosen with
|
||||
`preset default` follows the rename too. Prints {"renamed", "active",
|
||||
"default"}.
|
||||
"""
|
||||
d, old, new, state_path = argv[:4]
|
||||
defaults_path = argv[4] if len(argv) > 4 else ""
|
||||
src = os.path.join(d, old + ".json")
|
||||
dst = os.path.join(d, new + ".json")
|
||||
if not os.path.isfile(src):
|
||||
raise SystemExit("preset '%s' not found" % old)
|
||||
if os.path.exists(dst):
|
||||
raise SystemExit("preset '%s' already exists" % new)
|
||||
try:
|
||||
os.link(src, dst)
|
||||
except OSError:
|
||||
__import__("shutil").copy2(src, dst) # filesystems without hard links
|
||||
|
||||
state = load(state_path, {})
|
||||
active = isinstance(state, dict) and state.get("preset") == old
|
||||
if active:
|
||||
state["preset"] = new
|
||||
save(state_path, state)
|
||||
|
||||
moved = []
|
||||
if defaults_path:
|
||||
try:
|
||||
defaults = load(defaults_path, {})
|
||||
except (OSError, ValueError):
|
||||
defaults = {}
|
||||
if isinstance(defaults, dict):
|
||||
moved = [p for p, n in defaults.items() if n == old]
|
||||
if moved:
|
||||
for p in moved:
|
||||
defaults[p] = new
|
||||
save(defaults_path, defaults)
|
||||
|
||||
os.unlink(src)
|
||||
print(json.dumps({"renamed": True, "active": active, "default": bool(moved)}))
|
||||
|
||||
|
||||
def cmd_get(argv):
|
||||
data = load(argv[0])
|
||||
cur = data
|
||||
@@ -306,6 +548,83 @@ def cmd_or_models(argv):
|
||||
print("%s\t%s\t%s\t%s" % (m.get("id", ""), m.get("context_length", ""), per_m("prompt"), per_m("completion")))
|
||||
|
||||
|
||||
def cmd_cache_models(argv):
|
||||
"""cache-models <cache.json> <provider> <1|0> [baseUrl] TSV catalogue on stdin
|
||||
|
||||
Keeps the last catalogue each provider returned, for a reader that cannot
|
||||
afford a network round trip - the bar panel's model picker. One node per
|
||||
provider with its own fetchedAt and ok, so a failed OpenRouter fetch never
|
||||
invalidates a fresh LM Studio list. A failed fetch keeps the previous list
|
||||
(a stale list beats none when the network blinks) and marks it ok=false.
|
||||
|
||||
Model ids, context lengths and prices only: never a key reference or token.
|
||||
Prints the number of models stored by this call.
|
||||
"""
|
||||
path, provider, ok = argv[0], argv[1], argv[2] == "1"
|
||||
base = argv[3].rstrip("/") if len(argv) > 3 else ""
|
||||
kind = catalogue_kind(provider)
|
||||
per_server = per_server_catalogue(provider)
|
||||
|
||||
def num(s, cast):
|
||||
try:
|
||||
return cast(s)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
# The TSV columns are whatever that kind's parser prints (or-models,
|
||||
# lms-models, ollama-models, openai-models, or a static list's id/note).
|
||||
rows = []
|
||||
for line in sys.stdin.read().splitlines():
|
||||
parts = line.split("\t")
|
||||
mid = parts[0].strip() if parts else ""
|
||||
if not mid:
|
||||
continue
|
||||
m = {"id": mid}
|
||||
if kind == "openrouter":
|
||||
ctx, pin, pout = (parts[1:] + ["", "", ""])[:3]
|
||||
m["contextTokens"] = num(ctx, int)
|
||||
m["priceIn"] = num(pin, float)
|
||||
m["priceOut"] = num(pout, float)
|
||||
elif kind == "lmstudio":
|
||||
state, ctx = (parts[1:] + ["", ""])[:2]
|
||||
m["state"] = state
|
||||
m["contextTokens"] = num(ctx, int)
|
||||
elif kind == "ollama":
|
||||
params, quant = (parts[1:] + ["", ""])[:2]
|
||||
m["note"] = " ".join(x for x in (params, quant) if x and x != "-")
|
||||
elif len(parts) > 1 and parts[1]:
|
||||
m["note"] = parts[1]
|
||||
rows.append(m)
|
||||
|
||||
try:
|
||||
data = load(path, {})
|
||||
except (OSError, ValueError):
|
||||
data = {}
|
||||
providers = data.get("providers") if isinstance(data, dict) else None
|
||||
if not isinstance(providers, dict):
|
||||
providers = {}
|
||||
node = providers.get(provider)
|
||||
if not isinstance(node, dict):
|
||||
node = {}
|
||||
|
||||
now = __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
if ok:
|
||||
node = {"fetchedAt": now, "ok": True, "models": rows}
|
||||
if per_server:
|
||||
node["baseUrl"] = base
|
||||
else:
|
||||
# A list from a different server is not stale, it is wrong.
|
||||
if per_server and node.get("baseUrl", "") != base:
|
||||
node = {"baseUrl": base, "models": []}
|
||||
node["ok"] = False
|
||||
node["failedAt"] = now
|
||||
|
||||
providers[provider] = node
|
||||
save(path, {"schema": 1, "providers": providers})
|
||||
print(len(rows) if ok else 0)
|
||||
|
||||
|
||||
def cmd_lms_models(argv):
|
||||
"""LM Studio /api/v0/models on stdin -> 'id<TAB>state<TAB>ctx' lines."""
|
||||
data = json.loads(sys.stdin.read())
|
||||
@@ -313,6 +632,35 @@ def cmd_lms_models(argv):
|
||||
print("%s\t%s\t%s" % (m.get("id", ""), m.get("state", "unknown"), m.get("max_context_length", "")))
|
||||
|
||||
|
||||
def cmd_ollama_models(argv):
|
||||
"""Ollama /api/tags on stdin -> 'id<TAB>parameters<TAB>quantization<TAB>family'.
|
||||
|
||||
The id is `name` (e.g. qwen3-coder:30b), which is what the Anthropic
|
||||
endpoint accepts as a model. /api/tags carries no context length - that is
|
||||
a server setting (OLLAMA_CONTEXT_LENGTH), not a property of the model file.
|
||||
"""
|
||||
data = json.loads(sys.stdin.read())
|
||||
for m in sorted(data.get("models") or [], key=lambda x: x.get("name", "")):
|
||||
d = m.get("details") or {}
|
||||
# "-" rather than empty: bash's `read` with a tab IFS merges empty
|
||||
# fields, which would slide the later columns left.
|
||||
print("%s\t%s\t%s\t%s" % (m.get("name") or m.get("model", ""), d.get("parameter_size") or "-",
|
||||
d.get("quantization_level") or "-", d.get("family") or "-"))
|
||||
|
||||
|
||||
def cmd_openai_models(argv):
|
||||
"""A `GET /v1/models` list on stdin -> one id per line.
|
||||
|
||||
Covers both the OpenAI shape and Anthropic's own ({"data": [{"id": ...}]}),
|
||||
which is what a proxy in front of either tends to serve.
|
||||
"""
|
||||
data = json.loads(sys.stdin.read())
|
||||
items = data.get("data") if isinstance(data, dict) else data
|
||||
for m in sorted((items or []), key=lambda x: (x or {}).get("id", "")):
|
||||
if isinstance(m, dict) and m.get("id"):
|
||||
print(m["id"])
|
||||
|
||||
|
||||
# A model id carrying a bracket suffix - e.g. `claude-fable-5[1m]` - is Claude
|
||||
# Code's extended-context marker. It belongs to Anthropic's 1M models and no
|
||||
# gateway recognises it. A session that had it can carry the tag onto a new id
|
||||
@@ -420,7 +768,8 @@ def cmd_health(argv):
|
||||
"schema": 1,
|
||||
"tool": "claude-mode",
|
||||
"version": version or "0.0.0",
|
||||
"updatedAt": __import__("datetime").datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"updatedAt": __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"os": "posix",
|
||||
"mode": mode,
|
||||
"preset": "" if mode == "anthropic" else preset,
|
||||
@@ -483,6 +832,50 @@ def cmd_health(argv):
|
||||
})
|
||||
h["presets"] = catalogue
|
||||
|
||||
# Which preset `claude-mode <provider>` picks when none is named, in the
|
||||
# CLI's own order (default_preset_for, then resolve_preset): the chosen one
|
||||
# while its file exists, else the built-in name if that exists, else the
|
||||
# first by name. `defaultPresetChosen` holds only the explicit choices, so a
|
||||
# reader can tell a choice it could clear from the built-in fallback.
|
||||
names = {c["name"] for c in catalogue}
|
||||
try:
|
||||
chosen_raw = load(os.path.join(root, "defaults.json"), {})
|
||||
except (OSError, ValueError):
|
||||
chosen_raw = {}
|
||||
if not isinstance(chosen_raw, dict):
|
||||
chosen_raw = {}
|
||||
effective, chosen = {}, {}
|
||||
for prov, builtin in builtin_default_preset().items():
|
||||
pick = chosen_raw.get(prov)
|
||||
if pick and pick in names:
|
||||
effective[prov] = chosen[prov] = pick
|
||||
elif builtin in names:
|
||||
effective[prov] = builtin
|
||||
else:
|
||||
mine = sorted(c["name"] for c in catalogue if c["provider"] == prov)
|
||||
effective[prov] = mine[0] if mine else ""
|
||||
h["defaultPresetFor"] = effective
|
||||
h["defaultPresetChosen"] = chosen
|
||||
|
||||
# The providers as the widget needs them, in menu order: how to draw each
|
||||
# one and which panel features apply. With this published, a provider
|
||||
# added to providers.json shows up in the bar without touching its QML -
|
||||
# or restarting the shell, which a change to Modes.js would need.
|
||||
h["providers"] = [{
|
||||
"id": p["id"],
|
||||
"title": p.get("title") or p["id"],
|
||||
"blurb": p.get("blurb", ""),
|
||||
"logo": p.get("logo", ""),
|
||||
"logoScale": float(p.get("logoScale") or 1.0),
|
||||
"glyph": p.get("glyph", ""),
|
||||
"serverEditable": bool(_dig(p, "server.editable")),
|
||||
"serverHint": _dig(p, "server.hint", ""),
|
||||
"defaultBaseUrl": _dig(p, "preset.baseUrl", ""),
|
||||
"perServerCatalogue": bool(_dig(p, "catalogue.perServer")),
|
||||
"defaultKeyRef": _dig(p, "preset.auth.keyRef") or p["id"],
|
||||
"keyOptional": _dig(p, "setup.key", "required") != "required",
|
||||
} for p in providers()]
|
||||
|
||||
save(os.path.join(root, "health.json"), h)
|
||||
|
||||
|
||||
@@ -569,10 +962,16 @@ def cmd_set_auth(argv):
|
||||
never sees it either way.
|
||||
"""
|
||||
path, mode = argv[0], argv[1]
|
||||
key_ref = argv[2] if len(argv) > 2 and argv[2] else "lmstudio"
|
||||
p = load(path)
|
||||
# The provider's own names, not LM Studio's: an Ollama preset switched to
|
||||
# "none" sends Ollama's placeholder, and one switched to "key" defaults to
|
||||
# a vault slot named after its provider.
|
||||
pid = p.get("provider", "")
|
||||
key_ref = argv[2] if len(argv) > 2 and argv[2] else (
|
||||
_dig(provider(pid) or {}, "preset.auth.keyRef") or pid or "lmstudio")
|
||||
if mode == "none":
|
||||
p["auth"] = {"mode": "literal", "token": "lmstudio"}
|
||||
token = _dig(provider(pid) or {}, "preset.auth.token") or pid or "lmstudio"
|
||||
p["auth"] = {"mode": "literal", "token": token}
|
||||
elif mode == "key":
|
||||
p["auth"] = {"mode": "vault", "keyRef": key_ref}
|
||||
else:
|
||||
@@ -873,15 +1272,181 @@ def _last_msg_id_from_tail(path, window=262144):
|
||||
return None, whole
|
||||
|
||||
|
||||
def cmd_scan_sessions(argv):
|
||||
"""scan-sessions <projects-dir> - JSON list of transcripts worth repairing.
|
||||
def _broken_entry(path):
|
||||
"""The scan's record for one transcript if it needs repair, else None.
|
||||
|
||||
Only `repairable` is reported: a transcript carrying a genuine Anthropic
|
||||
message with a different provider's output after it. A session that ran
|
||||
entirely on a gateway, or never got a reply, is not damage.
|
||||
Raises OSError when the file cannot be read, which the scan counts as
|
||||
skipped rather than as healthy.
|
||||
"""
|
||||
last, whole = _last_msg_id_from_tail(path)
|
||||
# Healthy is decided by the tail alone, and is the common case.
|
||||
if last is not None and last.startswith("msg_"):
|
||||
return None
|
||||
if last is None and whole:
|
||||
return None # no replies at all; nothing to fix
|
||||
|
||||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||||
lines = fh.read().splitlines()
|
||||
|
||||
last_good, foreign = -1, []
|
||||
for i, line in enumerate(lines):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
d = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
mid = _msg_id(d)
|
||||
if not mid:
|
||||
continue
|
||||
if mid.startswith("msg_"):
|
||||
last_good = i
|
||||
elif mid.startswith("gen-"):
|
||||
foreign.append(str((d.get("message") or {}).get("model", "")))
|
||||
|
||||
if last_good < 0:
|
||||
return None # gateway-native or reply-less
|
||||
tail = [i for i in range(len(lines) - 1, last_good, -1)
|
||||
if lines[i].strip() and _msg_id(_safe(lines[i])) is not None]
|
||||
if not tail:
|
||||
return None
|
||||
|
||||
return {
|
||||
"sessionId": os.path.basename(path)[:-6],
|
||||
"project": os.path.basename(os.path.dirname(path)),
|
||||
"path": path,
|
||||
"lines": len(lines),
|
||||
"dropLines": len(lines) - (last_good + 1),
|
||||
"providers": sorted(set(f for f in foreign if f)),
|
||||
"mtime": int(os.path.getmtime(path)),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dismissed sessions
|
||||
#
|
||||
# Some damage is never going to be repaired - a throwaway session, or one whose
|
||||
# work was finished another way - and without a way to say so it holds the
|
||||
# bar's warning dot on forever, which teaches you to ignore the dot. So a broken
|
||||
# session can be dismissed, and one untouched for longer than a threshold is
|
||||
# hidden on its own. Neither is deleted or forgotten: both move to `ignored[]`,
|
||||
# with the reason, and stay one command away.
|
||||
#
|
||||
# Its own file because nothing else can hold authored state: state.json is
|
||||
# rewritten wholesale on every switch, health.json on every `health`.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
IGNORE_AGE_DAYS = 7
|
||||
SESSION_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
def _load_ignored(path):
|
||||
try:
|
||||
data = load(path, {})
|
||||
except (OSError, ValueError):
|
||||
data = {} # a corrupt file must not break the scan
|
||||
sessions = data.get("sessions") if isinstance(data, dict) else None
|
||||
return sessions if isinstance(sessions, dict) else {}
|
||||
|
||||
|
||||
def _save_ignored(path, sessions):
|
||||
save(path, {"schema": 1, "sessions": sessions})
|
||||
|
||||
|
||||
def _find_transcript(projects, sid):
|
||||
hits = sorted(glob.glob(os.path.join(projects, "*", sid + ".jsonl")))
|
||||
return hits[0] if hits else None
|
||||
|
||||
|
||||
def _prune_ignored(projects, sessions):
|
||||
"""Drop entries whose transcript has gone, so a session id that is later
|
||||
reused - or restored from a backup - does not come back already hidden."""
|
||||
gone = [sid for sid in sessions if not _find_transcript(projects, sid)]
|
||||
for sid in gone:
|
||||
del sessions[sid]
|
||||
return len(gone)
|
||||
|
||||
|
||||
def cmd_ignore_session(argv):
|
||||
"""ignore-session <ignored.json> <projects-dir> add|remove|clear|list [id]
|
||||
|
||||
Prints JSON. `list` reconciles every entry against the disk: whether the
|
||||
transcript still exists, and whether it is still broken at all.
|
||||
"""
|
||||
path, projects, action = argv[0], argv[1], argv[2]
|
||||
sid = argv[3] if len(argv) > 3 else ""
|
||||
if sid.endswith(".jsonl"):
|
||||
sid = sid[:-6]
|
||||
if action in ("add", "remove") and not SESSION_ID_RE.match(sid):
|
||||
raise SystemExit("not a session id: '%s'" % sid)
|
||||
|
||||
sessions = _load_ignored(path)
|
||||
|
||||
if action == "list":
|
||||
rows = []
|
||||
for key in sorted(sessions, key=lambda k: str(sessions[k].get("ignoredAt", ""))):
|
||||
found = _find_transcript(projects, key)
|
||||
try:
|
||||
still = bool(found and _broken_entry(found))
|
||||
except OSError:
|
||||
still = False
|
||||
rows.append({"sessionId": key,
|
||||
"project": sessions[key].get("project", ""),
|
||||
"ignoredAt": sessions[key].get("ignoredAt", ""),
|
||||
"exists": bool(found), "broken": still})
|
||||
print(json.dumps({"sessions": rows, "count": len(rows)}))
|
||||
return
|
||||
|
||||
pruned = _prune_ignored(projects, sessions)
|
||||
changed = False
|
||||
|
||||
if action == "add":
|
||||
found = _find_transcript(projects, sid)
|
||||
if not found:
|
||||
raise SystemExit("no transcript for session '%s'" % sid)
|
||||
if sid not in sessions:
|
||||
sessions[sid] = {
|
||||
"project": os.path.basename(os.path.dirname(found)),
|
||||
"ignoredAt": __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
}
|
||||
changed = True
|
||||
elif action == "remove":
|
||||
changed = sessions.pop(sid, None) is not None
|
||||
elif action == "clear":
|
||||
changed = bool(sessions)
|
||||
sessions = {}
|
||||
else:
|
||||
raise SystemExit("ignore-session: unknown action '%s'" % action)
|
||||
|
||||
if changed or pruned:
|
||||
_save_ignored(path, sessions)
|
||||
print(json.dumps({"action": action, "sessionId": sid, "changed": changed,
|
||||
"pruned": pruned, "count": len(sessions)}))
|
||||
|
||||
|
||||
def cmd_scan_sessions(argv):
|
||||
"""scan-sessions <projects-dir> [max-age-days] [ignored.json]
|
||||
|
||||
JSON list of transcripts worth repairing. Only `repairable` is reported: a
|
||||
transcript carrying a genuine Anthropic message with a different provider's
|
||||
output after it. A session that ran entirely on a gateway, or never got a
|
||||
reply, is not damage.
|
||||
|
||||
Broken transcripts that were dismissed, or not written to for longer than
|
||||
max-age-days (default 7; 0 turns the age rule off), are reported under
|
||||
`ignored[]` with a reason instead of under `broken[]`.
|
||||
"""
|
||||
root = argv[0]
|
||||
broken, scanned, skipped = [], 0, 0
|
||||
age_arg = argv[1] if len(argv) > 1 else ""
|
||||
try:
|
||||
max_age = int(age_arg) if age_arg != "" else IGNORE_AGE_DAYS
|
||||
except ValueError:
|
||||
raise SystemExit("max age must be a whole number of days, not '%s'" % age_arg)
|
||||
dismissed = _load_ignored(argv[2]) if len(argv) > 2 and argv[2] else {}
|
||||
cutoff = __import__("time").time() - max_age * 86400 if max_age > 0 else None
|
||||
|
||||
broken, ignored, scanned, skipped = [], [], 0, 0
|
||||
|
||||
for proj in sorted(glob.glob(os.path.join(root, "*"))):
|
||||
if not os.path.isdir(proj):
|
||||
@@ -889,58 +1454,27 @@ def cmd_scan_sessions(argv):
|
||||
for path in sorted(glob.glob(os.path.join(proj, "*.jsonl"))):
|
||||
scanned += 1
|
||||
try:
|
||||
last, whole = _last_msg_id_from_tail(path)
|
||||
entry = _broken_entry(path)
|
||||
except OSError:
|
||||
skipped += 1
|
||||
continue
|
||||
# Healthy is decided by the tail alone, and is the common case.
|
||||
if last is not None and last.startswith("msg_"):
|
||||
if entry is None:
|
||||
continue
|
||||
if last is None and whole:
|
||||
continue # no replies at all; nothing to fix
|
||||
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||||
lines = fh.read().splitlines()
|
||||
except OSError:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
last_good, foreign = -1, []
|
||||
for i, line in enumerate(lines):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
d = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
mid = _msg_id(d)
|
||||
if not mid:
|
||||
continue
|
||||
if mid.startswith("msg_"):
|
||||
last_good = i
|
||||
elif mid.startswith("gen-"):
|
||||
foreign.append(str((d.get("message") or {}).get("model", "")))
|
||||
|
||||
if last_good < 0:
|
||||
continue # gateway-native or reply-less
|
||||
tail = [i for i in range(len(lines) - 1, last_good, -1)
|
||||
if lines[i].strip() and _msg_id(_safe(lines[i])) is not None]
|
||||
if not tail:
|
||||
continue
|
||||
|
||||
broken.append({
|
||||
"sessionId": os.path.basename(path)[:-6],
|
||||
"project": os.path.basename(proj),
|
||||
"path": path,
|
||||
"lines": len(lines),
|
||||
"dropLines": len(lines) - (last_good + 1),
|
||||
"providers": sorted(set(f for f in foreign if f)),
|
||||
"mtime": int(os.path.getmtime(path)),
|
||||
})
|
||||
# Dismissed outranks stale: it is the reason a person gave.
|
||||
if entry["sessionId"] in dismissed:
|
||||
entry["reason"] = "dismissed"
|
||||
entry["ignoredAt"] = dismissed[entry["sessionId"]].get("ignoredAt", "")
|
||||
ignored.append(entry)
|
||||
elif cutoff is not None and entry["mtime"] < cutoff:
|
||||
entry["reason"] = "stale"
|
||||
ignored.append(entry)
|
||||
else:
|
||||
broken.append(entry)
|
||||
|
||||
print(json.dumps({"scanned": scanned, "skipped": skipped,
|
||||
"broken": broken, "count": len(broken)}))
|
||||
"broken": broken, "count": len(broken),
|
||||
"ignored": ignored, "ignoredCount": len(ignored),
|
||||
"maxAgeDays": max_age}))
|
||||
|
||||
COMMANDS = {
|
||||
"health": cmd_health,
|
||||
@@ -948,10 +1482,19 @@ COMMANDS = {
|
||||
"sessions-json": cmd_sessions_json,
|
||||
"repair-session": cmd_repair_session,
|
||||
"scan-sessions": cmd_scan_sessions,
|
||||
"ignore-session": cmd_ignore_session,
|
||||
"stale-models": cmd_stale_models,
|
||||
"strip-tags": cmd_strip_tags,
|
||||
"or-models": cmd_or_models,
|
||||
"lms-models": cmd_lms_models,
|
||||
"ollama-models": cmd_ollama_models,
|
||||
"openai-models": cmd_openai_models,
|
||||
"provider-tsv": cmd_provider_tsv,
|
||||
"provider-resolve": cmd_provider_resolve,
|
||||
"provider-get": cmd_provider_get,
|
||||
"provider-static": cmd_provider_static,
|
||||
"ollama-ctx": cmd_ollama_ctx,
|
||||
"cache-models": cmd_cache_models,
|
||||
"apply": cmd_apply,
|
||||
"summary": cmd_summary,
|
||||
"models": cmd_models,
|
||||
@@ -961,6 +1504,9 @@ COMMANDS = {
|
||||
"set-all": cmd_set_all,
|
||||
"set-auth": cmd_set_auth,
|
||||
"scaffold": cmd_scaffold,
|
||||
"preset-rename": cmd_preset_rename,
|
||||
"auth-of": cmd_auth_of,
|
||||
"set-default": cmd_set_default,
|
||||
"get": cmd_get,
|
||||
"presets": cmd_presets,
|
||||
"managed": cmd_managed,
|
||||
|
||||
+37
-1
@@ -20,7 +20,6 @@ for arg in "$@"; do
|
||||
esac
|
||||
done
|
||||
|
||||
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="${CM_ROOT:-$HOME/.claude-mode}"
|
||||
BINDIR="$HOME/.local/bin"
|
||||
|
||||
@@ -28,6 +27,24 @@ green() { printf ' \033[32mok \033[0m %s\n' "$*"; }
|
||||
warn() { printf ' \033[33mwarn\033[0m %s\n' "$*"; }
|
||||
fail() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; }
|
||||
|
||||
# A checkout next to this script is the normal source. Piped in
|
||||
# (curl ... | bash) there is no script file and no checkout, so fetch the
|
||||
# repository archive and install from that instead.
|
||||
SRC="$(cd "$(dirname "${BASH_SOURCE[0]:-''}")" 2>/dev/null && pwd)" || SRC=""
|
||||
if [ ! -f "$SRC/claude-mode" ] || [ ! -f "$SRC/cm-json.py" ]; then
|
||||
REPO_URL="${CM_REPO_URL:-https://git.nebulm.com/smoido/claude-mode}"
|
||||
command -v curl >/dev/null 2>&1 || { fail 'curl required to fetch the source'; exit 1; }
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/claude-mode-src.XXXXXX")"
|
||||
printf 'no local checkout found - fetching source from %s\n' "$REPO_URL"
|
||||
curl -fsSL "$REPO_URL/archive/master.tar.gz" | tar -xz -C "$TMP"
|
||||
# a plain glob rather than find -print -quit: BSD find (macOS) has no -quit
|
||||
SRC=''
|
||||
for _c in "$TMP"/*/linux/claude-mode "$TMP"/linux/claude-mode; do
|
||||
[ -f "$_c" ] && { SRC="$(dirname "$_c")"; break; }
|
||||
done
|
||||
[ -n "$SRC" ] || { fail 'archive did not contain linux/claude-mode - repo layout changed?'; exit 1; }
|
||||
fi
|
||||
|
||||
printf '\ninstalling claude-mode -> %s\n' "$ROOT"
|
||||
|
||||
# --- preflight -------------------------------------------------------------
|
||||
@@ -55,6 +72,11 @@ install -m 0755 "$SRC/claude-mode" "$ROOT/bin/claude-mode"
|
||||
install -m 0755 "$SRC/claude-key-helper.sh" "$ROOT/bin/claude-key-helper.sh"
|
||||
install -m 0644 "$SRC/cm-json.py" "$ROOT/bin/cm-json.py"
|
||||
install -m 0644 "$SRC/cm-vault.sh" "$ROOT/bin/cm-vault.sh"
|
||||
# The CLI is split across lib/*.sh, loaded from beside bin/claude-mode. Cleared
|
||||
# first, so a module removed from the source does not linger and get loaded.
|
||||
mkdir -p "$ROOT/bin/lib"
|
||||
rm -f "$ROOT/bin/lib/"*.sh
|
||||
for f in "$SRC"/lib/*.sh; do install -m 0644 "$f" "$ROOT/bin/lib/"; done
|
||||
green 'copied claude-mode + helpers'
|
||||
|
||||
# cm_version() reads this; without it every health.json reported 0.0.0.
|
||||
@@ -65,6 +87,20 @@ if [ -f "$VERSION_SRC" ]; then
|
||||
green "version $(tr -d '[:space:]' < "$ROOT/VERSION")"
|
||||
fi
|
||||
|
||||
# --- providers (shared with the Windows build) -----------------------------
|
||||
# Always replaced, unlike presets: it is the tool's own table of what each
|
||||
# provider is, not something a user edits. cm-json.py looks for it one level
|
||||
# above bin/.
|
||||
PROVIDERS_SRC="$SRC/../providers.json"
|
||||
[ -f "$PROVIDERS_SRC" ] || PROVIDERS_SRC="$SRC/providers.json"
|
||||
if [ -f "$PROVIDERS_SRC" ]; then
|
||||
install -m 0644 "$PROVIDERS_SRC" "$ROOT/providers.json"
|
||||
green "providers: $("$PY" -c 'import json,sys; print(", ".join(p["id"] for p in json.load(open(sys.argv[1]))["providers"]))' "$ROOT/providers.json")"
|
||||
else
|
||||
fail 'providers.json missing from the payload - claude-mode cannot run without it'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- presets (shared with the Windows build) -------------------------------
|
||||
PRESET_SRC="$SRC/../presets"
|
||||
[ -d "$PRESET_SRC" ] || PRESET_SRC="$SRC/presets"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# shellcheck shell=bash
|
||||
# linux/lib/catalogue.sh - fetching a provider's model list, and the cache every fetch leaves.
|
||||
#
|
||||
# Part of the claude-mode CLI: sourced by linux/claude-mode, which sets the
|
||||
# CM_* paths, PY and JSON used here. Not meant to run on its own.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider catalogues
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Every fetch leaves a copy behind for a reader that cannot afford the round
|
||||
# trip - the bar panel's model picker. It is written here, in the only places a
|
||||
# catalogue is ever fetched, so every command that already pays for the network
|
||||
# (models, doctor, setup, the menu's picker) keeps it fresh at no extra cost.
|
||||
cm_cache_catalogue() {
|
||||
"$PY" "$JSON" cache-models "$CM_MODELS_CACHE" "$1" "$2" "${3:-}" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# One catalogue fetch, for any provider: fetched according to its
|
||||
# catalogue.kind, parsed to TSV, and left in the cache for the panel. Prints
|
||||
# the TSV; the status says whether the fetch itself worked. TSV columns by kind:
|
||||
#
|
||||
# openrouter id ctx $in $out lmstudio id state ctx
|
||||
# ollama id params quant family openai id
|
||||
# static id note (a list kept in providers.json)
|
||||
provider_catalogue() {
|
||||
local pid="$1" base="${2%/}" token="${3:-}" out rc
|
||||
case "$(prov_field "$pid" 10)" in
|
||||
openrouter)
|
||||
out="$(curl -fsS --max-time 30 https://openrouter.ai/api/v1/models 2>/dev/null \
|
||||
| "$PY" "$JSON" or-models 2>/dev/null)" ;;
|
||||
lmstudio)
|
||||
out="$(curl -fsS --max-time 10 ${token:+-H "Authorization: Bearer $token"} \
|
||||
"$base/api/v0/models" 2>/dev/null | "$PY" "$JSON" lms-models 2>/dev/null)" ;;
|
||||
ollama)
|
||||
out="$(curl -fsS --max-time 10 ${token:+-H "Authorization: Bearer $token"} \
|
||||
"$base/api/tags" 2>/dev/null | "$PY" "$JSON" ollama-models 2>/dev/null)" ;;
|
||||
openai)
|
||||
# Both header styles: a proxy in front of Anthropic wants
|
||||
# x-api-key, one in front of anything else wants Bearer.
|
||||
out="$(curl -fsS --max-time 15 ${token:+-H "Authorization: Bearer $token"} \
|
||||
${token:+-H "x-api-key: $token"} -H 'anthropic-version: 2023-06-01' \
|
||||
"$base/v1/models" 2>/dev/null | "$PY" "$JSON" openai-models 2>/dev/null)" ;;
|
||||
static)
|
||||
out="$("$PY" "$JSON" provider-static "$pid" 2>/dev/null)" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
rc=$?
|
||||
cm_cache_catalogue "$pid" "$([ "$rc" -eq 0 ] && echo 1 || echo 0)" "$base" <<<"$out"
|
||||
[ -n "$out" ] && printf '%s\n' "$out"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# The credential a preset would send. Empty when there is nothing to send.
|
||||
cm_preset_token() {
|
||||
local pf="$1" am ref
|
||||
am="$(jget "$pf" auth.mode)"; [ -z "$am" ] && am=vault
|
||||
if [ "$am" = "vault" ]; then
|
||||
ref="$(jget "$pf" auth.keyRef)"; [ -z "$ref" ] && ref=openrouter
|
||||
cm_vault_get "$ref" 2>/dev/null || true
|
||||
else
|
||||
jget "$pf" auth.token
|
||||
fi
|
||||
}
|
||||
|
||||
# The token passed to provider_catalogue is not optional decoration. A local
|
||||
# server with authentication switched on answers its model list with 401 like
|
||||
# anything else, so without it the catalogue comes back empty and every caller
|
||||
# silently believes the server has no models - on exactly the setups that need
|
||||
# the list most.
|
||||
@@ -0,0 +1,175 @@
|
||||
# shellcheck shell=bash
|
||||
# linux/lib/commands.sh - status, presets, models and set-key.
|
||||
#
|
||||
# Part of the claude-mode CLI: sourced by linux/claude-mode, which sets the
|
||||
# CM_* paths, PY and JSON used here. Not meant to run on its own.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
cmd_status() {
|
||||
local mode preset
|
||||
mode="$(state_mode)"; preset="$(state_preset)"
|
||||
head_ "claude-mode: $mode"
|
||||
|
||||
if [ "$mode" = "anthropic" ]; then
|
||||
say 'native Anthropic login/subscription; no gateway env, no apiKeyHelper'
|
||||
else
|
||||
local pf; pf="$(preset_path "$preset")"
|
||||
say "preset: $preset"
|
||||
if [ -f "$pf" ]; then
|
||||
say "baseUrl: $(jget "$pf" baseUrl)"
|
||||
local t v
|
||||
for t in "${TIERS[@]}"; do
|
||||
v="$(jget "$pf" "models.$t")"; [ -n "$v" ] && printf ' %-10s%s\n' "$t:" "$v"
|
||||
done
|
||||
v="$(jget "$pf" subagentModel)"; [ -n "$v" ] && printf ' %-10s%s\n' "subagent:" "$v"
|
||||
v="$(jget "$pf" contextTokens)"; [ -n "$v" ] && printf ' %-10s%s tokens\n' "context:" "$v"
|
||||
local am; am="$(jget "$pf" auth.mode)"; [ -z "$am" ] && am=vault
|
||||
if [ "$am" = "vault" ]; then
|
||||
local ref; ref="$(jget "$pf" auth.keyRef)"; [ -z "$ref" ] && ref=openrouter
|
||||
printf ' %-10s%s -> %s [%s]\n' "key:" "$ref" "$(cm_vault_mask "$(cm_vault_get "$ref" 2>/dev/null || true)")" "$(cm_vault_backend_label)"
|
||||
else
|
||||
printf ' %-10s%s (inline, not a secret)\n' "token:" "$(jget "$pf" auth.token)"
|
||||
fi
|
||||
else
|
||||
err "preset '$preset' not found"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '\n settings.json managed keys:\n'
|
||||
local any=0 line
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] || continue
|
||||
printf ' %s\n' "$line"; any=1
|
||||
done < <("$PY" "$JSON" settings-env "$CM_SETTINGS" "$CM_STATE" 2>/dev/null)
|
||||
[ "$any" -eq 0 ] && printf ' (none - clean)\n'
|
||||
|
||||
printf '\n'
|
||||
check_stray_env "$mode" || true
|
||||
|
||||
# Keep the machine-readable mirror current for readers that poll it (the
|
||||
# Omarchy bar widget among them) rather than leaving it as stale as the
|
||||
# last switch.
|
||||
write_health "$mode" "$preset"
|
||||
}
|
||||
|
||||
cmd_presets() {
|
||||
head_ 'presets'
|
||||
local active_mode active_preset name provider desc mark
|
||||
active_mode="$(state_mode)"; active_preset="$(state_preset)"
|
||||
while IFS=$'\t' read -r name provider desc; do
|
||||
mark=' '
|
||||
[ "$name" = "$active_preset" ] && [ "$active_mode" != "anthropic" ] && mark='*'
|
||||
printf ' %s %-18s [%-10s] %s\n' "$mark" "$name" "$provider" "$desc"
|
||||
done < <("$PY" "$JSON" presets "$CM_PRESETS")
|
||||
}
|
||||
|
||||
cmd_models() {
|
||||
local filter='' pname='' refresh=0 as_json=0 a pf='' provider base='' tsv rc
|
||||
while [ $# -gt 0 ]; do
|
||||
a="$1"; shift
|
||||
case "$a" in
|
||||
--preset)
|
||||
[ $# -gt 0 ] || { err '--preset needs a preset name'; return 1; }
|
||||
pname="$1"; shift ;;
|
||||
--refresh) refresh=1 ;;
|
||||
--json) as_json=1 ;;
|
||||
-*) err "unknown option '$a'"; return 1 ;;
|
||||
*) filter="$a" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# The active preset by default. The panel's editor names one instead,
|
||||
# because the preset being edited is often not the one in use - and two
|
||||
# LM Studio presets can point at two different servers.
|
||||
if [ -z "$pname" ] && [ "$(state_mode)" != "anthropic" ]; then
|
||||
pname="$(state_preset)"
|
||||
fi
|
||||
if [ -n "$pname" ]; then
|
||||
pf="$(preset_path "$pname")"
|
||||
[ -f "$pf" ] || { err "preset '$pname' not found"; return 1; }
|
||||
provider="$(jget "$pf" provider)"; [ -z "$provider" ] && provider=openrouter
|
||||
base="$(jget "$pf" baseUrl)"
|
||||
else
|
||||
provider=openrouter # on anthropic, the catalogue worth browsing
|
||||
fi
|
||||
|
||||
local quiet=$(( refresh || as_json )) kind title
|
||||
kind="$(prov_field "$provider" 10)"; title="$(prov_field "$provider" 3)"
|
||||
if [ "$quiet" -eq 0 ]; then
|
||||
case "$kind" in
|
||||
static) head_ "$title models (from its docs - no public catalogue endpoint)" ;;
|
||||
openrouter) head_ 'fetching https://openrouter.ai/api/v1/models ...' ;;
|
||||
*) head_ "models on the $title server at $base" ;;
|
||||
esac
|
||||
fi
|
||||
tsv="$(provider_catalogue "$provider" "$base" "$(cm_preset_token "$pf")")"; rc=$?
|
||||
|
||||
if [ "$as_json" -eq 1 ]; then
|
||||
jget "$CM_MODELS_CACHE" "providers.$provider"
|
||||
return "$rc"
|
||||
fi
|
||||
|
||||
if [ "$refresh" -eq 1 ]; then
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
err "could not fetch the $provider catalogue${base:+ from $base}; the cached list, if any, is kept"
|
||||
# Not necessarily a fault: plenty of proxies serve Messages and
|
||||
# nothing else.
|
||||
[ "$(prov_field "$provider" 8)" = lenient ] && \
|
||||
say 'some endpoints serve no model list at all - model ids can still be typed by hand'
|
||||
return 1
|
||||
fi
|
||||
ok "cached $(printf '%s\n' "$tsv" | grep -c .) $provider model(s)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
[ "$rc" -eq 0 ] || { err "could not fetch the $provider catalogue${base:+ from $base}"; return 1; }
|
||||
local id c2 c3 c4
|
||||
while IFS=$'\t' read -r id c2 c3 c4; do
|
||||
[ -n "$id" ] || continue
|
||||
[ -z "$filter" ] || case "$id" in *"$filter"*) ;; *) continue ;; esac
|
||||
case "$kind" in
|
||||
lmstudio) printf ' %-58s %-11s %s\n' "$id" "$c2" "$c3" ;;
|
||||
static) say "$(printf '%-8s - %s' "$id" "$c2")" ;;
|
||||
ollama) printf ' %-44s %-8s %s\n' "$id" "$c2" "$c3" ;;
|
||||
openrouter) printf ' %-52s %10s $%-8s $%s\n' "$id" "$c2" "$c3" "$c4" ;;
|
||||
*) printf ' %s\n' "$id" ;;
|
||||
esac
|
||||
done <<<"$tsv"
|
||||
}
|
||||
|
||||
cmd_set_key() {
|
||||
local ref="${1:-openrouter}" inline="${2:-}" secret unit
|
||||
init_root
|
||||
printf ' storage backend: %s\n' "$(cm_vault_backend_label)"
|
||||
if [ "$(cm_vault_backend)" = "file" ]; then
|
||||
warn 'no keyring available - the key will be stored in a 0600 file, NOT encrypted.'
|
||||
warn 'install libsecret-tools (secret-tool) or pass for encrypted storage.'
|
||||
fi
|
||||
if [ -n "$inline" ]; then
|
||||
secret="$inline"
|
||||
warn 'the key was given on the command line, so it is in this shell history - the hidden prompt leaves no trace'
|
||||
else
|
||||
printf ' paste the API key for ref '\''%s'\'' (input hidden): ' "$ref"
|
||||
IFS= read -rs secret; printf '\n'
|
||||
fi
|
||||
[ -n "$secret" ] || { err 'empty key, aborted'; return 1; }
|
||||
|
||||
# A hidden prompt will happily swallow a mis-paste. Guard the two shapes that
|
||||
# are never a real key - the failure is otherwise invisible until the
|
||||
# provider answers 401 and the UI just spins.
|
||||
case "$secret" in
|
||||
*[[:space:]]*) err 'that value contains whitespace, so it is not an API key (a pasted command line?). Nothing was stored.'; return 1 ;;
|
||||
claude-mode*) err 'that value is a claude-mode command, not an API key. Nothing was stored.'; return 1 ;;
|
||||
esac
|
||||
if [ "${#secret}" -lt 16 ]; then
|
||||
unit=characters; [ "${#secret}" -eq 1 ] && unit=character
|
||||
warn "that key is only ${#secret} $unit - unusually short. Storing anyway."
|
||||
fi
|
||||
if [ "$ref" = "openrouter" ] && [ "${secret#sk-or-}" = "$secret" ]; then
|
||||
warn "key does not start with 'sk-or-' - storing anyway"
|
||||
fi
|
||||
printf '%s' "$secret" | cm_vault_set "$ref" && ok "stored key '$ref' via $(cm_vault_backend_label)"
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
# shellcheck shell=bash
|
||||
# linux/lib/core.sh - state, presets, the provider table, and which preset a mode resolves to.
|
||||
#
|
||||
# Part of the claude-mode CLI: sourced by linux/claude-mode, which sets the
|
||||
# CM_* paths, PY and JSON used here. Not meant to run on its own.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State / presets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
init_root() { mkdir -p "$CM_ROOT" "$CM_BIN" "$CM_PRESETS" "$CM_BACKUPS" "$CM_ROOT/vault"; }
|
||||
|
||||
jget() { "$PY" "$JSON" get "$1" "$2" 2>/dev/null; }
|
||||
|
||||
state_mode() { local m; m="$(jget "$CM_STATE" mode)"; [ -n "$m" ] && printf '%s' "$m" || printf 'anthropic'; }
|
||||
state_preset() { jget "$CM_STATE" preset; }
|
||||
|
||||
preset_path() { printf '%s/%s.json' "$CM_PRESETS" "$1"; }
|
||||
|
||||
# Preset names become file names, so nothing but a plain word gets through - no
|
||||
# slash, and no leading dot. Without this `preset show ../../etc/passwd`
|
||||
# printed whatever it pointed at.
|
||||
valid_preset_name() {
|
||||
case "$1" in
|
||||
''|.*|*[!A-Za-z0-9._-]*) return 1 ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
preset_names() { "$PY" "$JSON" presets "$CM_PRESETS" | cut -f1; }
|
||||
|
||||
# Deliberately no awk anywhere in this script: it is absent from minimal images
|
||||
# (this was found the hard way on a stock Fedora WSL rootfs). Bash can split TSV
|
||||
# on its own, and cut/sed/grep are far more reliably present.
|
||||
presets_for_provider() {
|
||||
local name provider desc
|
||||
while IFS=$'\t' read -r name provider desc; do
|
||||
[ "$provider" = "$1" ] && printf '%s\n' "$name"
|
||||
done < <("$PY" "$JSON" presets "$CM_PRESETS")
|
||||
}
|
||||
|
||||
# Read TSV on stdin, print the whole line whose first field equals $1.
|
||||
tsv_find() {
|
||||
local want="$1" line f1
|
||||
while IFS= read -r line; do
|
||||
f1="${line%%$'\t'*}"
|
||||
if [ "$f1" = "$want" ]; then printf '%s' "$line"; return 0; fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
tsv_field() { printf '%s' "$1" | cut -f"$2"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Providers
|
||||
#
|
||||
# Every gateway provider is an entry in providers.json; this script only knows
|
||||
# the *kinds* of behaviour (how a catalogue is fetched, how a server is probed)
|
||||
# and picks one by name from the entry. The table is read once, in the main
|
||||
# shell before dispatch - read inside a $( ) it would be re-read on every
|
||||
# lookup. Columns are cm-json.py's PROVIDER_TSV:
|
||||
#
|
||||
# 1 id 2 aliases 3 title 4 label 5 color 6 defaultPreset
|
||||
# 7 serverEditable 8 probe 9 probePaths 10 catalogueKind 11 perServer
|
||||
# 12 setupKey 13 setupModels 14 keyUrl 15 guardrail 16 doctor
|
||||
# 17 defaultKeyRef 18 literalToken 19 defaultBaseUrl 20 serverHint
|
||||
# 21 serverStart
|
||||
#
|
||||
# Always read with cut, never `IFS=$'\t' read`: tab counts as whitespace to
|
||||
# read, so an empty column (most providers have no aliases) would vanish and
|
||||
# shift every column after it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CM_PROVIDERS_TSV=''
|
||||
|
||||
cm_providers_load() {
|
||||
CM_PROVIDERS_TSV="$("$PY" "$JSON" provider-tsv)" || {
|
||||
echo "claude-mode: could not read providers.json (next to $CM_BIN)" >&2; exit 1; }
|
||||
local id
|
||||
for id in $(provider_ids); do MODES+=("$id"); done
|
||||
}
|
||||
|
||||
provider_ids() { printf '%s\n' "$CM_PROVIDERS_TSV" | cut -f1; }
|
||||
|
||||
prov_field() {
|
||||
local row
|
||||
row="$(printf '%s\n' "$CM_PROVIDERS_TSV" | tsv_find "$1")" || return 1
|
||||
tsv_field "$row" "$2"
|
||||
}
|
||||
|
||||
# An id or an alias (z.ai, z-ai) to the provider id; non-zero if neither.
|
||||
provider_resolve() {
|
||||
local w line
|
||||
w="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
|
||||
[ -n "$w" ] || return 1
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] || continue
|
||||
if [ "${line%%$'\t'*}" = "$w" ]; then printf '%s\n' "$w"; return 0; fi
|
||||
case ",$(tsv_field "$line" 2)," in
|
||||
*",$w,"*) printf '%s\n' "${line%%$'\t'*}"; return 0 ;;
|
||||
esac
|
||||
done <<EOF_PROVIDERS
|
||||
$CM_PROVIDERS_TSV
|
||||
EOF_PROVIDERS
|
||||
return 1
|
||||
}
|
||||
|
||||
# Whether a provider's entry lists a named doctor check.
|
||||
doctor_has() {
|
||||
case ",$(prov_field "$1" 16)," in *",$2,"*) return 0 ;; esac
|
||||
return 1
|
||||
}
|
||||
|
||||
term_cols() {
|
||||
local c=''
|
||||
if command -v tput >/dev/null 2>&1; then c="$(tput cols 2>/dev/null)"; fi
|
||||
if [ -z "$c" ] && command -v stty >/dev/null 2>&1; then
|
||||
c="$(stty size 2>/dev/null | cut -d' ' -f2)"
|
||||
fi
|
||||
[ -z "$c" ] && c="${COLUMNS:-80}"
|
||||
case "$c" in ''|*[!0-9]*) c=80 ;; esac
|
||||
[ "$c" -gt 20 ] || c=80
|
||||
printf '%s' "$c"
|
||||
}
|
||||
|
||||
# The preset `claude-mode <provider>` uses when none is named: the one chosen
|
||||
# with `preset default`, while its file still exists, else the built-in name.
|
||||
# The existence test is what lets a stale choice degrade instead of break.
|
||||
# cm-json.py's BUILTIN_DEFAULT_PRESET and the widget's Modes.DEFAULT_PRESET
|
||||
# mirror the built-in names.
|
||||
builtin_default_preset() { prov_field "$1" 6; }
|
||||
|
||||
default_preset_for() {
|
||||
local chosen
|
||||
chosen="$(jget "$CM_DEFAULTS" "$1")"
|
||||
if [ -n "$chosen" ] && valid_preset_name "$chosen" && [ -f "$(preset_path "$chosen")" ]; then
|
||||
printf '%s' "$chosen"; return 0
|
||||
fi
|
||||
builtin_default_preset "$1"
|
||||
}
|
||||
|
||||
resolve_preset() {
|
||||
local provider="$1" requested="${2:-}" fallback first
|
||||
if [ -n "$requested" ]; then
|
||||
[ -f "$(preset_path "$requested")" ] || { err "preset '$requested' not found"; return 1; }
|
||||
local got; got="$(jget "$(preset_path "$requested")" provider)"
|
||||
[ -z "$got" ] && got=openrouter
|
||||
if [ "$got" != "$provider" ]; then
|
||||
err "preset '$requested' is a '$got' preset, not '$provider'"; return 1
|
||||
fi
|
||||
printf '%s' "$requested"; return 0
|
||||
fi
|
||||
fallback="$(default_preset_for "$provider")"
|
||||
if [ -n "$fallback" ] && [ -f "$(preset_path "$fallback")" ]; then
|
||||
printf '%s' "$fallback"; return 0
|
||||
fi
|
||||
first="$(presets_for_provider "$provider" | head -n1)"
|
||||
[ -n "$first" ] || { err "no preset found for provider '$provider'"; return 1; }
|
||||
printf '%s' "$first"
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
# shellcheck shell=bash
|
||||
# linux/lib/doctor.sh - doctor, and its checks for each provider.
|
||||
#
|
||||
# Part of the claude-mode CLI: sourced by linux/claude-mode, which sets the
|
||||
# CM_* paths, PY and JSON used here. Not meant to run on its own.
|
||||
|
||||
# The preset's model ids against what the provider actually offers. A server
|
||||
# provider that does not answer is a failure; a hosted catalogue that cannot be
|
||||
# fetched is only a warning, since the endpoint may be fine regardless.
|
||||
doctor_catalogue() {
|
||||
local mode="$1" pf="$2" base="$3" kind title cat t id row declared c2 c3
|
||||
kind="$(prov_field "$mode" 10)"; title="$(prov_field "$mode" 3)"
|
||||
declared="$(jget "$pf" contextTokens)"
|
||||
cat="$(provider_catalogue "$mode" "$base" "$(cm_preset_token "$pf")")"
|
||||
if [ -z "$cat" ]; then
|
||||
if [ "$(prov_field "$mode" 7)" = 1 ]; then
|
||||
local start; start="$(prov_field "$mode" 21)"
|
||||
if [ "$(prov_field "$mode" 8)" = lenient ]; then
|
||||
warn "$title at $base lists no models - fine for a proxy, but the ids below cannot be checked"
|
||||
else
|
||||
err "$title not reachable at $base${start:+ - $start}"
|
||||
fi
|
||||
else
|
||||
warn "could not fetch the $title model list"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
[ "$(prov_field "$mode" 7)" = 1 ] && ok "$title reachable at $base ($(printf '%s\n' "$cat" | grep -c .) models)"
|
||||
|
||||
for t in "${TIERS[@]}"; do
|
||||
id="$(jget "$pf" "models.$t")"; [ -n "$id" ] || continue
|
||||
row="$(printf '%s\n' "$cat" | tsv_find "$id")" || row=''
|
||||
# Ollama lists every model with its tag, and a bare name means
|
||||
# `:latest` - `qwen3-coder` is served as `qwen3-coder:latest`.
|
||||
if [ -z "$row" ] && [ "$kind" = ollama ]; then
|
||||
case "$id" in *:*) ;; *) row="$(printf '%s\n' "$cat" | tsv_find "$id:latest")" || row='' ;; esac
|
||||
fi
|
||||
if [ -z "$row" ]; then
|
||||
# A fixed list is documentation, not the provider's word: an id
|
||||
# missing from it may still be served.
|
||||
if [ "$kind" = static ]; then ok "$(printf '%-6s %s (not in the documented list)' "$t" "$id")"
|
||||
else err "$t model NOT available from $title: $id"; fi
|
||||
continue
|
||||
fi
|
||||
c2="$(tsv_field "$row" 2)"; c3="$(tsv_field "$row" 3)"
|
||||
case "$kind" in
|
||||
openrouter) ok "$(printf '%-6s %s [ctx %s]' "$t" "$id" "$c2")"; doctor_ctx_vs "$t" "$c2" "$declared" ;;
|
||||
lmstudio) ok "$(printf '%-6s %s [%s, ctx %s]' "$t" "$id" "$c2" "$c3")"; doctor_ctx_vs "$t" "$c3" "$declared" ;;
|
||||
ollama) ok "$(printf '%-6s %s [%s %s]' "$t" "$id" "$c2" "$c3")" ;;
|
||||
*) ok "$(printf '%-6s %s' "$t" "$id")" ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$declared" ] && ok "declared context window: $declared tokens" \
|
||||
|| warn 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early'
|
||||
}
|
||||
|
||||
doctor_ctx_vs() {
|
||||
local t="$1" ctx="$2" declared="$3"
|
||||
{ [ -n "$declared" ] && [ -n "$ctx" ] && [ "$ctx" -lt "$declared" ]; } 2>/dev/null || return 0
|
||||
if [ "$t" = haiku ]; then
|
||||
warn "$t model has $ctx ctx, below the declared $declared - harmless, haiku runs short background tasks"
|
||||
else
|
||||
warn "$t model has $ctx ctx, below the declared $declared - this tier can overflow"
|
||||
fi
|
||||
}
|
||||
|
||||
# Ollama sets the context window on the server, not per request from Claude
|
||||
# Code: 4096 tokens unless `ollama serve` runs with OLLAMA_CONTEXT_LENGTH, and
|
||||
# anything past it is cut off without an error. contextTokens in a preset only
|
||||
# tells Claude Code what to expect - it cannot change what the server does - so
|
||||
# say what the server is actually running where that can be seen, and what to
|
||||
# set where it cannot.
|
||||
doctor_ollama_context() {
|
||||
local pf="$1" base="$2" declared ids id max live ps seen=0 short=0 t body
|
||||
declared="$(jget "$pf" contextTokens)"; [ -n "$declared" ] || return 0
|
||||
ids="$(for t in "${TIERS[@]}"; do jget "$pf" "models.$t"; done | sort -u | grep . || true)"
|
||||
[ -n "$ids" ] || return 0
|
||||
ps="$(curl -fsS --max-time 5 "$base/api/ps" 2>/dev/null)"
|
||||
while IFS= read -r id; do
|
||||
[ -n "$id" ] || continue
|
||||
body="$("$PY" -c 'import json,sys; print(json.dumps({"model": sys.argv[1]}))' "$id")"
|
||||
max="$(curl -fsS --max-time 8 -X POST -H 'content-type: application/json' -d "$body" \
|
||||
"$base/api/show" 2>/dev/null | "$PY" "$JSON" ollama-ctx show 2>/dev/null)"
|
||||
if [ -n "$max" ] && [ "$max" -lt "$declared" ] 2>/dev/null; then
|
||||
warn "$id supports at most $max tokens, below the declared $declared - lower contextTokens"
|
||||
fi
|
||||
live="$(printf '%s' "$ps" | "$PY" "$JSON" ollama-ctx ps "$id" 2>/dev/null)"
|
||||
[ -n "$live" ] || continue
|
||||
seen=1
|
||||
if [ "$live" -lt "$declared" ] 2>/dev/null; then
|
||||
short=1
|
||||
warn "$id is loaded with a $live-token context, below the declared $declared - requests past it are cut off"
|
||||
else
|
||||
ok "$id is loaded with a $live-token context"
|
||||
fi
|
||||
done <<EOF_IDS
|
||||
$ids
|
||||
EOF_IDS
|
||||
if [ "$seen" -eq 0 ]; then
|
||||
warn "none of these models is loaded, so the server's context window cannot be checked"
|
||||
say "Ollama defaults to 4096 tokens; run it with OLLAMA_CONTEXT_LENGTH=$declared or requests past that are cut off silently"
|
||||
elif [ "$short" -eq 1 ]; then
|
||||
say "restart it with OLLAMA_CONTEXT_LENGTH=$declared (or lower contextTokens to match)"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_doctor() {
|
||||
local mode preset pf
|
||||
mode="$(state_mode)"; preset="$(state_preset)"; pf="$(preset_path "$preset")"
|
||||
head_ "doctor - mode '$mode'"
|
||||
|
||||
if "$PY" -c "import json,sys; json.load(open(sys.argv[1])) if __import__('os').path.exists(sys.argv[1]) else None" "$CM_SETTINGS" 2>/dev/null; then
|
||||
ok 'settings.json parses'
|
||||
else
|
||||
err 'settings.json does not parse'; return 1
|
||||
fi
|
||||
|
||||
if [ -x "$CM_HELPER" ]; then ok "key helper present: $CM_HELPER"; else err "key helper missing/not executable: $CM_HELPER"; fi
|
||||
ok "secret backend: $(cm_vault_backend_label)"
|
||||
|
||||
if [ "$mode" != "anthropic" ]; then
|
||||
local am base; am="$(jget "$pf" auth.mode)"; [ -z "$am" ] && am=vault
|
||||
base="$(jget "$pf" baseUrl)"; base="${base%/}"
|
||||
|
||||
if [ "$am" = "vault" ]; then
|
||||
local ref key out
|
||||
ref="$(jget "$pf" auth.keyRef)"; [ -z "$ref" ] && ref=openrouter
|
||||
if key="$(cm_vault_get "$ref" 2>/dev/null)"; then
|
||||
ok "vault '$ref' resolves -> $(cm_vault_mask "$key")"
|
||||
case "$key" in
|
||||
*[[:space:]]*|claude-mode*)
|
||||
err "the stored '$ref' value looks like a pasted command, not a key. Re-run: claude-mode set-key $ref" ;;
|
||||
esac
|
||||
else
|
||||
err "vault '$ref' missing. Run: claude-mode set-key $ref"; key=''
|
||||
fi
|
||||
# Check the string settings.json actually holds, then run that
|
||||
# string through a shell. A path this script can quote correctly is
|
||||
# no evidence that the recorded one parses.
|
||||
local stored expected reswitch
|
||||
stored="$(jget "$CM_SETTINGS" apiKeyHelper)"
|
||||
expected="$("$PY" -c 'import shlex,sys; sys.stdout.write(shlex.quote(sys.argv[1]))' "$CM_HELPER")"
|
||||
reswitch="claude-mode $mode $(jget "$CM_STATE" preset)"
|
||||
if [ -z "$stored" ]; then
|
||||
err "settings.json has no apiKeyHelper. Run: $reswitch"
|
||||
elif [ "$stored" != "$expected" ]; then
|
||||
err "apiKeyHelper reads $stored"
|
||||
err " but should read $expected - run: $reswitch"
|
||||
else
|
||||
ok "apiKeyHelper wired as $stored"
|
||||
fi
|
||||
|
||||
if [ -n "$stored" ]; then
|
||||
out="$(sh -c "$stored" 2>&1)"
|
||||
if [ -n "$out" ] && [ "$out" = "$key" ]; then ok 'apiKeyHelper emits the correct key'
|
||||
elif printf '%s' "$out" | grep -q '[[:space:]]'; then
|
||||
# Whitespace means a diagnostic, not a credential; a key is
|
||||
# one unbroken token and must never be echoed.
|
||||
err "apiKeyHelper failed: $out"
|
||||
elif [ -n "$out" ]; then err "apiKeyHelper output does not match the vault (got: $(cm_vault_mask "$out"))"
|
||||
else err 'apiKeyHelper produced no output'; fi
|
||||
fi
|
||||
|
||||
if [ -n "$key" ] && doctor_has "$mode" openrouter-key; then
|
||||
local kinfo
|
||||
kinfo="$(curl -fsS --max-time 20 -H "Authorization: Bearer $key" https://openrouter.ai/api/v1/key 2>/dev/null)"
|
||||
if [ -n "$kinfo" ]; then
|
||||
ok 'OpenRouter accepted the key'
|
||||
printf '%s' "$kinfo" | "$PY" -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin).get('data',{})
|
||||
lim=d.get('limit'); use=d.get('usage',0)
|
||||
if lim is None: print(' ok spend %.2f this month (no key limit set)' % use)
|
||||
else: print(' ok spend %.2f of %.2f limit (%s), %.2f remaining' % (use, lim, d.get('limit_reset','?'), d.get('limit_remaining',0)))
|
||||
" 2>/dev/null
|
||||
else
|
||||
err 'OpenRouter rejected the key'
|
||||
fi
|
||||
|
||||
show_guardrail_status "$mode" "$key"
|
||||
fi
|
||||
# No key-info endpoint: the cheapest real check is a 1-token
|
||||
# message against the Anthropic-compatible surface itself.
|
||||
if [ -n "$key" ] && doctor_has "$mode" message-check; then
|
||||
if curl -fsS --max-time 45 -X POST "$base/v1/messages" \
|
||||
-H 'content-type: application/json' -H "x-api-key: $key" \
|
||||
-H "authorization: Bearer $key" -H 'anthropic-version: 2023-06-01' \
|
||||
-d "{\"model\":\"$(jget "$pf" models.haiku)\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" >/dev/null 2>&1; then
|
||||
ok "$(prov_field "$mode" 3) endpoint accepted the key ($base/v1/messages)"
|
||||
else
|
||||
err "$(prov_field "$mode" 3) request failed"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
ok "inline token '$(jget "$pf" auth.token)' (no secret in settings.json)"
|
||||
fi
|
||||
|
||||
doctor_has "$mode" catalogue-models && doctor_catalogue "$mode" "$pf" "$base"
|
||||
doctor_has "$mode" ollama-context && doctor_ollama_context "$pf" "$base"
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
if check_stale_models "$mode"; then
|
||||
[ "$mode" != "anthropic" ] && ok 'no cached Anthropic model ids'
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
if check_stray_env "$mode"; then ok 'no rc-file overrides'; fi
|
||||
|
||||
write_health "$mode" "$preset"
|
||||
|
||||
printf '\n'
|
||||
if command -v claude >/dev/null 2>&1; then
|
||||
say "claude: $(claude --version 2>&1 | head -n1) [$(command -v claude)]"
|
||||
else
|
||||
warn 'claude is not on PATH'
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
# shellcheck shell=bash
|
||||
# linux/lib/menu.sh - the interactive terminal menu and its pickers.
|
||||
#
|
||||
# Part of the claude-mode CLI: sourced by linux/claude-mode, which sets the
|
||||
# CM_* paths, PY and JSON used here. Not meant to run on its own.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive UI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ui_interactive() { [ -t 0 ] && [ -t 1 ]; }
|
||||
|
||||
# Read one keypress, normalised to a word. Escape sequences are consumed
|
||||
# whole: leaving `[C` behind in the tty is what made zsh report
|
||||
# "bad pattern: [C" after the menu exited.
|
||||
# Read the bytes that follow an Escape, up to two of them, and print them.
|
||||
#
|
||||
# There is no portable short-timeout `read` here. bash 3.2 - which is what
|
||||
# macOS ships - rejects fractional timeouts outright, and its `read -t 0`
|
||||
# availability poll reports nothing even when bytes are sitting in the buffer
|
||||
# (verified against 3.2.57), so polling cannot be used to tell a bare Escape
|
||||
# from the start of a sequence.
|
||||
#
|
||||
# On a tty the terminal itself answers this: with icanon off and min 0 /
|
||||
# time 1, a plain read returns the moment a byte arrives and gives up after
|
||||
# ~0.1s otherwise. So Escape costs 0.1s, not the full second `-t 1` would
|
||||
# take. Off a tty there is no such knob, so that path falls back to `-t 1`.
|
||||
cm_read_esc_tail() {
|
||||
local save out='' a raw=0
|
||||
if [ -t 0 ] && save="$(stty -g 2>/dev/null)"; then
|
||||
stty -echo -icanon min 0 time 1 2>/dev/null
|
||||
raw=1
|
||||
fi
|
||||
|
||||
if [ "$raw" -eq 1 ]; then IFS= read -r a 2>/dev/null; else
|
||||
IFS= read -rsn1 -t 1 a 2>/dev/null || a=''; fi
|
||||
out="$a"
|
||||
|
||||
case "$out" in
|
||||
'O') # SS3 - one more byte is the key itself
|
||||
if [ "$raw" -eq 1 ]; then IFS= read -r a 2>/dev/null;
|
||||
else IFS= read -rsn1 -t 1 a 2>/dev/null || a=''; fi
|
||||
out="$out$a" ;;
|
||||
'[') # CSI - parameters, then one final byte. Consume the lot: a
|
||||
# half-eaten sequence is what leaves `[C` in the tty for the
|
||||
# shell to report as a globbing error.
|
||||
while [ "${#out}" -lt 8 ]; do
|
||||
if [ "$raw" -eq 1 ]; then IFS= read -r a 2>/dev/null;
|
||||
else IFS= read -rsn1 -t 1 a 2>/dev/null || a=''; fi
|
||||
[ -n "$a" ] || break
|
||||
out="$out$a"
|
||||
case "$a" in
|
||||
[0-9]|';'|'?'|'<'|'>'|'='|':'|' ') continue ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done ;;
|
||||
esac
|
||||
|
||||
[ "$raw" -eq 1 ] && stty "$save" 2>/dev/null
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
read_key() {
|
||||
local k
|
||||
IFS= read -rsn1 k 2>/dev/null || return 1
|
||||
if [ "$k" = $'\033' ]; then
|
||||
local tail; tail="$(cm_read_esc_tail)"
|
||||
case "$tail" in
|
||||
'[A') printf 'up' ;;
|
||||
'[B') printf 'down' ;;
|
||||
'[C') printf 'right' ;;
|
||||
'[D') printf 'left' ;;
|
||||
'[H') printf 'home' ;;
|
||||
'[F') printf 'end' ;;
|
||||
'[5~') printf 'pgup' ;;
|
||||
'[6~') printf 'pgdn' ;;
|
||||
'OA') printf 'up' ;; # SS3 variants, cursor mode
|
||||
'OB') printf 'down' ;;
|
||||
'OC') printf 'right' ;;
|
||||
'OD') printf 'left' ;;
|
||||
'') printf 'esc' ;;
|
||||
*) printf 'other' ;;
|
||||
esac
|
||||
return 0
|
||||
fi
|
||||
case "$k" in
|
||||
'') printf 'enter' ;;
|
||||
$'\177'|$'\b') printf 'backspace' ;;
|
||||
*) printf 'char:%s' "$k" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Selection state shared with the callers, so bash does not have to return
|
||||
# structured data from a function.
|
||||
UI_LABELS=(); UI_DETAILS=(); UI_ACCENTS=(); UI_SEL=-1
|
||||
|
||||
ui_reset_items() { UI_LABELS=(); UI_DETAILS=(); UI_ACCENTS=(); }
|
||||
ui_add_item() { UI_LABELS+=("$1"); UI_DETAILS+=("${2:-}"); UI_ACCENTS+=("${3:-$C_CYAN}"); }
|
||||
|
||||
# ui_select <title> <status> -> sets UI_SEL (-1 = cancelled)
|
||||
ui_select() {
|
||||
local title="$1" status="${2:-}"
|
||||
local n=${#UI_LABELS[@]} idx=0 cols first=1 i key detail
|
||||
UI_SEL=-1
|
||||
[ "$n" -gt 0 ] || return 1
|
||||
cols=$(term_cols)
|
||||
|
||||
printf '\033[?25l' # hide cursor
|
||||
trap 'printf "\033[?25h"' RETURN
|
||||
|
||||
while true; do
|
||||
if [ "$first" -eq 1 ]; then first=0; else printf '\033[%dA' $((n + 4)); fi
|
||||
|
||||
printf '\033[2K\n'
|
||||
printf '\033[2K %s%s%s' "$C_BOLD$C_CYAN" "$title" "$C_RESET"
|
||||
[ -n "$status" ] && printf ' %s%s%s' "$C_DIM" "$status" "$C_RESET"
|
||||
printf '\n'
|
||||
printf '\033[2K %sup/down move enter select esc cancel%s\n' "$C_DIM" "$C_RESET"
|
||||
|
||||
for ((i = 0; i < n; i++)); do
|
||||
if [ "$i" -eq "$idx" ]; then
|
||||
printf '\033[2K%s > %-*s%s\n' "$(printf '\033[7m')${UI_ACCENTS[$i]}" $((cols - 5)) "${UI_LABELS[$i]}" "$C_RESET"
|
||||
else
|
||||
printf '\033[2K %s\n' "${UI_LABELS[$i]}"
|
||||
fi
|
||||
done
|
||||
|
||||
detail="${UI_DETAILS[$idx]}"
|
||||
printf '\033[2K %s%s%s\n' "$C_DIM" "${detail:0:$((cols - 8))}" "$C_RESET"
|
||||
|
||||
key="$(read_key)" || { UI_SEL=-1; return 1; }
|
||||
case "$key" in
|
||||
up) idx=$(( (idx - 1 + n) % n )) ;;
|
||||
down) idx=$(( (idx + 1) % n )) ;;
|
||||
home) idx=0 ;;
|
||||
end) idx=$((n - 1)) ;;
|
||||
enter) UI_SEL=$idx; return 0 ;;
|
||||
esc) UI_SEL=-1; return 1 ;;
|
||||
char:q|char:Q) UI_SEL=-1; return 1 ;;
|
||||
char:[1-9])
|
||||
local d="${key#char:}"
|
||||
[ "$d" -le "$n" ] && { UI_SEL=$((d - 1)); return 0; }
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ui_filter_select <title> <status> - same, plus a type-to-filter box.
|
||||
# Items come from UI_LABELS/UI_DETAILS; sets UI_SEL as an index into them.
|
||||
ui_filter_select() {
|
||||
local title="$1" status="${2:-}"
|
||||
local n=${#UI_LABELS[@]} query='' idx=0 off=0 rows=12 cols first=1
|
||||
local -a match_idx
|
||||
UI_SEL=-1
|
||||
[ "$n" -gt 0 ] || return 1
|
||||
cols=$(term_cols)
|
||||
|
||||
printf '\033[?25l'
|
||||
trap 'printf "\033[?25h"' RETURN
|
||||
|
||||
while true; do
|
||||
match_idx=()
|
||||
local i lower_q; lower_q="$(printf '%s' "$query" | tr '[:upper:]' '[:lower:]')"
|
||||
for ((i = 0; i < n; i++)); do
|
||||
if [ -z "$query" ]; then match_idx+=("$i")
|
||||
else
|
||||
local lab; lab="$(printf '%s' "${UI_LABELS[$i]}" | tr '[:upper:]' '[:lower:]')"
|
||||
case "$lab" in *"$lower_q"*) match_idx+=("$i") ;; esac
|
||||
fi
|
||||
done
|
||||
local m=${#match_idx[@]}
|
||||
[ "$idx" -ge "$m" ] && idx=$(( m > 0 ? m - 1 : 0 ))
|
||||
[ "$idx" -lt "$off" ] && off=$idx
|
||||
[ "$idx" -ge $((off + rows)) ] && off=$((idx - rows + 1))
|
||||
[ "$off" -lt 0 ] && off=0
|
||||
|
||||
if [ "$first" -eq 1 ]; then first=0; else printf '\033[%dA' $((rows + 6)); fi
|
||||
|
||||
printf '\033[2K\n'
|
||||
printf '\033[2K %s%s%s' "$C_BOLD$C_CYAN" "$title" "$C_RESET"
|
||||
[ -n "$status" ] && printf ' %s%s%s' "$C_DIM" "$status" "$C_RESET"
|
||||
printf '\n'
|
||||
printf '\033[2K %stype to filter up/down move enter select esc cancel%s\n' "$C_DIM" "$C_RESET"
|
||||
printf '\033[2K %sfilter:%s %s_\n' "$C_WHITE" "$C_RESET" "$query"
|
||||
|
||||
local r real
|
||||
for ((r = 0; r < rows; r++)); do
|
||||
local pos=$((off + r))
|
||||
if [ "$pos" -ge "$m" ]; then printf '\033[2K\n'; continue; fi
|
||||
real=${match_idx[$pos]}
|
||||
if [ "$pos" -eq "$idx" ]; then
|
||||
printf '\033[2K%s > %-*s%s\n' "$(printf '\033[7m')$C_CYAN" $((cols - 5)) "${UI_LABELS[$real]}" "$C_RESET"
|
||||
else
|
||||
printf '\033[2K %s\n' "${UI_LABELS[$real]}"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$m" -eq 0 ]; then
|
||||
printf '\033[2K %s(no match)%s\n' "$C_DIM" "$C_RESET"
|
||||
printf '\033[2K\n'
|
||||
else
|
||||
printf '\033[2K %s%d of %d%s\n' "$C_DIM" $((idx + 1)) "$m" "$([ "$m" -ne "$n" ] && printf ' (filtered from %d)' "$n")$C_RESET"
|
||||
printf '\033[2K %s%s%s\n' "$C_DIM" "${UI_DETAILS[${match_idx[$idx]}]:0:$((cols - 8))}" "$C_RESET"
|
||||
fi
|
||||
|
||||
local key; key="$(read_key)" || { UI_SEL=-1; return 1; }
|
||||
case "$key" in
|
||||
up) [ "$m" -gt 0 ] && idx=$(( (idx - 1 + m) % m )) ;;
|
||||
down) [ "$m" -gt 0 ] && idx=$(( (idx + 1) % m )) ;;
|
||||
pgup) idx=$(( idx - rows )); [ "$idx" -lt 0 ] && idx=0 ;;
|
||||
pgdn) idx=$(( idx + rows )); [ "$idx" -ge "$m" ] && idx=$(( m > 0 ? m - 1 : 0 )) ;;
|
||||
home) idx=0 ;;
|
||||
end) idx=$(( m > 0 ? m - 1 : 0 )) ;;
|
||||
enter) [ "$m" -gt 0 ] && { UI_SEL=${match_idx[$idx]}; return 0; } ;;
|
||||
esc) UI_SEL=-1; return 1 ;;
|
||||
backspace) query="${query%?}"; idx=0; off=0 ;;
|
||||
char:*) query="$query${key#char:}"; idx=0; off=0 ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
preset_summary_line() { "$PY" "$JSON" summary "$1" 2>/dev/null | sed -n '2p'; }
|
||||
|
||||
# These two draw a UI *and* produce a value. They must not be called inside
|
||||
# $( ) - command substitution captures stdout, so the whole interface would be
|
||||
# swallowed into the variable and the user would see nothing happen. They
|
||||
# publish their result in UI_PICKED instead.
|
||||
UI_PICKED=''
|
||||
|
||||
ui_pick_preset() {
|
||||
local provider="$1" def names name
|
||||
UI_PICKED=''
|
||||
# resolve_preset, not default_preset_for: the mark should land on whatever
|
||||
# `claude-mode <provider>` would actually pick, fallbacks included.
|
||||
def="$(resolve_preset "$provider" 2>/dev/null)"
|
||||
names=()
|
||||
while IFS= read -r name; do names+=("$name"); done < <(presets_for_provider "$provider")
|
||||
[ "${#names[@]}" -gt 0 ] || { err "no presets for '$provider'"; return 1; }
|
||||
|
||||
ui_reset_items
|
||||
local i=0 defidx=0
|
||||
for name in "${names[@]}"; do
|
||||
local label="$name"
|
||||
[ "$name" = "$def" ] && { label="$name (default)"; defidx=$i; }
|
||||
ui_add_item "$label" "$(preset_summary_line "$(preset_path "$name")")"
|
||||
i=$((i + 1))
|
||||
done
|
||||
ui_select "preset for $provider" '' || return 1
|
||||
UI_PICKED="${names[$UI_SEL]}"
|
||||
}
|
||||
|
||||
ui_pick_model() {
|
||||
local pf="$1" tier="$2" current="$3" provider base
|
||||
UI_PICKED=''
|
||||
provider="$(jget "$pf" provider)"; base="$(jget "$pf" baseUrl)"
|
||||
|
||||
ui_reset_items
|
||||
ui_add_item '<type an id manually>' 'enter any model id by hand'
|
||||
|
||||
local ids=() id ctx a b st kind
|
||||
kind="$(prov_field "$provider" 10)"
|
||||
while IFS=$'\t' read -r id a b st; do
|
||||
[ -n "$id" ] || continue
|
||||
ids+=("$id")
|
||||
case "$kind" in
|
||||
openrouter) ui_add_item "$id" "context $a \$$b in / \$$st out per 1M" ;;
|
||||
lmstudio) ui_add_item "$id" "state: $a max context: $b" ;;
|
||||
ollama) ui_add_item "$id" "$a $b" ;;
|
||||
*) ui_add_item "$id" "$a" ;;
|
||||
esac
|
||||
done < <(provider_catalogue "$provider" "$base" "$(cm_preset_token "$pf")")
|
||||
|
||||
if [ "${#ids[@]}" -gt 0 ]; then
|
||||
ui_filter_select "model for '$tier'" "current: $current" || return 1
|
||||
if [ "$UI_SEL" -gt 0 ]; then UI_PICKED="${ids[$((UI_SEL - 1))]}"; return 0; fi
|
||||
fi
|
||||
|
||||
printf '\n %scurrent %s : %s%s\n' "$C_DIM" "$tier" "$current" "$C_RESET"
|
||||
printf ' new model id for %s (blank = cancel): ' "$tier"
|
||||
local val; IFS= read -r val
|
||||
[ -n "$val" ] || return 1
|
||||
UI_PICKED="$val"
|
||||
}
|
||||
|
||||
ui_edit_preset() {
|
||||
local name="${1:-}"
|
||||
if [ -z "$name" ]; then
|
||||
local names=() n
|
||||
while IFS= read -r n; do names+=("$n"); done < <(preset_names)
|
||||
[ "${#names[@]}" -gt 0 ] || { warn 'no presets'; return; }
|
||||
ui_reset_items
|
||||
for n in "${names[@]}"; do
|
||||
ui_add_item "$(printf '%-18s [%s]' "$n" "$(jget "$(preset_path "$n")" provider)")" \
|
||||
"$(preset_summary_line "$(preset_path "$n")")"
|
||||
done
|
||||
ui_select 'edit which preset' '' || return
|
||||
name="${names[$UI_SEL]}"
|
||||
fi
|
||||
|
||||
local pf; pf="$(preset_path "$name")"
|
||||
while true; do
|
||||
ui_reset_items
|
||||
local tiers=() t v
|
||||
while IFS=$'\t' read -r t v; do
|
||||
tiers+=("$t")
|
||||
ui_add_item "$(printf '%-9s %s' "$t" "$v")" "change which model backs the '$t' tier"
|
||||
done < <("$PY" "$JSON" models "$pf")
|
||||
|
||||
ui_select "$name [$(jget "$pf" provider)]" 'esc = done' || return
|
||||
local tier="${tiers[$UI_SEL]}"
|
||||
local cur; cur="$(tsv_field "$("$PY" "$JSON" models "$pf" | tsv_find "$tier")" 2)"
|
||||
ui_pick_model "$pf" "$tier" "$cur" || continue
|
||||
local val="$UI_PICKED"
|
||||
[ -n "$val" ] || continue
|
||||
"$PY" "$JSON" set-tier "$pf" "$tier" "$val" && ok "$name : $tier -> $val"
|
||||
reapply_if_active "$name"
|
||||
done
|
||||
}
|
||||
|
||||
ui_new_preset() {
|
||||
ui_reset_items
|
||||
local provs=($(provider_ids)) p
|
||||
for p in "${provs[@]}"; do ui_add_item "$p" "$(mode_label "$p")"; done
|
||||
ui_select 'new preset - which provider' '' || return
|
||||
local provider="${provs[$UI_SEL]}"
|
||||
|
||||
local sibs=() s
|
||||
while IFS= read -r s; do sibs+=("$s"); done < <(presets_for_provider "$provider")
|
||||
ui_reset_items
|
||||
ui_add_item '<blank>' "empty $provider preset - pick every model yourself"
|
||||
for s in "${sibs[@]}"; do ui_add_item "copy of $s" "$(preset_summary_line "$(preset_path "$s")")"; done
|
||||
ui_select 'start from' '' || return
|
||||
local choice=$UI_SEL
|
||||
|
||||
printf '\n %snew %s preset%s\n' "$C_CYAN" "$provider" "$C_RESET"
|
||||
printf ' name (letters, digits, dash; blank = cancel): '
|
||||
local name; IFS= read -r name
|
||||
[ -n "$name" ] || return
|
||||
valid_preset_name "$name" || { err "invalid name '$name'"; return; }
|
||||
[ -f "$(preset_path "$name")" ] && { err "preset '$name' already exists"; return; }
|
||||
|
||||
if [ "$choice" -eq 0 ]; then
|
||||
"$PY" "$JSON" scaffold "$provider" > "$(preset_path "$name")"
|
||||
else
|
||||
cp "$(preset_path "${sibs[$((choice - 1))]}")" "$(preset_path "$name")"
|
||||
fi
|
||||
ok "created preset '$name' ($provider)"
|
||||
ui_edit_preset "$name"
|
||||
}
|
||||
|
||||
ui_more_menu() {
|
||||
while true; do
|
||||
ui_reset_items
|
||||
ui_add_item 'status' 'show the full active configuration'
|
||||
ui_add_item 'edit presets' 'pick models per tier from the provider catalogue'
|
||||
ui_add_item 'new preset' 'create a preset - blank or copied from an existing one'
|
||||
ui_add_item 'doctor' 'verify auth, endpoint, model ids, context window'
|
||||
ui_add_item 'back' 'return to the mode menu'
|
||||
ui_add_item 'quit' ''
|
||||
ui_select 'claude-mode - more' "currently: $(state_mode)" || return 0
|
||||
case "$UI_SEL" in
|
||||
0) cmd_status ;;
|
||||
1) ui_edit_preset ;;
|
||||
2) ui_new_preset ;;
|
||||
3) cmd_doctor ;;
|
||||
4) return 0 ;;
|
||||
5) return 2 ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
ui_menu() {
|
||||
if ! ui_interactive; then cmd_status; return; fi
|
||||
|
||||
local cur preset
|
||||
cur="$(state_mode)"; preset="$(state_preset)"
|
||||
[ "$cur" = "anthropic" ] && preset=''
|
||||
show_banner "$cur" "$preset"
|
||||
|
||||
while true; do
|
||||
cur="$(state_mode)"; preset="$(state_preset)"
|
||||
local desc="$cur"
|
||||
[ "$cur" != "anthropic" ] && [ -n "$preset" ] && desc="$cur / $preset"
|
||||
|
||||
local choices=() m
|
||||
for m in "${MODES[@]}"; do [ "$m" != "$cur" ] && choices+=("$m"); done
|
||||
|
||||
ui_reset_items
|
||||
for m in "${choices[@]}"; do
|
||||
ui_add_item "switch to $m" "$(mode_label "$m")" "$(mode_color "$m")"
|
||||
done
|
||||
ui_add_item 'more ...' 'status, presets, doctor'
|
||||
|
||||
ui_select 'claude-mode' "currently: $desc" || return
|
||||
|
||||
if [ "$UI_SEL" -eq "${#choices[@]}" ]; then
|
||||
ui_more_menu; [ $? -eq 2 ] && return
|
||||
continue
|
||||
fi
|
||||
|
||||
local mode="${choices[$UI_SEL]}"
|
||||
if [ "$mode" = "anthropic" ]; then set_mode anthropic; return; fi
|
||||
ui_pick_preset "$mode" || continue
|
||||
[ -n "$UI_PICKED" ] || continue
|
||||
set_mode "$mode" "$UI_PICKED"
|
||||
return
|
||||
done
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
# shellcheck shell=bash
|
||||
# linux/lib/output.sh - colours (a palette derived from the desktop theme), output helpers, the banner and usage.
|
||||
#
|
||||
# Part of the claude-mode CLI: sourced by linux/claude-mode, which sets the
|
||||
# CM_* paths, PY and JSON used here. Not meant to run on its own.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Colour / output
|
||||
#
|
||||
# The palette follows the desktop theme when there is one to follow. Omarchy
|
||||
# publishes its active theme as a flat colors.toml, so on those systems the
|
||||
# menu paints in the same colours as the bar and the terminal instead of in
|
||||
# whatever the sixteen ANSI slots happen to mean today.
|
||||
#
|
||||
# That indirection is not decoration. The ANSI slots carry no guarantee about
|
||||
# relative brightness, and monochrome themes exploit it. Under Omarchy's
|
||||
# Solitude, slot 36 (headings) resolves to #707070 and slot 31 (FAIL) to
|
||||
# #565d60; against #cacccc body text on a #101315 ground those are 3.8:1 and
|
||||
# 2.8:1 where the body text is 11.6:1 - so headings render as fine print and
|
||||
# an error message becomes the quietest thing on screen. Exactly backwards.
|
||||
#
|
||||
# Deriving from the theme instead lets every role be measured against the
|
||||
# background it will actually be drawn on, and lifted toward the foreground
|
||||
# when it comes up short. Hue is preserved where the theme has any; where it
|
||||
# does not, roles resolve to weight rather than to invisible colour.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CM_THEME_FILE="${CLAUDE_MODE_THEME:-$HOME/.local/state/omarchy/current/theme/colors.toml}"
|
||||
|
||||
# Flat `key = "#rrggbb"` lookup. Quotes are optional so this also reads the
|
||||
# handful of themes that ship the file unquoted.
|
||||
cm_theme_get() {
|
||||
[ -f "$CM_THEME_FILE" ] || return 1
|
||||
sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*\"\{0,1\}\([^\"#]*#\{0,1\}[0-9A-Fa-f]*\)\"\{0,1\}[[:space:]]*\$/\1/p" \
|
||||
"$CM_THEME_FILE" 2>/dev/null | head -n1
|
||||
}
|
||||
|
||||
cm_hex_ok() { case "$1" in \#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]) return 0 ;; *) return 1 ;; esac; }
|
||||
|
||||
# Relative brightness, 0-255. Deliberately the linear Rec.709 weighting without
|
||||
# the sRGB gamma step: bash has no floats, and this only ever has to answer
|
||||
# "which of these is further from the background", which it does correctly.
|
||||
cm_lum() {
|
||||
local h="${1#\#}" r g b
|
||||
r=$((16#${h:0:2})); g=$((16#${h:2:2})); b=$((16#${h:4:2}))
|
||||
printf '%d' $(( (2126 * r + 7152 * g + 722 * b) / 10000 ))
|
||||
}
|
||||
|
||||
# Distance from the theme background, which is what legibility actually is.
|
||||
cm_dist() { local a b; a="$(cm_lum "$1")"; b="$(cm_lum "$CM_BG")"; printf '%d' $(( a > b ? a - b : b - a )); }
|
||||
|
||||
cm_mix() {
|
||||
local x="${1#\#}" y="${2#\#}" p="$3" r g b
|
||||
r=$(( (16#${x:0:2} * (100 - p) + 16#${y:0:2} * p) / 100 ))
|
||||
g=$(( (16#${x:2:2} * (100 - p) + 16#${y:2:2} * p) / 100 ))
|
||||
b=$(( (16#${x:4:2} * (100 - p) + 16#${y:4:2} * p) / 100 ))
|
||||
printf '#%02x%02x%02x' "$r" "$g" "$b"
|
||||
}
|
||||
|
||||
# Blend a colour toward the foreground until it clears `pct` of the
|
||||
# foreground's own separation from the background. Hue survives the lift, so a
|
||||
# themed accent stays recognisably itself; a grey one just ends up brighter.
|
||||
cm_lift() {
|
||||
local c="$1" pct="$2" need step out
|
||||
need=$(( $(cm_dist "$CM_FG") * pct / 100 ))
|
||||
out="$c"
|
||||
for step in 0 15 30 45 60 75 90 100; do
|
||||
out="$(cm_mix "$c" "$CM_FG" "$step")"
|
||||
[ "$(cm_dist "$out")" -ge "$need" ] && break
|
||||
done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# Push a colour toward the background - for text that is meant to recede but
|
||||
# still be readable. Floored, so "dim" never becomes "gone".
|
||||
cm_sink() {
|
||||
local c="$1" pct="$2" need out step
|
||||
need=$(( $(cm_dist "$CM_FG") * pct / 100 ))
|
||||
out="$c"
|
||||
[ "$(cm_dist "$out")" -ge "$need" ] && { printf '%s' "$out"; return; }
|
||||
for step in 85 70 55 40 25 10 0; do
|
||||
out="$(cm_mix "$CM_BG" "$CM_FG" $((100 - step)))"
|
||||
[ "$(cm_dist "$out")" -ge "$need" ] && break
|
||||
done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# Saturation as a 0-100 proxy. Monochrome themes define `red` as a desaturated
|
||||
# slate, which carries none of the meaning the role needs.
|
||||
cm_sat() {
|
||||
local h="${1#\#}" r g b mx mn
|
||||
r=$((16#${h:0:2})); g=$((16#${h:2:2})); b=$((16#${h:4:2}))
|
||||
mx=$r; [ "$g" -gt "$mx" ] && mx=$g; [ "$b" -gt "$mx" ] && mx=$b
|
||||
mn=$r; [ "$g" -lt "$mn" ] && mn=$g; [ "$b" -lt "$mn" ] && mn=$b
|
||||
[ "$mx" -eq 0 ] && { printf '0'; return; }
|
||||
printf '%d' $(( (mx - mn) * 100 / mx ))
|
||||
}
|
||||
|
||||
cm_sgr() { local h="${1#\#}"; printf '\033[38;2;%d;%d;%dm' $((16#${h:0:2})) $((16#${h:2:2})) $((16#${h:4:2})); }
|
||||
|
||||
# Pick a themed colour for a role, falling back through the theme's own keys
|
||||
# and finally to the foreground. `minPct` is the share of the foreground's
|
||||
# contrast the role has to clear before it is allowed on screen.
|
||||
cm_role() {
|
||||
local minPct="$1" want c
|
||||
shift
|
||||
for want in "$@"; do
|
||||
c="$(cm_theme_get "$want")"
|
||||
cm_hex_ok "$c" || continue
|
||||
cm_sgr "$(cm_lift "$c" "$minPct")"
|
||||
return 0
|
||||
done
|
||||
cm_sgr "$CM_FG"
|
||||
}
|
||||
|
||||
cm_truecolor() { case "${COLORTERM:-}" in truecolor|24bit) return 0 ;; *) return 1 ;; esac; }
|
||||
|
||||
cm_theme_palette() {
|
||||
local bg fg red
|
||||
|
||||
cm_truecolor || return 1
|
||||
bg="$(cm_theme_get background)"; fg="$(cm_theme_get foreground)"
|
||||
cm_hex_ok "$bg" && cm_hex_ok "$fg" || return 1
|
||||
|
||||
CM_BG="$bg"; CM_FG="$fg"
|
||||
CM_THEME_MODE="$(cm_theme_get mode)"
|
||||
|
||||
C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'
|
||||
|
||||
# Body text sets the bar every other role is measured against.
|
||||
C_WHITE="$(cm_sgr "$CM_FG")"
|
||||
C_GRAY="$(cm_sgr "$(cm_mix "$CM_FG" "$CM_BG" 20)")"
|
||||
|
||||
# Headings and the selection accent. Lifted to 85% of body contrast: a
|
||||
# heading that is dimmer than the text beneath it is not a heading.
|
||||
C_CYAN="$(cm_role 85 accent blue cyan)"
|
||||
# Banner shading - subordinate to the heading but still structural.
|
||||
C_DKCYAN="$(cm_sgr "$(cm_sink "$(cm_mix "$(cm_theme_get accent)" "$CM_BG" 35)" 42)")"
|
||||
|
||||
# Deliberately recessive: help lines, hints, detail rows. The floor keeps
|
||||
# it off the background rather than merged into it.
|
||||
C_DIM="$(cm_sgr "$(cm_sink "$(cm_theme_get muted)" 30)")"
|
||||
|
||||
C_GREEN="$(cm_role 55 green bright_green)"
|
||||
C_YELLOW="$(cm_role 65 yellow bright_yellow)"
|
||||
C_MAGENTA="$(cm_role 70 magenta bright_magenta)"
|
||||
|
||||
# FAIL has to out-shout everything else, so it takes the theme's saturated
|
||||
# red where one exists and a lifted fallback where it does not. Solitude's
|
||||
# `red` is #565d60 - a slate with no hue left in it - which is why the
|
||||
# saturated `bright_red` is preferred over the nominal one here.
|
||||
red="$(cm_theme_get bright_red)"
|
||||
if ! cm_hex_ok "$red" || [ "$(cm_sat "$red")" -lt 25 ]; then
|
||||
red="$(cm_theme_get red)"
|
||||
fi
|
||||
if cm_hex_ok "$red" && [ "$(cm_sat "$red")" -ge 25 ]; then
|
||||
C_RED="$(cm_sgr "$(cm_lift "$red" 50)")"
|
||||
else
|
||||
# No usable red anywhere in the theme. Weight carries the role instead,
|
||||
# over a hue that at least leans warm.
|
||||
C_RED="$C_BOLD$(cm_sgr "$(cm_lift '#d2685f' 60)")"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
if [ ! -t 1 ] || [ -n "${NO_COLOR:-}" ]; then
|
||||
C_RESET=''; C_DIM=''; C_CYAN=''; C_GREEN=''; C_YELLOW=''; C_RED=''
|
||||
C_MAGENTA=''; C_WHITE=''; C_GRAY=''; C_DKCYAN=''; C_BOLD=''
|
||||
CM_THEME_MODE=''
|
||||
elif ! cm_theme_palette; then
|
||||
# No theme to read, or a terminal that cannot render one. Same sixteen
|
||||
# slots as before with the two roles the slots get wrong corrected:
|
||||
# bright red for FAIL, because slot 31 is a muted maroon under a good many
|
||||
# palettes, and bold on headings, which no palette can take away.
|
||||
C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'
|
||||
C_DIM=$'\033[90m'; C_CYAN=$'\033[36m'; C_GREEN=$'\033[32m'
|
||||
C_YELLOW=$'\033[33m'; C_RED=$'\033[91m'; C_MAGENTA=$'\033[35m'; C_WHITE=$'\033[97m'
|
||||
C_GRAY=$'\033[37m'; C_DKCYAN=$'\033[36;2m'
|
||||
CM_THEME_MODE=''
|
||||
fi
|
||||
|
||||
say() { printf ' %s\n' "$*"; }
|
||||
ok() { printf ' %sok %s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
|
||||
# warn/err go to stderr: several of these functions run inside $( ), where
|
||||
# anything on stdout is captured as the return value instead of being shown.
|
||||
warn() { printf ' %swarn%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; }
|
||||
err() { printf ' %sFAIL%s %s\n' "$C_BOLD$C_RED" "$C_RESET" "$*" >&2; }
|
||||
head_() { printf '\n%s%s%s\n' "$C_BOLD$C_CYAN" "$*" "$C_RESET"; }
|
||||
|
||||
mode_color() {
|
||||
[ "$1" = anthropic ] && { printf '%s' "$C_MAGENTA"; return; }
|
||||
case "$(prov_field "$1" 5)" in
|
||||
cyan) printf '%s' "$C_CYAN" ;;
|
||||
green) printf '%s' "$C_GREEN" ;;
|
||||
yellow) printf '%s' "$C_YELLOW" ;;
|
||||
magenta) printf '%s' "$C_MAGENTA" ;;
|
||||
white) printf '%s' "$C_WHITE" ;;
|
||||
dkcyan) printf '%s' "$C_DKCYAN" ;;
|
||||
*) printf '%s' "$C_GRAY" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
mode_label() {
|
||||
[ "$1" = anthropic ] && { printf 'Anthropic - your subscription login, no gateway'; return; }
|
||||
prov_field "$1" 4
|
||||
}
|
||||
|
||||
# Pure ASCII on purpose - renders identically in every terminal and locale.
|
||||
show_banner() {
|
||||
local mode="$1" preset="$2" tag
|
||||
printf '\n'
|
||||
printf '%s ____ _ _ __ __ _ %s\n' "$C_DKCYAN" "$C_RESET"
|
||||
printf '%s / ___| | __ _ _ _ __| | ___ | \\/ | ___ __| | ___ %s\n' "$C_CYAN" "$C_RESET"
|
||||
printf '%s | | | |/ _` | | | |/ _` |/ _ \\ | |\\/| |/ _ \\ / _` |/ _ \\%s\n' "$C_CYAN" "$C_RESET"
|
||||
printf '%s | |___| | (_| | |_| | (_| | __/ | | | | (_) | (_| | __/%s\n' "$C_CYAN" "$C_RESET"
|
||||
printf '%s \\____|_|\\__,_|\\__,_|\\__,_|\\___| |_| |_|\\___/ \\__,_|\\___|%s\n' "$C_DKCYAN" "$C_RESET"
|
||||
printf ' %s-----------------------------------------------------------%s\n' "$C_DIM" "$C_RESET"
|
||||
tag="$mode"; [ -n "$preset" ] && tag="$mode / $preset"
|
||||
printf ' %snow%s %s%s%s %sswitch Claude Code between providers%s\n' \
|
||||
"$C_DIM" "$C_RESET" "$(mode_color "$mode")" "$tag" "$C_RESET" "$C_DIM" "$C_RESET"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
claude-mode - switch Claude Code between Anthropic and gateway providers
|
||||
|
||||
claude-mode interactive menu
|
||||
claude-mode status active mode, preset, model map
|
||||
|
||||
claude-mode anthropic native login (clears all gateway config)
|
||||
EOF
|
||||
# One line per provider in providers.json, so a new one documents itself.
|
||||
local id
|
||||
for id in $(provider_ids); do
|
||||
printf ' claude-mode %-26s %s (default: %s)\n' "$id [preset]" \
|
||||
"$(prov_field "$id" 4 | sed 's/^[^-]*- //')" "$(default_preset_for "$id")"
|
||||
done
|
||||
cat <<'EOF'
|
||||
|
||||
claude-mode presets list presets
|
||||
claude-mode preset show <name>
|
||||
claude-mode preset new <name> [from] create a preset (copies 'from', else 'default')
|
||||
claude-mode preset new <name> --provider <p> [--blank]
|
||||
copy that provider's default, or start empty
|
||||
claude-mode preset rename <name> <new-name>
|
||||
claude-mode preset default [provider] [name|--clear]
|
||||
which preset 'claude-mode <provider>' picks
|
||||
claude-mode preset set <name> <tier> <model-id>
|
||||
claude-mode preset all <name> <model-id>
|
||||
claude-mode preset url <name> <base-url> point a preset at another server
|
||||
claude-mode preset auth <name> none|key [ref] whether that server needs a key
|
||||
claude-mode preset rm <name>
|
||||
|
||||
claude-mode set-key [ref] [key] store an API key (hidden prompt; key for scripts)
|
||||
claude-mode models [filter] models available from the active provider
|
||||
claude-mode models --preset <name> [--refresh|--json]
|
||||
that preset's provider instead; --refresh only
|
||||
updates the panel's cached list
|
||||
claude-mode doctor verify auth, endpoint, model ids, env
|
||||
claude-mode repair strip [1m] tags from cached model ids
|
||||
claude-mode health refresh health.json (machine-readable state)
|
||||
claude-mode setup <mode> [--terminal] first-run setup: key, server, models
|
||||
claude-mode preflight <mode> [preset] check a mode can actually serve, without switching
|
||||
claude-mode sessions [--stop|--restart]
|
||||
running sessions; close or reopen them
|
||||
claude-mode repair-session [id] [--apply]
|
||||
make a session resumable again after a bad switch,
|
||||
keeping the cut turns as markdown + a context note
|
||||
claude-mode repair-session --ignore <id> | --unignore <id> | --unignore-all | --ignored
|
||||
stop (or resume) counting one broken session
|
||||
claude-mode repair-session --all [--max-age <days>]
|
||||
broken sessions untouched for longer are hidden
|
||||
(default 7; 0 shows them all)
|
||||
claude-mode <mode> --force switch even if preflight says no
|
||||
claude-mode <mode> --yes switch without asking about running sessions
|
||||
EOF
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
# shellcheck shell=bash
|
||||
# linux/lib/preflight.sh - what has to be true before a switch may write.
|
||||
#
|
||||
# Part of the claude-mode CLI: sourced by linux/claude-mode, which sets the
|
||||
# CM_* paths, PY and JSON used here. Not meant to run on its own.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preflight
|
||||
#
|
||||
# A switch rewrites settings.json and is picked up by the next `claude` launch,
|
||||
# so a switch into a mode that cannot actually serve requests does not fail
|
||||
# loudly - it succeeds, and then every session started afterwards is broken in a
|
||||
# way that points at Claude Code rather than at here. The LM Studio case is the
|
||||
# sharp one: its token is an inline placeholder, so nothing about the switch
|
||||
# needs the server to exist, and pointing at a server that is not running yields
|
||||
# a config that looks perfectly healthy and answers nothing.
|
||||
#
|
||||
# So the preconditions are checked before the write, not after it, and the
|
||||
# failure names the thing to go and fix.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Set by cm_preflight for callers that want to report rather than re-derive.
|
||||
CM_PF_CODE=''; CM_PF_TITLE=''; CM_PF_DETAIL=''; CM_PF_REMEDY=''
|
||||
CM_PF_KIND=''; CM_PF_KEYREF=''; CM_PF_BASEURL=''
|
||||
|
||||
cm_pf_set() {
|
||||
CM_PF_CODE="$1"; CM_PF_TITLE="$2"; CM_PF_DETAIL="$3"
|
||||
CM_PF_REMEDY="$4"; CM_PF_KIND="$5"
|
||||
}
|
||||
|
||||
# Where the server lives changes how long to wait for it, not whether to ask.
|
||||
# An LM Studio instance is just as absent when it is a LAN box that is asleep or
|
||||
# a tunnel that is down as when it is a loopback port with nothing behind it,
|
||||
# and the failure is identical from here - so all of them get probed, with a
|
||||
# longer patience for anything off-machine.
|
||||
cm_url_is_local() {
|
||||
case "$1" in
|
||||
*://127.0.0.1*|*://localhost*|*://0.0.0.0*|*://\[::1\]*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cm_probe_timeout() { cm_url_is_local "$1" && printf '4' || printf '10'; }
|
||||
|
||||
# Probe result, one word on stdout:
|
||||
# ok server answered
|
||||
# auth server is there and refused the credential (401/403)
|
||||
# notfound something answered, but not an LM Studio API (404/wrong host)
|
||||
# refused nothing answered at all - down, unreachable, DNS, TLS, timeout
|
||||
# skip no curl, so no opinion
|
||||
#
|
||||
# The distinction matters because the remedies are opposites: `refused` means go
|
||||
# and start the server, `auth` means the server is fine and the key is not.
|
||||
cm_probe_server() {
|
||||
local base="${1%/}" token="${2:-}" paths="${3:-/api/v0/models,/v1/models}" t code ep
|
||||
command -v curl >/dev/null 2>&1 || { printf 'skip'; return 0; }
|
||||
t="$(cm_probe_timeout "$base")"
|
||||
|
||||
for ep in $(printf '%s' "$paths" | tr ',' ' '); do
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time "$t" \
|
||||
${token:+-H "Authorization: Bearer $token"} \
|
||||
"$base$ep" 2>/dev/null)"
|
||||
case "$code" in
|
||||
200|204) printf 'ok'; return 0 ;;
|
||||
401|403) printf 'auth'; return 0 ;;
|
||||
000|'') continue ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# A non-zero HTTP code on the last try means something is listening; only a
|
||||
# total failure to connect leaves it at 000.
|
||||
case "$code" in
|
||||
000|'') printf 'refused' ;;
|
||||
*) printf 'notfound' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# cm_preflight <mode> [preset] - 0 = clear to switch, 1 = blocked (see CM_PF_*)
|
||||
cm_preflight() {
|
||||
local mode="$1" preset="${2:-}" pf auth_mode key_ref base
|
||||
|
||||
CM_PF_CODE='ok'; CM_PF_TITLE=''; CM_PF_DETAIL=''; CM_PF_REMEDY=''
|
||||
CM_PF_KIND=''; CM_PF_KEYREF=''; CM_PF_BASEURL=''
|
||||
|
||||
[ "$mode" = "anthropic" ] && return 0
|
||||
|
||||
pf="$(preset_path "$preset")"
|
||||
if [ ! -f "$pf" ]; then
|
||||
cm_pf_set 'no-preset' "Preset '$preset' not found" \
|
||||
"No preset file at $pf." "claude-mode presets" 'none'
|
||||
return 1
|
||||
fi
|
||||
|
||||
local got; got="$(jget "$pf" provider)"; [ -z "$got" ] && got=openrouter
|
||||
if [ "$got" != "$mode" ]; then
|
||||
cm_pf_set 'provider-mismatch' "Preset '$preset' is not a $mode preset" \
|
||||
"It declares provider '$got'." "claude-mode presets" 'none'
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! preset_configured "$pf"; then
|
||||
cm_pf_set 'needs-setup' "$(mode_label "$mode" | cut -d- -f1 | sed 's/ *$//') has not been set up yet" \
|
||||
"The shipped preset is a starting point: it has no key stored, and its model ids are whatever was on the machine this was packaged on. Setup asks for what it needs and picks models from the provider's own catalogue." \
|
||||
"claude-mode setup $mode" 'setup'
|
||||
return 1
|
||||
fi
|
||||
|
||||
# A preset with no tier mapped - a fresh `preset new --blank` - would switch
|
||||
# cleanly and leave Claude Code asking the gateway for its own default
|
||||
# Anthropic models: billed at full list price on OpenRouter, refused by the
|
||||
# others. Nothing else here would catch it, since the cost guard only sees
|
||||
# ids that are actually set.
|
||||
if ! "$PY" "$JSON" models "$pf" 2>/dev/null | grep -v '^subagent' | cut -f2 | grep -q .; then
|
||||
cm_pf_set 'no-models' "Preset '$preset' has no models set" \
|
||||
"Every tier is empty, so Claude Code would ask for its own default Anthropic models instead - billed at full price through OpenRouter, refused by the other providers." \
|
||||
"claude-mode preset set $preset <tier> <model-id>" 'edit-preset'
|
||||
return 1
|
||||
fi
|
||||
|
||||
base="$(jget "$pf" baseUrl)"; CM_PF_BASEURL="$base"
|
||||
auth_mode="$(jget "$pf" auth.mode)"; [ -z "$auth_mode" ] && auth_mode=vault
|
||||
|
||||
# A custom endpoint ships with no address, since there is no sensible one
|
||||
# to guess; switching to it would point every session at nothing.
|
||||
if [ -z "$base" ]; then
|
||||
cm_pf_set 'no-url' "Preset '$preset' has no server address" \
|
||||
"$(prov_field "$mode" 3) needs to know where the server is before anything can be sent to it." \
|
||||
"claude-mode preset url $preset <base-url>" 'set-url'
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$auth_mode" = "vault" ]; then
|
||||
key_ref="$(jget "$pf" auth.keyRef)"; [ -z "$key_ref" ] && key_ref=openrouter
|
||||
CM_PF_KEYREF="$key_ref"
|
||||
if ! cm_vault_has "$key_ref"; then
|
||||
cm_pf_set 'missing-key' "No API key stored for '$key_ref'" \
|
||||
"$(mode_label "$mode" | sed 's/ */ /g') needs a key before it can serve anything. It is kept in $(cm_vault_backend_label), never in settings.json." \
|
||||
"claude-mode set-key $key_ref" 'set-key'
|
||||
return 1
|
||||
fi
|
||||
if [ ! -x "$CM_HELPER" ]; then
|
||||
cm_pf_set 'helper-missing' 'Key helper is missing or not executable' \
|
||||
"Expected an executable at $CM_HELPER; Claude Code reads the key through it." \
|
||||
"bash linux/install.sh" 'reinstall'
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# A server provider is checked wherever it is (probe "always", or
|
||||
# "lenient" for a proxy that may list no models); a public gateway only
|
||||
# when it has been pointed at this machine (probe "local"). A public
|
||||
# gateway that is briefly unreachable is the network's problem and not
|
||||
# worth blocking a config change over.
|
||||
local probe_rule title
|
||||
probe_rule="$(prov_field "$mode" 8)"; title="$(prov_field "$mode" 3)"
|
||||
if [ "$probe_rule" = always ] || [ "$probe_rule" = lenient ] || cm_url_is_local "$base"; then
|
||||
local token='' probe where
|
||||
if [ "$auth_mode" = "vault" ]; then
|
||||
token="$(cm_vault_get "$key_ref" 2>/dev/null || true)"
|
||||
else
|
||||
token="$(jget "$pf" auth.token)"
|
||||
fi
|
||||
probe="$(cm_probe_server "$base" "$token" "$(prov_field "$mode" 9)")"
|
||||
cm_url_is_local "$base" && where='on this machine' || where='at that address'
|
||||
|
||||
case "$probe" in
|
||||
ok|skip) ;;
|
||||
auth)
|
||||
if [ "$auth_mode" = "vault" ]; then
|
||||
cm_pf_set 'server-auth' 'The server rejected the stored key' \
|
||||
"$base is running but refused the key held as '$key_ref'. Either the key is wrong, or the server expects a different one." \
|
||||
"claude-mode set-key $key_ref" 'set-key'
|
||||
else
|
||||
cm_pf_set 'server-auth' 'The server wants an API key' \
|
||||
"$base is running but is refusing an unauthenticated request. This preset is set to send $title's placeholder token, which only works on a server with authentication switched off." \
|
||||
"claude-mode preset auth $preset key" 'needs-key'
|
||||
fi
|
||||
return 1 ;;
|
||||
notfound)
|
||||
# A proxy in front of a Messages API often serves no model
|
||||
# list at all; for one of those, something answering is
|
||||
# as much as can be checked.
|
||||
[ "$probe_rule" = lenient ] && return 0
|
||||
cm_pf_set 'server-wrong' "That address answered, but not as $title" \
|
||||
"Something is listening at $base, but $(prov_field "$mode" 9 | sed 's/,/ and /g') is not there. Check the port, or whether a proxy in front of it is rewriting the path." \
|
||||
"claude-mode preset url $preset <base-url>" 'set-url'
|
||||
return 1 ;;
|
||||
*)
|
||||
cm_pf_set 'server-unreachable' 'The server is not responding' \
|
||||
"Nothing is answering at $base $where. Switching would leave every new session pointed at a server that is not there." \
|
||||
"claude-mode preset url $preset <base-url>" 'start-server'
|
||||
return 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_preflight() {
|
||||
local mode="${1:-}" preset="${2:-}" p
|
||||
if [ "$mode" != anthropic ]; then
|
||||
mode="$(provider_resolve "$mode")" || { err "unknown mode '${1:-}'"; return 1; }
|
||||
p="$(resolve_preset "$mode" "$preset" 2>/dev/null)" || p="$preset"
|
||||
preset="$p"
|
||||
fi
|
||||
|
||||
if cm_preflight "$mode" "$preset"; then
|
||||
"$PY" "$JSON" preflight-json ok "$mode" "$preset" '' '' '' '' '' '' ''
|
||||
return 0
|
||||
fi
|
||||
"$PY" "$JSON" preflight-json blocked "$mode" "$preset" \
|
||||
"$CM_PF_CODE" "$CM_PF_TITLE" "$CM_PF_DETAIL" "$CM_PF_REMEDY" "$CM_PF_KIND" \
|
||||
"$CM_PF_KEYREF" "$CM_PF_BASEURL"
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
# shellcheck shell=bash
|
||||
# linux/lib/presets.sh - the preset subcommand, and re-applying the preset in use.
|
||||
#
|
||||
# Part of the claude-mode CLI: sourced by linux/claude-mode, which sets the
|
||||
# CM_* paths, PY and JSON used here. Not meant to run on its own.
|
||||
|
||||
cmd_preset() {
|
||||
local sub="${1:-}" name="${2:-}"
|
||||
if [ -n "$name" ] && ! valid_preset_name "$name"; then
|
||||
err "invalid preset name '$name' - letters, digits, . _ and - only, not starting with a dot"
|
||||
return 1
|
||||
fi
|
||||
case "$sub" in
|
||||
show)
|
||||
[ -n "$name" ] || { err 'usage: claude-mode preset show <name>'; return 1; }
|
||||
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
|
||||
cat "$(preset_path "$name")"
|
||||
;;
|
||||
new)
|
||||
[ -n "$name" ] || { err 'usage: claude-mode preset new <name> [from] | <name> --provider <p> [--blank]'; return 1; }
|
||||
[ -f "$(preset_path "$name")" ] && { err "preset '$name' already exists"; return 1; }
|
||||
local from='' provider='' blank=0 a
|
||||
shift 2
|
||||
while [ $# -gt 0 ]; do
|
||||
a="$1"; shift
|
||||
case "$a" in
|
||||
--provider)
|
||||
[ $# -gt 0 ] || { err "--provider needs one of: $(provider_ids | tr '\n' ' ')"; return 1; }
|
||||
provider="$1"; shift ;;
|
||||
--blank) blank=1 ;;
|
||||
-*) err "unknown option '$a'"; return 1 ;;
|
||||
*) from="$a" ;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$provider" ]; then
|
||||
local want="$provider"
|
||||
provider="$(provider_resolve "$want")" || {
|
||||
err "unknown provider '$want' - one of: $(provider_ids | tr '\n' ' ')"; return 1; }
|
||||
fi
|
||||
|
||||
if [ "$blank" -eq 1 ]; then
|
||||
[ -n "$provider" ] || { err '--blank needs --provider to know which template'; return 1; }
|
||||
[ -z "$from" ] || { err '--blank starts from nothing; drop the source preset'; return 1; }
|
||||
"$PY" "$JSON" scaffold "$provider" > "$(preset_path "$name")" || {
|
||||
rm -f "$(preset_path "$name")"; return 1; }
|
||||
ok "created blank $provider preset '$name'"
|
||||
say 'every tier is empty: set them with claude-mode preset set, or the bar panel'
|
||||
write_health "$(state_mode)" "$(state_preset)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# A literal `default` is only right for OpenRouter. Given a provider
|
||||
# and no source, start from that provider's own default preset.
|
||||
if [ -z "$from" ]; then
|
||||
if [ -n "$provider" ]; then from="$(resolve_preset "$provider")" || return 1
|
||||
else from=default; fi
|
||||
fi
|
||||
valid_preset_name "$from" || { err "invalid source preset name '$from'"; return 1; }
|
||||
[ -f "$(preset_path "$from")" ] || { err "source preset '$from' not found"; return 1; }
|
||||
if [ -n "$provider" ]; then
|
||||
local got; got="$(jget "$(preset_path "$from")" provider)"; [ -z "$got" ] && got=openrouter
|
||||
[ "$got" = "$provider" ] || { err "'$from' is for $got, not $provider"; return 1; }
|
||||
fi
|
||||
cp "$(preset_path "$from")" "$(preset_path "$name")"
|
||||
ok "created $(preset_path "$name") from '$from'"
|
||||
write_health "$(state_mode)" "$(state_preset)"
|
||||
;;
|
||||
rename)
|
||||
local new="${3:-}" out
|
||||
[ -n "$name" ] && [ -n "$new" ] || { err 'usage: claude-mode preset rename <name> <new-name>'; return 1; }
|
||||
valid_preset_name "$new" || { err "invalid preset name '$new' - letters, digits, . _ and - only, not starting with a dot"; return 1; }
|
||||
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
|
||||
[ "$name" = "$new" ] && { ok "already called '$new'"; return 0; }
|
||||
[ -e "$(preset_path "$new")" ] && { err "preset '$new' already exists"; return 1; }
|
||||
out="$("$PY" "$JSON" preset-rename "$CM_PRESETS" "$name" "$new" "$CM_STATE" "$CM_DEFAULTS" 2>&1)" || {
|
||||
err "$out"; return 1; }
|
||||
ok "renamed '$name' -> '$new'"
|
||||
case "$out" in
|
||||
*'"active": true'*)
|
||||
say 'it is the active preset; state.json now names it, and running sessions keep working' ;;
|
||||
esac
|
||||
case "$out" in
|
||||
*'"default": true'*) say 'it was chosen as its provider'"'"'s default, and that follows the new name' ;;
|
||||
esac
|
||||
write_health "$(state_mode)" "$(state_preset)"
|
||||
;;
|
||||
rm)
|
||||
[ -n "$name" ] || { err 'usage: claude-mode preset rm <name>'; return 1; }
|
||||
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
|
||||
if [ "$name" = "$(state_preset)" ] && [ "$(state_mode)" != "anthropic" ]; then
|
||||
err "preset '$name' is active. Switch away first."; return 1
|
||||
fi
|
||||
local prov; prov="$(jget "$(preset_path "$name")" provider)"; [ -z "$prov" ] && prov=openrouter
|
||||
rm -f "$(preset_path "$name")"; ok "deleted preset '$name'"
|
||||
if [ "$(jget "$CM_DEFAULTS" "$prov")" = "$name" ]; then
|
||||
"$PY" "$JSON" set-default "$CM_DEFAULTS" "$prov" "" >/dev/null
|
||||
say "it was the chosen default for $prov; 'claude-mode $prov' means '$(resolve_preset "$prov" 2>/dev/null || printf 'nothing')' now"
|
||||
fi
|
||||
# Allowed, but not quietly: with no preset left, `claude-mode <provider>`
|
||||
# has nothing to resolve to.
|
||||
if [ -z "$(presets_for_provider "$prov")" ]; then
|
||||
warn "that was the last $prov preset - 'claude-mode $prov' has nothing to switch to now"
|
||||
say "create one with: claude-mode preset new <name> --provider $prov [--blank]"
|
||||
fi
|
||||
write_health "$(state_mode)" "$(state_preset)"
|
||||
;;
|
||||
default)
|
||||
# Which preset `claude-mode <provider>` means when none is named.
|
||||
local prov="$name" target="${3:-}" p cur got
|
||||
if [ -z "$prov" ]; then
|
||||
head_ 'default preset per provider'
|
||||
for p in $(provider_ids); do
|
||||
cur="$(jget "$CM_DEFAULTS" "$p")"
|
||||
if [ -n "$cur" ] && [ -f "$(preset_path "$cur")" ]; then
|
||||
printf ' %-11s %s (chosen)\n' "$p" "$cur"
|
||||
else
|
||||
# "built-in" only when it really is the built-in name;
|
||||
# with that preset gone, resolve_preset takes the first.
|
||||
local got_p why
|
||||
got_p="$(resolve_preset "$p" 2>/dev/null)" || got_p=''
|
||||
if [ -z "$got_p" ]; then why='no preset left'
|
||||
elif [ "$got_p" = "$(builtin_default_preset "$p")" ]; then why='built-in'
|
||||
else why='first by name'; fi
|
||||
printf ' %-11s %s (%s%s)\n' "$p" "${got_p:--}" "$why" \
|
||||
"$([ -n "$cur" ] && printf "; chosen '%s' no longer exists" "$cur")"
|
||||
fi
|
||||
done
|
||||
printf '\n %sclaude-mode preset default <provider> <name> (or --clear)%s\n' "$C_DIM" "$C_RESET"
|
||||
return 0
|
||||
fi
|
||||
prov="$(provider_resolve "$name")" || {
|
||||
err "unknown provider '$name' - one of: $(provider_ids | tr '\n' ' ')"; return 1; }
|
||||
if [ -z "$target" ]; then
|
||||
resolve_preset "$prov" && printf '\n'
|
||||
return
|
||||
fi
|
||||
if [ "$target" = "--clear" ]; then
|
||||
"$PY" "$JSON" set-default "$CM_DEFAULTS" "$prov" "" >/dev/null || return 1
|
||||
ok "$prov : no longer chosen; 'claude-mode $prov' means '$(resolve_preset "$prov" 2>/dev/null || printf 'nothing - no preset left')'"
|
||||
else
|
||||
valid_preset_name "$target" || { err "invalid preset name '$target'"; return 1; }
|
||||
[ -f "$(preset_path "$target")" ] || { err "preset '$target' not found"; return 1; }
|
||||
got="$(jget "$(preset_path "$target")" provider)"; [ -z "$got" ] && got=openrouter
|
||||
[ "$got" = "$prov" ] || { err "'$target' is for $got, not $prov"; return 1; }
|
||||
"$PY" "$JSON" set-default "$CM_DEFAULTS" "$prov" "$target" >/dev/null || return 1
|
||||
ok "$prov : 'claude-mode $prov' now means '$target'"
|
||||
fi
|
||||
write_health "$(state_mode)" "$(state_preset)"
|
||||
;;
|
||||
set)
|
||||
local tier="${3:-}" model="${4:-}"
|
||||
[ -n "$name" ] && [ -n "$tier" ] && [ -n "$model" ] || { err 'usage: claude-mode preset set <name> <tier> <model-id>'; return 1; }
|
||||
# set-tier saves whatever it loaded, so a mistyped name would
|
||||
# otherwise quietly become a new, half-empty preset.
|
||||
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
|
||||
"$PY" "$JSON" set-tier "$(preset_path "$name")" "$tier" "$model" || return 1
|
||||
ok "$name : $tier -> $model"
|
||||
reapply_if_active "$name" models
|
||||
;;
|
||||
all)
|
||||
local model="${3:-}"
|
||||
[ -n "$name" ] && [ -n "$model" ] || { err 'usage: claude-mode preset all <name> <model-id>'; return 1; }
|
||||
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
|
||||
local t
|
||||
for t in "${TIERS[@]}" subagent; do
|
||||
"$PY" "$JSON" set-tier "$(preset_path "$name")" "$t" "$model" || return 1
|
||||
done
|
||||
ok "$name : all tiers + subagent -> $model"
|
||||
reapply_if_active "$name" models
|
||||
;;
|
||||
url)
|
||||
local url="${3:-}"
|
||||
[ -n "$name" ] && [ -n "$url" ] || { err 'usage: claude-mode preset url <name> <base-url>'; return 1; }
|
||||
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
|
||||
"$PY" "$JSON" set-url "$(preset_path "$name")" "$url" >/dev/null || return 1
|
||||
ok "$name : baseUrl -> $url"
|
||||
reapply_if_active "$name"
|
||||
;;
|
||||
auth)
|
||||
local amode="${3:-}" ref="${4:-}"
|
||||
[ -n "$name" ] && [ -n "$amode" ] || { err 'usage: claude-mode preset auth <name> none|key [keyRef]'; return 1; }
|
||||
[ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; }
|
||||
# The provider's own slot name by default: lmstudio, ollama, custom.
|
||||
[ -n "$ref" ] || ref="$(prov_field "$(jget "$(preset_path "$name")" provider)" 17)"
|
||||
[ -n "$ref" ] || ref=lmstudio
|
||||
"$PY" "$JSON" set-auth "$(preset_path "$name")" "$amode" "$ref" >/dev/null || return 1
|
||||
if [ "$amode" = "key" ]; then
|
||||
ok "$name : auth -> vault key '$ref'"
|
||||
cm_vault_has "$ref" || warn "no key stored yet for '$ref' - run: claude-mode set-key $ref"
|
||||
else
|
||||
ok "$name : auth -> none (inline placeholder token)"
|
||||
fi
|
||||
reapply_if_active "$name"
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# scope `models` marks an edit that changed tier mappings only. What makes a
|
||||
# switch dangerous to running sessions is the endpoint or the key changing under
|
||||
# them, and a tier edit changes neither - so there is nothing to ask them about,
|
||||
# and the bar panel (which cannot answer a prompt) can edit the active preset.
|
||||
reapply_if_active() {
|
||||
local name="$1" scope="${2:-}" mode
|
||||
mode="$(state_mode)"
|
||||
if [ "$mode" != "anthropic" ] && [ "$(state_preset)" = "$name" ]; then
|
||||
say 're-applying active preset...'
|
||||
[ "$scope" = models ] && CM_SAME_ENDPOINT=1
|
||||
if ! set_mode "$mode" "$name"; then
|
||||
CM_SAME_ENDPOINT=0
|
||||
# Last on stderr on purpose: it is the line the panel shows, and
|
||||
# the file *was* written, which the failure above does not say.
|
||||
err "saved, but re-applying the active preset failed - run: claude-mode $mode $name"
|
||||
return 1
|
||||
fi
|
||||
CM_SAME_ENDPOINT=0
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
# shellcheck shell=bash
|
||||
# linux/lib/repair.sh - repairing, dismissing and listing session transcripts.
|
||||
#
|
||||
# Part of the claude-mode CLI: sourced by linux/claude-mode, which sets the
|
||||
# CM_* paths, PY and JSON used here. Not meant to run on its own.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transcript repair
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Claude Code files transcripts under ~/.claude/projects/<slug>, and the slug
|
||||
# is not "slashes to dashes": *every* non-alphanumeric character becomes one
|
||||
# dash, nothing collapsed. Verified against 2.1.269 - a directory named
|
||||
# `slug._test x` was filed as `-tmp-cmtest-slug--test-x`, so the dot, the
|
||||
# underscore and the space each became a dash of their own. Paths with a dot in
|
||||
# them are ordinary on macOS (iCloud Drive sits under `Mobile Documents`), and
|
||||
# a slash-only rule points at a directory that does not exist.
|
||||
cm_project_slug() {
|
||||
printf '%s' "$1" | sed 's|[^a-zA-Z0-9]|-|g'
|
||||
}
|
||||
|
||||
# It is also the *physical* directory that gets slugged. Claude Code asks the OS
|
||||
# for its working directory and symlinks come back resolved, so a session
|
||||
# started in /tmp/x is filed under -private-tmp-x on macOS (where /tmp is a
|
||||
# symlink) while the shell's $PWD still reads /tmp/x and looks in -tmp-x.
|
||||
# The logical path is tried first, since that is what a user types and what a
|
||||
# plain project looks like; the resolved one is the fallback.
|
||||
cm_project_dir() {
|
||||
local path="${1:-$PWD}" slug phys
|
||||
slug="$(cm_project_slug "$path")"
|
||||
if [ -d "$CM_SETTINGS_DIR/projects/$slug" ]; then
|
||||
printf '%s/projects/%s' "$CM_SETTINGS_DIR" "$slug"; return 0
|
||||
fi
|
||||
phys="$(cd "$path" 2>/dev/null && pwd -P)" || phys=''
|
||||
if [ -n "$phys" ] && [ "$phys" != "$path" ]; then
|
||||
slug="$(cm_project_slug "$phys")"
|
||||
fi
|
||||
printf '%s/projects/%s' "$CM_SETTINGS_DIR" "$slug"
|
||||
}
|
||||
|
||||
# Modification time as an epoch second. GNU stat spells it -c %Y, BSD/macOS
|
||||
# stat spells it -f %m.
|
||||
cm_file_mtime() {
|
||||
stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0
|
||||
}
|
||||
|
||||
# Dismissing is bookkeeping, not repair: the transcript is left exactly as it
|
||||
# is, and only whether the scan - and so the bar's warning dot - counts it
|
||||
# changes. Hence no confirmation, and one flag to undo it.
|
||||
cm_ignore_session() {
|
||||
local act="$1" id="${2:-}" out op pruned projects="$CM_SETTINGS_DIR/projects"
|
||||
case "$act" in
|
||||
list)
|
||||
out="$("$PY" "$JSON" ignore-session "$CM_IGNORED" "$projects" list 2>&1)" || {
|
||||
err "$out"; return 1; }
|
||||
head_ 'dismissed sessions'
|
||||
printf '%s' "$out" | "$PY" -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
D,X='\033[90m','\033[0m'
|
||||
if not d['sessions']:
|
||||
print(' none')
|
||||
for s in d['sessions']:
|
||||
state = 'still broken' if s['broken'] else ('transcript gone' if not s['exists'] else 'no longer broken')
|
||||
print(' %-38s %s' % (s['sessionId'], s['project']))
|
||||
print(' %signored %s, %s%s' % (D, s['ignoredAt'][:10], state, X))
|
||||
print()
|
||||
print(' %sclaude-mode repair-session --unignore <id> (or --unignore-all)%s' % (D,X))
|
||||
"
|
||||
return 0 ;;
|
||||
ignore|unignore)
|
||||
[ -n "$id" ] || { err "--$act needs a session id"; return 1; }
|
||||
[ "$act" = ignore ] && op=add || op=remove ;;
|
||||
unignore-all)
|
||||
op=clear ;;
|
||||
esac
|
||||
|
||||
out="$("$PY" "$JSON" ignore-session "$CM_IGNORED" "$projects" "$op" "${id%.jsonl}" 2>&1)" || {
|
||||
err "$out"; return 1; }
|
||||
id="${id%.jsonl}"
|
||||
case "$act:$out" in
|
||||
ignore:*'"changed": true'*) ok "hidden $id"
|
||||
say "${C_DIM}claude-mode repair-session --unignore $id brings it back${C_RESET}" ;;
|
||||
ignore:*) ok "$id was already hidden" ;;
|
||||
unignore:*'"changed": true'*) ok "restored $id" ;;
|
||||
unignore:*) ok "$id was not hidden" ;;
|
||||
*'"changed": true'*) ok 'restored every dismissed session' ;;
|
||||
*) ok 'nothing was dismissed' ;;
|
||||
esac
|
||||
pruned="$(printf '%s' "$out" | sed -n 's/.*"pruned": \([0-9]*\).*/\1/p')"
|
||||
[ "${pruned:-0}" -gt 0 ] && say "${C_DIM}(forgot $pruned whose transcript no longer exists)${C_RESET}"
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_repair_session() {
|
||||
local target='' apply=0 reinject=1 scan_all=0 as_json=0 dir='' a file verdict age
|
||||
local ignore_act='' max_age="${CM_IGNORE_AGE_DAYS:-}"
|
||||
while [ $# -gt 0 ]; do
|
||||
a="$1"; shift
|
||||
case "$a" in
|
||||
--apply) apply=1 ;;
|
||||
--dry-run) apply=0 ;;
|
||||
--no-reinject) reinject=0 ;;
|
||||
--list) target='--list' ;;
|
||||
--all) scan_all=1 ;;
|
||||
--json) as_json=1; scan_all=1 ;;
|
||||
# The id may follow the flag or stand anywhere as the positional, so
|
||||
# `--ignore <id>` and `<id> --ignore` both do what they say.
|
||||
--ignore|--unignore)
|
||||
ignore_act="${a#--}"
|
||||
if [ $# -gt 0 ] && [ "${1#-}" = "$1" ]; then target="$1"; shift; fi ;;
|
||||
--unignore-all) ignore_act='unignore-all' ;;
|
||||
--ignored) ignore_act='list' ;;
|
||||
--max-age)
|
||||
[ $# -gt 0 ] || { err '--max-age needs a number of days'; return 1; }
|
||||
max_age="$1"; shift ;;
|
||||
-*) err "unknown option '$a'"; return 1 ;;
|
||||
*) target="$a" ;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$max_age" ] && ! [[ "$max_age" =~ ^[0-9]+$ ]]; then
|
||||
err "max age is a whole number of days, not '$max_age' (--max-age / CM_IGNORE_AGE_DAYS)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -n "$ignore_act" ]; then
|
||||
cm_ignore_session "$ignore_act" "$target"
|
||||
return $?
|
||||
fi
|
||||
|
||||
# A session you need to repair is one you could not resume, which is a poor
|
||||
# position from which to remember which project it belonged to. --all drops
|
||||
# the working-directory scoping and reports only what is actually broken.
|
||||
if [ "$scan_all" -eq 1 ]; then
|
||||
local scan
|
||||
scan="$("$PY" "$JSON" scan-sessions "$CM_SETTINGS_DIR/projects" "$max_age" "$CM_IGNORED" 2>&1)" || {
|
||||
err "could not scan transcripts: $scan"; return 1; }
|
||||
|
||||
if [ "$as_json" -eq 1 ]; then
|
||||
printf '%s\n' "$scan"
|
||||
return 0
|
||||
fi
|
||||
|
||||
head_ 'scanning every session transcript'
|
||||
printf '%s' "$scan" | "$PY" -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
G,Y,D,X='\033[32m','\033[33m','\033[90m','\033[0m'
|
||||
for b in d['broken']:
|
||||
print(' %-38s %s' % (b['sessionId'], b['project']))
|
||||
prov = ', '.join(b['providers']) or 'another provider'
|
||||
print(' %d line(s) after the last good message, from %s' % (b['dropLines'], prov))
|
||||
print()
|
||||
ig = d.get('ignored') or []
|
||||
if not d['broken']:
|
||||
print(' %sok %s nothing to repair across %d transcript(s)%s' % (G,X,d['scanned'],
|
||||
' that is not hidden' if ig else ''))
|
||||
else:
|
||||
print(' %swarn%s %d of %d transcript(s) were cut short by a mode switch' % (Y,X,d['count'],d['scanned']))
|
||||
print(' %sclaude-mode repair-session <id> --apply (or --ignore <id> to stop counting it)%s' % (D,X))
|
||||
# Age-hiding must never look like damage vanishing, so whatever was held back
|
||||
# is always named, with the way to see it.
|
||||
if ig:
|
||||
nd = sum(1 for i in ig if i.get('reason') == 'dismissed')
|
||||
ns = len(ig) - nd
|
||||
bits, hints = [], []
|
||||
if nd:
|
||||
bits.append('%d ignored' % nd); hints.append('--ignored lists them')
|
||||
if ns:
|
||||
bits.append('%d older than %d days' % (ns, d.get('maxAgeDays', 0))); hints.append('--max-age 0 shows all')
|
||||
print(' %s%d hidden: %s (%s)%s' % (D, len(ig), ', '.join(bits), '; '.join(hints), X))
|
||||
print(' %ssessions that ran entirely on a gateway are not listed: they carry that%s' % (D,X))
|
||||
print(' %sprovider\'s ids by design and resume fine under it%s' % (D,X))
|
||||
"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Claude Code keys transcripts by the directory the session was started in,
|
||||
# which is rarely the one you are standing in when you come to fix it. Walk
|
||||
# up first, and for a named session fall back to looking through every
|
||||
# project - the id is unique, so there is nothing ambiguous to resolve.
|
||||
local probe="$PWD"
|
||||
while [ -n "$probe" ]; do
|
||||
[ -d "$(cm_project_dir "$probe")" ] && { dir="$(cm_project_dir "$probe")"; break; }
|
||||
[ "$probe" = "/" ] && break
|
||||
probe="$(dirname "$probe")"
|
||||
done
|
||||
|
||||
if [ -n "$target" ] && [ "$target" != "--list" ]; then
|
||||
if [ -z "$dir" ] || [ ! -f "$dir/${target%.jsonl}.jsonl" ]; then
|
||||
local hit
|
||||
hit="$(ls -1 "$CM_SETTINGS_DIR"/projects/*/"${target%.jsonl}".jsonl 2>/dev/null | head -n1)"
|
||||
[ -n "$hit" ] && dir="$(dirname "$hit")"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$dir" ] || [ ! -d "$dir" ]; then
|
||||
err 'no session transcripts found for this directory'
|
||||
say 'run it from the project the session belongs to, or name the session id'
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -z "$target" ] || [ "$target" = "--list" ]; then
|
||||
head_ 'session transcripts here'
|
||||
# This listing classifies each file itself rather than going through
|
||||
# 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 row
|
||||
hidden="$("$PY" "$JSON" scan-sessions "$CM_SETTINGS_DIR/projects" "$max_age" "$CM_IGNORED" 2>/dev/null \
|
||||
| "$PY" -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
for i in d.get('ignored') or []:
|
||||
print('%s\t%s' % (i['sessionId'], 'ignored' if i.get('reason') == 'dismissed'
|
||||
else 'older than %d days' % d.get('maxAgeDays', 0)))
|
||||
" 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=''
|
||||
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)
|
||||
state = 'ok' if d['healthy'] else ('repairable, would drop %d line(s)' % d['dropLines'] if d['repairable'] else 'no anthropic message found')
|
||||
if sys.argv[1]:
|
||||
state += ' (hidden: %s)' % sys.argv[1]
|
||||
print(' %-40s %s' % (os.path.basename(d['path'])[:-6], state))
|
||||
" "$reason"
|
||||
done
|
||||
printf '\n %sclaude-mode repair-session <session-id> --apply%s\n' "$C_DIM" "$C_RESET"
|
||||
printf ' %s--all checks every project, not just this one%s\n' "$C_DIM" "$C_RESET"
|
||||
return 0
|
||||
fi
|
||||
|
||||
file="$dir/${target%.jsonl}.jsonl"
|
||||
[ -f "$file" ] || { err "no transcript $file"; return 1; }
|
||||
|
||||
# A transcript that is still being appended to belongs to a session that is
|
||||
# still alive; truncating it underneath a running process helps nobody.
|
||||
age=$(( $(date +%s) - $(cm_file_mtime "$file") ))
|
||||
if [ "$age" -lt 90 ] && [ "$apply" -eq 1 ]; then
|
||||
err "that transcript was written to ${age}s ago - it looks live"
|
||||
say 'close the session that owns it first'
|
||||
return 1
|
||||
fi
|
||||
|
||||
local flags=''
|
||||
[ "$apply" -eq 1 ] && flags="$flags --apply"
|
||||
[ "$reinject" -eq 0 ] && flags="$flags --no-reinject"
|
||||
# shellcheck disable=SC2086
|
||||
verdict="$("$PY" "$JSON" repair-session "$file" $flags)" || {
|
||||
err 'could not read that transcript'; return 1; }
|
||||
|
||||
# A repaired session is no longer damage. Left dismissed, it would stay
|
||||
# hidden if the same session broke again later.
|
||||
case "$verdict" in
|
||||
*'"applied": true'*)
|
||||
"$PY" "$JSON" ignore-session "$CM_IGNORED" "$CM_SETTINGS_DIR/projects" \
|
||||
remove "${target%.jsonl}" >/dev/null 2>&1 ;;
|
||||
esac
|
||||
|
||||
printf '%s' "$verdict" | "$PY" -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
G,Y,R,D,X = '\033[32m','\033[33m','\033[91m','\033[90m','\033[0m'
|
||||
print()
|
||||
if d['healthy']:
|
||||
print(' %sok %s last message is Anthropic-issued; nothing to repair' % (G,X))
|
||||
raise SystemExit(0)
|
||||
if d['kind'] == 'gateway-native':
|
||||
prov = (d['foreignIds'][0]['model'] or 'a gateway') if d['foreignIds'] else 'a gateway'
|
||||
print(' %sok %s this session ran entirely on %s' % (G,X,prov))
|
||||
print(' %sits ids come from that provider by design; it resumes under it, not%s' % (D,X))
|
||||
print(' %sunder Anthropic. There is nothing here to repair.%s' % (D,X))
|
||||
raise SystemExit(0)
|
||||
if not d['repairable']:
|
||||
print(' %sok %s this transcript has no assistant replies to resume from' % (G,X))
|
||||
raise SystemExit(0)
|
||||
for f in d['foreignIds']:
|
||||
print(' %swarn%s line %d carries a %s id from %s' % (Y,X,f['line'],f['id'].split('-')[0]+'-',f['model'] or 'another provider'))
|
||||
n = sum(1 for s in d['syntheticIds'] if s['apiError'] and s['line'] > d['lastGoodLine'])
|
||||
if n:
|
||||
print(' %swarn%s %d client-side error placeholder(s) after the last good message' % (Y,X,n))
|
||||
if d['applied']:
|
||||
print(' %sok %s truncated to line %d, dropping %d' % (G,X,d['lastGoodLine'],d['dropLines']))
|
||||
print(' %sok %s original saved as %s' % (G,X,d['backup']))
|
||||
if d.get('recovered'):
|
||||
print(' %sok %s dropped turns written to %s' % (G,X,d['recovered']))
|
||||
if d.get('reinjected'):
|
||||
print(' %sok %s and handed back to the session as a context note' % (G,X))
|
||||
print()
|
||||
print(' %sthat session should resume, and will know what it did%s' % (D,X))
|
||||
else:
|
||||
print(' %swarn%s would truncate to line %d, dropping %d line(s)' % (Y,X,d['lastGoodLine'],d['dropLines']))
|
||||
print()
|
||||
print(' %sre-run with --apply: the original is backed up, the dropped turns are%s' % (D,X))
|
||||
print(' %ssaved as markdown, and handed back to the session as a context note%s' % (D,X))
|
||||
print(' %s(--no-reinject writes the file but leaves the session untouched)%s' % (D,X))
|
||||
"
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
# shellcheck shell=bash
|
||||
# linux/lib/sessions.sh - finding running Claude Code sessions, and asking about them before a switch.
|
||||
#
|
||||
# Part of the claude-mode CLI: sourced by linux/claude-mode, which sets the
|
||||
# CM_* paths, PY and JSON used here. Not meant to run on its own.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Running sessions
|
||||
#
|
||||
# A switch breaks running sessions. Not "leaves them on the old provider" -
|
||||
# breaks them, and it is worth being exact about why, because 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 the values it started with.
|
||||
#
|
||||
# The credential is not. It is fetched by 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 *now*. So a
|
||||
# switch reaches into a live session through the one thing that was never
|
||||
# cached:
|
||||
#
|
||||
# -> anthropic the helper returns nothing at all, by design, and the
|
||||
# session's next refresh comes back with no credential
|
||||
# -> another provider
|
||||
# the helper hands over the new provider's key while the
|
||||
# session is still pointed at the old base URL, which rejects
|
||||
# it
|
||||
#
|
||||
# Either way the session starts failing its calls, at whatever moment the TTL
|
||||
# happens to expire - mid-turn as easily as between turns. The one case that
|
||||
# does survive is a switch between two presets of the same provider sharing a
|
||||
# keyRef: same key, same endpoint, and the session simply carries on with the
|
||||
# model ids it started with.
|
||||
#
|
||||
# Sessions are found through /proc/<pid>/exe rather than by matching process
|
||||
# names. `claude` is a real ELF binary here, so the symlink resolves to it
|
||||
# exactly, and a name match would sweep up every shell that merely mentions
|
||||
# claude in its command line - including the ones this tool is invoked from.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
cm_is_claude_pid() {
|
||||
local exe
|
||||
exe="$(readlink "/proc/$1/exe" 2>/dev/null)" || return 1
|
||||
case "$exe" in
|
||||
*/claude|*/claude-code) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# /proc/<pid>/stat has the command name in parentheses, and it may contain
|
||||
# spaces - so fields are only safe to count after the last ')'. Everything below
|
||||
# indexes into that remainder, where field 1 is the process state.
|
||||
cm_stat_rest() {
|
||||
local s
|
||||
s="$(cat "/proc/$1/stat" 2>/dev/null)" || return 1
|
||||
printf '%s' "${s##*) }"
|
||||
}
|
||||
|
||||
cm_ppid_of() {
|
||||
local r; r="$(cm_stat_rest "$1")" || return 1
|
||||
printf '%s' "$r" | cut -d' ' -f2
|
||||
}
|
||||
|
||||
# utime + stime, in jiffies. Sampled twice to tell a session that is thinking
|
||||
# from one that is sitting at a prompt.
|
||||
cm_cputime_of() {
|
||||
local r u s; r="$(cm_stat_rest "$1")" || return 1
|
||||
u="$(printf '%s' "$r" | cut -d' ' -f12)"
|
||||
s="$(printf '%s' "$r" | cut -d' ' -f13)"
|
||||
case "$u$s" in ''|*[!0-9]*) printf '0'; return 0 ;; esac
|
||||
printf '%s' $((u + s))
|
||||
}
|
||||
|
||||
# The session this very command is running inside, if any. It is listed like the
|
||||
# others but never acted on by default: killing the session that asked for the
|
||||
# kill is not a thing anyone means.
|
||||
cm_self_session() {
|
||||
local p="${PPID:-0}" guard=0
|
||||
while [ "$p" -gt 1 ] && [ "$guard" -lt 40 ]; do
|
||||
if cm_is_claude_pid "$p"; then printf '%s' "$p"; return 0; fi
|
||||
p="$(cm_ppid_of "$p")" || return 1
|
||||
case "$p" in ''|*[!0-9]*) return 1 ;; esac
|
||||
guard=$((guard + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# TSV: pid \t ppid \t tty \t busy \t cwd \t self \t parent-cmd
|
||||
# Session discovery reads /proc, so it is Linux-only. Elsewhere the tool
|
||||
# cannot see running sessions at all - which has to be said rather than
|
||||
# silently reported as "none running", since that is the answer that gets
|
||||
# people to switch out from under a live session.
|
||||
cm_sessions_supported() { [ -d /proc/self ]; }
|
||||
|
||||
cm_session_rows() {
|
||||
local self pid ppid tty cwd busy isself d
|
||||
cm_sessions_supported || return 0
|
||||
self="$(cm_self_session 2>/dev/null || true)"
|
||||
|
||||
local pids=() before=() after=() pp
|
||||
for d in /proc/[0-9]*; do
|
||||
pid="${d#/proc/}"
|
||||
cm_is_claude_pid "$pid" || continue
|
||||
# A session's parent is a terminal or a shell. A busy session also forks
|
||||
# children off its own binary while it works, and those inherit the same
|
||||
# /proc/<pid>/exe - so without this the count climbs and falls with how
|
||||
# hard the machine is thinking, and the list fills with pids that are
|
||||
# gone a second later. Anything whose parent is itself claude is one of
|
||||
# those, not a session.
|
||||
pp="$(cm_ppid_of "$pid")" || continue
|
||||
cm_is_claude_pid "$pp" && continue
|
||||
pids+=("$pid")
|
||||
before+=("$(cm_cputime_of "$pid")")
|
||||
done
|
||||
[ "${#pids[@]}" -gt 0 ] || return 0
|
||||
|
||||
# A single shared sample window rather than one per process, so the whole
|
||||
# listing costs 300ms no matter how many sessions are open.
|
||||
sleep 0.3
|
||||
local i
|
||||
for i in "${!pids[@]}"; do after+=("$(cm_cputime_of "${pids[$i]}")"); done
|
||||
|
||||
for i in "${!pids[@]}"; do
|
||||
pid="${pids[$i]}"
|
||||
[ -d "/proc/$pid" ] || continue
|
||||
ppid="$(cm_ppid_of "$pid")"
|
||||
cwd="$(readlink "/proc/$pid/cwd" 2>/dev/null)"; [ -n "$cwd" ] || cwd='?'
|
||||
tty="$(ps -o tty= -p "$pid" 2>/dev/null | tr -d ' ')"; [ -n "$tty" ] || tty='?'
|
||||
# 0.3s of wall clock is ~30 jiffies at the usual 100Hz; a few of them
|
||||
# spent is a session doing work rather than waiting on a keystroke.
|
||||
busy=no
|
||||
[ $(( ${after[$i]:-0} - ${before[$i]:-0} )) -ge 3 ] && busy=yes
|
||||
isself=no; [ "$pid" = "$self" ] && isself=yes
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"$pid" "$ppid" "$tty" "$busy" "$cwd" "$isself" \
|
||||
"$(tr '\0' ' ' < "/proc/$ppid/cmdline" 2>/dev/null | sed 's/[[:space:]]*$//')"
|
||||
done
|
||||
}
|
||||
|
||||
cm_session_count() { cm_session_rows | grep -c . || true; }
|
||||
|
||||
cmd_sessions() {
|
||||
local action=list dry=0 assume=0 json=0 a
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--json) json=1 ;;
|
||||
--stop) action=stop ;;
|
||||
--restart) action=restart ;;
|
||||
--dry-run) dry=1 ;;
|
||||
-y|--yes) assume=1 ;;
|
||||
list|'') ;;
|
||||
*) err "unknown option '$a'"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
local rows n busy
|
||||
rows="$(cm_session_rows)"
|
||||
n="$(printf '%s' "$rows" | grep -c . || true)"
|
||||
|
||||
if [ "$json" -eq 1 ]; then
|
||||
printf '%s\n' "$rows" | "$PY" "$JSON" sessions-json
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! cm_sessions_supported; then
|
||||
head_ 'running sessions'
|
||||
warn 'session control needs /proc, so it is Linux-only'
|
||||
say 'on macOS, restart Claude Code yourself after a switch'
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "${n:-0}" -eq 0 ]; then
|
||||
head_ 'running sessions'
|
||||
ok 'no Claude Code sessions running'
|
||||
return 0
|
||||
fi
|
||||
|
||||
head_ "running sessions ($n)"
|
||||
local pid ppid tty b cwd isself pcmd tag
|
||||
while IFS=$'\t' read -r pid ppid tty b cwd isself pcmd; do
|
||||
[ -n "$pid" ] || continue
|
||||
tag=''
|
||||
[ "$b" = yes ] && tag=" ${C_YELLOW}working${C_RESET}"
|
||||
[ "$isself" = yes ] && tag="$tag ${C_DIM}(this session - never touched)${C_RESET}"
|
||||
printf ' %-8s %-8s %s%s\n' "$pid" "$tty" "$cwd" "$tag"
|
||||
done <<EOF_ROWS
|
||||
$rows
|
||||
EOF_ROWS
|
||||
|
||||
if [ "$action" = "list" ]; then
|
||||
printf '\n %stheir credential is re-fetched on a timer, so a switch breaks them%s\n' "$C_DIM" "$C_RESET"
|
||||
printf ' %sclaude-mode sessions --stop close them%s\n' "$C_DIM" "$C_RESET"
|
||||
printf ' %sclaude-mode sessions --restart close and reopen each in its own directory%s\n' "$C_DIM" "$C_RESET"
|
||||
printf ' %s--dry-run shows what either would do, and does nothing%s\n' "$C_DIM" "$C_RESET"
|
||||
return 0
|
||||
fi
|
||||
|
||||
busy="$(printf '%s' "$rows" | cut -f4 | grep -c '^yes$' || true)"
|
||||
|
||||
if [ "$dry" -eq 1 ]; then
|
||||
printf '\n %sdry run - nothing will be signalled%s\n' "$C_DIM" "$C_RESET"
|
||||
cm_session_act "$action" "$rows" 1
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Stopping someone's editor mid-thought is not undoable, so an interactive
|
||||
# run asks first. --yes is for the bar widget, which has already asked in
|
||||
# its own dialog and would otherwise hang here with nowhere to type.
|
||||
if [ "$assume" -eq 0 ]; then
|
||||
if ! ui_interactive; then
|
||||
err 'refusing to stop sessions without a confirmation'
|
||||
say 'pass --yes if you mean it, or --dry-run to see what would happen'
|
||||
return 1
|
||||
fi
|
||||
printf '\n'
|
||||
if [ "${busy:-0}" -gt 0 ]; then
|
||||
warn "$busy of these is mid-request and will lose that turn"
|
||||
fi
|
||||
if [ "$action" = restart ]; then
|
||||
printf ' close and reopen these sessions? [y/N] '
|
||||
else
|
||||
printf ' close these sessions? [y/N] '
|
||||
fi
|
||||
local reply; IFS= read -r reply
|
||||
case "$reply" in
|
||||
y|Y|yes|YES) ;;
|
||||
*) say 'left alone'; return 0 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
cm_session_act "$action" "$rows" 0
|
||||
}
|
||||
|
||||
# Terminate, and optionally reopen. SIGTERM only: Claude Code cleans up its
|
||||
# transcript on the way out, and SIGKILL would cost that for no gain.
|
||||
cm_session_act() {
|
||||
local action="$1" rows="$2" dry="${3:-0}" pid ppid tty busy cwd isself pcmd acted=0 skipped=0
|
||||
local -a relaunch_cwd relaunch_cmd
|
||||
|
||||
while IFS=$'\t' read -r pid ppid tty busy cwd isself pcmd; do
|
||||
[ -n "$pid" ] || continue
|
||||
if [ "$isself" = yes ]; then
|
||||
warn "skipping $pid - that is the session running this command"
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
if [ "$action" = "restart" ]; then
|
||||
relaunch_cwd+=("$cwd")
|
||||
relaunch_cmd+=("$pcmd")
|
||||
fi
|
||||
if [ "$dry" -eq 1 ]; then
|
||||
say "would stop $pid ($cwd)"
|
||||
acted=$((acted + 1))
|
||||
elif kill -TERM "$pid" 2>/dev/null; then
|
||||
ok "stopped $pid ($cwd)"
|
||||
acted=$((acted + 1))
|
||||
else
|
||||
err "could not stop $pid"
|
||||
fi
|
||||
done <<EOF_ROWS
|
||||
$rows
|
||||
EOF_ROWS
|
||||
|
||||
[ "$acted" -gt 0 ] && [ "$dry" -eq 0 ] && sleep 0.6
|
||||
|
||||
if [ "$action" = "restart" ] && [ "${#relaunch_cwd[@]}" -gt 0 ]; then
|
||||
local i c t
|
||||
for i in "${!relaunch_cwd[@]}"; do
|
||||
c="${relaunch_cwd[$i]}"; t="${relaunch_cmd[$i]}"
|
||||
[ -d "$c" ] || c="$HOME"
|
||||
# The parent of a session started from the app launcher is the
|
||||
# terminal that was told to run claude, so re-running its command
|
||||
# line reproduces the session exactly - same terminal, same flags.
|
||||
# A session started by hand inside an existing shell has no such
|
||||
# parent to copy, and there is no way to type into that shell from
|
||||
# here, so it gets a fresh terminal in the same directory instead.
|
||||
case "$t" in
|
||||
*" -e "*claude*|*" --command"*claude*) ;;
|
||||
*) t="$(cm_terminal_cmd) -e claude" ;;
|
||||
esac
|
||||
if [ "$dry" -eq 1 ]; then
|
||||
say "would reopen in $c: $t"
|
||||
else
|
||||
( cd "$c" && setsid nohup $t >/dev/null 2>&1 & )
|
||||
ok "reopened in $c"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
[ "$skipped" -gt 0 ] && say 'this session was left running'
|
||||
return 0
|
||||
}
|
||||
|
||||
cm_terminal_cmd() {
|
||||
local t
|
||||
for t in "${TERMINAL:-}" foot alacritty ghostty kitty; do
|
||||
[ -n "$t" ] || continue
|
||||
command -v "$t" >/dev/null 2>&1 && { printf '%s' "$t"; return 0; }
|
||||
done
|
||||
printf 'xterm'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live sessions: asked before the write, not reported after it
|
||||
#
|
||||
# The damage a switch does to a running session is not limited to it failing
|
||||
# calls. If the 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-<epoch>-<rand>` 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 only fix is to truncate the
|
||||
# transcript to the last message Anthropic issued, losing everything after it
|
||||
# (see `claude-mode repair-session`). A confirmation that costs one keystroke is
|
||||
# cheap against a failure that costs an afternoon of conversation.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CM_ASSUME_YES=0
|
||||
CM_SESSION_ACTION=none
|
||||
CM_SESSION_ROWS=''
|
||||
|
||||
cm_confirm_sessions() {
|
||||
local mode="$1" rows n busy reply
|
||||
CM_SESSION_ACTION=none
|
||||
CM_SESSION_ROWS=''
|
||||
|
||||
# Re-applying the active preset after a tier edit: same endpoint, same key,
|
||||
# so running sessions are not at risk - see reapply_if_active.
|
||||
[ "$CM_SAME_ENDPOINT" -eq 1 ] && return 0
|
||||
|
||||
if ! cm_sessions_supported; then
|
||||
printf '\n'
|
||||
warn 'cannot list running sessions here (no /proc) - the switch will not wait for them'
|
||||
return 0
|
||||
fi
|
||||
|
||||
rows="$(cm_session_rows)"
|
||||
n="$(printf '%s' "$rows" | grep -c . || true)"
|
||||
[ "${n:-0}" -gt 0 ] || return 0
|
||||
CM_SESSION_ROWS="$rows"
|
||||
|
||||
busy="$(printf '%s' "$rows" | cut -f4 | grep -c '^yes$' || true)"
|
||||
|
||||
printf '\n'
|
||||
warn "$n Claude Code session(s) are running right now"
|
||||
local pid ppid tty b cwd isself pcmd tag
|
||||
while IFS=$'\t' read -r pid ppid tty b cwd isself pcmd; do
|
||||
[ -n "$pid" ] || continue
|
||||
tag=''
|
||||
[ "$b" = yes ] && tag=" ${C_YELLOW}working${C_RESET}"
|
||||
[ "$isself" = yes ] && tag="$tag ${C_DIM}(this one)${C_RESET}"
|
||||
printf ' %-8s %-8s %s%s\n' "$pid" "$tty" "$cwd" "$tag"
|
||||
done <<EOF_ROWS
|
||||
$rows
|
||||
EOF_ROWS
|
||||
|
||||
printf '\n'
|
||||
say 'Their key is re-fetched on a timer and will resolve to the new mode,'
|
||||
say 'which the endpoint they are still pointed at will not accept. If one'
|
||||
say 'of them takes a reply from the new provider first, that provider'"'"'s'
|
||||
say 'message-id format goes into its transcript and Anthropic will then'
|
||||
say 'refuse to resume that session at all - recoverable only by truncating'
|
||||
say 'it (claude-mode repair-session), which loses the turns after the cut.'
|
||||
[ "${busy:-0}" -gt 0 ] && warn "$busy of them is mid-request and is the most likely to be caught"
|
||||
|
||||
if [ "$CM_ASSUME_YES" -eq 1 ]; then
|
||||
say 'proceeding (--yes)'
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! ui_interactive; then
|
||||
printf '\n'
|
||||
err 'refusing to switch while sessions are running'
|
||||
say 'restart or close them first, or pass --yes to switch anyway'
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
say 'r switch, then close and reopen them on the new mode (safest)'
|
||||
say 'c switch, then close them'
|
||||
say 's switch and leave them running (risks the above)'
|
||||
say 'a abort'
|
||||
printf '\n [r/c/s/A] '
|
||||
IFS= read -r reply
|
||||
case "$reply" in
|
||||
r|R) CM_SESSION_ACTION=restart; return 0 ;;
|
||||
c|C) CM_SESSION_ACTION=stop; return 0 ;;
|
||||
s|S) CM_SESSION_ACTION=none; return 0 ;;
|
||||
*) say 'aborted; nothing was changed'; return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Run after the write, never before: a session reopened first would come back up
|
||||
# on the mode being left behind.
|
||||
cm_apply_session_action() {
|
||||
[ "$CM_SESSION_ACTION" = "none" ] && return 0
|
||||
[ -n "$CM_SESSION_ROWS" ] || return 0
|
||||
printf '\n'
|
||||
cm_session_act "$CM_SESSION_ACTION" "$CM_SESSION_ROWS" 0
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
# shellcheck shell=bash
|
||||
# linux/lib/setup.sh - first-run setup: key, server, models.
|
||||
#
|
||||
# Part of the claude-mode CLI: sourced by linux/claude-mode, which sets the
|
||||
# CM_* paths, PY and JSON used here. Not meant to run on its own.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# First-run setup
|
||||
#
|
||||
# A shipped preset is a starting point, not a working configuration. OpenRouter
|
||||
# needs a key before it can serve anything; LM Studio needs to be told where the
|
||||
# server is and which of the models it actually has installed to use - and the
|
||||
# ids it ships with are whatever happened to be on the machine this was written
|
||||
# on, which is almost certainly not yours.
|
||||
#
|
||||
# So a preset says whether it has been through setup. `configured: false` is
|
||||
# written into the shipped presets and cleared once setup has run, and preflight
|
||||
# treats it as a blocker: better to be walked through it once than to switch
|
||||
# into something that half-works and produces a confusing failure later.
|
||||
#
|
||||
# Absent means configured. That is deliberate - presets that predate this, and
|
||||
# ones the user built by hand with `preset new`, are their own business and must
|
||||
# not suddenly start demanding a wizard.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
preset_configured() {
|
||||
local v; v="$(jget "$1" configured)"
|
||||
[ "$v" = "false" ] && return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
mark_configured() {
|
||||
"$PY" "$JSON" set-flag "$1" configured true >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# A y/N prompt that defaults to no on anything that is not a clear yes.
|
||||
ask_yes() {
|
||||
local prompt="$1" reply
|
||||
printf ' %s [y/N] ' "$prompt"
|
||||
IFS= read -r reply || return 1
|
||||
case "$reply" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
|
||||
# Prompt with a default shown in brackets; empty input keeps the default.
|
||||
#
|
||||
# The prompt goes to stderr for the same reason warn/err do: this is called
|
||||
# inside $( ), where anything on stdout is captured as the return value. Printed
|
||||
# to stdout it came back as part of the answer - " server base URL [...]: " with
|
||||
# the typed URL glued on the end, which set-url then rejected.
|
||||
ask_value() {
|
||||
local prompt="$1" default="$2" reply
|
||||
if [ -n "$default" ]; then printf ' %s [%s]: ' "$prompt" "$default" >&2
|
||||
else printf ' %s: ' "$prompt" >&2; fi
|
||||
IFS= read -r reply || return 1
|
||||
[ -n "$reply" ] && printf '%s' "$reply" || printf '%s' "$default"
|
||||
}
|
||||
|
||||
setup_key() {
|
||||
local ref="$1" label="$2"
|
||||
if cm_vault_has "$ref"; then
|
||||
ok "a key is already stored for '$ref' ($(cm_vault_backend_label))"
|
||||
ask_yes "replace it?" || return 0
|
||||
else
|
||||
say "$label needs an API key. It goes into $(cm_vault_backend_label),"
|
||||
say 'not into settings.json.'
|
||||
fi
|
||||
cmd_set_key "$ref"
|
||||
}
|
||||
|
||||
# Offer the provider's own catalogue rather than asking someone to type a model
|
||||
# id from memory. Falls back to typing when the catalogue cannot be reached,
|
||||
# because being offline should not block finishing setup.
|
||||
setup_models() {
|
||||
local pf="$1" provider="$2" name="$3"
|
||||
printf '\n'
|
||||
say 'current model map:'
|
||||
local t v
|
||||
for t in "${TIERS[@]}"; do
|
||||
v="$(jget "$pf" "models.$t")"
|
||||
[ -n "$v" ] && printf ' %-8s %s\n' "$t" "$v"
|
||||
done
|
||||
printf '\n'
|
||||
ask_yes 'change which models back these tiers?' || return 0
|
||||
|
||||
if ! ui_interactive; then
|
||||
warn 'model picking needs an interactive terminal'
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$(prov_field "$provider" 13)" = one-for-all ]; then
|
||||
# One model for every tier is the normal shape for a local server: it
|
||||
# has one loaded at a time, and mapping tiers to different models just
|
||||
# means paying the load cost on every tier change.
|
||||
local base ids=() id st ctx
|
||||
base="$(jget "$pf" baseUrl)"
|
||||
while IFS=$'\t' read -r id st ctx; do
|
||||
[ -n "$id" ] || continue
|
||||
ids+=("$id")
|
||||
done < <(provider_catalogue "$provider" "$base" "$(cm_preset_token "$pf")")
|
||||
|
||||
if [ "${#ids[@]}" -eq 0 ]; then
|
||||
warn 'the server returned no models; type an id by hand instead'
|
||||
local manual; manual="$(ask_value 'model id for every tier' "$(jget "$pf" models.opus)")"
|
||||
[ -n "$manual" ] && "$PY" "$JSON" set-all "$pf" "$manual" >/dev/null && ok "all tiers -> $manual"
|
||||
return 0
|
||||
fi
|
||||
|
||||
ui_reset_items
|
||||
for id in "${ids[@]}"; do ui_add_item "$id" 'use this for every tier'; done
|
||||
if ui_filter_select "model for all tiers of '$name'" 'esc = keep current'; then
|
||||
"$PY" "$JSON" set-all "$pf" "${ids[$UI_SEL]}" >/dev/null
|
||||
ok "all tiers -> ${ids[$UI_SEL]}"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Remote gateways map a different model per tier, which is the whole point
|
||||
# of them, so each tier is asked for separately.
|
||||
local cur
|
||||
for t in "${TIERS[@]}"; do
|
||||
cur="$(jget "$pf" "models.$t")"
|
||||
ui_pick_model "$pf" "$t" "$cur" || continue
|
||||
[ -n "$UI_PICKED" ] || continue
|
||||
"$PY" "$JSON" set-tier "$pf" "$t" "$UI_PICKED" >/dev/null && ok "$t -> $UI_PICKED"
|
||||
done
|
||||
}
|
||||
|
||||
# Where a server provider (LM Studio, Ollama, a custom endpoint) lives, and
|
||||
# whether it wants a key. None of them has to be on this machine.
|
||||
setup_server() {
|
||||
local pf="$1" name="$2" mode="$3" url probe token title hint start
|
||||
title="$(prov_field "$mode" 3)"; hint="$(prov_field "$mode" 20)"; start="$(prov_field "$mode" 21)"
|
||||
url="$(jget "$pf" baseUrl)"; [ -n "$url" ] || url="$(prov_field "$mode" 19)"
|
||||
|
||||
printf '\n'
|
||||
[ -n "$hint" ] && say "$hint"
|
||||
url="$(ask_value 'server base URL' "$url")"
|
||||
[ -n "$url" ] || { err 'a server address is needed'; return 1; }
|
||||
"$PY" "$JSON" set-url "$pf" "$url" >/dev/null || return 1
|
||||
ok "baseUrl -> $url"
|
||||
|
||||
printf '\n'
|
||||
if ask_yes 'does that server require an API key?'; then
|
||||
local ref; ref="$(ask_value 'key name to store it under' "$(prov_field "$mode" 17)")"
|
||||
"$PY" "$JSON" set-auth "$pf" key "$ref" >/dev/null
|
||||
ok "auth -> vault key '$ref'"
|
||||
cm_vault_has "$ref" || cmd_set_key "$ref"
|
||||
token="$(cm_vault_get "$ref" 2>/dev/null || true)"
|
||||
else
|
||||
"$PY" "$JSON" set-auth "$pf" none >/dev/null
|
||||
token="$(prov_field "$mode" 18)"
|
||||
ok "auth -> none (inline placeholder token '$token')"
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
say "checking $url ..."
|
||||
probe="$(cm_probe_server "$url" "$token" "$(prov_field "$mode" 9)")"
|
||||
case "$probe" in
|
||||
ok) ok 'server answered' ;;
|
||||
auth) err 'the server refused that credential'; return 1 ;;
|
||||
notfound)
|
||||
if [ "$(prov_field "$mode" 8)" = lenient ]; then
|
||||
warn "something answered, but it lists no models - fine for a proxy; model ids are typed by hand"
|
||||
else
|
||||
err "something answered there, but not a $title API"; return 1
|
||||
fi ;;
|
||||
skip) warn 'curl is missing, so the server was not checked' ;;
|
||||
*) err 'nothing answered at that address'
|
||||
say "${start:+$start, then }run: claude-mode setup $mode"
|
||||
return 1 ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_setup() {
|
||||
local mode="${1:-}" name pf
|
||||
|
||||
case "$mode" in
|
||||
anthropic)
|
||||
head_ 'setup: anthropic'
|
||||
ok 'nothing to configure - it uses your existing Claude login'
|
||||
return 0 ;;
|
||||
'') err 'usage: claude-mode setup <mode>'; return 1 ;;
|
||||
*) mode="$(provider_resolve "$mode")" || { err "unknown mode '${1:-}'"; return 1; } ;;
|
||||
esac
|
||||
|
||||
name="$(resolve_preset "$mode" "${2:-}")" || return 1
|
||||
pf="$(preset_path "$name")"
|
||||
|
||||
if ! ui_interactive; then
|
||||
err 'setup needs an interactive terminal'
|
||||
say "run: claude-mode setup $mode"
|
||||
return 1
|
||||
fi
|
||||
|
||||
head_ "setup: $mode / preset '$name'"
|
||||
say "$(mode_label "$mode")"
|
||||
|
||||
# A server provider is asked where it is and whether it wants a key; a
|
||||
# hosted one always wants a key, and the entry says where to get one.
|
||||
if [ "$(prov_field "$mode" 7)" = 1 ]; then
|
||||
setup_server "$pf" "$name" "$mode" || return 1
|
||||
else
|
||||
local ref url
|
||||
ref="$(jget "$pf" auth.keyRef)"; [ -n "$ref" ] || ref="$(prov_field "$mode" 17)"
|
||||
url="$(prov_field "$mode" 14)"
|
||||
printf '\n'
|
||||
[ -n "$url" ] && say "get a key from $url"
|
||||
setup_key "$ref" "$(prov_field "$mode" 3)"
|
||||
fi
|
||||
setup_models "$pf" "$mode" "$name"
|
||||
|
||||
mark_configured "$pf"
|
||||
printf '\n'
|
||||
ok "$mode is set up"
|
||||
say "switch to it with: claude-mode $mode"
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
# shellcheck shell=bash
|
||||
# linux/lib/switch.sh - the write into settings.json, and what is checked and reported around it.
|
||||
#
|
||||
# Part of the claude-mode CLI: sourced by linux/claude-mode, which sets the
|
||||
# CM_* paths, PY and JSON used here. Not meant to run on its own.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Switching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
backup_settings() {
|
||||
[ -f "$CM_SETTINGS" ] || return 0
|
||||
local stamp dest
|
||||
stamp="$(date +%Y%m%d-%H%M%S-%3N 2>/dev/null || date +%Y%m%d-%H%M%S)"
|
||||
dest="$CM_BACKUPS/settings.$stamp.json"
|
||||
cp "$CM_SETTINGS" "$dest"
|
||||
# keep the 20 most recent
|
||||
ls -1t "$CM_BACKUPS"/settings.*.json 2>/dev/null | tail -n +21 | while read -r f; do rm -f "$f"; done
|
||||
printf '%s' "$dest"
|
||||
}
|
||||
|
||||
set_mode() {
|
||||
local mode="$1" preset_name="${2:-}" preset_file="" backup
|
||||
init_root
|
||||
mkdir -p "$CM_SETTINGS_DIR"
|
||||
|
||||
if [ "$mode" != "anthropic" ]; then
|
||||
preset_file="$(preset_path "$preset_name")"
|
||||
|
||||
# Everything that has to be true before the write is checked in one
|
||||
# place, shared with `claude-mode preflight` and the bar widget, so a
|
||||
# switch cannot succeed into a mode that has no key or no server.
|
||||
if [ "$CM_FORCE" -eq 0 ] && ! cm_preflight "$mode" "$preset_name"; then
|
||||
err "$CM_PF_TITLE"
|
||||
[ -n "$CM_PF_DETAIL" ] && say "$CM_PF_DETAIL"
|
||||
|
||||
# Standing in a terminal with the fix one keystroke away, printing
|
||||
# the command to type next is a poor substitute for running it.
|
||||
if ui_interactive && [ "$CM_PF_KIND" = "setup" ]; then
|
||||
printf '\n'
|
||||
if ask_yes "set up $mode now?"; then
|
||||
cmd_setup "$mode" "$preset_name" || return 1
|
||||
cm_preflight "$mode" "$preset_name" || {
|
||||
err "$CM_PF_TITLE"; return 1
|
||||
}
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
[ -n "$CM_PF_REMEDY" ] && printf ' %sfix:%s %s\n' "$C_DIM" "$C_RESET" "$CM_PF_REMEDY"
|
||||
printf ' %s--force switches anyway%s\n' "$C_DIM" "$C_RESET"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
[ -f "$preset_file" ] || { err "preset '$preset_name' not found"; return 1; }
|
||||
fi
|
||||
|
||||
cm_confirm_sessions "$mode" || return 1
|
||||
|
||||
backup="$(backup_settings)"
|
||||
|
||||
if ! "$PY" "$JSON" apply "$CM_SETTINGS" "$CM_STATE" "$mode" "$preset_file" "$CM_HELPER" >/dev/null; then
|
||||
err "failed to update $CM_SETTINGS"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$mode" = "anthropic" ]; then
|
||||
head_ "switched to: anthropic"
|
||||
else
|
||||
head_ "switched to: $mode / preset '$preset_name'"
|
||||
fi
|
||||
[ -n "$backup" ] && ok "settings.json backed up to $backup"
|
||||
|
||||
if [ "$mode" = "anthropic" ]; then
|
||||
ok "all gateway env + apiKeyHelper removed; native Anthropic login is authoritative"
|
||||
else
|
||||
ok "base url $(jget "$preset_file" baseUrl)"
|
||||
local t v
|
||||
for t in "${TIERS[@]}"; do
|
||||
v="$(jget "$preset_file" "models.$t")"
|
||||
[ -n "$v" ] && ok "$(printf '%-7s -> %s' "$t" "$v")"
|
||||
done
|
||||
v="$(jget "$preset_file" contextTokens)"
|
||||
if [ -n "$v" ]; then ok "context -> $v tokens (max + auto-compact window)"
|
||||
else warn "no contextTokens in this preset - Claude Code will guess a small window and compact early"; fi
|
||||
local am; am="$(jget "$preset_file" auth.mode)"; [ -z "$am" ] && am=vault
|
||||
if [ "$am" = "vault" ]; then ok "auth via apiKeyHelper (key never enters settings.json)"
|
||||
else ok "auth inline placeholder token '$(jget "$preset_file" auth.token)' (not a secret)"; fi
|
||||
fi
|
||||
|
||||
check_stray_env "$mode"
|
||||
|
||||
if [ "$mode" != "anthropic" ]; then
|
||||
local am2 ref2
|
||||
am2="$(jget "$preset_file" auth.mode)"; [ -z "$am2" ] && am2=vault
|
||||
if [ "$am2" = "vault" ]; then
|
||||
ref2="$(jget "$preset_file" auth.keyRef)"; [ -z "$ref2" ] && ref2=openrouter
|
||||
show_guardrail_status "$mode" "$(cm_vault_get "$ref2" 2>/dev/null || true)"
|
||||
fi
|
||||
fi
|
||||
# Auto-repair on the way into a gateway: a [1m] tag on a non-Anthropic id is
|
||||
# meaningless there and breaks compaction. Anthropic ids are left alone, so
|
||||
# switching back to anthropic keeps whatever 1M selection was made.
|
||||
check_stale_models "$mode"
|
||||
write_health "$mode" "$preset_name"
|
||||
|
||||
cm_apply_session_action
|
||||
|
||||
printf '\n %srestart claude (and reload the VS Code window) to pick this up%s\n' "$C_DIM" "$C_RESET"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stray environment variables
|
||||
#
|
||||
# Windows has User/Machine registry scopes; here the equivalent persistence is a
|
||||
# shell rc file, so that is what gets scanned. An export there outranks
|
||||
# settings.json for any shell that sources it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
managed_keys() { "$PY" "$JSON" managed "$CM_STATE"; }
|
||||
|
||||
# Set by show_guardrail_status when it probes, so health.json can carry the
|
||||
# tri-state result rather than re-probing.
|
||||
CM_GUARDRAIL=''
|
||||
|
||||
cm_version() {
|
||||
local v=''
|
||||
[ -f "$CM_ROOT/VERSION" ] && v="$(tr -d '[:space:]' < "$CM_ROOT/VERSION")"
|
||||
printf '%s' "${v:-0.0.0}"
|
||||
}
|
||||
|
||||
# Machine-readable state for a fleet reader. See cm-json.py cmd_health for the
|
||||
# contract; the short version is: no key material, model lists carry an
|
||||
# `anthropic` flag, guardrailStatus is tri-state.
|
||||
write_health() {
|
||||
local mode="$1" preset="$2" backend=''
|
||||
case "$(cm_vault_backend)" in
|
||||
security) backend='keychain' ;;
|
||||
secret-tool) backend='secret-tool' ;;
|
||||
pass) backend='pass' ;;
|
||||
file) backend='file' ;;
|
||||
esac
|
||||
"$PY" "$JSON" health "$CM_ROOT" "$HOME/.claude.json" "$mode" "$preset" \
|
||||
"$CM_GUARDRAIL" "$backend" "$(cm_version)" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Ask OpenRouter whether this key can still reach Anthropic models, by trying the
|
||||
# cheapest possible request against one. Free when the guardrail blocks it; a
|
||||
# fraction of a cent when it does not, which is exactly the case worth knowing.
|
||||
#
|
||||
# OpenRouter only: Z.AI and LM Studio have no equivalent control, so there is
|
||||
# nothing actionable to print for them.
|
||||
show_guardrail_status() {
|
||||
local mode="$1" key="$2" code
|
||||
# An OpenRouter feature, flagged per provider rather than by name.
|
||||
[ "$(prov_field "$mode" 15)" = 1 ] || return 0
|
||||
[ -n "$key" ] || return 0
|
||||
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 25 \
|
||||
-X POST 'https://openrouter.ai/api/v1/messages' \
|
||||
-H 'content-type: application/json' -H "x-api-key: $key" \
|
||||
-H "authorization: Bearer $key" -H 'anthropic-version: 2023-06-01' \
|
||||
-d '{"model":"claude-opus-5","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}' 2>/dev/null)"
|
||||
|
||||
# 403/404 is OpenRouter refusing the model, which is what a guardrail looks
|
||||
# like. 401 is the KEY being rejected - that says nothing about the guardrail
|
||||
# and must not read as an all-clear.
|
||||
case "$code" in
|
||||
403|404)
|
||||
CM_GUARDRAIL=active
|
||||
printf ' %sguardrail %s%sactive%s%s - Anthropic models blocked for this key%s\n' \
|
||||
"$C_DIM" "$C_RESET" "$C_GREEN" "$C_RESET" "$C_DIM" "$C_RESET"
|
||||
;;
|
||||
401)
|
||||
CM_GUARDRAIL=unknown
|
||||
printf ' %sguardrail %s%sunknown%s%s - OpenRouter rejected the key, so it could not be checked%s\n' \
|
||||
"$C_DIM" "$C_RESET" "$C_YELLOW" "$C_RESET" "$C_DIM" "$C_RESET"
|
||||
;;
|
||||
200)
|
||||
CM_GUARDRAIL=not_set
|
||||
printf ' %sguardrail %s%sNOT SET%s%s - Anthropic models reachable, billed at list price%s\n' \
|
||||
"$C_DIM" "$C_RESET" "$C_RED" "$C_RESET" "$C_DIM" "$C_RESET"
|
||||
printf ' %sopenrouter.ai -> Guardrails -> new, select this key,%s\n' "$C_DIM" "$C_RESET"
|
||||
printf ' %sthen exclude anthropic models (or allow only the ones you use)%s\n' "$C_DIM" "$C_RESET"
|
||||
;;
|
||||
*)
|
||||
# Only an explicit rejection proves the guardrail; anything else says
|
||||
# nothing, and a false all-clear on a safety check is worse than none.
|
||||
CM_GUARDRAIL=unknown
|
||||
printf ' %sguardrail %s%sunknown%s%s - could not reach OpenRouter to check%s\n' \
|
||||
"$C_DIM" "$C_RESET" "$C_YELLOW" "$C_RESET" "$C_DIM" "$C_RESET"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Claude Code caches a resolved model per (entrypoint, model, org) in
|
||||
# ~/.claude.json. A session running before a switch, or a model picked from a
|
||||
# gateway's own catalogue, keeps that id - and the gateway then bills it at full
|
||||
# list price. Detect and say so; claude-mode cannot police runtime model choice.
|
||||
check_stale_models() {
|
||||
local mode="$1" cfg="$HOME/.claude.json" found
|
||||
[ "$mode" = "anthropic" ] && return 0
|
||||
[ -f "$cfg" ] || return 0
|
||||
found="$("$PY" "$JSON" stale-models "$cfg" 2>/dev/null)"
|
||||
[ -n "$found" ] || return 0
|
||||
|
||||
local na nt
|
||||
na="$(printf '%s\n' "$found" | grep -c '^anthropic' || true)"
|
||||
nt="$(printf '%s\n' "$found" | grep -c '^tagged' || true)"
|
||||
|
||||
if [ "${na:-0}" -gt 0 ]; then
|
||||
printf ' %ssessions %s%s%s cached Anthropic model ids%s%s - restart running claude sessions%s\n' \
|
||||
"$C_DIM" "$C_RESET" "$C_YELLOW" "$na" "$C_RESET" "$C_DIM" "$C_RESET"
|
||||
fi
|
||||
if [ "${nt:-0}" -gt 0 ]; then
|
||||
printf ' %stagged %s%s%s model id(s) carry a [1m] tag%s%s - breaks compaction on gateways; claude-mode repair%s\n' \
|
||||
"$C_DIM" "$C_RESET" "$C_YELLOW" "$nt" "$C_RESET" "$C_DIM" "$C_RESET"
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Strip extended-context tags from cached model ids. Backed up first; the file is
|
||||
# the user's own Claude Code config, not ours.
|
||||
# $1 = 'all' to include Anthropic ids, '' for gateway ids only.
|
||||
# $2 = 'quiet' for the one-line form used on a mode switch.
|
||||
cmd_repair() {
|
||||
local scope="${1:-}" quiet="${2:-}" cfg="$HOME/.claude.json" bak out ns nk
|
||||
[ -f "$cfg" ] || { [ "$quiet" = "quiet" ] || err 'no ~/.claude.json'; return 0; }
|
||||
init_root
|
||||
bak="$CM_BACKUPS/claude.json.$(date +%Y%m%d-%H%M%S).bak"
|
||||
cp "$cfg" "$bak"
|
||||
|
||||
out="$("$PY" "$JSON" strip-tags "$cfg" "$scope" 2>/dev/null)"
|
||||
ns="$(printf '%s\n' "$out" | grep -c '^strip' || true)"
|
||||
nk="$(printf '%s\n' "$out" | grep -c '^keep' || true)"
|
||||
|
||||
if [ "${ns:-0}" -eq 0 ]; then
|
||||
rm -f "$bak"
|
||||
if [ "$quiet" != "quiet" ]; then
|
||||
if [ "${nk:-0}" -gt 0 ]; then
|
||||
ok "nothing to strip - $nk tagged id(s) are Anthropic models, where the tag is meaningful"
|
||||
printf '%s\n' "$out" | grep '^keep' | cut -f2 | sed 's/^/ keeping /'
|
||||
printf ' %suse --all to strip those too (downgrades them to the 200k variant)%s\n' "$C_DIM" "$C_RESET"
|
||||
else
|
||||
ok 'no tagged model ids in ~/.claude.json'
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$quiet" = "quiet" ]; then
|
||||
printf ' %srepaired %s%s%s gateway model id(s) had a [1m] tag stripped%s\n' \
|
||||
"$C_DIM" "$C_RESET" "$C_GREEN" "$ns" "$C_RESET"
|
||||
else
|
||||
printf '%s\n' "$out" | grep '^strip' | cut -f2 | sed 's/^/ /'
|
||||
printf '%s\n' "$out" | grep '^keep' | cut -f2 | sed 's/^/ keeping /'
|
||||
ok "stripped $ns tag(s); backup at $bak"
|
||||
printf ' %srestart claude for this to take effect%s\n' "$C_DIM" "$C_RESET"
|
||||
fi
|
||||
}
|
||||
|
||||
check_stray_env() {
|
||||
local mode="$1" problems=0 k f
|
||||
local rcfiles=("$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile" "$HOME/.zshrc" "$HOME/.zshenv" "/etc/environment")
|
||||
|
||||
if [ "$mode" != "anthropic" ] && [ -n "${ANTHROPIC_API_KEY:-}" ]; then
|
||||
warn 'ANTHROPIC_API_KEY is set in THIS shell. The `claude` wrapper strips it; other shells are unaffected.'
|
||||
fi
|
||||
|
||||
while IFS= read -r k; do
|
||||
[ -n "$k" ] || continue
|
||||
for f in "${rcfiles[@]}"; do
|
||||
[ -f "$f" ] || continue
|
||||
# our own managed block is not a stray export
|
||||
if grep -qE "^[[:space:]]*(export[[:space:]]+)?$k=" "$f" 2>/dev/null; then
|
||||
err "$k is exported in $f - it overrides claude-mode for every new shell"
|
||||
problems=$((problems+1))
|
||||
fi
|
||||
done
|
||||
done < <(managed_keys)
|
||||
|
||||
return $problems
|
||||
}
|
||||
+9
-3
@@ -36,11 +36,17 @@ fi
|
||||
|
||||
# --- payload ---------------------------------------------------------------
|
||||
mkdir -p "$DEST_DIR"
|
||||
for f in manifest.json BarWidget.qml Panel.qml BrandIcon.qml Modes.js; do
|
||||
for f in manifest.json BarWidget.qml Panel.qml Modes.js; do
|
||||
[ -f "$SRC/$f" ] || { fail "missing from payload: $f"; exit 1; }
|
||||
install -m 0644 "$SRC/$f" "$DEST_DIR/$f"
|
||||
done
|
||||
green 'plugin files copied'
|
||||
# Every QML and JS file, not a fixed list: the panel is split into one file per
|
||||
# stage. The old ones are cleared first, so a file removed from the source does
|
||||
# not linger in the plugin directory, where QML would still find it by name.
|
||||
rm -f "$DEST_DIR"/*.qml "$DEST_DIR"/*.js
|
||||
for f in "$SRC"/manifest.json "$SRC"/*.qml "$SRC"/*.js; do
|
||||
install -m 0644 "$f" "$DEST_DIR/$(basename "$f")"
|
||||
done
|
||||
green "plugin files copied ($(ls "$SRC"/*.qml | wc -l | tr -d ' ') QML files)"
|
||||
|
||||
# --- bar layout ------------------------------------------------------------
|
||||
# shell.json is the user's own file and may carry unrelated customisation, so
|
||||
|
||||
@@ -42,6 +42,38 @@ BarWidget {
|
||||
}
|
||||
readonly property bool known: mode !== ""
|
||||
|
||||
// ---- Providers
|
||||
//
|
||||
// claude-mode publishes its providers in health.json - how to draw each one
|
||||
// and which panel features it has - so one added to providers.json appears
|
||||
// here without this file or Modes.js changing (and Modes.js cannot change
|
||||
// without a shell restart). Modes.js stays the fallback: for anthropic,
|
||||
// which is the native login and never in the list, and for a health.json
|
||||
// written before the list existed. The panel asks through these, too.
|
||||
readonly property var providerList: health && health.providers ? health.providers : []
|
||||
readonly property var modeOrder: {
|
||||
if (providerList.length === 0) return Modes.ORDER
|
||||
var out = ["anthropic"]
|
||||
for (var i = 0; i < providerList.length; i++) out.push(String(providerList[i].id))
|
||||
return out
|
||||
}
|
||||
|
||||
function providerInfo(m) {
|
||||
for (var i = 0; i < providerList.length; i++) {
|
||||
if (String(providerList[i].id) === m) return providerList[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
function modeTitle(m) { var p = providerInfo(m); return p && p.title ? String(p.title) : Modes.title(m) }
|
||||
function modeBlurb(m) { var p = providerInfo(m); return p ? String(p.blurb || "") : Modes.blurb(m) }
|
||||
function modeLogo(m) { var p = providerInfo(m); return p ? String(p.logo || "") : Modes.logo(m) }
|
||||
function modeLogoScale(m) { var p = providerInfo(m); return p && p.logoScale ? Number(p.logoScale) : Modes.logoScale(m) }
|
||||
function modeGlyph(m) {
|
||||
var p = providerInfo(m)
|
||||
if (p && p.glyph) return String.fromCodePoint(parseInt(String(p.glyph), 16))
|
||||
return Modes.glyph(m)
|
||||
}
|
||||
|
||||
// Measured against the neighbours: every stock bar glyph in this shell paints
|
||||
// 11px of ink from a 13px font. These marks fill their box rather than
|
||||
// carrying a font's internal padding, so the box itself has to be the smaller
|
||||
@@ -54,8 +86,8 @@ BarWidget {
|
||||
var n = Math.round(Number(root.setting("iconSize", Style.bar.iconFont)))
|
||||
return n % 2 === 0 ? n + 1 : n
|
||||
}
|
||||
readonly property string logo: Modes.logo(mode)
|
||||
readonly property string glyph: Modes.glyph(mode)
|
||||
readonly property string logo: modeLogo(mode)
|
||||
readonly property string glyph: modeGlyph(mode)
|
||||
readonly property string label: Modes.shortLabel(mode, preset)
|
||||
|
||||
// A gateway mode is spending money or leaning on a local server; native
|
||||
@@ -111,6 +143,21 @@ BarWidget {
|
||||
onLoadFailed: root.fallbackState = null
|
||||
}
|
||||
|
||||
// The model catalogue the panel's picker offers. claude-mode leaves it
|
||||
// behind whenever it fetches a provider's list anyway, so reading it here
|
||||
// costs no network - and nothing here polls a provider on a timer.
|
||||
property var modelsCache: null
|
||||
|
||||
FileView {
|
||||
id: modelsFile
|
||||
path: root.cmRoot + "/models-cache.json"
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onFileChanged: reload()
|
||||
onLoaded: root.parseInto("modelsCache", text())
|
||||
onLoadFailed: root.modelsCache = null
|
||||
}
|
||||
|
||||
// claude-mode writes health.json itself, so a switch launched from the panel
|
||||
// lands back here through the FileView above. This only covers the case
|
||||
// where the file was never written at all.
|
||||
@@ -132,6 +179,12 @@ BarWidget {
|
||||
// to sit on a timer: ~60ms for 59 transcripts here, against 5s for the naive
|
||||
// version that read every byte of every one.
|
||||
property var brokenSessions: []
|
||||
// Dismissed, or broken but untouched for longer than the age limit. Kept
|
||||
// out of the dot on purpose: hiding one is how you tell the bar to stop
|
||||
// asking about it.
|
||||
property var ignoredSessions: []
|
||||
property int ignoreAgeDays: 7
|
||||
property bool rescanPending: false
|
||||
|
||||
Process {
|
||||
id: scanProc
|
||||
@@ -143,11 +196,24 @@ BarWidget {
|
||||
var d = null
|
||||
try { d = JSON.parse(String(text || "")) } catch (e) { d = null }
|
||||
root.brokenSessions = (d && d.broken) ? d.broken : []
|
||||
root.ignoredSessions = (d && d.ignored) ? d.ignored : []
|
||||
if (d && d.maxAgeDays !== undefined) root.ignoreAgeDays = Number(d.maxAgeDays)
|
||||
}
|
||||
}
|
||||
// A scan already under way was started before whatever asked for this
|
||||
// one, so its answer may predate the change - run once more after it.
|
||||
onExited: function (code) {
|
||||
if (root.rescanPending) {
|
||||
root.rescanPending = false
|
||||
Qt.callLater(root.scanSessions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scanSessions() { if (!scanProc.running) scanProc.running = true }
|
||||
function scanSessions() {
|
||||
if (scanProc.running) root.rescanPending = true
|
||||
else scanProc.running = true
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 20 * 60 * 1000
|
||||
@@ -160,6 +226,7 @@ BarWidget {
|
||||
function refresh() {
|
||||
healthFile.reload()
|
||||
stateFile.reload()
|
||||
modelsFile.reload()
|
||||
if (!root.health) seedProc.running = true
|
||||
root.scanSessions()
|
||||
}
|
||||
@@ -191,7 +258,7 @@ BarWidget {
|
||||
id: icon
|
||||
anchors.fill: parent
|
||||
pathData: root.logo
|
||||
opticalScale: Modes.logoScale(root.mode)
|
||||
opticalScale: root.modeLogoScale(root.mode)
|
||||
color: root.activeColor
|
||||
iconSize: root.iconPx
|
||||
}
|
||||
@@ -247,7 +314,7 @@ BarWidget {
|
||||
|
||||
function tooltipLabel() {
|
||||
if (!root.known) return "claude-mode: not installed"
|
||||
var lines = [Modes.title(root.mode)]
|
||||
var lines = [root.modeTitle(root.mode)]
|
||||
if (root.preset !== "") lines.push("preset " + root.preset)
|
||||
var models = root.health && root.health.models ? root.health.models : null
|
||||
if (models && models.opus) lines.push("opus " + models.opus)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
// BlockedStage.qml - Preflight refused. Name the missing thing and offer the one action that fixes it, rather than failing the click silently.
|
||||
//
|
||||
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
|
||||
// and owns all the state and actions; this file only draws them.
|
||||
Column {
|
||||
id: stage
|
||||
required property var panel
|
||||
|
||||
width: parent.width
|
||||
visible: stage.panel.stage === "blocked" && stage.panel.blocker !== null
|
||||
spacing: Style.space(7)
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: stage.panel.blocker ? String(stage.panel.blocker.title) : ""
|
||||
color: stage.panel.urgentColor
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(13)
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: stage.panel.blocker ? String(stage.panel.blocker.detail) : ""
|
||||
color: Color.popups.text
|
||||
wrapMode: Text.WordWrap
|
||||
lineHeight: 1.2
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(11)
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: stage.panel.blocker && String(stage.panel.blocker.remedy) !== ""
|
||||
text: stage.panel.blocker ? "$ " + String(stage.panel.blocker.remedy) : ""
|
||||
color: Color.muted
|
||||
wrapMode: Text.WrapAnywhere
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
// Flow, not Row: these can total more than the card is wide, and a
|
||||
// Row lays them straight past its right edge instead of wrapping.
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: Style.space(7)
|
||||
|
||||
PillButton { fontFamily: stage.panel.uiFont;
|
||||
label: "Set up " + stage.panel.pTitle(stage.panel.pendingMode) + "…"
|
||||
primary: true
|
||||
visible: stage.panel.blocker && String(stage.panel.blocker.remedyKind) === "setup"
|
||||
onTriggered: stage.panel.runSetup()
|
||||
}
|
||||
PillButton { fontFamily: stage.panel.uiFont;
|
||||
label: "Store the key…"
|
||||
primary: true
|
||||
visible: stage.panel.blocker && String(stage.panel.blocker.remedyKind) === "set-key"
|
||||
onTriggered: stage.panel.runRemedy()
|
||||
}
|
||||
// A preset with every tier empty: the fix is one click away here,
|
||||
// not in a terminal.
|
||||
PillButton { fontFamily: stage.panel.uiFont;
|
||||
label: "Edit preset…"
|
||||
primary: true
|
||||
visible: stage.panel.blocker && String(stage.panel.blocker.remedyKind) === "edit-preset"
|
||||
onTriggered: {
|
||||
var p = stage.panel.pendingPreset
|
||||
stage.panel.resetFlow()
|
||||
stage.panel.openPresetEditor(p)
|
||||
}
|
||||
}
|
||||
PillButton { fontFamily: stage.panel.uiFont;
|
||||
label: "Server settings…"
|
||||
primary: stage.panel.blocker && ["start-server", "set-url", "needs-key"].indexOf(String(stage.panel.blocker.remedyKind)) >= 0
|
||||
visible: stage.panel.serverEditable(stage.panel.pendingMode)
|
||||
&& !(stage.panel.blocker && String(stage.panel.blocker.remedyKind) === "setup")
|
||||
onTriggered: stage.panel.openServerSettings(stage.panel.pendingPreset)
|
||||
}
|
||||
PillButton { fontFamily: stage.panel.uiFont;
|
||||
label: stage.panel.blocker && ["start-server", "setup"].indexOf(String(stage.panel.blocker.remedyKind)) >= 0
|
||||
? "Check again" : "Try again"
|
||||
primary: stage.panel.blocker && String(stage.panel.blocker.remedyKind) === "start-server"
|
||||
onTriggered: stage.panel.retryPending()
|
||||
}
|
||||
PillButton { fontFamily: stage.panel.uiFont; label: "Cancel"; onTriggered: stage.panel.resetFlow() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
// BrokenSessions.qml - Sessions that a switch cut short. Shown in the list, because the point is to be noticed without going looking.
|
||||
//
|
||||
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
|
||||
// and owns all the state and actions; this file only draws them.
|
||||
Column {
|
||||
id: stage
|
||||
required property var panel
|
||||
|
||||
width: parent.width
|
||||
visible: stage.panel.stage === "list" && (stage.panel.broken.length > 0 || stage.panel.ignored.length > 0)
|
||||
spacing: Style.space(4)
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: stage.panel.broken.length > 0
|
||||
text: stage.panel.broken.length === 1
|
||||
? "1 session cannot be resumed"
|
||||
: stage.panel.broken.length + " sessions cannot be resumed"
|
||||
color: stage.panel.urgentColor
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(11)
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: stage.panel.stage === "list" ? stage.panel.broken : []
|
||||
|
||||
Item {
|
||||
id: brokenRow
|
||||
required property var modelData
|
||||
width: stage.width
|
||||
height: Style.space(26)
|
||||
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.right: ignoreBtn.left
|
||||
anchors.rightMargin: Style.space(6)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 0
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: String(brokenRow.modelData.sessionId).substring(0, 8)
|
||||
+ " · " + stage.panel.projectLabel(brokenRow.modelData.project)
|
||||
color: Color.popups.text
|
||||
elide: Text.ElideMiddle
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: brokenRow.modelData.dropLines + " turn lines after the last good message"
|
||||
color: Color.muted
|
||||
elide: Text.ElideRight
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(9)
|
||||
}
|
||||
}
|
||||
|
||||
PillButton { fontFamily: stage.panel.uiFont;
|
||||
id: ignoreBtn
|
||||
anchors.right: repairBtn.left
|
||||
anchors.rightMargin: Style.space(5)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
label: "Ignore"
|
||||
onTriggered: stage.panel.setIgnored(brokenRow.modelData, true)
|
||||
}
|
||||
|
||||
PillButton { fontFamily: stage.panel.uiFont;
|
||||
id: repairBtn
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
label: "Repair"
|
||||
onTriggered: stage.panel.askRepair(brokenRow.modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Hidden: dismissed here, or broken but untouched for longer than
|
||||
// the age limit. Collapsed, because the point of hiding them was that
|
||||
// they stop taking up attention - but never gone without a trace.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: Style.space(20)
|
||||
visible: stage.panel.ignored.length > 0
|
||||
|
||||
Text {
|
||||
id: hiddenLabel
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "hidden (" + stage.panel.ignored.length + ")"
|
||||
color: hiddenArea.containsMouse ? Color.popups.text : Color.muted
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: hiddenLabel.right
|
||||
anchors.leftMargin: Style.space(4)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: String.fromCodePoint(stage.panel.hiddenExpanded ? 0xF0140 : 0xF0142)
|
||||
color: hiddenLabel.color
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(11)
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: hiddenArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: stage.panel.hiddenExpanded = !stage.panel.hiddenExpanded
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: stage.panel.hiddenExpanded && stage.panel.ignored.length > 0
|
||||
spacing: Style.space(3)
|
||||
|
||||
Text {
|
||||
visible: stage.panel.dismissedList.length > 0
|
||||
text: "Ignored (" + stage.panel.dismissedList.length + ")"
|
||||
color: Color.popups.text
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: stage.panel.hiddenExpanded ? stage.panel.dismissedList.slice(0, stage.panel.hiddenCap) : []
|
||||
|
||||
Item {
|
||||
id: dismissedRow
|
||||
required property var modelData
|
||||
width: stage.width
|
||||
height: Style.space(26)
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.right: restoreBtn.left
|
||||
anchors.rightMargin: Style.space(6)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: String(dismissedRow.modelData.sessionId).substring(0, 8)
|
||||
+ " · " + stage.panel.projectLabel(dismissedRow.modelData.project)
|
||||
color: Color.muted
|
||||
elide: Text.ElideMiddle
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
PillButton { fontFamily: stage.panel.uiFont;
|
||||
id: restoreBtn
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
label: "Restore"
|
||||
onTriggered: stage.panel.setIgnored(dismissedRow.modelData, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: stage.panel.dismissedList.length > stage.panel.hiddenCap
|
||||
text: "… and " + (stage.panel.dismissedList.length - stage.panel.hiddenCap)
|
||||
+ " more · claude-mode repair-session --ignored"
|
||||
color: Color.muted
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(9)
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: stage.panel.staleList.length > 0
|
||||
text: "Older than " + stage.panel.ageDays + " days (" + stage.panel.staleList.length + ")"
|
||||
color: Color.popups.text
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: stage.panel.hiddenExpanded ? stage.panel.staleList.slice(0, stage.panel.hiddenCap) : []
|
||||
|
||||
Text {
|
||||
required property var modelData
|
||||
width: stage.width
|
||||
text: String(modelData.sessionId).substring(0, 8)
|
||||
+ " · " + Math.floor((Date.now() / 1000 - Number(modelData.mtime)) / 86400) + "d"
|
||||
+ " · " + stage.panel.projectLabel(modelData.project)
|
||||
color: Color.muted
|
||||
elide: Text.ElideMiddle
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: stage.panel.staleList.length > 0
|
||||
text: (stage.panel.staleList.length > stage.panel.hiddenCap
|
||||
? "… and " + (stage.panel.staleList.length - stage.panel.hiddenCap) + " more. " : "")
|
||||
+ "Not counted because nothing has touched them since. "
|
||||
+ "claude-mode repair-session --all --max-age 0 lists them."
|
||||
color: Color.muted
|
||||
opacity: 0.85
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(9)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
// DeleteStage.qml - Delete: confirmed, and says so when it leaves a provider empty.
|
||||
//
|
||||
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
|
||||
// and owns all the state and actions; this file only draws them.
|
||||
Column {
|
||||
id: stage
|
||||
required property var panel
|
||||
|
||||
width: parent.width
|
||||
visible: stage.panel.stage === "presetDelete"
|
||||
spacing: Style.space(7)
|
||||
|
||||
readonly property bool lastOne: stage.panel.stage === "presetDelete"
|
||||
&& stage.panel.presetsOf(stage.panel.editProvider).length <= 1
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Delete '" + stage.panel.editPreset + "'?"
|
||||
color: Color.popups.text
|
||||
elide: Text.ElideRight
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(13)
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "The preset file is removed, and there is no undo from here. Its key stays in the vault."
|
||||
color: Color.muted
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: parent.lastOne
|
||||
text: "It is the only " + stage.panel.pTitle(stage.panel.editProvider) + " preset, so "
|
||||
+ stage.panel.pTitle(stage.panel.editProvider) + " will have nothing to switch to until you create another."
|
||||
color: stage.panel.urgentColor
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: Style.space(7)
|
||||
|
||||
PillButton { fontFamily: stage.panel.uiFont; label: "Delete it"; primary: true; onTriggered: stage.panel.deletePreset() }
|
||||
PillButton { fontFamily: stage.panel.uiFont; label: "Back"; onTriggered: { stage.panel.lastError = ""; stage.panel.stage = "preset" } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "Modes.js" as Modes
|
||||
|
||||
// ModeList.qml - the modes to switch to, each unfolding its presets.
|
||||
//
|
||||
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
|
||||
// and owns all the state and actions; this file only draws them.
|
||||
Column {
|
||||
id: stage
|
||||
required property var panel
|
||||
|
||||
width: parent.width
|
||||
visible: stage.panel.stage === "list"
|
||||
spacing: Style.space(10)
|
||||
|
||||
PanelSectionHeader {
|
||||
text: "SWITCH TO"
|
||||
visible: stage.panel.stage === "list"
|
||||
foreground: Color.popups.text
|
||||
fontFamily: stage.panel.uiFont
|
||||
}
|
||||
|
||||
// ---- The modes, current one marked rather than hidden. The CLI omits
|
||||
// the active mode because a list you arrow through should not offer a
|
||||
// no-op; here the list is also the status display, so it stays.
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: stage.panel.stage === "list"
|
||||
spacing: Style.space(1)
|
||||
|
||||
Repeater {
|
||||
model: stage.panel.stage === "list" ? stage.panel.modeOrder : []
|
||||
|
||||
Column {
|
||||
id: modeEntry
|
||||
required property var modelData
|
||||
width: stage.width
|
||||
|
||||
readonly property string thisMode: String(modelData)
|
||||
readonly property bool isCurrent: thisMode === stage.panel.mode
|
||||
readonly property bool isExpanded: stage.panel.expandedMode === thisMode
|
||||
readonly property var presetList: Modes.presetsFor(stage.panel.health, thisMode, stage.panel.defaultPresets)
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: Style.space(30)
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: -Style.space(6)
|
||||
anchors.rightMargin: -Style.space(6)
|
||||
radius: Style.space(4)
|
||||
color: modeEntry.isCurrent
|
||||
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.12)
|
||||
: (modeHover.containsMouse
|
||||
? Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.07)
|
||||
: "transparent")
|
||||
}
|
||||
|
||||
BrandIcon {
|
||||
id: modeGlyph
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Style.space(3)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
pathData: stage.panel.pLogo(modeEntry.thisMode)
|
||||
opticalScale: stage.panel.pLogoScale(modeEntry.thisMode)
|
||||
color: modeEntry.isCurrent ? Color.accent : Color.popups.text
|
||||
iconSize: Style.space(16)
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: modeGlyph.right
|
||||
anchors.leftMargin: Style.space(8)
|
||||
anchors.right: modeMark.left
|
||||
anchors.rightMargin: Style.space(6)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 0
|
||||
|
||||
Text {
|
||||
text: stage.panel.pTitle(modeEntry.thisMode)
|
||||
color: Color.popups.text
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.font.body
|
||||
font.bold: modeEntry.isCurrent
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: stage.panel.pBlurb(modeEntry.thisMode)
|
||||
color: Color.muted
|
||||
elide: Text.ElideRight
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
}
|
||||
|
||||
// Checkmark on the active mode; a chevron on a gateway row that
|
||||
// has a preset list waiting behind it.
|
||||
Text {
|
||||
id: modeMark
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modeEntry.isCurrent
|
||||
? String.fromCodePoint(0xF012C)
|
||||
: (Modes.needsPreset(modeEntry.thisMode)
|
||||
? String.fromCodePoint(modeEntry.isExpanded ? 0xF0140 : 0xF0142)
|
||||
: "")
|
||||
color: modeEntry.isCurrent ? Color.accent : Color.muted
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.font.body
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: modeHover
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
enabled: !stage.panel.busy
|
||||
onClicked: stage.panel.activate(modeEntry.thisMode)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Presets for this provider, unfolded in place. Shown even
|
||||
// with none left, so "New preset…" is still there to click.
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: modeEntry.isExpanded
|
||||
spacing: Style.space(1)
|
||||
|
||||
Repeater {
|
||||
model: modeEntry.isExpanded ? modeEntry.presetList : []
|
||||
|
||||
Item {
|
||||
id: presetRow
|
||||
required property var modelData
|
||||
width: modeEntry.width
|
||||
height: Style.space(26)
|
||||
|
||||
readonly property bool isActive: modeEntry.isCurrent && String(modelData.name) === stage.panel.preset
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Style.space(20)
|
||||
anchors.rightMargin: -Style.space(6)
|
||||
radius: Style.space(4)
|
||||
color: presetHover.containsMouse
|
||||
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.14)
|
||||
: "transparent"
|
||||
}
|
||||
|
||||
Text {
|
||||
id: presetName
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Style.space(30)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: presetRow.modelData.name
|
||||
color: presetRow.isActive ? Color.accent : Color.popups.text
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(12)
|
||||
font.bold: presetRow.isActive
|
||||
}
|
||||
|
||||
// Only worth saying when there is a choice to tell apart.
|
||||
Text {
|
||||
id: defaultTag
|
||||
visible: modeEntry.presetList.length > 1
|
||||
&& String(presetRow.modelData.name) === stage.panel.defaultPresets[modeEntry.thisMode]
|
||||
anchors.left: presetName.right
|
||||
anchors.leftMargin: Style.space(6)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "default"
|
||||
color: Color.muted
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(9)
|
||||
}
|
||||
|
||||
// The opus mapping is the one that tells you what you are
|
||||
// about to be talking to; the rest is in the tooltip.
|
||||
Text {
|
||||
anchors.left: defaultTag.visible ? defaultTag.right : presetName.right
|
||||
anchors.leftMargin: Style.space(8)
|
||||
anchors.right: gearButton.visible ? gearButton.left : parent.right
|
||||
anchors.rightMargin: Style.space(4)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: presetRow.modelData.models && presetRow.modelData.models.opus
|
||||
? String(presetRow.modelData.models.opus)
|
||||
: ""
|
||||
color: Color.muted
|
||||
elide: Text.ElideLeft
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
// Clicking the row switches; the gear opens that preset in the
|
||||
// editor instead. Per row rather than per provider, because
|
||||
// two presets of one provider can map tiers differently - and
|
||||
// two LM Studio presets can sit on two different machines.
|
||||
MouseArea {
|
||||
id: presetHover
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: gearButton.visible ? gearButton.width : 0
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
enabled: !stage.panel.busy
|
||||
onClicked: stage.panel.switchTo(modeEntry.thisMode, String(presetRow.modelData.name))
|
||||
}
|
||||
|
||||
Item {
|
||||
id: gearButton
|
||||
visible: true
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Style.space(22)
|
||||
height: parent.height
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: String.fromCodePoint(0xF0493)
|
||||
color: gearArea.containsMouse ? Color.accent : Color.muted
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(12)
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: gearArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
enabled: !stage.panel.busy
|
||||
onClicked: stage.panel.openPresetEditor(String(presetRow.modelData.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: modeEntry.width
|
||||
height: Style.space(24)
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Style.space(20)
|
||||
anchors.rightMargin: -Style.space(6)
|
||||
radius: Style.space(4)
|
||||
color: newPresetArea.containsMouse
|
||||
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.14)
|
||||
: "transparent"
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Style.space(30)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "New " + stage.panel.pTitle(modeEntry.thisMode) + " preset…"
|
||||
color: newPresetArea.containsMouse ? Color.accent : Color.muted
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(11)
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: newPresetArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
enabled: !stage.panel.busy
|
||||
onClicked: stage.panel.openNewPreset(modeEntry.thisMode, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
// ModelMap.qml - the active preset's tier-to-model map, behind a gateway.
|
||||
//
|
||||
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
|
||||
// and owns all the state and actions; this file only draws them.
|
||||
Column {
|
||||
id: stage
|
||||
required property var panel
|
||||
|
||||
width: parent.width
|
||||
visible: stage.panel.modelRows.length > 0 && stage.panel.stage === "list"
|
||||
spacing: Style.space(10)
|
||||
|
||||
PanelSeparator { width: parent.width; visible: stage.panel.modelRows.length > 0 && stage.panel.stage === "list" }
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Style.space(1)
|
||||
visible: stage.panel.modelRows.length > 0 && stage.panel.stage === "list"
|
||||
|
||||
PanelSectionHeader {
|
||||
text: "MODEL MAP"
|
||||
foreground: Color.popups.text
|
||||
fontFamily: stage.panel.uiFont
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: stage.panel.modelRows
|
||||
|
||||
Item {
|
||||
required property var modelData
|
||||
width: stage.width
|
||||
height: Style.space(19)
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: parent.modelData.tier
|
||||
color: Color.muted
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(11)
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Style.space(54)
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: parent.modelData.id
|
||||
color: Color.popups.text
|
||||
elide: Text.ElideLeft
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(11)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
.pragma library
|
||||
|
||||
// The four modes claude-mode understands, in the order the CLI menu lists
|
||||
// them. Kept here rather than in either QML file because the bar widget needs
|
||||
// the glyph and the panel needs the prose, and a second copy of this table is
|
||||
// exactly the sort of thing that drifts.
|
||||
// Fallback presentation only. claude-mode publishes its providers - titles,
|
||||
// blurbs, logos, scales, order - in health.json from providers.json, and the
|
||||
// widget reads them from there (BarWidget.providerInfo). What remains here is
|
||||
// anthropic, which is the native login and never in that list, and the
|
||||
// original three gateways for a health.json written before the list existed.
|
||||
// Nothing new belongs in this file: it is a .pragma library, cached until the
|
||||
// shell restarts.
|
||||
|
||||
// Material Design icons from the Nerd Font patch set, written as codepoints
|
||||
// rather than literals so they survive any editor or transport that is not
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
// NewPresetStage.qml - New preset: a name, and what to start from.
|
||||
//
|
||||
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
|
||||
// and owns all the state and actions; this file only draws them.
|
||||
Column {
|
||||
id: stage
|
||||
required property var panel
|
||||
|
||||
width: parent.width
|
||||
visible: stage.panel.stage === "presetNew"
|
||||
spacing: Style.space(7)
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "New " + stage.panel.pTitle(stage.panel.newProvider) + " preset"
|
||||
color: Color.popups.text
|
||||
elide: Text.ElideRight
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(13)
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: newNameField
|
||||
width: parent.width
|
||||
text: stage.panel.newName
|
||||
placeholderText: "name"
|
||||
foreground: Color.popups.text
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(11)
|
||||
onTextChanged: stage.panel.newName = text
|
||||
onAccepted: stage.panel.createPreset()
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
readonly property string problem: stage.panel.stage === "presetNew" ? stage.panel.nameProblem(stage.panel.newName, "") : ""
|
||||
visible: problem !== ""
|
||||
text: problem
|
||||
color: stage.panel.urgentColor
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Start from"
|
||||
color: Color.muted
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
// Only this provider's presets: a copy keeps the provider, and a
|
||||
// preset cannot change provider afterwards.
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: Style.space(6)
|
||||
|
||||
Repeater {
|
||||
model: stage.panel.stage === "presetNew" ? stage.panel.presetsOf(stage.panel.newProvider) : []
|
||||
|
||||
PillButton { fontFamily: stage.panel.uiFont;
|
||||
required property var modelData
|
||||
label: "copy of " + String(modelData.name)
|
||||
primary: stage.panel.newFrom === String(modelData.name)
|
||||
onTriggered: stage.panel.newFrom = String(modelData.name)
|
||||
}
|
||||
}
|
||||
|
||||
PillButton { fontFamily: stage.panel.uiFont;
|
||||
label: "blank"
|
||||
primary: stage.panel.newFrom === ""
|
||||
onTriggered: stage.panel.newFrom = ""
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: newFromHint
|
||||
width: parent.width
|
||||
text: stage.panel.newFrom === ""
|
||||
? "Every tier starts empty; the editor opens next to fill them in."
|
||||
: "Same server, key and models as '" + stage.panel.newFrom + "'. The editor opens next to change them."
|
||||
color: Color.muted
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: Style.space(7)
|
||||
|
||||
PillButton { fontFamily: stage.panel.uiFont; label: "Create"; primary: true; onTriggered: stage.panel.createPreset() }
|
||||
PillButton { fontFamily: stage.panel.uiFont;
|
||||
label: "Cancel"
|
||||
onTriggered: {
|
||||
stage.panel.lastError = ""
|
||||
if (stage.panel.editPreset !== "") stage.panel.stage = "preset"
|
||||
else stage.panel.resetFlow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: stage.panel
|
||||
function onNewPresetOpened() {
|
||||
newNameField.text = stage.panel.newName
|
||||
Qt.callLater(function () { newNameField.forceActiveFocus(); newNameField.selectAll() })
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
|
||||
// PillButton.qml - the panel's button: a bordered pill, filled when primary.
|
||||
// `enabled: false` greys it out.
|
||||
Rectangle {
|
||||
id: pb
|
||||
property string label: ""
|
||||
property bool primary: false
|
||||
// The bar's font; this file cannot see the panel, so it is handed in.
|
||||
property string fontFamily: Style.font.family
|
||||
signal triggered()
|
||||
|
||||
implicitWidth: pbText.implicitWidth + Style.space(20)
|
||||
implicitHeight: Style.space(25)
|
||||
radius: Style.space(4)
|
||||
// `enabled: false` greys it out; the MouseArea inherits the flag.
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
|
||||
readonly property color tint: primary ? Color.accent : Color.popups.text
|
||||
color: pbArea.containsMouse
|
||||
? Qt.rgba(tint.r, tint.g, tint.b, primary ? 0.30 : 0.14)
|
||||
: Qt.rgba(tint.r, tint.g, tint.b, primary ? 0.16 : 0.00)
|
||||
border.width: 1
|
||||
border.color: Qt.rgba(tint.r, tint.g, tint.b, primary ? 0.75 : 0.30)
|
||||
|
||||
Text {
|
||||
id: pbText
|
||||
anchors.centerIn: parent
|
||||
text: pb.label
|
||||
color: Color.popups.text
|
||||
font.family: pb.fontFamily
|
||||
font.pixelSize: Style.space(11)
|
||||
font.bold: pb.primary
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: pbArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: pb.triggered()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
// PresetStage.qml - One preset: its tier map, each row opening a picker.
|
||||
//
|
||||
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
|
||||
// and owns all the state and actions; this file only draws them.
|
||||
Column {
|
||||
id: stage
|
||||
required property var panel
|
||||
|
||||
width: parent.width
|
||||
visible: stage.panel.stage === "preset" && stage.panel.editPreset !== ""
|
||||
spacing: Style.space(6)
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "'" + stage.panel.editPreset + "' · " + stage.panel.pTitle(stage.panel.editProvider)
|
||||
color: Color.popups.text
|
||||
elide: Text.ElideRight
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(13)
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: text !== ""
|
||||
text: stage.panel.editEntry && stage.panel.editEntry.description ? String(stage.panel.editEntry.description) : ""
|
||||
color: Color.muted
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: stage.panel.editIsActive
|
||||
text: "In use now. A change is applied straight away and reaches sessions started after it."
|
||||
color: Color.accent
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: stage.panel.editIsDefault
|
||||
text: "claude-mode " + stage.panel.editProvider + " picks this one when no preset is named"
|
||||
+ (stage.panel.editIsChosenDefault ? "." : " (the built-in choice).")
|
||||
color: Color.muted
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Style.space(1)
|
||||
|
||||
Repeater {
|
||||
model: stage.panel.stage === "preset" ? stage.panel.tierNames : []
|
||||
|
||||
Item {
|
||||
id: tierRow
|
||||
required property var modelData
|
||||
readonly property string tier: String(modelData)
|
||||
readonly property string current: stage.panel.editCurrent(tier)
|
||||
width: stage.width
|
||||
height: Style.space(26)
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: -Style.space(6)
|
||||
anchors.rightMargin: -Style.space(6)
|
||||
radius: Style.space(4)
|
||||
color: tierArea.containsMouse
|
||||
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.14)
|
||||
: "transparent"
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: tierRow.tier
|
||||
color: Color.muted
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(11)
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Style.space(54)
|
||||
anchors.right: tierChevron.left
|
||||
anchors.rightMargin: Style.space(6)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: tierRow.current !== "" ? tierRow.current : "not set"
|
||||
color: tierRow.current !== "" ? Color.popups.text : Color.muted
|
||||
elide: Text.ElideLeft
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(11)
|
||||
}
|
||||
|
||||
Text {
|
||||
id: tierChevron
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: String.fromCodePoint(0xF0142)
|
||||
color: tierArea.containsMouse ? Color.accent : Color.muted
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.font.body
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: tierArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
enabled: !stage.panel.busy
|
||||
onClicked: stage.panel.openTierEditor(tierRow.tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flow, not Row: these can total more than the card is wide, and a
|
||||
// Row lays them straight past its right edge instead of wrapping.
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: Style.space(7)
|
||||
|
||||
PillButton { fontFamily: stage.panel.uiFont;
|
||||
label: "Server settings…"
|
||||
visible: stage.panel.serverEditable(stage.panel.editProvider)
|
||||
onTriggered: stage.panel.openServerSettings(stage.panel.editPreset, "preset")
|
||||
}
|
||||
PillButton { fontFamily: stage.panel.uiFont; label: "Make default"; visible: !stage.panel.editIsDefault; onTriggered: stage.panel.setDefault(true) }
|
||||
PillButton { fontFamily: stage.panel.uiFont; label: "Clear default"; visible: stage.panel.editIsChosenDefault; onTriggered: stage.panel.setDefault(false) }
|
||||
PillButton { fontFamily: stage.panel.uiFont; label: "Duplicate…"; onTriggered: stage.panel.openNewPreset(stage.panel.editProvider, stage.panel.editPreset) }
|
||||
PillButton { fontFamily: stage.panel.uiFont; label: "Rename…"; onTriggered: stage.panel.openRename() }
|
||||
// The CLI refuses to delete the preset in use. A button that always
|
||||
// fails would be worse than one that says why it is off.
|
||||
PillButton { fontFamily: stage.panel.uiFont;
|
||||
label: "Delete…"
|
||||
enabled: !stage.panel.editIsActive
|
||||
onTriggered: { stage.panel.lastError = ""; stage.panel.stage = "presetDelete" }
|
||||
}
|
||||
PillButton { fontFamily: stage.panel.uiFont; label: "Done"; primary: true; onTriggered: stage.panel.resetFlow() }
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: stage.panel.editIsActive
|
||||
text: "In use, so it cannot be deleted. Switch to another preset first."
|
||||
color: Color.muted
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(9)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
// RenameStage.qml - Rename: the file moves, and state.json follows if it is in use.
|
||||
//
|
||||
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
|
||||
// and owns all the state and actions; this file only draws them.
|
||||
Column {
|
||||
id: stage
|
||||
required property var panel
|
||||
|
||||
width: parent.width
|
||||
visible: stage.panel.stage === "presetRename"
|
||||
spacing: Style.space(7)
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Rename '" + stage.panel.editPreset + "'"
|
||||
color: Color.popups.text
|
||||
elide: Text.ElideRight
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(13)
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: renameField
|
||||
width: parent.width
|
||||
text: stage.panel.renameTo
|
||||
placeholderText: "new name"
|
||||
foreground: Color.popups.text
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(11)
|
||||
onTextChanged: stage.panel.renameTo = text
|
||||
onAccepted: stage.panel.renamePreset()
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
readonly property string problem: stage.panel.stage === "presetRename" ? stage.panel.nameProblem(stage.panel.renameTo, stage.panel.editPreset) : ""
|
||||
visible: problem !== ""
|
||||
text: problem
|
||||
color: stage.panel.urgentColor
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: stage.panel.editIsActive
|
||||
text: "It is the preset in use. The switch follows the new name, and running sessions keep working."
|
||||
color: Color.muted
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: Style.space(7)
|
||||
|
||||
PillButton { fontFamily: stage.panel.uiFont; label: "Rename"; primary: true; onTriggered: stage.panel.renamePreset() }
|
||||
PillButton { fontFamily: stage.panel.uiFont; label: "Back"; onTriggered: { stage.panel.lastError = ""; stage.panel.stage = "preset" } }
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: stage.panel
|
||||
function onRenameOpened() {
|
||||
renameField.text = stage.panel.renameTo
|
||||
Qt.callLater(function () { renameField.forceActiveFocus(); renameField.selectAll() })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
// RepairStage.qml - Confirming one repair.
|
||||
//
|
||||
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
|
||||
// and owns all the state and actions; this file only draws them.
|
||||
Column {
|
||||
id: stage
|
||||
required property var panel
|
||||
|
||||
width: parent.width
|
||||
visible: stage.panel.stage === "repair" && stage.panel.repairTarget !== null
|
||||
spacing: Style.space(7)
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Repair " + (stage.panel.repairTarget ? String(stage.panel.repairTarget.sessionId).substring(0, 8) : "")
|
||||
color: Color.popups.text
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(13)
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: {
|
||||
if (!stage.panel.repairTarget) return ""
|
||||
var p = (stage.panel.repairTarget.providers || []).join(", ")
|
||||
return "This session took " + (p !== "" ? p + "'s" : "another provider's")
|
||||
+ " output while the mode was switched under it, and Anthropic will not "
|
||||
+ "resume it. Rolling it back to its last good message drops "
|
||||
+ stage.panel.repairTarget.dropLines + " lines."
|
||||
}
|
||||
color: Color.muted
|
||||
wrapMode: Text.WordWrap
|
||||
lineHeight: 1.2
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "The original is backed up, the dropped turns are saved as Markdown, "
|
||||
+ "and handed back to the session so it still knows what it did."
|
||||
color: Color.muted
|
||||
opacity: 0.85
|
||||
wrapMode: Text.WordWrap
|
||||
lineHeight: 1.2
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: Style.space(7)
|
||||
|
||||
PillButton { fontFamily: stage.panel.uiFont; label: "Repair it"; primary: true; onTriggered: stage.panel.doRepair() }
|
||||
PillButton { fontFamily: stage.panel.uiFont; label: "Cancel"; onTriggered: stage.panel.resetFlow() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
// ServerStage.qml - Where a server provider is, and whether it needs a key.
|
||||
//
|
||||
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
|
||||
// and owns all the state and actions; this file only draws them.
|
||||
Column {
|
||||
id: stage
|
||||
required property var panel
|
||||
|
||||
width: parent.width
|
||||
visible: stage.panel.stage === "server"
|
||||
spacing: Style.space(8)
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Server for '" + stage.panel.serverPreset + "'"
|
||||
color: Color.popups.text
|
||||
elide: Text.ElideRight
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(13)
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: stage.panel.serverHint
|
||||
color: Color.muted
|
||||
wrapMode: Text.WordWrap
|
||||
lineHeight: 1.2
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: urlField
|
||||
width: parent.width
|
||||
text: stage.panel.serverUrl
|
||||
placeholderText: stage.panel.serverDefault !== "" ? stage.panel.serverDefault : "https://…"
|
||||
foreground: Color.popups.text
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(11)
|
||||
onTextChanged: stage.panel.serverUrl = text
|
||||
onAccepted: stage.panel.saveServerSettings()
|
||||
}
|
||||
|
||||
// Flow, not Row: these can total more than the card is wide, and a
|
||||
// Row lays them straight past its right edge instead of wrapping.
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: Style.space(7)
|
||||
|
||||
// A custom endpoint has no usual address to go back to.
|
||||
PillButton { fontFamily: stage.panel.uiFont;
|
||||
label: "Use local default"
|
||||
visible: stage.panel.serverDefault !== ""
|
||||
onTriggered: { stage.panel.serverUrl = stage.panel.serverDefault; urlField.text = stage.panel.serverDefault }
|
||||
}
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Style.space(9)
|
||||
|
||||
ToggleSwitch {
|
||||
id: authToggle
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: stage.panel.serverNeedsKey
|
||||
foreground: Color.popups.text
|
||||
accent: Color.accent
|
||||
onToggled: stage.panel.serverNeedsKey = !stage.panel.serverNeedsKey
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - authToggle.width - parent.spacing
|
||||
spacing: Style.space(1)
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Server requires an API key"
|
||||
color: Color.popups.text
|
||||
elide: Text.ElideRight
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(11)
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: stage.panel.serverNeedsKey
|
||||
? "Kept in the vault as '" + stage.panel.serverKeyRef + "', never in settings.json."
|
||||
: "Sends " + stage.panel.pTitle(stage.panel.serverProvider) + "'s placeholder token, which is not a secret."
|
||||
color: Color.muted
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flow, not Row: these can total more than the card is wide, and a
|
||||
// Row lays them straight past its right edge instead of wrapping.
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: Style.space(7)
|
||||
visible: stage.panel.serverNeedsKey
|
||||
|
||||
PillButton { fontFamily: stage.panel.uiFont; label: "Store the key…"; onTriggered: stage.panel.storeServerKey() }
|
||||
}
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
// Flow, not Row: these can total more than the card is wide, and a
|
||||
// Row lays them straight past its right edge instead of wrapping.
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: Style.space(7)
|
||||
|
||||
PillButton { fontFamily: stage.panel.uiFont; label: "Save"; primary: true; onTriggered: stage.panel.saveServerSettings() }
|
||||
PillButton { fontFamily: stage.panel.uiFont;
|
||||
label: stage.panel.serverReturn !== "" ? "Back" : "Cancel"
|
||||
onTriggered: {
|
||||
if (stage.panel.serverReturn !== "") stage.panel.stage = stage.panel.serverReturn
|
||||
else stage.panel.resetFlow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
// SessionsStage.qml - Sessions are running. They will keep the old provider until they are restarted, and one mid-request can lose that turn outright, so this is a decision rather than a notification.
|
||||
//
|
||||
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
|
||||
// and owns all the state and actions; this file only draws them.
|
||||
Column {
|
||||
id: stage
|
||||
required property var panel
|
||||
|
||||
width: parent.width
|
||||
visible: stage.panel.stage === "confirm" && stage.panel.sessionInfo !== null
|
||||
spacing: Style.space(7)
|
||||
|
||||
readonly property int total: stage.panel.sessionInfo ? Number(stage.panel.sessionInfo.count) : 0
|
||||
readonly property int busyCount: stage.panel.sessionInfo ? Number(stage.panel.sessionInfo.busy) : 0
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: parent.total === 1
|
||||
? "1 Claude session is running"
|
||||
: parent.total + " Claude sessions are running"
|
||||
color: Color.popups.text
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(13)
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
// Not "they will keep using the old provider". Their endpoint and model
|
||||
// ids are fixed at startup, but the key is re-fetched on a timer and
|
||||
// will resolve to the new mode, which the old endpoint refuses - so
|
||||
// they fail rather than carry on.
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "They keep pointing at " + stage.panel.pTitle(stage.panel.mode) + ", but their key is "
|
||||
+ "re-fetched on a timer and will switch under them. Worse, if one takes a "
|
||||
+ "reply from the new provider first, that provider's message-id format "
|
||||
+ "goes into its transcript and Anthropic will refuse to resume it at all."
|
||||
color: Color.muted
|
||||
wrapMode: Text.WordWrap
|
||||
lineHeight: 1.2
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: parent.busyCount > 0
|
||||
text: parent.busyCount === 1
|
||||
? "One is working right now and will break mid-turn."
|
||||
: parent.busyCount + " are working right now and will break mid-turn."
|
||||
color: stage.panel.urgentColor
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Style.space(1)
|
||||
|
||||
Repeater {
|
||||
model: stage.panel.sessionInfo ? stage.panel.sessionInfo.sessions : []
|
||||
|
||||
Item {
|
||||
required property var modelData
|
||||
width: stage.width
|
||||
height: Style.space(17)
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: parent.modelData.tty
|
||||
color: Color.muted
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Style.space(52)
|
||||
anchors.right: busyTag.left
|
||||
anchors.rightMargin: Style.space(6)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: parent.modelData.cwd
|
||||
color: Color.popups.text
|
||||
elide: Text.ElideLeft
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
Text {
|
||||
id: busyTag
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: parent.modelData.busy === true
|
||||
text: "working"
|
||||
color: stage.panel.urgentColor
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flow, not Row: these can total more than the card is wide, and a
|
||||
// Row lays them straight past its right edge instead of wrapping.
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: Style.space(7)
|
||||
|
||||
// Restart is the only option that ends with every session on the mode
|
||||
// the bar is now claiming, so it leads and it is the primary.
|
||||
PillButton { fontFamily: stage.panel.uiFont;
|
||||
label: "Switch and restart"
|
||||
primary: true
|
||||
onTriggered: stage.panel.applySwitch("restart")
|
||||
}
|
||||
PillButton { fontFamily: stage.panel.uiFont; label: "Switch and close"; onTriggered: stage.panel.applySwitch("stop") }
|
||||
PillButton { fontFamily: stage.panel.uiFont; label: "Switch only"; onTriggered: stage.panel.applySwitch("none") }
|
||||
PillButton { fontFamily: stage.panel.uiFont; label: "Cancel"; onTriggered: stage.panel.resetFlow() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
// TierStage.qml - One tier: pick from the cached catalogue, or type any id.
|
||||
//
|
||||
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
|
||||
// and owns all the state and actions; this file only draws them.
|
||||
Column {
|
||||
id: stage
|
||||
required property var panel
|
||||
|
||||
width: parent.width
|
||||
visible: stage.panel.stage === "presetTier"
|
||||
spacing: Style.space(7)
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: stage.panel.editTier + " · '" + stage.panel.editPreset + "'"
|
||||
color: Color.popups.text
|
||||
elide: Text.ElideRight
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(13)
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "now: " + (stage.panel.editCurrent(stage.panel.editTier) || "not set")
|
||||
color: Color.muted
|
||||
elide: Text.ElideLeft
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
// One field that both filters the catalogue and takes any id typed by
|
||||
// hand, with the matches listed inline beneath it - rather than the
|
||||
// shell's SearchableDropdown, whose list is a second popup layered on
|
||||
// this card. Inline, the card's height is the only thing to manage,
|
||||
// and the list can never be clipped by the card's edge.
|
||||
TextField {
|
||||
id: modelField
|
||||
width: parent.width
|
||||
text: stage.panel.editModel
|
||||
placeholderText: stage.panel.modelOptions.length > 0
|
||||
? "Search " + stage.panel.modelOptions.length + " models, or type any id"
|
||||
: "Model id"
|
||||
foreground: Color.popups.text
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(11)
|
||||
onTextChanged: {
|
||||
stage.panel.editModel = text
|
||||
modelList.currentIndex = -1
|
||||
}
|
||||
// Enter takes the highlighted match if the arrows chose one, and
|
||||
// saves what is in the field otherwise.
|
||||
onAccepted: {
|
||||
if (modelList.currentIndex >= 0 && modelList.currentIndex < stage.panel.modelMatches.length)
|
||||
stage.panel.pickModel(String(stage.panel.modelMatches[modelList.currentIndex].value))
|
||||
else
|
||||
stage.panel.saveTier()
|
||||
}
|
||||
Keys.onDownPressed: function (event) {
|
||||
if (modelList.count > 0) {
|
||||
modelList.currentIndex = Math.min(modelList.currentIndex + 1, modelList.count - 1)
|
||||
modelList.positionViewAtIndex(modelList.currentIndex, ListView.Contain)
|
||||
}
|
||||
event.accepted = true
|
||||
}
|
||||
Keys.onUpPressed: function (event) {
|
||||
if (modelList.currentIndex >= 0) {
|
||||
modelList.currentIndex = modelList.currentIndex - 1
|
||||
if (modelList.currentIndex >= 0) modelList.positionViewAtIndex(modelList.currentIndex, ListView.Contain)
|
||||
}
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
|
||||
// Scrolls inside a box of at most six rows: the card clamps to its
|
||||
// content rather than scrolling, so an unbounded list would push the
|
||||
// buttons off the bottom edge.
|
||||
Rectangle {
|
||||
id: modelListFrame
|
||||
readonly property int rowHeight: Style.space(30)
|
||||
width: parent.width
|
||||
visible: stage.panel.modelOptions.length > 0
|
||||
height: rowHeight * Math.max(1, Math.min(modelList.count, 6)) + 2
|
||||
radius: Style.space(4)
|
||||
color: "transparent"
|
||||
border.width: 1
|
||||
border.color: Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.14)
|
||||
|
||||
ListView {
|
||||
id: modelList
|
||||
anchors.fill: parent
|
||||
anchors.margins: 1
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
currentIndex: -1
|
||||
model: stage.panel.stage === "presetTier" ? stage.panel.modelMatches : []
|
||||
|
||||
delegate: Item {
|
||||
id: matchRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
readonly property bool chosen: String(modelData.value) === stage.panel.editModel.trim()
|
||||
width: modelList.width
|
||||
height: modelListFrame.rowHeight
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: matchRow.index === modelList.currentIndex || matchArea.containsMouse
|
||||
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.16)
|
||||
: (matchRow.chosen ? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.10) : "transparent")
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: Style.space(8)
|
||||
anchors.rightMargin: Style.space(8)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 0
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: String(matchRow.modelData.label)
|
||||
color: matchRow.chosen ? Color.accent : Color.popups.text
|
||||
elide: Text.ElideMiddle
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(11)
|
||||
font.bold: matchRow.chosen
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: text !== ""
|
||||
text: String(matchRow.modelData.description || "")
|
||||
color: Color.muted
|
||||
elide: Text.ElideRight
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(9)
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: matchArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: stage.panel.pickModel(String(matchRow.modelData.value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: modelList.count === 0
|
||||
text: "No match. Save uses the id as typed."
|
||||
color: Color.muted
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: stage.panel.modelOptions.length === 0
|
||||
text: stage.panel.catalogueNode && stage.panel.catalogueNode.ok === false
|
||||
? "The last fetch failed and no list is cached. Type an id, or try fetching again."
|
||||
: "No model list cached for " + stage.panel.pTitle(stage.panel.editProvider) + " yet. Type an id, or fetch the list."
|
||||
color: Color.muted
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: stage.panel.catalogueNode !== null && stage.panel.modelOptions.length > 0
|
||||
text: stage.panel.catalogueNode
|
||||
? stage.panel.modelOptions.length + " models cached, fetched " + stage.panel.ageLabel(stage.panel.catalogueNode.fetchedAt)
|
||||
+ (stage.panel.catalogueNode.ok === false ? " · the last refresh failed" : "")
|
||||
: ""
|
||||
color: Color.muted
|
||||
opacity: 0.85
|
||||
elide: Text.ElideRight
|
||||
font.family: stage.panel.uiFont
|
||||
font.pixelSize: Style.space(9)
|
||||
}
|
||||
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: Style.space(7)
|
||||
|
||||
PillButton { fontFamily: stage.panel.uiFont; label: "Save"; primary: true; onTriggered: stage.panel.saveTier() }
|
||||
PillButton { fontFamily: stage.panel.uiFont;
|
||||
label: stage.panel.modelOptions.length > 0 ? "Refresh list" : "Fetch models"
|
||||
onTriggered: stage.panel.fetchModels()
|
||||
}
|
||||
PillButton { fontFamily: stage.panel.uiFont;
|
||||
label: "Back"
|
||||
onTriggered: { stage.panel.lastError = ""; stage.panel.stage = "preset" }
|
||||
}
|
||||
}
|
||||
|
||||
// The panel cannot reach this field by id, so when a tier is opened or a
|
||||
// model picked it says so, and the field is reset or filled here. Typing
|
||||
// breaks the field's binding, so without the reset a value left from another
|
||||
// tier would survive into this one.
|
||||
Connections {
|
||||
target: stage.panel
|
||||
function onTierEditorOpened() {
|
||||
modelField.text = ""
|
||||
modelList.currentIndex = -1
|
||||
Qt.callLater(function () { modelField.forceActiveFocus() })
|
||||
}
|
||||
function onModelPicked(modelId) {
|
||||
modelField.text = modelId
|
||||
modelField.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "smoido.claude-mode",
|
||||
"name": "Claude Mode",
|
||||
"version": "1.8.0",
|
||||
"version": "1.13.0",
|
||||
"author": "smoido",
|
||||
"description": "Which provider Claude Code is pointed at, and a one-click switch between them",
|
||||
"kinds": ["bar-widget"],
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"provider": "custom",
|
||||
"description": "Any Anthropic-compatible endpoint. Setup asks where it is and whether it needs a key.",
|
||||
"baseUrl": "",
|
||||
"auth": {
|
||||
"mode": "vault",
|
||||
"keyRef": "custom"
|
||||
},
|
||||
"models": {
|
||||
"opus": "",
|
||||
"sonnet": "",
|
||||
"haiku": "",
|
||||
"fable": ""
|
||||
},
|
||||
"subagentModel": "inherit",
|
||||
"gatewayModelDiscovery": false,
|
||||
"contextTokens": 200000,
|
||||
"configured": false
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"provider": "openrouter",
|
||||
"description": "Daily driver. Flash-class models on the hot tiers, GLM on fable.",
|
||||
"description": "Daily driver. DeepSeek 4.1 Flash on opus, GLM Flash on sonnet, GLM on fable.",
|
||||
"baseUrl": "https://openrouter.ai/api",
|
||||
"auth": {
|
||||
"mode": "vault",
|
||||
"keyRef": "openrouter"
|
||||
},
|
||||
"models": {
|
||||
"opus": "z-ai/glm-5.3-flash",
|
||||
"sonnet": "deepseek/deepseek-v4-flash-0731",
|
||||
"opus": "deepseek/deepseek-v4.1-flash",
|
||||
"sonnet": "z-ai/glm-5.3-flash",
|
||||
"haiku": "openrouter/free",
|
||||
"fable": "z-ai/glm-5.3"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"provider": "ollama",
|
||||
"description": "Local Ollama server. Setup picks the model from whatever the server has pulled.",
|
||||
"baseUrl": "http://127.0.0.1:11434",
|
||||
"auth": {
|
||||
"mode": "literal",
|
||||
"token": "ollama"
|
||||
},
|
||||
"models": {
|
||||
"opus": "qwen3-coder",
|
||||
"sonnet": "qwen3-coder",
|
||||
"haiku": "qwen3-coder",
|
||||
"fable": "qwen3-coder"
|
||||
},
|
||||
"subagentModel": "inherit",
|
||||
"gatewayModelDiscovery": false,
|
||||
"extraEnv": {
|
||||
"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"
|
||||
},
|
||||
"contextTokens": 65536,
|
||||
"configured": false
|
||||
}
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
{
|
||||
"_comment": [
|
||||
"Every gateway provider claude-mode can switch to. anthropic is not here: it is the native login.",
|
||||
"Read by linux/cm-json.py (CLI + bar widget, via health.json) and claude-mode.ps1 (Windows).",
|
||||
"Behaviour that differs in kind is chosen by name: catalogue.kind (openrouter | lmstudio | ollama | openai | static),",
|
||||
"server.probe (always | lenient | local), setup.key (required | optional), setup.models (per-tier | one-for-all),",
|
||||
"and the doctor checks. A new provider that reuses those is an entry here and a preset in presets/.",
|
||||
"Logos are single-path 24x24 SVG marks; logoScale corrects apparent size (see Modes.js)."
|
||||
],
|
||||
"providers": [
|
||||
{
|
||||
"id": "openrouter",
|
||||
"aliases": [],
|
||||
"title": "OpenRouter",
|
||||
"label": "OpenRouter - remote, pay-per-token, any vendor",
|
||||
"blurb": "Remote gateway, pay per token, any vendor.",
|
||||
"color": "cyan",
|
||||
"defaultPreset": "default",
|
||||
"preset": {
|
||||
"baseUrl": "https://openrouter.ai/api",
|
||||
"auth": {
|
||||
"mode": "vault",
|
||||
"keyRef": "openrouter"
|
||||
},
|
||||
"contextTokens": 1000000,
|
||||
"gatewayModelDiscovery": true
|
||||
},
|
||||
"server": {
|
||||
"editable": false,
|
||||
"probe": "local"
|
||||
},
|
||||
"catalogue": {
|
||||
"kind": "openrouter",
|
||||
"url": "https://openrouter.ai/api/v1/models"
|
||||
},
|
||||
"setup": {
|
||||
"key": "required",
|
||||
"models": "per-tier",
|
||||
"keyUrl": "https://openrouter.ai/settings/keys"
|
||||
},
|
||||
"guardrail": true,
|
||||
"doctor": [
|
||||
"openrouter-key",
|
||||
"guardrail",
|
||||
"catalogue-models"
|
||||
],
|
||||
"logo": "M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z",
|
||||
"logoScale": 1.12,
|
||||
"glyph": "F0469"
|
||||
},
|
||||
{
|
||||
"id": "zai",
|
||||
"aliases": [
|
||||
"z.ai",
|
||||
"z-ai"
|
||||
],
|
||||
"title": "Z.AI",
|
||||
"label": "Z.AI - GLM coding plan",
|
||||
"blurb": "GLM coding plan on Z.AI's Anthropic endpoint.",
|
||||
"color": "green",
|
||||
"defaultPreset": "zai",
|
||||
"preset": {
|
||||
"baseUrl": "https://api.z.ai/api/anthropic",
|
||||
"auth": {
|
||||
"mode": "vault",
|
||||
"keyRef": "zai"
|
||||
},
|
||||
"contextTokens": 1000000,
|
||||
"gatewayModelDiscovery": false,
|
||||
"extraEnv": {
|
||||
"API_TIMEOUT_MS": "3000000",
|
||||
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"editable": false,
|
||||
"probe": "local"
|
||||
},
|
||||
"catalogue": {
|
||||
"kind": "static",
|
||||
"docs": "https://docs.z.ai/devpack/tool/claude",
|
||||
"static": [
|
||||
{
|
||||
"id": "glm-5.3",
|
||||
"note": "flagship coding model - opus/sonnet tier"
|
||||
},
|
||||
{
|
||||
"id": "glm-4.7",
|
||||
"note": "fast/cheap tier - haiku"
|
||||
}
|
||||
]
|
||||
},
|
||||
"setup": {
|
||||
"key": "required",
|
||||
"models": "per-tier",
|
||||
"keyUrl": "https://z.ai/manage-apikey/apikey-list"
|
||||
},
|
||||
"doctor": [
|
||||
"message-check"
|
||||
],
|
||||
"logo": "M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z",
|
||||
"logoScale": 1.07,
|
||||
"glyph": "F015F"
|
||||
},
|
||||
{
|
||||
"id": "lmstudio",
|
||||
"aliases": [
|
||||
"lm-studio"
|
||||
],
|
||||
"title": "LM Studio",
|
||||
"label": "LM Studio - local server, offline, free",
|
||||
"blurb": "Local LM Studio server. Offline and free.",
|
||||
"color": "yellow",
|
||||
"defaultPreset": "lmstudio",
|
||||
"preset": {
|
||||
"baseUrl": "http://127.0.0.1:1234",
|
||||
"auth": {
|
||||
"mode": "literal",
|
||||
"token": "lmstudio"
|
||||
},
|
||||
"contextTokens": 262144,
|
||||
"gatewayModelDiscovery": false,
|
||||
"extraEnv": {
|
||||
"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"editable": true,
|
||||
"probe": "always",
|
||||
"paths": [
|
||||
"/api/v0/models",
|
||||
"/v1/models"
|
||||
],
|
||||
"hint": "LM Studio does not have to be on this machine. Point this at a LAN address, or anything reachable through a tunnel or proxy.",
|
||||
"start": "start the server (Developer > Start Server)"
|
||||
},
|
||||
"catalogue": {
|
||||
"kind": "lmstudio",
|
||||
"perServer": true
|
||||
},
|
||||
"setup": {
|
||||
"key": "optional",
|
||||
"models": "one-for-all"
|
||||
},
|
||||
"doctor": [
|
||||
"catalogue-models",
|
||||
"lmstudio-templates"
|
||||
],
|
||||
"logo": "M14.025 0c3.492 0 5.237 0 6.571.68a6.24 6.24 0 0 1 2.725 2.724C24 4.738 24 6.484 24 9.975v4.05c0 3.492 0 5.237-.68 6.571a6.24 6.24 0 0 1-2.724 2.725c-1.334.679-3.08.679-6.571.679h-4.05c-3.492 0-5.237 0-6.571-.68A6.24 6.24 0 0 1 .68 20.597C0 19.262 0 17.516 0 14.025v-4.05c0-3.492 0-5.237.68-6.571A6.23 6.23 0 0 1 3.404.68C4.738 0 6.484 0 9.975 0zM7.688 16.313a1.313 1.313 0 0 0 0 2.625h11.625a1.313 1.313 0 0 0 0-2.625zm-3-3.75a1.313 1.313 0 0 0 0 2.624h11.625a1.313 1.313 0 0 0 0-2.624zm3-3.75a1.313 1.313 0 0 0 0 2.624h11.625a1.313 1.313 0 0 0 0-2.624zm-3-3.75a1.313 1.313 0 0 0 0 2.625h11.625a1.313 1.313 0 0 0 0-2.625z",
|
||||
"logoScale": 0.86,
|
||||
"glyph": "F048B"
|
||||
},
|
||||
{
|
||||
"id": "ollama",
|
||||
"aliases": [],
|
||||
"title": "Ollama",
|
||||
"label": "Ollama - local server, offline, free",
|
||||
"blurb": "Local Ollama server. Offline and free.",
|
||||
"color": "white",
|
||||
"defaultPreset": "ollama",
|
||||
"preset": {
|
||||
"baseUrl": "http://127.0.0.1:11434",
|
||||
"auth": {
|
||||
"mode": "literal",
|
||||
"token": "ollama"
|
||||
},
|
||||
"contextTokens": 65536,
|
||||
"gatewayModelDiscovery": false,
|
||||
"extraEnv": {
|
||||
"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"editable": true,
|
||||
"probe": "always",
|
||||
"paths": [
|
||||
"/api/tags"
|
||||
],
|
||||
"hint": "Ollama does not have to be on this machine. Serve it with OLLAMA_HOST=0.0.0.0 to reach it over the LAN.",
|
||||
"start": "start it with: ollama serve"
|
||||
},
|
||||
"catalogue": {
|
||||
"kind": "ollama",
|
||||
"perServer": true
|
||||
},
|
||||
"setup": {
|
||||
"key": "optional",
|
||||
"models": "one-for-all"
|
||||
},
|
||||
"doctor": [
|
||||
"catalogue-models",
|
||||
"ollama-context"
|
||||
],
|
||||
"logo": "M16.361 10.26a.894.894 0 0 0-.558.47l-.072.148.001.207c0 .193.004.217.059.353.076.193.152.312.291.448.24.238.51.3.872.205a.86.86 0 0 0 .517-.436.752.752 0 0 0 .08-.498c-.064-.453-.33-.782-.724-.897a1.06 1.06 0 0 0-.466 0zm-9.203.005c-.305.096-.533.32-.65.639a1.187 1.187 0 0 0-.06.52c.057.309.31.59.598.667.362.095.632.033.872-.205.14-.136.215-.255.291-.448.055-.136.059-.16.059-.353l.001-.207-.072-.148a.894.894 0 0 0-.565-.472 1.02 1.02 0 0 0-.474.007Zm4.184 2c-.131.071-.223.25-.195.383.031.143.157.288.353.407.105.063.112.072.117.136.004.038-.01.146-.029.243-.02.094-.036.194-.036.222.002.074.07.195.143.253.064.052.076.054.255.059.164.005.198.001.264-.03.169-.082.212-.234.15-.525-.052-.243-.042-.28.087-.355.137-.08.281-.219.324-.314a.365.365 0 0 0-.175-.48.394.394 0 0 0-.181-.033c-.126 0-.207.03-.355.124l-.085.053-.053-.032c-.219-.13-.259-.145-.391-.143a.396.396 0 0 0-.193.032zm.39-2.195c-.373.036-.475.05-.654.086-.291.06-.68.195-.951.328-.94.46-1.589 1.226-1.787 2.114-.04.176-.045.234-.045.53 0 .294.005.357.043.524.264 1.16 1.332 2.017 2.714 2.173.3.033 1.596.033 1.896 0 1.11-.125 2.064-.727 2.493-1.571.114-.226.169-.372.22-.602.039-.167.044-.23.044-.523 0-.297-.005-.355-.045-.531-.288-1.29-1.539-2.304-3.072-2.497a6.873 6.873 0 0 0-.855-.031zm.645.937a3.283 3.283 0 0 1 1.44.514c.223.148.537.458.671.662.166.251.26.508.303.82.02.143.01.251-.043.482-.08.345-.332.705-.672.957a3.115 3.115 0 0 1-.689.348c-.382.122-.632.144-1.525.138-.582-.006-.686-.01-.853-.042-.57-.107-1.022-.334-1.35-.68-.264-.28-.385-.535-.45-.946-.03-.192.025-.509.137-.776.136-.326.488-.73.836-.963.403-.269.934-.46 1.422-.512.187-.02.586-.02.773-.002zm-5.503-11a1.653 1.653 0 0 0-.683.298C5.617.74 5.173 1.666 4.985 2.819c-.07.436-.119 1.04-.119 1.503 0 .544.064 1.24.155 1.721.02.107.031.202.023.208a8.12 8.12 0 0 1-.187.152 5.324 5.324 0 0 0-.949 1.02 5.49 5.49 0 0 0-.94 2.339 6.625 6.625 0 0 0-.023 1.357c.091.78.325 1.438.727 2.04l.13.195-.037.064c-.269.452-.498 1.105-.605 1.732-.084.496-.095.629-.095 1.294 0 .67.009.803.088 1.266.095.555.288 1.143.503 1.534.071.128.243.393.264.407.007.003-.014.067-.046.141a7.405 7.405 0 0 0-.548 1.873c-.062.417-.071.552-.071.991 0 .56.031.832.148 1.279L3.42 24h1.478l-.05-.091c-.297-.552-.325-1.575-.068-2.597.117-.472.25-.819.498-1.296l.148-.29v-.177c0-.165-.003-.184-.057-.293a.915.915 0 0 0-.194-.25 1.74 1.74 0 0 1-.385-.543c-.424-.92-.506-2.286-.208-3.451.124-.486.329-.918.544-1.154a.787.787 0 0 0 .223-.531c0-.195-.07-.355-.224-.522a3.136 3.136 0 0 1-.817-1.729c-.14-.96.114-2.005.69-2.834.563-.814 1.353-1.336 2.237-1.475.199-.033.57-.028.776.01.226.04.367.028.512-.041.179-.085.268-.19.374-.431.093-.215.165-.333.36-.576.234-.29.46-.489.822-.729.413-.27.884-.467 1.352-.561.17-.035.25-.04.569-.04.319 0 .398.005.569.04a4.07 4.07 0 0 1 1.914.997c.117.109.398.457.488.602.034.057.095.177.132.267.105.241.195.346.374.43.14.068.286.082.503.045.343-.058.607-.053.943.016 1.144.23 2.14 1.173 2.581 2.437.385 1.108.276 2.267-.296 3.153-.097.15-.193.27-.333.419-.301.322-.301.722-.001 1.053.493.539.801 1.866.708 3.036-.062.772-.26 1.463-.533 1.854a2.096 2.096 0 0 1-.224.258.916.916 0 0 0-.194.25c-.054.109-.057.128-.057.293v.178l.148.29c.248.476.38.823.498 1.295.253 1.008.231 2.01-.059 2.581a.845.845 0 0 0-.044.098c0 .006.329.009.732.009h.73l.02-.074.036-.134c.019-.076.057-.3.088-.516.029-.217.029-1.016 0-1.258-.11-.875-.295-1.57-.597-2.226-.032-.074-.053-.138-.046-.141.008-.005.057-.074.108-.152.376-.569.607-1.284.724-2.228.031-.26.031-1.378 0-1.628-.083-.645-.182-1.082-.348-1.525a6.083 6.083 0 0 0-.329-.7l-.038-.064.131-.194c.402-.604.636-1.262.727-2.04a6.625 6.625 0 0 0-.024-1.358 5.512 5.512 0 0 0-.939-2.339 5.325 5.325 0 0 0-.95-1.02 8.097 8.097 0 0 1-.186-.152.692.692 0 0 1 .023-.208c.208-1.087.201-2.443-.017-3.503-.19-.924-.535-1.658-.98-2.082-.354-.338-.716-.482-1.15-.455-.996.059-1.8 1.205-2.116 3.01a6.805 6.805 0 0 0-.097.726c0 .036-.007.066-.015.066a.96.96 0 0 1-.149-.078A4.857 4.857 0 0 0 12 3.03c-.832 0-1.687.243-2.456.698a.958.958 0 0 1-.148.078c-.008 0-.015-.03-.015-.066a6.71 6.71 0 0 0-.097-.725C8.997 1.392 8.337.319 7.46.048a2.096 2.096 0 0 0-.585-.041Zm.293 1.402c.248.197.523.759.682 1.388.03.113.06.244.069.292.007.047.026.152.041.233.067.365.098.76.102 1.24l.002.475-.12.175-.118.178h-.278c-.324 0-.646.041-.954.124l-.238.06c-.033.007-.038-.003-.057-.144a8.438 8.438 0 0 1 .016-2.323c.124-.788.413-1.501.696-1.711.067-.05.079-.049.157.013zm9.825-.012c.17.126.358.46.498.888.28.854.36 2.028.212 3.145-.019.14-.024.151-.057.144l-.238-.06a3.693 3.693 0 0 0-.954-.124h-.278l-.119-.178-.119-.175.002-.474c.004-.669.066-1.19.214-1.772.157-.623.434-1.185.68-1.382.078-.062.09-.063.159-.012z",
|
||||
"logoScale": 1.0,
|
||||
"glyph": ""
|
||||
},
|
||||
{
|
||||
"id": "custom",
|
||||
"aliases": [],
|
||||
"title": "Custom",
|
||||
"label": "Custom - any Anthropic-compatible endpoint",
|
||||
"blurb": "Your own endpoint: a proxy, gateway or self-hosted server.",
|
||||
"color": "gray",
|
||||
"defaultPreset": "custom",
|
||||
"preset": {
|
||||
"baseUrl": "",
|
||||
"auth": {
|
||||
"mode": "vault",
|
||||
"keyRef": "custom"
|
||||
},
|
||||
"contextTokens": 200000,
|
||||
"gatewayModelDiscovery": false
|
||||
},
|
||||
"server": {
|
||||
"editable": true,
|
||||
"probe": "lenient",
|
||||
"paths": [
|
||||
"/v1/models"
|
||||
],
|
||||
"hint": "Any server that speaks Anthropic's Messages API: a LiteLLM or Vercel gateway, vLLM, llama.cpp, a company proxy.",
|
||||
"start": ""
|
||||
},
|
||||
"catalogue": {
|
||||
"kind": "openai",
|
||||
"perServer": true
|
||||
},
|
||||
"setup": {
|
||||
"key": "optional",
|
||||
"models": "per-tier"
|
||||
},
|
||||
"doctor": [
|
||||
"catalogue-models"
|
||||
],
|
||||
"logo": "M9 3.5 1 12l8 8.5 2-2L4.8 12 11 5.5zm6 0-2 2 6.2 6.5-6.2 6.5 2 2 8-8.5z",
|
||||
"logoScale": 1.09,
|
||||
"glyph": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
<#
|
||||
Build the distributable claude-mode package.
|
||||
|
||||
Produces, under dist/<version>/ :
|
||||
claude-mode-<version>.zip the payload (script, key helper, presets, installer)
|
||||
manifest.json version + package name + SHA-256
|
||||
|
||||
The Arkylx Index serves that directory, plus dist/bootstrap.ps1 as
|
||||
/tools/claude-mode/install.ps1. See ARKYLX_INDEX_INTEGRATION.md.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param([string] $OutRoot)
|
||||
|
||||
Set-StrictMode -Version 1.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
if (-not $OutRoot) { $OutRoot = Join-Path $root 'dist' }
|
||||
|
||||
$version = (Get-Content (Join-Path $root 'VERSION') -Raw).Trim()
|
||||
if (-not $version) { throw 'VERSION file is empty' }
|
||||
|
||||
Write-Host "building claude-mode $version" -ForegroundColor Cyan
|
||||
|
||||
$stage = Join-Path ([System.IO.Path]::GetTempPath()) ("cm-stage-" + [Guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
New-Item -ItemType Directory -Path (Join-Path $stage 'bin') -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path (Join-Path $stage 'presets') -Force | Out-Null
|
||||
|
||||
# Everything the installer needs, and nothing else - no README, no docs, no
|
||||
# .git. The payload is what lands on a user's machine.
|
||||
Copy-Item (Join-Path $root 'claude-mode.ps1') $stage -Force
|
||||
Copy-Item (Join-Path $root 'install.ps1') $stage -Force
|
||||
Copy-Item (Join-Path $root 'profile-snippet.ps1') $stage -Force
|
||||
Copy-Item (Join-Path $root 'VERSION') $stage -Force
|
||||
Get-ChildItem (Join-Path $root 'bin') -File | ForEach-Object { Copy-Item $_.FullName (Join-Path $stage 'bin') -Force }
|
||||
Get-ChildItem (Join-Path $root 'presets') -File | ForEach-Object { Copy-Item $_.FullName (Join-Path $stage 'presets') -Force }
|
||||
|
||||
# Refuse to ship a package whose main script does not parse - a broken payload
|
||||
# would be installed by every user before anyone noticed.
|
||||
$errs = $null
|
||||
[void][System.Management.Automation.Language.Parser]::ParseFile((Join-Path $stage 'claude-mode.ps1'), [ref]$null, [ref]$errs)
|
||||
if ($errs.Count -gt 0) {
|
||||
$errs | ForEach-Object { Write-Host " L$($_.Extent.StartLineNumber): $($_.Message)" -ForegroundColor Red }
|
||||
throw 'claude-mode.ps1 does not parse - refusing to package'
|
||||
}
|
||||
foreach ($p in (Get-ChildItem (Join-Path $stage 'presets') -File)) {
|
||||
try { [void](Get-Content $p.FullName -Raw | ConvertFrom-Json) }
|
||||
catch { throw "preset $($p.Name) is not valid JSON - refusing to package" }
|
||||
}
|
||||
Write-Host ' ok payload validated' -ForegroundColor Green
|
||||
|
||||
$outDir = Join-Path $OutRoot $version
|
||||
if (-not (Test-Path -LiteralPath $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null }
|
||||
|
||||
$zipName = "claude-mode-$version.zip"
|
||||
$zipPath = Join-Path $outDir $zipName
|
||||
if (Test-Path -LiteralPath $zipPath) { Remove-Item -LiteralPath $zipPath -Force }
|
||||
Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zipPath -Force
|
||||
|
||||
$sha = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLower()
|
||||
|
||||
$manifest = [ordered]@{
|
||||
tool = 'claude-mode'
|
||||
version = $version
|
||||
package = $zipName
|
||||
sha256 = $sha
|
||||
size = (Get-Item $zipPath).Length
|
||||
requires = [ordered]@{ powershell = '5.1'; os = 'windows' }
|
||||
entry = 'install.ps1'
|
||||
}
|
||||
$manifest | ConvertTo-Json -Depth 5 |
|
||||
Set-Content -LiteralPath (Join-Path $outDir 'manifest.json') -Encoding UTF8
|
||||
|
||||
# --- POSIX payload (Linux + macOS) -----------------------------------------
|
||||
# Shipped as a tar.gz because zip does not preserve the executable bit, and the
|
||||
# installer must be runnable straight out of the archive.
|
||||
$posixStage = Join-Path ([System.IO.Path]::GetTempPath()) ("cm-posix-" + [Guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
New-Item -ItemType Directory -Path (Join-Path $posixStage 'linux') -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path (Join-Path $posixStage 'presets') -Force | Out-Null
|
||||
Get-ChildItem (Join-Path $root 'linux') -File | ForEach-Object { Copy-Item $_.FullName (Join-Path $posixStage 'linux') -Force }
|
||||
Get-ChildItem (Join-Path $root 'presets') -File | ForEach-Object { Copy-Item $_.FullName (Join-Path $posixStage 'presets') -Force }
|
||||
Copy-Item (Join-Path $root 'VERSION') $posixStage -Force
|
||||
|
||||
# Shell scripts authored on Windows carry CRLF, which makes the kernel reject
|
||||
# the "#!/usr/bin/env bash" line. Normalise before packaging.
|
||||
Get-ChildItem (Join-Path $posixStage 'linux') -File | ForEach-Object {
|
||||
$text = [System.IO.File]::ReadAllText($_.FullName) -replace "`r`n", "`n"
|
||||
[System.IO.File]::WriteAllText($_.FullName, $text, (New-Object System.Text.UTF8Encoding($false)))
|
||||
}
|
||||
|
||||
$tarName = "claude-mode-$version-posix.tar.gz"
|
||||
$tarPath = Join-Path $outDir $tarName
|
||||
if (Test-Path -LiteralPath $tarPath) { Remove-Item -LiteralPath $tarPath -Force }
|
||||
& tar.exe -czf $tarPath -C $posixStage 'linux' 'presets' 'VERSION'
|
||||
if ($LASTEXITCODE -ne 0) { throw 'tar failed - cannot build the POSIX package' }
|
||||
|
||||
$shaPosix = (Get-FileHash -LiteralPath $tarPath -Algorithm SHA256).Hash.ToLower()
|
||||
[ordered]@{
|
||||
tool = 'claude-mode'
|
||||
version = $version
|
||||
package = $tarName
|
||||
sha256 = $shaPosix
|
||||
size = (Get-Item $tarPath).Length
|
||||
requires = [ordered]@{ bash = '4.0'; python3 = '3.6'; os = 'linux,darwin' }
|
||||
entry = 'linux/install.sh'
|
||||
} | ConvertTo-Json -Depth 5 |
|
||||
Set-Content -LiteralPath (Join-Path $outDir 'manifest.posix.json') -Encoding UTF8
|
||||
|
||||
Remove-Item -LiteralPath $posixStage -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Write-Host " ok $tarPath" -ForegroundColor Green
|
||||
Write-Host " ok sha256 $shaPosix" -ForegroundColor Green
|
||||
|
||||
# "latest" is a copy rather than a symlink so a plain static file server can
|
||||
# serve it with no extra configuration.
|
||||
$latest = Join-Path $OutRoot 'latest'
|
||||
if (-not (Test-Path -LiteralPath $latest)) { New-Item -ItemType Directory -Path $latest -Force | Out-Null }
|
||||
Copy-Item $zipPath (Join-Path $latest $zipName) -Force
|
||||
Copy-Item (Join-Path $outDir 'manifest.json') (Join-Path $latest 'manifest.json') -Force
|
||||
Copy-Item $tarPath (Join-Path $latest $tarName) -Force
|
||||
Copy-Item (Join-Path $outDir 'manifest.posix.json') (Join-Path $latest 'manifest.posix.json') -Force
|
||||
|
||||
Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host " ok $zipPath" -ForegroundColor Green
|
||||
Write-Host " ok sha256 $sha" -ForegroundColor Green
|
||||
Write-Host " ok also copied to dist/latest/" -ForegroundColor Green
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Set the version everywhere it is recorded: VERSION (read by both CLIs and
|
||||
# stamped into health.json) and the bar widget's manifest. tests/static.sh
|
||||
# fails when the two disagree - they drifted apart once already (1.8.0 against
|
||||
# 1.9.3) with nothing to notice.
|
||||
#
|
||||
# scripts/bump-version.sh 1.13.0
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
new="${1:-}"
|
||||
if ! [[ "$new" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "usage: scripts/bump-version.sh <major.minor.patch> (now $(cat VERSION))" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$new" > VERSION
|
||||
python3 - "$new" <<'PY'
|
||||
import json, re, sys
|
||||
path = "omarchy/smoido.claude-mode/manifest.json"
|
||||
text = open(path, encoding="utf-8").read()
|
||||
# A targeted substitution rather than a json.dump, so the file's own layout
|
||||
# (key order, spacing, the kinds array on one line) is left alone.
|
||||
new = re.sub(r'("version"\s*:\s*")[^"]*(")', lambda m: m.group(1) + sys.argv[1] + m.group(2), text, count=1)
|
||||
json.loads(new)
|
||||
open(path, "w", encoding="utf-8").write(new)
|
||||
PY
|
||||
echo "version $new - add a CHANGELOG.md entry for it"
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Everything that can be checked from this machine, in one command.
|
||||
#
|
||||
# scripts/test.sh static checks, Python unit tests, CLI tests
|
||||
# scripts/test.sh --windows [host] ...and the PowerShell suite on a Windows host
|
||||
# over SSH (default host: winbox)
|
||||
#
|
||||
# Nothing here touches the real install. The CLI tests run in a sandbox with its
|
||||
# own HOME, CM_ROOT and CLAUDE_CONFIG_DIR and a file-only vault (tests/lib.sh),
|
||||
# and the Windows suite points USERPROFILE at a temp folder (tests/windows/run.ps1).
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
windows=0
|
||||
host=winbox
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--windows)
|
||||
windows=1
|
||||
if [ $# -gt 1 ] && [ "${2#-}" = "$2" ]; then host="$2"; shift; fi ;;
|
||||
-h|--help) sed -n '2,11p' "$0"; exit 0 ;;
|
||||
*) echo "unknown option '$1'" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
failed=''
|
||||
step() {
|
||||
local name="$1"; shift
|
||||
"$@" || failed="$failed $name"
|
||||
}
|
||||
|
||||
printf '== static\n'
|
||||
step static bash tests/static.sh
|
||||
|
||||
printf '\n== python\n'
|
||||
step python env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests/python -p 'test_*.py'
|
||||
|
||||
printf '\n== cli\n'
|
||||
for t in tests/cli/test_*.sh; do
|
||||
step "cli/$(basename "$t" .sh)" bash "$t"
|
||||
done
|
||||
|
||||
if [ "$windows" -eq 1 ]; then
|
||||
printf '\n== windows (%s)\n' "$host"
|
||||
step windows bash tests/windows/run-remote.sh "$host"
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
if [ -z "$failed" ]; then
|
||||
echo 'all passed'
|
||||
else
|
||||
echo "FAILED:$failed"
|
||||
exit 1
|
||||
fi
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# doctor on a server provider: model ids against the catalogue, and Ollama's
|
||||
# server-side context window.
|
||||
. "$(dirname "$0")/../lib.sh"
|
||||
start_fake
|
||||
|
||||
ready_preset ollama "$OLLAMA_URL"
|
||||
set_state ollama ollama
|
||||
run_cm doctor
|
||||
expect_out "Ollama reachable at $OLLAMA_URL" 'the server is found'
|
||||
expect_out 'opus qwen3-coder [30.5B Q4_K_M]' 'a bare name matches its :latest tag'
|
||||
expect_no_out 'NOT available' 'no model is reported missing'
|
||||
expect_out 'loaded with a 4096-token context, below the declared 65536' 'the short server context is caught'
|
||||
expect_out 'OLLAMA_CONTEXT_LENGTH=65536' 'and the fix is named'
|
||||
|
||||
ready_preset lmstudio "$LMSTUDIO_URL"
|
||||
python3 "$J" set-all "$P/lmstudio.json" gemma-small >/dev/null
|
||||
set_state lmstudio lmstudio
|
||||
run_cm doctor
|
||||
expect_out 'gemma-small [not-loaded, ctx 8192]' 'lm studio models show their state'
|
||||
expect_out 'below the declared 262144 - this tier can overflow' 'a small model window is warned about'
|
||||
|
||||
python3 "$J" set-url "$P/lmstudio.json" http://127.0.0.1:1 >/dev/null
|
||||
run_cm doctor
|
||||
expect_out 'LM Studio not reachable' 'a dead server is a failure'
|
||||
|
||||
finish
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# linux/install.sh, run for real into a sandbox home: every file lands, the
|
||||
# ~/.local/bin symlink runs (which exercises the CLI finding its lib/ beside
|
||||
# itself through a symlink), re-running is a safe upgrade.
|
||||
. "$(dirname "$0")/../lib.sh"
|
||||
|
||||
export HOME="$SB/ihome"
|
||||
export CM_ROOT="$HOME/.claude-mode"
|
||||
mkdir -p "$HOME"
|
||||
: > "$HOME/.bashrc"
|
||||
|
||||
install_() { OUT="$(bash "$REPO/linux/install.sh" --skip-key-prompt 2>&1)"; RC=$?; OUT="$(printf '%s' "$OUT" | sed "s/${ESC}\[[0-9;]*m//g")"; }
|
||||
|
||||
install_
|
||||
expect_rc 0 'the installer succeeds'
|
||||
for f in bin/claude-mode bin/cm-json.py bin/cm-vault.sh bin/claude-key-helper.sh \
|
||||
bin/lib/core.sh bin/lib/repair.sh providers.json VERSION presets/ollama.json health.json; do
|
||||
expect_file "$CM_ROOT/$f" "installs $f"
|
||||
done
|
||||
expect_eq "$(readlink "$HOME/.local/bin/claude-mode")" "$CM_ROOT/bin/claude-mode" 'links the entry point'
|
||||
expect_eq "$(grep -c '>>> claude-mode >>>' "$HOME/.bashrc")" 1 'adds the shell block'
|
||||
|
||||
OUT="$("$HOME/.local/bin/claude-mode" presets 2>&1)"
|
||||
expect_out 'ollama' 'runs through the symlink, loading its lib/'
|
||||
|
||||
python3 "$CM_ROOT/bin/cm-json.py" set-tier "$CM_ROOT/presets/default.json" opus my/model
|
||||
: > "$CM_ROOT/bin/lib/stale.sh"
|
||||
install_
|
||||
expect_rc 0 're-running the installer succeeds'
|
||||
expect_eq "$(jget "$CM_ROOT/presets/default.json" models.opus)" my/model 'and keeps an edited preset'
|
||||
expect_no_file "$CM_ROOT/bin/lib/stale.sh" 'and drops a module the source no longer has'
|
||||
expect_eq "$(grep -c '>>> claude-mode >>>' "$HOME/.bashrc")" 1 'and does not add the block twice'
|
||||
|
||||
finish
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# The key helper Claude Code runs on a timer in every live session.
|
||||
. "$(dirname "$0")/../lib.sh"
|
||||
H="$CM_ROOT/bin/claude-key-helper.sh"
|
||||
|
||||
helper() { OUT="$("$H" 2>&1)"; RC=$?; }
|
||||
|
||||
set_state anthropic
|
||||
helper
|
||||
expect_eq "$RC:$OUT" '0:' 'silent on anthropic'
|
||||
set_state lmstudio lmstudio
|
||||
helper
|
||||
expect_eq "$RC:$OUT" '0:' 'silent for an inline token'
|
||||
set_state lmstudio nosuch
|
||||
helper
|
||||
expect_rc 1 'a missing preset fails'
|
||||
expect_out 'does not exist' 'and says which'
|
||||
|
||||
"$CM" set-key zai sk-test-0123456789abcdef >/dev/null 2>&1
|
||||
set_state zai zai
|
||||
helper
|
||||
expect_eq "$OUT" sk-test-0123456789abcdef 'emits the vault key, and nothing else'
|
||||
|
||||
# Renaming the active preset while the helper loops: no fetch may see a gap.
|
||||
cp "$P/lmstudio.json" "$P/racer.json"
|
||||
set_state lmstudio racer
|
||||
(
|
||||
misses=0
|
||||
end=$((SECONDS + 4))
|
||||
while [ "$SECONDS" -lt "$end" ]; do
|
||||
"$H" >/dev/null 2>"$SB/helper.err" || { grep -q 'does not exist' "$SB/helper.err" && misses=$((misses + 1)); }
|
||||
done
|
||||
echo "$misses" > "$SB/misses"
|
||||
) &
|
||||
loop=$!
|
||||
cur=racer
|
||||
for i in $(seq 1 30); do
|
||||
"$CM" preset rename "$cur" "racer$i" >/dev/null 2>&1
|
||||
cur="racer$i"
|
||||
done
|
||||
wait "$loop"
|
||||
expect_eq "$(cat "$SB/misses")" 0 'no key fetch is lost to a rename'
|
||||
expect_eq "$(jget "$CM_ROOT/state.json" preset)" racer30 'state ends on the last name'
|
||||
|
||||
finish
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Model catalogues: fetched by each provider's kind, cached per server.
|
||||
. "$(dirname "$0")/../lib.sh"
|
||||
start_fake
|
||||
C="$CM_ROOT/models-cache.json"
|
||||
|
||||
ready_preset ollama "$OLLAMA_URL"
|
||||
run_cm models --preset ollama
|
||||
expect_out 'qwen3-coder:latest' 'ollama models are listed'
|
||||
run_cm models --preset ollama --refresh
|
||||
expect_out 'cached 2 ollama model(s)' 'and cached'
|
||||
expect_eq "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["providers"]["ollama"]["baseUrl"])' "$C")" \
|
||||
"$OLLAMA_URL" 'against the server they came from'
|
||||
|
||||
ready_preset lmstudio "$LMSTUDIO_URL"
|
||||
run_cm models --preset lmstudio
|
||||
expect_out 'qwen3-coder-30b' 'lm studio models are listed'
|
||||
|
||||
python3 - "$P/custom.json" "$KEYED_URL" <<'PY'
|
||||
import json, sys
|
||||
p = json.load(open(sys.argv[1]))
|
||||
p.update(baseUrl=sys.argv[2], auth={"mode": "literal", "token": "sk-real"}, configured=True)
|
||||
json.dump(p, open(sys.argv[1], "w"), indent=2)
|
||||
PY
|
||||
run_cm models --preset custom
|
||||
expect_out 'gw/model-b' 'a keyed endpoint lists its models with the key'
|
||||
|
||||
cp "$P/custom.json" "$P/proxy.json"
|
||||
python3 "$J" set-url "$P/proxy.json" "$PROXY_URL" >/dev/null
|
||||
run_cm models --preset proxy --refresh
|
||||
expect_rc 1 'a proxy has no list to fetch'
|
||||
expect_out 'model ids can still be typed by hand' 'and that is explained'
|
||||
|
||||
run_cm models --preset zai
|
||||
expect_out 'glm-5.3' 'the static zai list'
|
||||
run_cm models --preset zai --json
|
||||
expect_out '"glm-4.7"' '--json prints the cached node'
|
||||
|
||||
run_cm models --preset nope --refresh
|
||||
expect_rc 1 'an unknown preset is refused'
|
||||
|
||||
for word in keyRef token auth; do
|
||||
case "$(cat "$C")" in *"\"$word\""*) fail "the cache holds a $word" ;; *) pass ;; esac
|
||||
done
|
||||
|
||||
finish
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Preflight: what refuses a switch, and why - against fake servers.
|
||||
. "$(dirname "$0")/../lib.sh"
|
||||
start_fake
|
||||
|
||||
code() { preflight_code "$@" | cut -d' ' -f1; }
|
||||
|
||||
expect_eq "$(code ollama)" needs-setup 'a shipped preset is set up before use'
|
||||
|
||||
ready_preset ollama "$OLLAMA_URL"
|
||||
expect_eq "$(code ollama)" ok 'ollama answers'
|
||||
cp "$P/ollama.json" "$P/dead.json"
|
||||
python3 "$J" set-url "$P/dead.json" http://127.0.0.1:1 >/dev/null
|
||||
expect_eq "$(code ollama dead)" server-unreachable 'a dead ollama is refused'
|
||||
|
||||
ready_preset lmstudio "$LMSTUDIO_URL"
|
||||
expect_eq "$(code lmstudio)" ok 'lm studio answers'
|
||||
|
||||
# custom: every tier empty first, then no address, then the key, then a proxy
|
||||
ready_preset custom
|
||||
expect_eq "$(preflight_code custom)" 'no-models edit-preset' 'a preset with no models is refused'
|
||||
python3 "$J" set-tier "$P/custom.json" opus gw/model-a
|
||||
expect_eq "$(preflight_code custom)" 'no-url set-url' 'a custom endpoint needs an address'
|
||||
python3 "$J" set-url "$P/custom.json" "$KEYED_URL" >/dev/null
|
||||
python3 "$J" set-auth "$P/custom.json" none >/dev/null
|
||||
expect_eq "$(code custom)" server-auth 'a keyed endpoint refuses the placeholder'
|
||||
cp "$P/custom.json" "$P/proxy.json"
|
||||
python3 "$J" set-url "$P/proxy.json" "$PROXY_URL" >/dev/null
|
||||
expect_eq "$(code custom proxy)" ok 'a proxy with no model list passes the lenient probe'
|
||||
|
||||
run_cm preset new b1 --provider openrouter --blank
|
||||
expect_eq "$(code openrouter b1)" no-models 'a blank preset cannot be switched to'
|
||||
|
||||
finish
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# Preset lifecycle: names, new/rename/rm, and the per-provider default.
|
||||
. "$(dirname "$0")/../lib.sh"
|
||||
|
||||
run_cm preset show ../../etc/passwd
|
||||
expect_rc 1 'a path is not a preset name'
|
||||
expect_out 'invalid preset name' 'and it says why'
|
||||
run_cm preset new .hidden
|
||||
expect_rc 1 'a leading dot is refused'
|
||||
run_cm preset new a/b
|
||||
expect_rc 1 'a slash is refused'
|
||||
|
||||
run_cm preset new cheap
|
||||
expect_rc 0 'new copies default'
|
||||
expect_eq "$(jget "$P/cheap.json" provider)" openrouter 'the copy keeps the provider'
|
||||
run_cm preset new z2 --provider zai
|
||||
expect_out "from 'zai'" "--provider copies that provider's default"
|
||||
run_cm preset new z3 --provider zai default
|
||||
expect_rc 1 'copying across providers is refused'
|
||||
run_cm preset new b1 --provider ollama --blank
|
||||
expect_eq "$(jget "$P/b1.json" auth.token)" ollama "a blank preset carries its provider's token"
|
||||
run_cm preset new b2 --blank
|
||||
expect_rc 1 '--blank needs --provider'
|
||||
run_cm preset new x --provider foo
|
||||
expect_rc 1 'an unknown provider is refused'
|
||||
run_cm preset set nope opus x
|
||||
expect_rc 1 'setting a tier on a missing preset is refused'
|
||||
expect_no_file "$P/nope.json" 'and does not create it'
|
||||
|
||||
run_cm preset rename cheap cheap2
|
||||
expect_file "$P/cheap2.json" 'rename moves the file'
|
||||
expect_no_file "$P/cheap.json" 'and removes the old name'
|
||||
run_cm preset rename cheap2 default
|
||||
expect_rc 1 'renaming onto an existing preset is refused'
|
||||
set_state openrouter cheap2
|
||||
run_cm preset rename cheap2 cheap3
|
||||
expect_eq "$(jget "$CM_ROOT/state.json" preset)" cheap3 'state follows the active preset'
|
||||
run_cm preset rm cheap3
|
||||
expect_rc 1 'the active preset cannot be deleted'
|
||||
set_state anthropic
|
||||
|
||||
run_cm preset default openrouter cheap3
|
||||
expect_eq "$("$CM" preset default openrouter)" cheap3 'a chosen default is used'
|
||||
run_cm preset rename cheap3 cheap4
|
||||
expect_eq "$(jget "$CM_ROOT/defaults.json" openrouter)" cheap4 'the choice follows a rename'
|
||||
run_cm preset rm cheap4
|
||||
expect_eq "$(jget "$CM_ROOT/defaults.json" openrouter)" '' 'deleting clears the choice'
|
||||
expect_eq "$("$CM" preset default openrouter)" default 'and the built-in applies again'
|
||||
run_cm preset default openrouter zai
|
||||
expect_rc 1 "a default from another provider is refused"
|
||||
|
||||
"$CM" preset new c5 >/dev/null 2>&1
|
||||
"$CM" preset default openrouter c5 >/dev/null 2>&1
|
||||
rm -f "$P/c5.json"
|
||||
expect_eq "$("$CM" preset default openrouter)" default 'a vanished choice falls back'
|
||||
|
||||
run_cm preset rm z2
|
||||
run_cm preset rm zai
|
||||
expect_out 'that was the last zai preset' 'removing the last preset warns'
|
||||
|
||||
finish
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# Every provider in providers.json is a mode: listed, dispatched, aliased.
|
||||
. "$(dirname "$0")/../lib.sh"
|
||||
|
||||
run_cm --help
|
||||
for id in openrouter zai lmstudio ollama custom; do
|
||||
expect_out "claude-mode $id [preset]" "usage lists $id"
|
||||
done
|
||||
|
||||
run_cm presets
|
||||
expect_out 'ollama' 'presets lists ollama'
|
||||
expect_out 'custom' 'presets lists custom'
|
||||
|
||||
for word in zai Z.AI z-ai lm-studio OLLAMA; do
|
||||
code="$(preflight_code "$word")"
|
||||
expect_eq "${code%% *}" needs-setup "preflight resolves '$word'"
|
||||
done
|
||||
|
||||
run_cm nope
|
||||
expect_rc 1 'an unknown command fails'
|
||||
expect_out "unknown command 'nope'" 'and says so'
|
||||
|
||||
run_cm preset default
|
||||
expect_out 'ollama ollama (built-in)' 'ollama has a built-in default'
|
||||
expect_out 'custom custom (built-in)' 'custom has a built-in default'
|
||||
|
||||
finish
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Broken transcripts: the scan, dismissing and restoring, the age rule, repair.
|
||||
. "$(dirname "$0")/../lib.sh"
|
||||
|
||||
# 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() { # <session-id> [age-in-hours]
|
||||
printf '%s\n' \
|
||||
'{"type":"assistant","message":{"id":"msg_1","model":"claude-opus-5","content":[{"type":"text","text":"hi"}]}}' \
|
||||
'{"type":"assistant","message":{"id":"gen-1-a","model":"deepseek/x","content":[{"type":"text","text":"yo"}]}}' \
|
||||
> "$proj/$1.jsonl"
|
||||
python3 -c 'import os, sys, time; t = time.time() - float(sys.argv[2]) * 3600; os.utime(sys.argv[1], (t, t))' \
|
||||
"$proj/$1.jsonl" "${2:-0}"
|
||||
}
|
||||
transcript aaaa-new
|
||||
transcript bbbb-old 240
|
||||
transcript dddd-fix 1
|
||||
printf '%s\n' '{"type":"assistant","message":{"id":"msg_1"}}' > "$proj/cccc-ok.jsonl"
|
||||
|
||||
ids() { # <broken|ignored> - the session ids in that list of the scan
|
||||
"$CM" repair-session --json "${@:2}" | python3 -c '
|
||||
import json, sys
|
||||
print(" ".join(x["sessionId"] for x in json.load(sys.stdin)[sys.argv[1]]))' "$1"
|
||||
}
|
||||
|
||||
expect_eq "$(ids broken)" 'aaaa-new dddd-fix' 'broken transcripts are found'
|
||||
expect_eq "$(ids ignored)" 'bbbb-old' 'one untouched for 10 days is hidden'
|
||||
expect_eq "$(ids broken --max-age 0)" 'aaaa-new bbbb-old dddd-fix' '--max-age 0 shows all'
|
||||
|
||||
run_cm repair-session --ignore aaaa-new
|
||||
expect_out 'hidden aaaa-new' 'a session is dismissed'
|
||||
expect_eq "$(ids broken)" 'dddd-fix' 'and leaves the broken list'
|
||||
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
|
||||
expect_eq "$(ids broken)" 'aaaa-new dddd-fix' 'restoring brings it back'
|
||||
|
||||
run_cm repair-session --ignore dddd-fix
|
||||
run_cm repair-session dddd-fix --apply
|
||||
expect_rc 0 'a broken session is repaired'
|
||||
expect_eq "$(ls "$proj" | grep -c 'dddd-fix.jsonl.pre-repair-backup')" 1 'the original is backed up'
|
||||
expect_eq "$(ids ignored)" 'bbbb-old' 'a repair clears its dismissal'
|
||||
|
||||
run_cm repair-session --ignore bbbb-old
|
||||
rm -f "$proj/bbbb-old.jsonl"
|
||||
run_cm repair-session --ignore aaaa-new
|
||||
expect_out 'forgot 1 whose transcript no longer exists' 'entries for deleted transcripts are pruned'
|
||||
run_cm repair-session --unignore-all
|
||||
expect_out 'restored every dismissed session' 'unignore-all empties the list'
|
||||
|
||||
finish
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# A real switch - into the sandbox's own settings.json - and editing the preset
|
||||
# in use. --yes because the machine running the tests may well have Claude Code
|
||||
# sessions open; with --yes the CLI proceeds and leaves them alone.
|
||||
. "$(dirname "$0")/../lib.sh"
|
||||
start_fake
|
||||
S="$CLAUDE_CONFIG_DIR/settings.json"
|
||||
|
||||
ready_preset ollama "$OLLAMA_URL"
|
||||
run_cm ollama --yes
|
||||
expect_rc 0 'switch to ollama'
|
||||
expect_eq "$(jget "$S" env.ANTHROPIC_BASE_URL)" "$OLLAMA_URL" 'base url written'
|
||||
expect_eq "$(jget "$S" env.ANTHROPIC_AUTH_TOKEN)" ollama 'placeholder token written'
|
||||
expect_eq "$(jget "$S" env.ANTHROPIC_DEFAULT_OPUS_MODEL)" qwen3-coder 'tier mapped'
|
||||
expect_eq "$(jget "$S" env.CLAUDE_CODE_MAX_CONTEXT_TOKENS)" 65536 'context declared'
|
||||
expect_eq "$(jget "$S" env.CLAUDE_CODE_ATTRIBUTION_HEADER)" 0 'extra env written'
|
||||
expect_eq "$(jget "$CM_ROOT/state.json" mode)" ollama 'state records the mode'
|
||||
expect_eq "$(jget "$CM_ROOT/health.json" preset)" ollama 'health.json follows'
|
||||
|
||||
run_cm anthropic --yes
|
||||
expect_rc 0 'switch back to anthropic'
|
||||
expect_eq "$(jget "$S" env.ANTHROPIC_BASE_URL)" '' 'gateway env removed'
|
||||
expect_eq "$(jget "$S" env.CLAUDE_CODE_ATTRIBUTION_HEADER)" '' 'including the extra env'
|
||||
|
||||
# The preset in use: a tier edit re-applies without asking about sessions.
|
||||
set_state openrouter default
|
||||
run_cm preset set default sonnet test/model-c --force
|
||||
expect_rc 0 'a tier edit on the active preset re-applies'
|
||||
expect_no_out 'refusing to switch while sessions are running' 'without the sessions prompt'
|
||||
expect_eq "$(jget "$S" env.ANTHROPIC_DEFAULT_SONNET_MODEL)" test/model-c 'and reaches settings.json'
|
||||
|
||||
run_cm preset set default opus anthropic/claude-opus-5 --force
|
||||
expect_rc 1 'the cost guard still refuses an Anthropic model'
|
||||
expect_out 'saved, but re-applying the active preset failed' 'and says the edit was saved'
|
||||
|
||||
finish
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fake provider servers for the CLI and Windows tests, all on 127.0.0.1.
|
||||
|
||||
ollama GET /api/tags tagged names only, as the real server lists them
|
||||
GET /api/ps qwen3-coder:latest loaded with a 4096-token context
|
||||
POST /api/show a 262144-token maximum
|
||||
lmstudio GET /api/v0/models
|
||||
keyed GET /v1/models, only with `Bearer sk-real` or `x-api-key: sk-real`
|
||||
proxy 404 for everything - a Messages-only proxy with no model list
|
||||
|
||||
Ports are chosen by the OS. The first line printed is a JSON map of kind to
|
||||
port, which the harness reads before running anything.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def send(self, code, obj):
|
||||
body = json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("content-type", "application/json")
|
||||
self.send_header("content-length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
kind, path = self.server.kind, self.path
|
||||
if kind == "ollama" and path == "/api/tags":
|
||||
return self.send(200, {"models": [
|
||||
{"name": "qwen3-coder:latest", "model": "qwen3-coder:latest",
|
||||
"details": {"parameter_size": "30.5B", "quantization_level": "Q4_K_M", "family": "qwen3moe"}},
|
||||
{"name": "glm-4.7:cloud", "model": "glm-4.7:cloud", "details": {}}]})
|
||||
if kind == "ollama" and path == "/api/ps":
|
||||
return self.send(200, {"models": [
|
||||
{"name": "qwen3-coder:latest", "model": "qwen3-coder:latest", "context_length": 4096}]})
|
||||
if kind == "lmstudio" and path == "/api/v0/models":
|
||||
return self.send(200, {"data": [
|
||||
{"id": "qwen3-coder-30b", "state": "loaded", "max_context_length": 262144},
|
||||
{"id": "gemma-small", "state": "not-loaded", "max_context_length": 8192}]})
|
||||
if kind == "keyed" and path == "/v1/models":
|
||||
if self.headers.get("authorization") == "Bearer sk-real" or self.headers.get("x-api-key") == "sk-real":
|
||||
return self.send(200, {"data": [{"id": "gw/model-a"}, {"id": "gw/model-b"}]})
|
||||
return self.send(401, {"error": "unauthorized"})
|
||||
return self.send(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
self.rfile.read(int(self.headers.get("content-length") or 0))
|
||||
if self.server.kind == "ollama" and self.path == "/api/show":
|
||||
return self.send(200, {"model_info": {"general.architecture": "qwen3moe",
|
||||
"qwen3moe.context_length": 262144}})
|
||||
return self.send(404, {"error": "not found"})
|
||||
|
||||
|
||||
def main():
|
||||
ports = {}
|
||||
for kind in ("ollama", "lmstudio", "keyed", "proxy"):
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
server.kind = kind
|
||||
ports[kind] = server.server_address[1]
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
print(json.dumps(ports), flush=True)
|
||||
threading.Event().wait()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
# Shared by tests/cli/test_*.sh - source it first.
|
||||
#
|
||||
# Sourcing gives the test its own sandbox: a HOME, CM_ROOT and CLAUDE_CONFIG_DIR
|
||||
# of its own, the repository's scripts installed into it the way linux/install.sh
|
||||
# lays them out, and the vault forced to the plain-file backend. Nothing on the
|
||||
# machine running the tests is read or written - not the real keyring, not
|
||||
# ~/.claude-mode, not ~/.claude/settings.json - and a switch performed by a test
|
||||
# only rewrites the sandbox's settings.json.
|
||||
#
|
||||
# Assertions count rather than stop, so one run reports every failure; the
|
||||
# file ends with `finish`, whose status is the test's.
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
T_NAME="$(basename "$0" .sh)"
|
||||
T_PASS=0
|
||||
T_FAIL=0
|
||||
OUT=''
|
||||
RC=0
|
||||
FAKE_PID=''
|
||||
|
||||
SB="$(mktemp -d "${TMPDIR:-/tmp}/cm-test.XXXXXX")"
|
||||
mkdir -p "$SB/root/bin" "$SB/root/presets" "$SB/home/.claude/projects"
|
||||
cp "$REPO/linux/claude-mode" "$REPO/linux/cm-json.py" "$REPO/linux/cm-vault.sh" \
|
||||
"$REPO/linux/claude-key-helper.sh" "$SB/root/bin/"
|
||||
if [ -d "$REPO/linux/lib" ]; then cp -R "$REPO/linux/lib" "$SB/root/bin/"; fi
|
||||
chmod +x "$SB/root/bin/claude-mode" "$SB/root/bin/claude-key-helper.sh"
|
||||
cp "$REPO"/presets/*.json "$SB/root/presets/"
|
||||
cp "$REPO/providers.json" "$REPO/VERSION" "$SB/root/"
|
||||
|
||||
export HOME="$SB/home"
|
||||
export CM_ROOT="$SB/root"
|
||||
export CLAUDE_CONFIG_DIR="$SB/home/.claude"
|
||||
export CLAUDE_MODE_VAULT=file
|
||||
unset CM_IGNORE_AGE_DAYS CLAUDE_MODE_THEME
|
||||
|
||||
CM="$SB/root/bin/claude-mode"
|
||||
J="$SB/root/bin/cm-json.py"
|
||||
P="$SB/root/presets"
|
||||
|
||||
set_state() {
|
||||
printf '{"mode":"%s","preset":"%s","writtenEnvKeys":[]}\n' "$1" "${2:-}" > "$CM_ROOT/state.json"
|
||||
}
|
||||
set_state anthropic
|
||||
|
||||
t_cleanup() {
|
||||
if [ -n "$FAKE_PID" ]; then kill "$FAKE_PID" 2>/dev/null; fi
|
||||
rm -rf "$SB"
|
||||
}
|
||||
trap t_cleanup EXIT
|
||||
|
||||
# Starts tests/fake_server.py and sets OLLAMA_URL, LMSTUDIO_URL, KEYED_URL and
|
||||
# PROXY_URL from the ports it chose.
|
||||
start_fake() {
|
||||
python3 "$REPO/tests/fake_server.py" > "$SB/ports" 2>/dev/null &
|
||||
FAKE_PID=$!
|
||||
local i
|
||||
for i in $(seq 1 50); do
|
||||
[ -s "$SB/ports" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[ -s "$SB/ports" ] || { echo "$T_NAME: the fake server did not start" >&2; exit 1; }
|
||||
eval "$(python3 -c '
|
||||
import json, sys
|
||||
for kind, port in json.load(open(sys.argv[1])).items():
|
||||
print("%s_URL=http://127.0.0.1:%d" % (kind.upper(), port))
|
||||
' "$SB/ports")"
|
||||
}
|
||||
|
||||
ESC="$(printf '\033')"
|
||||
|
||||
# run_cm <args...>: the CLI's combined output, colour codes removed, in OUT;
|
||||
# its exit status in RC.
|
||||
run_cm() {
|
||||
OUT="$("$CM" "$@" 2>&1)"
|
||||
RC=$?
|
||||
OUT="$(printf '%s' "$OUT" | sed "s/${ESC}\[[0-9;]*m//g")"
|
||||
}
|
||||
|
||||
pass() { T_PASS=$((T_PASS + 1)); }
|
||||
|
||||
fail() {
|
||||
T_FAIL=$((T_FAIL + 1))
|
||||
printf ' FAIL %s: %s\n' "$T_NAME" "$*"
|
||||
if [ -n "$OUT" ]; then printf '%s\n' "$OUT" | tail -n 8 | sed 's/^/ | /'; fi
|
||||
}
|
||||
|
||||
expect_rc() { if [ "$RC" -eq "$1" ]; then pass; else fail "$2 (exit $RC, wanted $1)"; fi; }
|
||||
expect_out() { case "$OUT" in *"$1"*) pass ;; *) fail "$2 (output lacks: $1)" ;; esac; }
|
||||
expect_no_out() { case "$OUT" in *"$1"*) fail "$2 (output has: $1)" ;; *) pass ;; esac; }
|
||||
expect_eq() { if [ "$1" = "$2" ]; then pass; else fail "$3 (got '$1', wanted '$2')"; fi; }
|
||||
expect_file() { if [ -e "$1" ]; then pass; else fail "$2 (no $1)"; fi; }
|
||||
expect_no_file(){ if [ -e "$1" ]; then fail "$2 ($1 exists)"; else pass; fi; }
|
||||
|
||||
jget() { python3 "$J" get "$1" "$2"; }
|
||||
|
||||
# The preflight verdict for a mode (and preset) as "<code> <remedyKind>".
|
||||
preflight_code() {
|
||||
"$CM" preflight "$@" 2>/dev/null | python3 -c '
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
print(d["code"], d["remedyKind"])'
|
||||
}
|
||||
|
||||
# Point a shipped preset at a fake server and mark it set up, so preflight
|
||||
# gets past "not set up yet" to the checks under test.
|
||||
ready_preset() {
|
||||
python3 "$J" set-flag "$P/$1.json" configured true
|
||||
[ -n "${2:-}" ] && python3 "$J" set-url "$P/$1.json" "$2" >/dev/null
|
||||
return 0
|
||||
}
|
||||
|
||||
finish() {
|
||||
printf '%-22s %3d passed' "$T_NAME" "$T_PASS"
|
||||
if [ "$T_FAIL" -gt 0 ]; then printf ', %d FAILED' "$T_FAIL"; fi
|
||||
printf '\n'
|
||||
[ "$T_FAIL" -eq 0 ]
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Unit tests for linux/cm-json.py - the JSON engine behind the POSIX CLI.
|
||||
|
||||
Standard library only. cm-json.py is loaded from the repository, so it reads the
|
||||
repository's providers.json (it looks one level above itself). Every test that
|
||||
writes works in a temporary directory.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
_spec = importlib.util.spec_from_file_location("cm_json", os.path.join(REPO, "linux", "cm-json.py"))
|
||||
cm = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(cm)
|
||||
|
||||
|
||||
def call(fn, argv, stdin=""):
|
||||
"""Run a cm-json command function; return what it printed."""
|
||||
out, old = io.StringIO(), sys.stdin
|
||||
sys.stdin = io.StringIO(stdin)
|
||||
try:
|
||||
with contextlib.redirect_stdout(out):
|
||||
fn(argv)
|
||||
finally:
|
||||
sys.stdin = old
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def call_json(fn, argv, stdin=""):
|
||||
return json.loads(call(fn, argv, stdin))
|
||||
|
||||
|
||||
class TempDir(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="cm-json-test.")
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def path(self, *parts):
|
||||
return os.path.join(self.tmp, *parts)
|
||||
|
||||
def write_json(self, rel, data):
|
||||
p = self.path(rel)
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
with open(p, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh)
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# providers.json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Providers(unittest.TestCase):
|
||||
def test_order(self):
|
||||
self.assertEqual([p["id"] for p in cm.providers()],
|
||||
["openrouter", "zai", "lmstudio", "ollama", "custom"])
|
||||
|
||||
def test_builtin_defaults(self):
|
||||
self.assertEqual(cm.builtin_default_preset(), {
|
||||
"openrouter": "default", "zai": "zai", "lmstudio": "lmstudio",
|
||||
"ollama": "ollama", "custom": "custom"})
|
||||
|
||||
def test_tsv_column_contract(self):
|
||||
# The bash CLI addresses these columns by number (see prov_field in
|
||||
# linux/claude-mode). Reordering them silently breaks it; append only.
|
||||
self.assertEqual([name for name, _ in cm.PROVIDER_TSV], [
|
||||
"id", "aliases", "title", "label", "color", "defaultPreset",
|
||||
"serverEditable", "probe", "probePaths", "catalogueKind", "perServer",
|
||||
"setupKey", "setupModels", "keyUrl", "guardrail", "doctor",
|
||||
"defaultKeyRef", "literalToken", "defaultBaseUrl", "serverHint", "serverStart"])
|
||||
for line in call(cm.cmd_provider_tsv, []).splitlines():
|
||||
self.assertEqual(len(line.split("\t")), len(cm.PROVIDER_TSV), line)
|
||||
|
||||
def test_resolve(self):
|
||||
for word, want in [("zai", "zai"), ("Z.AI", "zai"), ("z-ai", "zai"),
|
||||
("lm-studio", "lmstudio"), ("OLLAMA", "ollama")]:
|
||||
self.assertEqual(call(cm.cmd_provider_resolve, [word]).strip(), want)
|
||||
for word in ("anthropic", "nope", ""):
|
||||
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")
|
||||
|
||||
|
||||
class Scaffold(unittest.TestCase):
|
||||
def blank(self, **over):
|
||||
base = {"provider": None, "description": "new preset", "baseUrl": None, "auth": None,
|
||||
"models": {t: "" for t in cm.TIERS}, "subagentModel": "inherit",
|
||||
"gatewayModelDiscovery": False, "contextTokens": None}
|
||||
base.update(over)
|
||||
return json.dumps(base, indent=2) + "\n"
|
||||
|
||||
# The original three, exactly as the hard-coded scaffold produced them
|
||||
# before providers.json existed.
|
||||
def test_openrouter(self):
|
||||
self.assertEqual(call(cm.cmd_scaffold, ["openrouter"]), self.blank(
|
||||
provider="openrouter", baseUrl="https://openrouter.ai/api",
|
||||
auth={"mode": "vault", "keyRef": "openrouter"}, gatewayModelDiscovery=True,
|
||||
contextTokens=1000000))
|
||||
|
||||
def test_zai(self):
|
||||
want = json.loads(self.blank(
|
||||
provider="zai", baseUrl="https://api.z.ai/api/anthropic",
|
||||
auth={"mode": "vault", "keyRef": "zai"}, contextTokens=1000000))
|
||||
want["extraEnv"] = {"API_TIMEOUT_MS": "3000000", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"}
|
||||
self.assertEqual(call(cm.cmd_scaffold, ["zai"]), json.dumps(want, indent=2) + "\n")
|
||||
|
||||
def test_lmstudio(self):
|
||||
want = json.loads(self.blank(
|
||||
provider="lmstudio", baseUrl="http://127.0.0.1:1234",
|
||||
auth={"mode": "literal", "token": "lmstudio"}, contextTokens=262144))
|
||||
want["extraEnv"] = {"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"}
|
||||
self.assertEqual(call(cm.cmd_scaffold, ["lmstudio"]), json.dumps(want, indent=2) + "\n")
|
||||
|
||||
def test_new_providers(self):
|
||||
o = json.loads(call(cm.cmd_scaffold, ["ollama"]))
|
||||
self.assertEqual((o["baseUrl"], o["auth"], o["contextTokens"]),
|
||||
("http://127.0.0.1:11434", {"mode": "literal", "token": "ollama"}, 65536))
|
||||
c = json.loads(call(cm.cmd_scaffold, ["custom"]))
|
||||
self.assertEqual((c["baseUrl"], c["auth"]), ("", {"mode": "vault", "keyRef": "custom"}))
|
||||
|
||||
def test_unknown(self):
|
||||
with self.assertRaises(SystemExit):
|
||||
call(cm.cmd_scaffold, ["nope"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalogues
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Parsers(unittest.TestCase):
|
||||
def test_openrouter(self):
|
||||
src = {"data": [{"id": "b/x", "context_length": 1000, "pricing": {"prompt": "0.000001", "completion": "0.000002"}},
|
||||
{"id": "a/y", "context_length": 5, "pricing": {}}]}
|
||||
self.assertEqual(call(cm.cmd_or_models, [], json.dumps(src)).splitlines(),
|
||||
["a/y\t5\t\t", "b/x\t1000\t1.0\t2.0"])
|
||||
|
||||
def test_lmstudio(self):
|
||||
src = {"data": [{"id": "m", "state": "loaded", "max_context_length": 8192}]}
|
||||
self.assertEqual(call(cm.cmd_lms_models, [], json.dumps(src)), "m\tloaded\t8192\n")
|
||||
|
||||
def test_ollama_placeholders(self):
|
||||
# "-" rather than empty, so bash's tab-IFS read cannot merge columns.
|
||||
src = {"models": [{"name": "glm:cloud", "details": {}},
|
||||
{"name": "q:latest", "details": {"parameter_size": "7B", "quantization_level": "Q4"}}]}
|
||||
self.assertEqual(call(cm.cmd_ollama_models, [], json.dumps(src)).splitlines(),
|
||||
["glm:cloud\t-\t-\t-", "q:latest\t7B\tQ4\t-"])
|
||||
|
||||
def test_openai_both_shapes(self):
|
||||
self.assertEqual(call(cm.cmd_openai_models, [], json.dumps({"data": [{"id": "b"}, {"id": "a"}]})), "a\nb\n")
|
||||
self.assertEqual(call(cm.cmd_openai_models, [], json.dumps([{"id": "x"}])), "x\n")
|
||||
|
||||
|
||||
class Cache(TempDir):
|
||||
def cache(self, provider, ok, base, tsv):
|
||||
call(cm.cmd_cache_models, [self.path("c.json"), provider, "1" if ok else "0", base], tsv)
|
||||
with open(self.path("c.json")) as fh:
|
||||
return json.load(fh)["providers"][provider]
|
||||
|
||||
def test_per_server_and_failure(self):
|
||||
node = self.cache("ollama", True, "http://a:1/", "q:latest\t7B\tQ4\t-\n")
|
||||
self.assertEqual((node["baseUrl"], node["models"]), ("http://a:1", [{"id": "q:latest", "note": "7B Q4"}]))
|
||||
node = self.cache("ollama", False, "http://a:1", "")
|
||||
self.assertEqual((node["ok"], len(node["models"])), (False, 1)) # a stale list beats none
|
||||
node = self.cache("ollama", False, "http://b:2", "")
|
||||
self.assertEqual((node["baseUrl"], node["models"]), ("http://b:2", [])) # another server's list is wrong
|
||||
|
||||
def test_openrouter_prices_and_no_secrets(self):
|
||||
node = self.cache("openrouter", True, "", "a/y\t5\t0.1\t0.2\n")
|
||||
self.assertEqual(node["models"], [{"id": "a/y", "contextTokens": 5, "priceIn": 0.1, "priceOut": 0.2}])
|
||||
self.assertNotIn("baseUrl", node)
|
||||
with open(self.path("c.json")) as fh:
|
||||
text = fh.read()
|
||||
for word in ("keyRef", "token", "auth"):
|
||||
self.assertNotIn('"%s"' % word, text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session transcripts: scan, dismiss, repair
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GOOD = {"type": "assistant", "message": {"id": "msg_1", "model": "claude-opus-5", "content": [{"type": "text", "text": "hi"}]}}
|
||||
FOREIGN = {"type": "assistant", "message": {"id": "gen-1-a", "model": "deepseek/x", "content": [{"type": "text", "text": "yo"}]}}
|
||||
|
||||
|
||||
class Sessions(TempDir):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.projects = self.path("projects")
|
||||
os.makedirs(os.path.join(self.projects, "-p"))
|
||||
self.ignored = self.path("ignored.json")
|
||||
|
||||
def transcript(self, sid, lines, age_days=0):
|
||||
p = os.path.join(self.projects, "-p", sid + ".jsonl")
|
||||
with open(p, "w") as fh:
|
||||
fh.write("\n".join(json.dumps(x) for x in lines) + "\n")
|
||||
t = time.time() - age_days * 86400
|
||||
os.utime(p, (t, t))
|
||||
return p
|
||||
|
||||
def scan(self, max_age=""):
|
||||
return call_json(cm.cmd_scan_sessions, [self.projects, max_age, self.ignored])
|
||||
|
||||
def test_scan_routes_by_reason(self):
|
||||
self.transcript("new", [GOOD, FOREIGN])
|
||||
self.transcript("old", [GOOD, FOREIGN], age_days=10)
|
||||
self.transcript("healthy", [GOOD])
|
||||
self.transcript("native", [FOREIGN]) # ran entirely on a gateway: not damage
|
||||
d = self.scan()
|
||||
self.assertEqual([b["sessionId"] for b in d["broken"]], ["new"])
|
||||
self.assertEqual([(i["sessionId"], i["reason"]) for i in d["ignored"]], [("old", "stale")])
|
||||
self.assertEqual(self.scan("0")["count"], 2)
|
||||
|
||||
def test_dismiss_list_prune(self):
|
||||
self.transcript("new", [GOOD, FOREIGN])
|
||||
gone = self.transcript("gone", [GOOD, FOREIGN])
|
||||
call(cm.cmd_ignore_session, [self.ignored, self.projects, "add", "new"])
|
||||
call(cm.cmd_ignore_session, [self.ignored, self.projects, "add", "gone"])
|
||||
self.assertEqual([(i["sessionId"], i["reason"]) for i in self.scan()["ignored"]],
|
||||
[("gone", "dismissed"), ("new", "dismissed")])
|
||||
rows = call_json(cm.cmd_ignore_session, [self.ignored, self.projects, "list"])["sessions"]
|
||||
self.assertTrue(all(r["broken"] and r["exists"] for r in rows))
|
||||
os.remove(gone)
|
||||
out = call_json(cm.cmd_ignore_session, [self.ignored, self.projects, "remove", "new"])
|
||||
self.assertEqual((out["changed"], out["pruned"], out["count"]), (True, 1, 0))
|
||||
with self.assertRaises(SystemExit):
|
||||
call(cm.cmd_ignore_session, [self.ignored, self.projects, "add", "../x"])
|
||||
with self.assertRaises(SystemExit):
|
||||
call(cm.cmd_ignore_session, [self.ignored, self.projects, "add", "nosuch"])
|
||||
|
||||
def test_repair_truncates_and_hands_back(self):
|
||||
p = self.transcript("fix", [GOOD, FOREIGN])
|
||||
v = call_json(cm.cmd_repair_session, [p, "--apply"])
|
||||
self.assertTrue(v["applied"] and v["reinjected"])
|
||||
self.assertTrue(os.path.exists(v["backup"]) and os.path.exists(v["recovered"]))
|
||||
with open(p) as fh:
|
||||
lines = [json.loads(x) for x in fh.read().splitlines()]
|
||||
self.assertEqual(lines[0]["message"]["id"], "msg_1")
|
||||
self.assertTrue(lines[1]["isMeta"])
|
||||
self.assertIn("<recovered-transcript>", lines[1]["message"]["content"])
|
||||
self.assertTrue(call_json(cm.cmd_repair_session, [p])["healthy"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Presets: rename, defaults, auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Presets(TempDir):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.presets = self.path("presets")
|
||||
shutil.copytree(os.path.join(REPO, "presets"), self.presets)
|
||||
self.state = self.write_json("state.json", {"mode": "openrouter", "preset": "default", "writtenEnvKeys": []})
|
||||
self.defaults = self.write_json("defaults.json", {"openrouter": "default"})
|
||||
|
||||
def load(self, p):
|
||||
with open(p) as fh:
|
||||
return json.load(fh)
|
||||
|
||||
def test_rename_repoints_state_and_default(self):
|
||||
out = call_json(cm.cmd_preset_rename, [self.presets, "default", "daily", self.state, self.defaults])
|
||||
self.assertEqual(out, {"renamed": True, "active": True, "default": True})
|
||||
self.assertEqual(self.load(self.state)["preset"], "daily")
|
||||
self.assertEqual(self.load(self.defaults)["openrouter"], "daily")
|
||||
self.assertFalse(os.path.exists(os.path.join(self.presets, "default.json")))
|
||||
with self.assertRaises(SystemExit):
|
||||
call(cm.cmd_preset_rename, [self.presets, "daily", "zai", self.state])
|
||||
with self.assertRaises(SystemExit):
|
||||
call(cm.cmd_preset_rename, [self.presets, "nosuch", "x", self.state])
|
||||
|
||||
def test_set_default_and_clear(self):
|
||||
call(cm.cmd_set_default, [self.defaults, "zai", "zai"])
|
||||
call(cm.cmd_set_default, [self.defaults, "openrouter", ""])
|
||||
self.assertEqual(self.load(self.defaults), {"zai": "zai"})
|
||||
|
||||
def test_auth_of(self):
|
||||
self.assertEqual(call(cm.cmd_auth_of, [os.path.join(self.presets, "zai.json")]), "vault\tzai\n")
|
||||
self.assertEqual(call(cm.cmd_auth_of, [os.path.join(self.presets, "ollama.json")]), "literal\topenrouter\n")
|
||||
with self.assertRaises(SystemExit) as e:
|
||||
call(cm.cmd_auth_of, [os.path.join(self.presets, "nosuch.json")])
|
||||
self.assertEqual(e.exception.code, 1)
|
||||
|
||||
def test_set_auth_uses_the_providers_names(self):
|
||||
p = os.path.join(self.presets, "ollama.json")
|
||||
call(cm.cmd_set_auth, [p, "key"])
|
||||
self.assertEqual(self.load(p)["auth"], {"mode": "vault", "keyRef": "ollama"})
|
||||
call(cm.cmd_set_auth, [p, "none"])
|
||||
self.assertEqual(self.load(p)["auth"], {"mode": "literal", "token": "ollama"})
|
||||
|
||||
|
||||
class Health(Presets):
|
||||
def health(self, mode="anthropic", preset=""):
|
||||
call(cm.cmd_health, [self.tmp, "/nonexistent", mode, preset, "", "file", "9.9.9"])
|
||||
return self.load(self.path("health.json"))
|
||||
|
||||
def test_effective_defaults(self):
|
||||
self.assertEqual(self.health()["defaultPresetFor"]["openrouter"], "default")
|
||||
shutil.copy(os.path.join(self.presets, "default.json"), os.path.join(self.presets, "cheap.json"))
|
||||
self.write_json("defaults.json", {"openrouter": "cheap"})
|
||||
h = self.health()
|
||||
self.assertEqual((h["defaultPresetFor"]["openrouter"], h["defaultPresetChosen"]), ("cheap", {"openrouter": "cheap"}))
|
||||
os.remove(os.path.join(self.presets, "cheap.json")) # a vanished choice falls back
|
||||
self.assertEqual(self.health()["defaultPresetFor"]["openrouter"], "default")
|
||||
os.remove(os.path.join(self.presets, "default.json")) # and with the built-in gone, the first by name
|
||||
shutil.copy(os.path.join(self.presets, "zai.json"), os.path.join(self.presets, "alpha.json"))
|
||||
alpha = self.load(os.path.join(self.presets, "alpha.json"))
|
||||
alpha["provider"] = "openrouter"
|
||||
self.write_json("presets/alpha.json", alpha)
|
||||
self.assertEqual(self.health()["defaultPresetFor"]["openrouter"], "alpha")
|
||||
|
||||
def test_publishes_providers(self):
|
||||
h = self.health()
|
||||
self.assertEqual(h["version"], "9.9.9")
|
||||
byid = {p["id"]: p for p in h["providers"]}
|
||||
self.assertEqual(list(byid), ["openrouter", "zai", "lmstudio", "ollama", "custom"])
|
||||
self.assertTrue(byid["ollama"]["serverEditable"] and byid["ollama"]["perServerCatalogue"])
|
||||
self.assertFalse(byid["zai"]["serverEditable"])
|
||||
self.assertEqual(byid["custom"]["defaultBaseUrl"], "")
|
||||
self.assertTrue(byid["openrouter"]["logo"])
|
||||
|
||||
|
||||
class OllamaCtx(unittest.TestCase):
|
||||
def test_show(self):
|
||||
src = {"model_info": {"general.architecture": "q", "q.context_length": 262144}}
|
||||
self.assertEqual(call(cm.cmd_ollama_ctx, ["show"], json.dumps(src)), "262144\n")
|
||||
|
||||
def test_ps_matches_latest(self):
|
||||
src = {"models": [{"name": "qwen3-coder:latest", "model": "qwen3-coder:latest", "context_length": 4096}]}
|
||||
self.assertEqual(call(cm.cmd_ollama_ctx, ["ps", "qwen3-coder"], json.dumps(src)), "4096\n")
|
||||
self.assertEqual(call(cm.cmd_ollama_ctx, ["ps", "other"], json.dumps(src)), "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# apply - the write into settings.json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Apply(TempDir):
|
||||
def preset(self, **over):
|
||||
base = {"provider": "openrouter", "baseUrl": "https://gw", "auth": {"mode": "vault", "keyRef": "openrouter"},
|
||||
"models": {"opus": "x/opus", "sonnet": "x/sonnet"}, "contextTokens": 1000}
|
||||
base.update(over)
|
||||
return self.write_json("p.json", base)
|
||||
|
||||
def apply(self, mode, preset="", helper="/opt/key helper/h.sh"):
|
||||
return call(cm.cmd_apply, [self.path("settings.json"), self.path("state.json"), mode, preset, helper])
|
||||
|
||||
def settings(self):
|
||||
with open(self.path("settings.json")) as fh:
|
||||
return json.load(fh)
|
||||
|
||||
def test_writes_env_and_quotes_the_helper(self):
|
||||
self.apply("openrouter", self.preset())
|
||||
s = self.settings()
|
||||
self.assertEqual(s["env"]["ANTHROPIC_BASE_URL"], "https://gw")
|
||||
self.assertEqual(s["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"], "x/opus")
|
||||
self.assertEqual(s["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"], "1000")
|
||||
self.assertEqual(s["env"]["ANTHROPIC_API_KEY"], "")
|
||||
self.assertEqual(s["apiKeyHelper"], "'/opt/key helper/h.sh'")
|
||||
|
||||
def test_cost_guard(self):
|
||||
with self.assertRaises(SystemExit) as e:
|
||||
self.apply("openrouter", self.preset(models={"opus": "anthropic/claude-opus-5"}))
|
||||
self.assertIn("refusing to switch", str(e.exception))
|
||||
self.apply("openrouter", self.preset(models={"opus": "anthropic/claude-opus-5"}, allowAnthropicModels=True))
|
||||
|
||||
def test_extra_env_is_removed_on_the_way_out(self):
|
||||
self.apply("openrouter", self.preset(extraEnv={"SOME_CUSTOM_KEY": "1"}))
|
||||
self.assertIn("SOME_CUSTOM_KEY", self.settings()["env"])
|
||||
self.apply("anthropic")
|
||||
s = self.settings()
|
||||
self.assertNotIn("env", s)
|
||||
self.assertNotIn("apiKeyHelper", s)
|
||||
|
||||
def test_literal_token_and_provider_check(self):
|
||||
self.apply("ollama", self.preset(provider="ollama", auth={"mode": "literal", "token": "ollama"}))
|
||||
self.assertEqual(self.settings()["env"]["ANTHROPIC_AUTH_TOKEN"], "ollama")
|
||||
with self.assertRaises(SystemExit):
|
||||
self.apply("zai", self.preset())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+184
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env bash
|
||||
# Checks that need nothing but the tree: script syntax, JSON validity, the
|
||||
# version recorded in both places, and providers.json against the kinds of
|
||||
# behaviour the code knows. qmllint and shellcheck run when installed and are
|
||||
# skipped - out loud - when not.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
fails=0
|
||||
err="$(mktemp)"
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$err" "$tmp"' EXIT
|
||||
|
||||
check() {
|
||||
local what="$1"; shift
|
||||
if "$@" >"$err" 2>&1; then
|
||||
printf ' ok %s\n' "$what"
|
||||
else
|
||||
printf ' FAIL %s\n' "$what"
|
||||
sed 's/^/ | /' "$err" | head -n 20
|
||||
fails=$((fails + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
bash_syntax() {
|
||||
local f
|
||||
for f in linux/claude-mode linux/*.sh linux/lib/*.sh omarchy/install.sh scripts/*.sh \
|
||||
tests/*.sh tests/cli/*.sh tests/windows/*.sh; do
|
||||
[ -e "$f" ] || continue
|
||||
bash -n "$f" || { echo "$f"; return 1; }
|
||||
done
|
||||
}
|
||||
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
|
||||
|
||||
check 'JSON files parse' python3 -c '
|
||||
import json, sys, glob
|
||||
for f in ["providers.json", "omarchy/smoido.claude-mode/manifest.json"] + glob.glob("presets/*.json"):
|
||||
try:
|
||||
json.load(open(f, encoding="utf-8"))
|
||||
except ValueError as e:
|
||||
sys.exit("%s: %s" % (f, e))'
|
||||
|
||||
check 'VERSION matches the widget manifest' python3 -c '
|
||||
import json
|
||||
v = open("VERSION").read().strip()
|
||||
m = json.load(open("omarchy/smoido.claude-mode/manifest.json"))["version"]
|
||||
assert v == m, "VERSION is %s but manifest.json says %s - use scripts/bump-version.sh" % (v, m)'
|
||||
|
||||
check 'CHANGELOG has an entry for VERSION' python3 -c '
|
||||
v = open("VERSION").read().strip()
|
||||
assert ("## %s " % v) in open("CHANGELOG.md", encoding="utf-8").read(), "no CHANGELOG.md entry for " + v'
|
||||
|
||||
# The values each field may take are the ones the code has a branch for. A new
|
||||
# kind of behaviour needs code in cm-json.py, the bash CLI and claude-mode.ps1
|
||||
# before it can appear here - this is where that is enforced.
|
||||
check 'providers.json is consistent with the code and the presets' python3 - <<'PY'
|
||||
import json, os, sys
|
||||
KINDS = {"openrouter", "lmstudio", "ollama", "openai", "static"}
|
||||
PROBES = {"always", "lenient", "local"}
|
||||
KEYS = {"required", "optional"}
|
||||
MODELS = {"per-tier", "one-for-all"}
|
||||
CHECKS = {"openrouter-key", "guardrail", "message-check", "catalogue-models",
|
||||
"ollama-context", "lmstudio-templates"}
|
||||
COLORS = {"cyan", "green", "yellow", "magenta", "white", "gray", "dkcyan"}
|
||||
COMMANDS = {"menu", "status", "anthropic", "presets", "preset", "set-key", "models",
|
||||
"doctor", "health", "preflight", "setup", "sessions", "repair-session",
|
||||
"repair", "help"}
|
||||
errors = []
|
||||
def bad(pid, msg): errors.append("%s: %s" % (pid, msg))
|
||||
|
||||
doc = json.load(open("providers.json", encoding="utf-8"))
|
||||
seen = {}
|
||||
for p in doc["providers"]:
|
||||
pid = p.get("id", "?")
|
||||
for name in [pid] + list(p.get("aliases") or []):
|
||||
if name in COMMANDS:
|
||||
bad(pid, "'%s' would shadow the '%s' command" % (name, name))
|
||||
if name in seen:
|
||||
bad(pid, "'%s' is already used by %s" % (name, seen[name]))
|
||||
seen[name] = pid
|
||||
for field in ("title", "label", "blurb", "defaultPreset", "preset", "catalogue", "setup", "logo"):
|
||||
if not p.get(field):
|
||||
bad(pid, "missing " + field)
|
||||
cat, srv, setup = p.get("catalogue") or {}, p.get("server") or {}, p.get("setup") or {}
|
||||
if cat.get("kind") not in KINDS:
|
||||
bad(pid, "catalogue.kind %r is not one of %s" % (cat.get("kind"), sorted(KINDS)))
|
||||
if cat.get("kind") == "static" and not cat.get("static"):
|
||||
bad(pid, "a static catalogue needs a static list")
|
||||
if srv.get("probe", "local") not in PROBES:
|
||||
bad(pid, "server.probe %r is not one of %s" % (srv.get("probe"), sorted(PROBES)))
|
||||
if srv.get("probe") in ("always", "lenient") and not srv.get("paths"):
|
||||
bad(pid, "a probed server needs server.paths")
|
||||
if setup.get("key", "required") not in KEYS:
|
||||
bad(pid, "setup.key %r is not one of %s" % (setup.get("key"), sorted(KEYS)))
|
||||
if setup.get("models", "per-tier") not in MODELS:
|
||||
bad(pid, "setup.models %r is not one of %s" % (setup.get("models"), sorted(MODELS)))
|
||||
for c in p.get("doctor") or []:
|
||||
if c not in CHECKS:
|
||||
bad(pid, "unknown doctor check %r" % c)
|
||||
if p.get("color", "gray") not in COLORS:
|
||||
bad(pid, "color %r is not one of %s" % (p.get("color"), sorted(COLORS)))
|
||||
auth = (p.get("preset") or {}).get("auth") or {}
|
||||
if auth.get("mode") not in ("vault", "literal"):
|
||||
bad(pid, "preset.auth.mode must be vault or literal")
|
||||
preset = os.path.join("presets", "%s.json" % p.get("defaultPreset"))
|
||||
if not os.path.exists(preset):
|
||||
bad(pid, "default preset %s does not exist" % preset)
|
||||
elif json.load(open(preset, encoding="utf-8")).get("provider") != pid:
|
||||
bad(pid, "default preset %s belongs to another provider" % preset)
|
||||
for f in sorted(os.listdir("presets")):
|
||||
pp = json.load(open(os.path.join("presets", f), encoding="utf-8"))
|
||||
if pp.get("provider") not in {p["id"] for p in doc["providers"]}:
|
||||
errors.append("presets/%s: provider %r is not in providers.json" % (f, pp.get("provider")))
|
||||
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
|
||||
# The shell's own modules (qs.*, Quickshell) do not resolve outside it, so
|
||||
# only real syntax errors count; unresolved-type warnings are expected.
|
||||
# qmllint reports a syntax error as a *warning* tagged [syntax] - matching
|
||||
# on the word "error" instead caught every file that mentions lastError.
|
||||
qml_syntax() {
|
||||
local f out
|
||||
for f in omarchy/smoido.claude-mode/*.qml; do
|
||||
out="$("$QMLLINT" "$f" 2>&1 | grep -F '[syntax]' || true)"
|
||||
[ -z "$out" ] || { echo "$f"; echo "$out"; return 1; }
|
||||
done
|
||||
}
|
||||
check 'QML has no syntax errors (qmllint)' qml_syntax
|
||||
else
|
||||
printf ' skip QML syntax (qmllint not installed)\n'
|
||||
fi
|
||||
|
||||
if command -v shellcheck >/dev/null 2>&1; then
|
||||
check 'shellcheck (errors only)' shellcheck -S error -s bash \
|
||||
linux/claude-mode linux/*.sh omarchy/install.sh scripts/*.sh tests/*.sh tests/cli/*.sh
|
||||
else
|
||||
printf ' skip shellcheck (not installed)\n'
|
||||
fi
|
||||
|
||||
[ "$fails" -eq 0 ]
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs tests/windows/run.ps1 on a Windows host over SSH (default: winbox):
|
||||
# copies the payload to the host's %TEMP%, runs it there, and removes it again.
|
||||
# Needs key-based SSH to the host and python on the host (for the fake servers).
|
||||
# The suite itself runs in a USERPROFILE sandbox; see run.ps1.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
host="${1:-winbox}"
|
||||
name='cm-test-payload'
|
||||
ssh_() { ssh -o BatchMode=yes -o LogLevel=ERROR -o ConnectTimeout=10 "$host" "$@"; }
|
||||
|
||||
parent="$(mktemp -d)"
|
||||
trap 'rm -rf "$parent"' EXIT
|
||||
stage="$parent/$name"
|
||||
mkdir -p "$stage/presets" "$stage/bin" "$stage/lib"
|
||||
cp claude-mode.ps1 install.ps1 providers.json VERSION tests/fake_server.py tests/windows/run.ps1 "$stage/"
|
||||
cp presets/*.json "$stage/presets/"
|
||||
cp bin/* "$stage/bin/"
|
||||
cp lib/*.ps1 "$stage/lib/"
|
||||
|
||||
rtemp="$(ssh_ 'Write-Output $env:TEMP' | tr -d '\r')" || { echo "cannot reach $host" >&2; exit 1; }
|
||||
[ -n "$rtemp" ] || { echo "no %TEMP% on $host" >&2; exit 1; }
|
||||
ssh_ "Remove-Item -Recurse -Force '$rtemp\\$name' -ErrorAction SilentlyContinue" || true
|
||||
scp -q -r -o BatchMode=yes -o LogLevel=ERROR "$stage" "$host:${rtemp//\\//}/" || { echo "copy to $host failed" >&2; exit 1; }
|
||||
|
||||
ssh_ "powershell -NoProfile -ExecutionPolicy Bypass -File '$rtemp\\$name\\run.ps1'"
|
||||
rc=$?
|
||||
ssh_ "Remove-Item -Recurse -Force '$rtemp\\$name' -ErrorAction SilentlyContinue" || true
|
||||
exit "$rc"
|
||||
@@ -0,0 +1,134 @@
|
||||
# Windows suite for claude-mode.ps1. Run on a Windows host by
|
||||
# tests/windows/run-remote.sh, from a copy of the payload in that host's %TEMP%.
|
||||
#
|
||||
# Everything happens in a sandbox: USERPROFILE is pointed at a temp folder for
|
||||
# this process and its children, and the script derives every path from it, so
|
||||
# the host's real ~\.claude-mode, vault and Claude settings are never read or
|
||||
# written. The fake provider servers run on the host's own loopback.
|
||||
# ASCII only: Windows PowerShell 5.1 reads a .ps1 without a BOM as ANSI.
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$src = $PSScriptRoot
|
||||
$sb = Join-Path $env:TEMP ('cm-test-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
$root = Join-Path $sb '.claude-mode'
|
||||
$real = $env:USERPROFILE
|
||||
$script:pass = 0
|
||||
$script:fail = 0
|
||||
|
||||
function Pass { $script:pass++ }
|
||||
function Fail([string] $what, $out) {
|
||||
$script:fail++
|
||||
Write-Host " FAIL windows: $what"
|
||||
if ($out) { @($out) | Select-Object -Last 8 | ForEach-Object { Write-Host " | $_" } }
|
||||
}
|
||||
function Expect-Match($out, [string] $pattern, [string] $what) {
|
||||
if ((@($out) -join "`n") -match $pattern) { Pass } else { Fail $what $out }
|
||||
}
|
||||
function Expect-Eq($got, $want, [string] $what) {
|
||||
if ("$got" -eq "$want") { Pass } else { Fail "$what (got '$got', wanted '$want')" $null }
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path "$root\presets", "$root\bin", "$root\lib", "$sb\.claude" | Out-Null
|
||||
Copy-Item "$src\claude-mode.ps1", "$src\providers.json", "$src\VERSION" $root
|
||||
Copy-Item "$src\presets\*.json" "$root\presets"
|
||||
Copy-Item "$src\bin\*" "$root\bin"
|
||||
Copy-Item "$src\lib\*.ps1" "$root\lib"
|
||||
$cm = Join-Path $root 'claude-mode.ps1'
|
||||
|
||||
# install.ps1 is parsed but never run: it edits the real PowerShell profile and
|
||||
# the User PATH, which no USERPROFILE sandbox contains.
|
||||
$parse = @($cm) + @(Get-ChildItem "$root\lib\*.ps1" | ForEach-Object { $_.FullName })
|
||||
if (Test-Path "$src\install.ps1") { $parse += "$src\install.ps1" }
|
||||
foreach ($file in $parse) {
|
||||
$tokens = $null; $errors = $null
|
||||
[void][System.Management.Automation.Language.Parser]::ParseFile($file, [ref]$tokens, [ref]$errors)
|
||||
Expect-Eq $errors.Count 0 "$(Split-Path -Leaf $file) parses"
|
||||
foreach ($e in $errors) { Write-Host " | $(Split-Path -Leaf $file) line $($e.Extent.StartLineNumber): $($e.Message)" }
|
||||
}
|
||||
|
||||
$portsFile = Join-Path $sb 'ports.json'
|
||||
$fake = Start-Process python -ArgumentList ('"' + (Join-Path $src 'fake_server.py') + '"') `
|
||||
-NoNewWindow -RedirectStandardOutput $portsFile -PassThru
|
||||
for ($i = 0; $i -lt 50; $i++) {
|
||||
if ((Test-Path $portsFile) -and (Get-Item $portsFile).Length -gt 0) { break }
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
$ports = Get-Content $portsFile -TotalCount 1 | ConvertFrom-Json
|
||||
$ollama = "http://127.0.0.1:$($ports.ollama)"
|
||||
$keyed = "http://127.0.0.1:$($ports.keyed)"
|
||||
|
||||
$env:USERPROFILE = $sb
|
||||
$script:rc = 0
|
||||
function CM {
|
||||
param([Parameter(ValueFromRemainingArguments = $true)] [string[]] $a)
|
||||
$o = & powershell -NoProfile -ExecutionPolicy Bypass -File $cm @a 2>&1 | ForEach-Object { "$_" }
|
||||
$script:rc = $LASTEXITCODE
|
||||
return $o
|
||||
}
|
||||
function Set-PresetJson([string] $name, [scriptblock] $edit) {
|
||||
$p = Join-Path $root "presets\$name.json"
|
||||
$j = Get-Content $p -Raw | ConvertFrom-Json
|
||||
& $edit $j
|
||||
$j | ConvertTo-Json -Depth 6 | Set-Content $p -Encoding UTF8
|
||||
}
|
||||
|
||||
$o = CM help
|
||||
foreach ($id in 'openrouter', 'zai', 'lmstudio', 'ollama', 'custom') {
|
||||
Expect-Match $o "claude-mode $id \[preset\]" "usage lists $id"
|
||||
}
|
||||
|
||||
$o = CM custom
|
||||
Expect-Eq $rc 1 'custom without an address is refused'
|
||||
Expect-Match $o 'has no server address' 'and says why'
|
||||
Copy-Item "$root\presets\custom.json" "$root\presets\blank.json"
|
||||
Set-PresetJson 'blank' { param($j) $j.baseUrl = 'https://llm.example.com' }
|
||||
$o = CM custom blank
|
||||
Expect-Eq $rc 1 'a preset with no models is refused'
|
||||
Expect-Match $o 'has no models set' 'and says why'
|
||||
|
||||
Set-PresetJson 'ollama' { param($j) $j.baseUrl = $ollama }
|
||||
$o = CM ollama
|
||||
Expect-Eq $rc 0 'switch to ollama'
|
||||
$envBlock = (Get-Content "$sb\.claude\settings.json" -Raw | ConvertFrom-Json).env
|
||||
Expect-Eq $envBlock.ANTHROPIC_BASE_URL $ollama 'base url written'
|
||||
Expect-Eq $envBlock.ANTHROPIC_AUTH_TOKEN 'ollama' 'placeholder token written'
|
||||
Expect-Eq $envBlock.CLAUDE_CODE_MAX_CONTEXT_TOKENS '65536' 'context declared'
|
||||
|
||||
$o = CM models
|
||||
Expect-Match $o 'qwen3-coder:latest' 'ollama models listed'
|
||||
$o = CM doctor
|
||||
Expect-Match $o 'Ollama reachable' 'doctor finds the server'
|
||||
Expect-Match $o 'opus\s+qwen3-coder\s+\[30\.5B Q4_K_M\]' 'a bare name matches its :latest tag'
|
||||
Expect-Match $o 'loaded with a 4096-token context' 'the short server context is caught'
|
||||
Expect-Match $o 'OLLAMA_CONTEXT_LENGTH=65536' 'and the fix is named'
|
||||
|
||||
Set-PresetJson 'custom' {
|
||||
param($j)
|
||||
$j.baseUrl = $keyed
|
||||
$j.auth = [pscustomobject]@{ mode = 'literal'; token = 'sk-real' }
|
||||
$j.models.opus = 'gw/model-a'
|
||||
}
|
||||
$o = CM custom
|
||||
Expect-Eq $rc 0 'switch to a custom endpoint'
|
||||
$o = CM models
|
||||
Expect-Match $o 'gw/model-b' 'a keyed endpoint lists its models'
|
||||
|
||||
$o = CM 'Z.AI'
|
||||
Expect-Match $o "no key stored for ref 'zai'" 'an alias resolves to its provider'
|
||||
$o = CM nope
|
||||
Expect-Eq $rc 1 'an unknown command fails'
|
||||
|
||||
$want = '{"provider":"zai","description":"new preset","baseUrl":"https://api.z.ai/api/anthropic","auth":{"mode":"vault","keyRef":"zai"},"models":{"opus":"","sonnet":"","haiku":"","fable":""},"subagentModel":"inherit","gatewayModelDiscovery":false,"contextTokens":1000000,"extraEnv":{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1"}}'
|
||||
$got = & powershell -NoProfile -ExecutionPolicy Bypass -Command ". '$cm' help *> `$null; New-PresetScaffold 'zai' | ConvertTo-Json -Depth 5 -Compress"
|
||||
Expect-Eq $got $want 'the zai scaffold is unchanged'
|
||||
|
||||
Stop-Process -Id $fake.Id -Force -ErrorAction SilentlyContinue
|
||||
$env:USERPROFILE = $real
|
||||
Start-Sleep -Milliseconds 300
|
||||
Remove-Item -Recurse -Force $sb -ErrorAction SilentlyContinue
|
||||
|
||||
$line = '{0,-22} {1,3} passed' -f 'windows', $script:pass
|
||||
if ($script:fail -gt 0) { $line += ", $($script:fail) FAILED" }
|
||||
Write-Host $line
|
||||
if ($script:fail -gt 0) { exit 1 }
|
||||
exit 0
|
||||
Reference in New Issue
Block a user