Compare commits

...
19 Commits
Author SHA1 Message Date
smoidoandClaude Opus 5 5a3c6f86eb 1.13.0: tests, docs, and the code split into modules
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 02:14:08 +03:00
smoidoandClaude Opus 5 bb01418ce0 Split the bar panel into one QML file per stage
Panel.qml keeps the state, the CLI calls and the stage switch (889 lines,
was 2360); each stage is its own file taking the panel as a required
property. The omarchy installer copies every QML/JS file and clears stale
ones. tests/static.sh filters qmllint on [syntax], not the word error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 02:12:49 +03:00
smoidoandClaude Opus 5 4091746d4e Split the Windows script into modules; drop the Arkylx Index pieces
claude-mode.ps1 was 2,407 lines. It is now 153: the help block, parameters,
paths, the managed-key list, a loader, and the dispatch. The rest moved,
verbatim, into eleven files under lib/ - providers, output, files, core,
vault, switch, guards, health, catalogue, commands, menu - dot-sourced into
the script's scope in their original order, with the same check as the bash
split that every original line landed in exactly one file. Inside a module
$PSScriptRoot is lib\, so the one path beside the main script
(providers.json) now goes through $script:Here.

install.ps1 ships lib\, clearing old modules first. The Windows suite
parses every module and install.ps1 (36 checks, all green on Windows
PowerShell 5.1); install.ps1 is parsed but never run, since it edits the
real profile and User PATH.

linux/bootstrap.sh and scripts/build-package.ps1 existed only to build and
serve packages for the Arkylx Index. Both installers fetch the repository's
own archive, so a push to master is the release; the two scripts, the dist/
ignore and their mentions in the docs are gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 02:06:45 +03:00
smoidoandClaude Opus 5 441166d003 Split the POSIX CLI into modules
linux/claude-mode was 3,035 lines. It is now 177: the paths and flags, a
loader, and the command dispatch. Everything else moved, verbatim, into
twelve files under linux/lib/, one per concern - output, core, preflight,
sessions, switch, catalogue, commands, doctor, presets, menu, setup, repair.
The move was done by line range with a check that every original line landed
in exactly one file; the only thing that changed place is the switch's
running-sessions question, which now sits with session detection.

The CLI finds lib/, 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 (it used to mix the two). The key helper path written
into settings.json is still the installed one.

linux/install.sh ships bin/lib/, clearing old modules first so a removed one
cannot linger. The package build copies linux/ recursively, which a flat copy
would not.

tests/cli/test_install.sh runs the real installer into a sandbox home: every
file lands, the symlink runs, a reinstall keeps edited presets and drops a
stale module. docs/architecture.md lists the modules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 02:00:10 +03:00
smoidoandClaude Opus 5 b514e00745 README down to what a user needs; the rest into docs/
The README had grown to 1,010 lines of user docs and design notes in one file,
and had gone stale: it still showed the old numbered menu, set up only three
providers, and listed preflight checks and a file layout that predate the last
three releases. It now carries install, first run, the full command
reference, the providers at a glance, troubleshooting and a docs index.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 01:52:03 +03:00
smoidoandClaude Opus 5 51e42823d1 Tests, licence, and the tooling to keep the repo consistent
A test suite with no dependencies beyond bash and the Python standard
library, run by one command:

  scripts/test.sh                    (or: make test)
  scripts/test.sh --windows [host]   adds the PowerShell suite over SSH

- tests/static.sh: script syntax, JSON validity, VERSION against the widget
  manifest and CHANGELOG, providers.json against the kinds, probes and checks
  the code implements (and ids that would shadow a command), qmllint and
  shellcheck when installed.
- tests/python: unit tests for cm-json.py - providers and the column contract
  bash depends on, the original three scaffolds byte for byte, every
  catalogue parser and the cache, session scan/dismiss/repair, rename,
  defaults, health, the cost guard.
- tests/cli: the CLI in a sandbox with its own HOME, CM_ROOT,
  CLAUDE_CONFIG_DIR and a file-only vault, against fake Ollama, LM Studio,
  keyed-gateway and proxy servers - preflight, presets, models, doctor, a real
  switch, sessions, and the key helper under a rename race.
- tests/windows: the same idea on a Windows host, with USERPROFILE pointed at
  a temp folder so the real install is never touched.

Also: MIT licence, CHANGELOG.md reconstructed from history, .editorconfig and
.gitattributes pinning LF everywhere (what the tree already is), and
scripts/bump-version.sh, since the version lives in two files and drifted
once before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 01:42:38 +03:00
smoidoandClaude Opus 5 b5824c6611 Providers as data; add Ollama and Custom endpoints
Every gateway provider is now an entry in providers.json - endpoint and auth
template, how its model list is read, how it is probed before a switch, what
setup asks, which doctor checks apply, and its title, colour and logo -
installed next to the presets and read by all three consumers: the bash CLI
(through cm-json.py), the Windows script, and the bar widget (through
health.json). anthropic stays built in; it is the native login, not a gateway.

Behaviour that differs in kind stays in code, chosen by name from the entry:
catalogue parsers (openrouter, lmstudio, ollama, openai, static), probe rules
(always, lenient, local), and named doctor checks. A provider that reuses them
is an entry and a default preset, with no code. The widget draws providers
from health.json, so a new one needs no QML change and no shell restart.

The existing three are unchanged in behaviour: their blank presets come out
byte-identical from the file, and setup, doctor, models and the picker run the
same checks through the generic paths.

Ollama: local server on :11434, placeholder token, one model for every tier,
models from /api/tags. doctor reads the context each loaded model actually runs
with (/api/ps) and its maximum (/api/show), because Ollama defaults to 4096
tokens unless OLLAMA_CONTEXT_LENGTH is set and silently truncates past it. A
bare model name matches its :latest tag.

Custom: any Anthropic-compatible endpoint. Ships with no address and is refused
until it has one; key optional; models from /v1/models when the endpoint has a
list, and a lenient probe so a proxy without one is not blocked.

Also: preflight (and Set-ClaudeMode on Windows) refuses a preset with no
server address; the server form, setup and set-auth use each provider's own
default URL, key name and placeholder token instead of LM Studio's; the
Windows build gains the no-models and no-address guards it never had.
Tested on Linux against fake Ollama/Custom servers, and on Windows 5.1 in a
USERPROFILE sandbox on winbox.

1.12.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 01:20:33 +03:00
smoidoandClaude Opus 5 a54f7bcb55 1.11.0: preset editor in the bar panel
Tier editing with the cached model catalogue, preset create / duplicate /
rename / delete, and a choosable default preset per provider.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 00:39:12 +03:00
smoidoandClaude Opus 5 c5c3fc57d9 Choose which preset claude-mode <provider> picks
Omitting the preset used a fixed name per provider (default, zai,
lmstudio). It can now be chosen:

  claude-mode preset default                     what each picks, and why
  claude-mode preset default openrouter cheap
  claude-mode preset default openrouter --clear

The choice is stored in ~/.claude-mode/defaults.json and read first by
default_preset_for, while its file still exists. A choice whose file is gone
falls back to the built-in name, and if that is gone too, to the first
preset by name, as before. Renaming a chosen preset moves the choice with it,
and deleting it clears the choice. The terminal menu marks what
resolve_preset would actually pick.

health.json publishes defaultPresetFor (what each provider resolves to, in
the CLI's order) and defaultPresetChosen (the explicit choices only). The
panel sorts and tags from it, and the editor gains Make default / Clear
default. preset new, rename and rm now refresh health.json, so the bar
follows edits made in a terminal too.

This changes existing CLI behaviour only once a default is chosen. The
Windows build does not read defaults.json yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 00:34:14 +03:00
smoidoandClaude Opus 5 de9f10a8c2 Create, duplicate, rename and delete presets from the panel
Each provider's preset list ends in "New ... preset...", and the preset
editor gains Duplicate, Rename and Delete. New asks for a name and what to
start from: a copy of one of that provider's presets, or a blank template.
Delete is greyed out, with the reason, on the preset in use, and warns when
it would leave a provider with no preset at all.

CLI:
- preset rename <name> <new>: the new name is hard-linked in, state.json is
  repointed, and only then does the old name go.
- preset new <name> --provider <p> [--blank]: with a provider and no source
  it copies that provider's own default instead of the OpenRouter-only
  `default`; --blank starts from the scaffold.
- valid_preset_name on every preset subcommand: no slash, no leading dot.
  `preset show ../../etc/passwd` used to print the file.
- preset rm warns when it removes a provider's last preset.
- preflight refuses a preset with every tier empty. Otherwise a blank preset
  would switch cleanly and Claude Code would ask the gateway for its default
  Anthropic models, billed at full price on OpenRouter. The panel's blocked
  card offers "Edit preset..." for it.

The key helper now reads the active preset in a single open (new cm-json
auth-of) and re-reads state.json once on a miss. Renaming the active preset
could otherwise catch a running session between reading the old name and
opening the file: 1 failure in 51 key fetches in a race test before, 0 in
118 across 60 renames after. It could also briefly hand out the openrouter
key for a preset that uses another.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 00:25:39 +03:00
smoidoandClaude Opus 5 83b59f34a1 Edit a preset's tiers from the bar panel
The gear on every preset row now opens an editor: the four tiers with the
model each maps to, and for each one field that filters the provider's
catalogue as you type (every word must match; arrows and Enter work), listed
inline with context length and price, and that also takes any id typed by
hand. LM Studio's server form moves one click inside the editor.

The panel card moves from PopupCard to KeyboardPanel. PopupCard is an
xdg-popup, which only receives keys after focus is routed through its parent
surface, so no text field in it could ever be typed into - the existing
server URL form included. KeyboardPanel primes layer-shell keyboard focus on
open, which is why every shell panel with a text field uses it. Esc now
closes the panel.

The picker reads ~/.claude-mode/models-cache.json, which or_catalogue and
lms_catalogue now write as a side effect, so models, doctor, setup and the
menu's picker all keep it fresh and the panel never hits the network itself.
One node per provider with its own fetchedAt/ok; a failed fetch keeps the
old list, an LM Studio list is tied to its server, and no key or key name is
ever stored. `models` gains --preset, --refresh and --json, and the Z.AI list
now lives in one place.

A tier edit to the active preset re-applies without the running-sessions
prompt: that prompt guards against the endpoint or key moving, and a tier
edit moves neither (preset url/auth still ask). A failed re-apply now says
the edit was saved. `preset set` on an unknown name no longer creates it.

Panel edits run through one chain that stops on the first failure and
refreshes health.json at the end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 00:14:30 +03:00
smoidoandClaude Opus 5 cbd3b1d8a9 Dismiss broken sessions, and hide ones untouched for a week
A broken session that will never be repaired kept the bar's warning dot lit
forever. repair-session now takes --ignore/--unignore/--unignore-all/--ignored,
recorded in ~/.claude-mode/ignored-sessions.json, and the scan hides broken
transcripts older than --max-age days (default 7, 0 disables,
CM_IGNORE_AGE_DAYS sets it). Hidden sessions move to a separate ignored[]
list with a reason, and --all always names how many it held back.

A repair clears the session's dismissal, and entries whose transcript is gone
are pruned, so a session that breaks again is never silently hidden.

The panel gets an Ignore button beside Repair and a collapsed "hidden (N)"
section with Restore. The dot still counts broken sessions only.

Bumps to 1.10.0 and fixes the widget manifest, which still said 1.8.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 23:40:24 +03:00
smoidoandClaude Code f776ce099e Build the SecureString for an inline key on Windows too
The vault is written with ConvertFrom-SecureString -SecureString, so the
inline branch has to produce a SecureString as well - it only set the
plain text, and under Set-StrictMode the vault write then failed with
'the variable $secure cannot be retrieved'. Caught by running the new
form on the Windows box rather than assuming it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-13 00:00:46 +03:00
smoidoandClaude Code 5bf5a6d36e set-key takes the ref it was given, and a key inline
`claude-mode set-key openrouter sk-or-...` did the wrong thing on the
POSIX port: the dispatcher loop kept the *last* positional, so the key
became the ref name and the prompt asked for "the key for ref
'sk-or-...'". Storing to that ref succeeds, so nothing looks wrong until
the switch later reports no key for 'openrouter' - which is exactly what
happened on macOS.

Both ports now take `set-key [ref] [key]`: the first positional is the
ref unless it is key-shaped (sk-* or longer than a ref name would
plausibly be), in which case it is the key for the default ref, and a
second positional is always the key. An inline key warns that it is now
in the shell history, since the hidden prompt leaves no trace. Extra
arguments are refused instead of ignored. The Windows dispatch already
read the first positional correctly, so it only gains the inline form.

The shipped default preset moves to the models asked for: DeepSeek 4.1
Flash on opus, GLM Flash on sonnet. Existing installs keep their own
presets - the installer never overwrites one without --force - so an
upgrade needs `claude-mode preset set default opus ...` and the same for
sonnet, or a --force install.

Verified in the bash 3.2 container: ref+key stores under the ref, a bare
key stores under openrouter, an empty prompt aborts, a custom ref still
works, the stored value is byte-identical, and four positionals are
refused.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-12 23:26:19 +03:00
smoidoandClaude Code d86a2ffe77 Point transcript lookup at the project directory Claude Code actually uses
Claude Code's project slug is not "slashes become dashes". Every
non-alphanumeric character becomes one dash, nothing collapsed - verified
against 2.1.269 by running a session in a directory named `slug._test x`,
which was filed as `-tmp-cmtest-slug--test-x`: the dot, the underscore and
the space each became a dash of their own. Paths with dots are ordinary on
macOS (iCloud Drive lives under `Mobile Documents`), and the old slash-only
rule pointed at a directory that does not exist, so repair-session could
not find a single transcript there.

It is also the physical directory that gets slugged. Claude Code asks the
OS for its working directory, and symlinks come back resolved: 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. Verified the same way -
a session started in /tmp/cmtest/linkdir landed under -tmp-cmtest-realdir.

cm_project_dir now applies the real rule and falls back to the resolved
path, which also covers the walk-up search in repair-session. Both cases
were re-tested end to end afterwards: from the dotted-and-spaced directory
and from the symlinked one, the transcripts are found.

The bar widget still reverses a slug for display ("-home-smoido-Work" ->
"home/smoido/Work"), which is lossy in the same way but is only a label,
and only ever sees Linux paths.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-12 22:34:44 +03:00
smoidoandClaude Code 5a1af9f7e6 Run on macOS: bash 3.2, arrow keys, and honest session reporting
macOS ships bash 3.2, and the port used three things it does not have:
mapfile, fractional `read -t`, and GNU `stat -c`. The first two took the
interactive menu down - `mapfile: command not found` in the preset
picker, and an arrow key that parsed as a bare Escape, leaving `[C` in
the tty for zsh to report as a bad pattern.

read_key now reads the escape tail through the terminal itself: icanon
off with min 0 / time 1, so a plain read returns the moment a byte
arrives and gives up after ~0.1s. bash 3.2's `read -t 0` cannot be used
to poll for this - it reports nothing even with bytes buffered (checked
against 3.2.57). CSI sequences are consumed whole, so modified keys like
Ctrl+Right no longer leak their tail either. Verified interactively
under bash 3.2.57 in a pty: arrows, pgup/pgdn, home/end, enter,
backspace, and a bare Escape all parse, with no stray bytes in the tty.

Session listing reads /proc, which macOS does not have; it now says so
rather than silently reporting no running sessions - the answer that
gets people to switch out from under a live session. install.sh swaps
GNU `find -print -quit` for a glob, and the README notes both.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-12 21:58:58 +03:00
smoido 69c7810345 README: install commands first, sections reorganized 2026-09-07 20:37:11 +03:00
smoidoandClaude Opus 5 b60c00450c Quote the apiKeyHelper path, and say why the helper failed
apiKeyHelper is a shell command line, not a path, so the raw value written
into settings.json was split at the first space. A Windows profile named
"Mohammed Ahmed" produced an attempt to run C:\Users\Mohammed, surfacing as
"your apiKeyHelper script is failing" with nothing to go on. Quote the value
when it contains anything a shell cares about, and leave it bare otherwise so
no existing settings.json churns on the next switch. The POSIX port had the
same bug against a /Users/First Last home; shlex.quote has exactly the wanted
"leave ordinary paths alone" behaviour.

doctor could not see any of this. It quoted the path itself before running it,
so it exercised a command line Claude Code never uses and passed while the
real one failed. It now reads the string out of settings.json, reports it when
it is not what a switch would write, and runs that string through a shell.

The helper itself exited 1 in silence on four distinct faults - no state, no
preset, no key, undecryptable key - collapsing them into one indistinguishable
message. Each now names itself on stderr, which is what /status displays. The
DPAPI case says what it actually means: a key stored by a different Windows
account than the one Claude Code runs as. Success paths stay silent, so stdout
still carries the key and nothing else.

Also make install.ps1 survive a Restricted execution policy: piped through
iex it is not subject to the policy, but invoking the installed script for the
key prompt is, which is where a fresh install died. Set Process scope for the
install, offer to set CurrentUser to RemoteSigned, and clear the
mark-of-the-web that Expand-Archive can leave on the extracted scripts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 00:49:50 +03:00
smoidoandClaude Code b32651fe00 1.9.0
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 23:09:10 +03:00
86 changed files with 11531 additions and 6240 deletions
+19
View File
@@ -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
+8
View File
@@ -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
View File
@@ -1,3 +1,4 @@
dist/
*.bak
*.bak.*
__pycache__/
*.pyc
+87
View File
@@ -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.
+18
View File
@@ -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).
+21
View File
@@ -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.
+18
View File
@@ -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
+176 -726
View File
@@ -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).
+1 -1
View File
@@ -1 +1 @@
1.8.0
1.13.0
+32 -7
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+203
View File
@@ -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.
+117
View File
@@ -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
View File
@@ -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.
+121
View File
@@ -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.
+159
View File
@@ -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.
+305
View File
@@ -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
View File
@@ -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'
+309
View File
@@ -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.'
}
}
}
}
+350
View File
@@ -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'])
}
+86
View File
@@ -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'"
}
+98
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
+90
View File
@@ -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
}
+60
View File
@@ -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
View File
@@ -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
}
+85
View File
@@ -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
}
-96
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+625 -79
View File
@@ -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
View File
@@ -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"
+70
View File
@@ -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.
+175
View File
@@ -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)"
}
+160
View File
@@ -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"
}
+218
View File
@@ -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
}
+408
View File
@@ -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
}
+277
View File
@@ -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
}
+216
View File
@@ -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
}
+218
View File
@@ -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
}
+303
View File
@@ -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))
"
}
+404
View File
@@ -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
}
+218
View File
@@ -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
}
+283
View File
@@ -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
View File
@@ -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
+72 -5
View File
@@ -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" } }
}
}
+276
View File
@@ -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)
}
}
}
}
}
}
}
+62
View File
@@ -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)
}
}
}
}
}
+7 -4
View File
@@ -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
+44
View File
@@ -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()
}
}
+162
View File
@@ -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() }
}
}
+132
View File
@@ -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() }
}
}
+223
View File
@@ -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()
}
}
}
+1 -1
View File
@@ -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"],
+19
View File
@@ -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
}
+3 -3
View File
@@ -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"
},
+22
View File
@@ -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
View File
@@ -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": ""
}
]
}
-127
View File
@@ -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
+28
View File
@@ -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"
+55
View File
@@ -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
+27
View File
@@ -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
+34
View File
@@ -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
+45
View File
@@ -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
+46
View File
@@ -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
+34
View File
@@ -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
+61
View File
@@ -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
+27
View File
@@ -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
+63
View File
@@ -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
+36
View File
@@ -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
+76
View File
@@ -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
View File
@@ -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 ]
}
+406
View File
@@ -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()
+184
View File
@@ -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 ]
+30
View File
@@ -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"
+134
View File
@@ -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