Import claude-code-switcher from the Windows build

Source of truth so far has been c:\Users\smoido\projects\cli on the Windows
box, which has no git history of its own. This is that tree copied verbatim over
SSH, minus dist/ - the PowerShell build, the POSIX port under linux/, and the
presets both share.

Recorded as its own commit so that everything after it is a reviewable diff
rather than an undifferentiated first drop.
This commit is contained in:
smoido
2026-08-30 21:05:48 +03:00
commit 112068314c
20 changed files with 4994 additions and 0 deletions
+326
View File
@@ -0,0 +1,326 @@
# claude-mode
Switch Claude Code system-wide between **Anthropic**, **OpenRouter**, **Z.AI**, and a
local **LM Studio** server — with named per-tier model presets.
Windows / PowerShell 5.1, no external dependencies.
```
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)
```
Works for the CLI, the VS Code extension, and the desktop app from a single
switch. Restart Claude Code afterwards — nothing else.
---
## 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
```
Omitting the preset uses a **fixed** per-provider default, not "most recently
used" — so `claude-mode openrouter` always means `default`.
## Presets shipped
| preset | provider | opus | sonnet | haiku | fable |
|---|---|---|---|---|---|
| `default` | openrouter | `deepseek/deepseek-v4-flash` | `qwen/qwen3.7-flash` | `openrouter/free` | `z-ai/glm-5.2` |
| `cheap` | openrouter | `deepseek/deepseek-v4-pro` | `deepseek/deepseek-v4-flash` | `qwen/qwen3.7-flash` | `openai/gpt-5.6-luna-pro` |
| `claude-via-or` | openrouter | `anthropic/claude-opus-5` | `anthropic/claude-sonnet-5` | `anthropic/claude-haiku-4.5` | `anthropic/claude-fable-5` |
| `zai` | zai | `glm-5.2` | `glm-5.2` | `glm-4.7` | `glm-5.2` |
| `lmstudio` | lmstudio | `kwaipilot_kat-coder-v2.5-dev` (all tiers) | | | |
| `lmstudio-qwen` | lmstudio | `qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved` (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.
## 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`, `cheap`, `claude-via-or`, `zai` | 1,000,000 |
| `lmstudio`, `lmstudio-qwen` | 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** — which is
exactly the `lmstudio-qwen` preset, 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 it spams, switch:
```powershell
claude-mode lmstudio lmstudio-qwen
```
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
```powershell
cd c:\Users\smoido\Projects\cli\claude-code-switcher
.\install.ps1
```
Installs to `~/.claude-mode/` (ACL: you only), drops `claude-mode.cmd` into
`~/.local/bin` (already on your User PATH, next to `claude.exe`), and adds 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).
If the profile doesn't load: `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`.
## Restarting sessions
Claude Code reads all of this **once at startup**. A switch does not affect a
running session — that's how the process loads config, not something scripting
can change.
- **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.
## 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
```
+1
View File
@@ -0,0 +1 @@
1.8.0
+4
View File
@@ -0,0 +1,4 @@
@echo off
REM Shim so Claude Code's apiKeyHelper works whether it is spawned via cmd.exe,
REM PowerShell, or a POSIX shell (Git Bash) on Windows.
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0claude-key-helper.ps1"
+48
View File
@@ -0,0 +1,48 @@
# claude-key-helper.ps1
#
# Invoked by Claude Code via the `apiKeyHelper` setting. Prints the API key for
# the currently active preset to stdout and nothing else.
#
# Only presets whose auth mode is "vault" have a secret to emit. In anthropic
# mode, or for a preset using an inline placeholder token (LM Studio), this
# exits silently so no token can leak into a context that must not have one.
#
# The key is stored DPAPI-encrypted (CurrentUser scope), so decryption only
# succeeds for the Windows account that ran `claude-mode set-key`. Reading the
# .cred file as another user - or copying it to another machine - yields nothing.
$ErrorActionPreference = 'Stop'
try {
$root = Join-Path $env:USERPROFILE '.claude-mode'
$state = Get-Content -LiteralPath (Join-Path $root 'state.json') -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 }
$preset = Get-Content -LiteralPath $presetPath -Raw -Encoding UTF8 | ConvertFrom-Json
$authMode = 'vault'
$keyRef = 'openrouter'
if ($preset.PSObject.Properties.Name -contains 'auth' -and $preset.auth) {
if ($preset.auth.PSObject.Properties.Name -contains 'mode' -and $preset.auth.mode) { $authMode = [string]$preset.auth.mode }
if ($preset.auth.PSObject.Properties.Name -contains 'keyRef' -and $preset.auth.keyRef) { $keyRef = [string]$preset.auth.keyRef }
}
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 }
$secure = ConvertTo-SecureString (Get-Content -LiteralPath $credPath -Raw).Trim()
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
try {
[Console]::Out.Write([Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr))
} finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
} catch {
exit 1
}
+2171
View File
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
<#
One-time installer for claude-mode.
Installs to %USERPROFILE%\.claude-mode, drops a cmd.exe shim on PATH, and
wires `claude-mode` + a `claude` wrapper into the PowerShell profile.
Idempotent: safe to re-run to upgrade. Existing presets are not overwritten
unless -Force is passed. Your ~/.claude/settings.json is NOT touched here -
that only happens when you actually run `claude-mode openrouter|anthropic`.
#>
[CmdletBinding()]
param(
[switch] $Force, # overwrite existing presets
[switch] $SkipKeyPrompt # do not prompt for the OpenRouter key
)
Set-StrictMode -Version 1.0
$ErrorActionPreference = 'Stop'
$src = Split-Path -Parent $MyInvocation.MyCommand.Path
$root = Join-Path $env:USERPROFILE '.claude-mode'
Write-Host "installing claude-mode -> $root" -ForegroundColor Cyan
# --- 1. directories ---------------------------------------------------------
foreach ($d in @($root, "$root\presets", "$root\vault", "$root\backups", "$root\bin")) {
if (-not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
}
# Lock the whole tree to the current user. The vault is DPAPI-encrypted on top
# of this, but there is no reason for the directory to be broadly readable.
try {
$acl = Get-Acl -LiteralPath $root
if ($acl.AreAccessRulesProtected) {
# Already locked down by a previous run. Re-applying would need
# SeSecurityPrivilege on some systems, so leave it alone.
Write-Host ' ok .claude-mode ACL already locked to current user' -ForegroundColor Green
throw [System.OperationCanceledException]::new('already-protected')
}
$acl.SetAccessRuleProtection($true, $false)
foreach ($r in @($acl.Access)) { [void]$acl.RemoveAccessRule($r) }
$me = New-Object System.Security.Principal.NTAccount($env:USERDOMAIN, $env:USERNAME)
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule(
$me, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
Set-Acl -LiteralPath $root -AclObject $acl
Write-Host ' ok locked .claude-mode ACL to current user' -ForegroundColor Green
} catch [System.OperationCanceledException] {
# already protected - nothing to do
} catch {
Write-Host " warn ACL hardening failed: $($_.Exception.Message)" -ForegroundColor Yellow
}
# --- 2. scripts -------------------------------------------------------------
Copy-Item -LiteralPath (Join-Path $src 'claude-mode.ps1') -Destination $root -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
# --- 3. presets -------------------------------------------------------------
foreach ($p in Get-ChildItem -LiteralPath (Join-Path $src 'presets') -Filter '*.json') {
$dest = Join-Path "$root\presets" $p.Name
if ((Test-Path -LiteralPath $dest) -and -not $Force) {
Write-Host " skip preset $($p.BaseName) (exists; -Force to overwrite)" -ForegroundColor DarkGray
} else {
Copy-Item -LiteralPath $p.FullName -Destination $dest -Force
Write-Host " ok preset $($p.BaseName)" -ForegroundColor Green
}
}
# --- 4. initial state (native mode; settings.json untouched) ----------------
$statePath = Join-Path $root 'state.json'
if (-not (Test-Path -LiteralPath $statePath)) {
@{ mode = 'anthropic'; preset = 'default'; updated = (Get-Date).ToString('o') } |
ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8
Write-Host ' ok state.json initialised (mode=anthropic)' -ForegroundColor Green
}
# --- 5. cmd.exe / PATH shim -------------------------------------------------
$binDir = Join-Path $env:USERPROFILE '.local\bin'
if (-not (Test-Path -LiteralPath $binDir)) { New-Item -ItemType Directory -Path $binDir -Force | Out-Null }
$shim = Join-Path $binDir 'claude-mode.cmd'
@"
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%USERPROFILE%\.claude-mode\claude-mode.ps1" %*
"@ | Set-Content -LiteralPath $shim -Encoding ASCII
Write-Host " ok shim $shim" -ForegroundColor Green
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($userPath -notlike "*$binDir*") {
[Environment]::SetEnvironmentVariable('Path', "$userPath;$binDir", 'User')
Write-Host " ok added $binDir to User PATH (new terminals only)" -ForegroundColor Green
}
# --- 6. PowerShell profile --------------------------------------------------
$profilePath = $PROFILE.CurrentUserAllHosts
$profileDir = Split-Path -Parent $profilePath
if (-not (Test-Path -LiteralPath $profileDir)) { New-Item -ItemType Directory -Path $profileDir -Force | Out-Null }
$snippet = Get-Content -LiteralPath (Join-Path $src 'profile-snippet.ps1') -Raw
$current = ''
if (Test-Path -LiteralPath $profilePath) { $current = Get-Content -LiteralPath $profilePath -Raw }
$startMark = '# >>> claude-mode >>>'
$endMark = '# <<< claude-mode <<<'
if ($current -match [regex]::Escape($startMark)) {
$pattern = '(?s)' + [regex]::Escape($startMark) + '.*?' + [regex]::Escape($endMark)
$current = [regex]::Replace($current, $pattern, $snippet.TrimEnd())
Set-Content -LiteralPath $profilePath -Value $current -Encoding UTF8
Write-Host " ok updated claude-mode block in $profilePath" -ForegroundColor Green
} else {
Add-Content -LiteralPath $profilePath -Value ("`r`n" + $snippet) -Encoding UTF8
Write-Host " ok appended claude-mode block to $profilePath" -ForegroundColor Green
}
# --- 7. execution policy check ---------------------------------------------
$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
}
# --- 8. key ----------------------------------------------------------------
if (-not $SkipKeyPrompt) {
$vault = Join-Path $root 'vault\openrouter.cred'
if ((Test-Path -LiteralPath $vault) -and -not $Force) {
Write-Host ' ok OpenRouter key already stored' -ForegroundColor Green
} else {
Write-Host ''
& (Join-Path $root 'claude-mode.ps1') set-key openrouter
}
}
Write-Host ''
Write-Host 'done. Open a NEW terminal, then:' -ForegroundColor Cyan
Write-Host ' claude-mode status'
Write-Host ' claude-mode openrouter default'
Write-Host ' claude-mode doctor'
Write-Host ' claude-mode anthropic'
+96
View File
@@ -0,0 +1,96 @@
#!/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
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Invoked by Claude Code via the `apiKeyHelper` setting. Prints the API key for
# the active preset to stdout and nothing else.
#
# Only presets whose auth mode is "vault" have a secret to emit. In anthropic
# mode, or for a preset using an inline placeholder token (LM Studio), this exits
# silently so a token cannot leak into a context that must not have one.
set -u
CM_ROOT="${CM_ROOT:-$HOME/.claude-mode}"
# shellcheck source=/dev/null
. "$CM_ROOT/bin/cm-vault.sh"
PY="${CLAUDE_MODE_PYTHON:-python3}"
JSON="$CM_ROOT/bin/cm-json.py"
state="$CM_ROOT/state.json"
[ -f "$state" ] || exit 0
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="$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" = "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
+1133
View File
File diff suppressed because it is too large Load Diff
+481
View File
@@ -0,0 +1,481 @@
#!/usr/bin/env python3
"""
JSON engine for claude-mode (POSIX port).
Bash cannot safely read or rewrite JSON, and jq is not installed often enough to
depend on. Everything that touches structured data goes through here, so the
shell script only ever handles flat lines of text.
Subcommands:
apply <settings> <state> <mode> [preset] [helper] rewrite the managed keys
summary <preset> human lines for the UI
models <preset> "tier<TAB>model" lines
set-tier <preset> <tier> <model> edit one tier in place
scaffold <provider> print a blank preset
get <file> <dotted.path> print one value
presets <dir> "name<TAB>provider<TAB>desc"
"""
import json
import os
import re
import sys
# Matches both the qualified gateway id (anthropic/claude-opus-5) and the bare
# internal id Claude Code persists (claude-opus-5, claude-haiku-4-5-20251001).
ANTHROPIC_MODEL_RE = re.compile(r"(^|/)claude[-.]|^anthropic/")
def is_anthropic_model(model_id):
return bool(model_id) and bool(ANTHROPIC_MODEL_RE.search(str(model_id)))
# Must stay in lockstep with the Windows build's $script:BaseManagedEnvKeys.
BASE_MANAGED = [
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_FABLE_MODEL",
# Both pin a concrete model outside the tier mapping. A stale value in either
# survives a switch, and the small/fast slot is used by background
# summarisation - a known cause of "works normally, dies on compaction".
"ANTHROPIC_MODEL",
"ANTHROPIC_SMALL_FAST_MODEL",
"CLAUDE_CODE_SUBAGENT_MODEL",
"CLAUDE_CODE_DISABLE_1M_CONTEXT",
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY",
"CLAUDE_CODE_ATTRIBUTION_HEADER",
"CLAUDE_CODE_AUTO_COMPACT_WINDOW",
"CLAUDE_CODE_MAX_CONTEXT_TOKENS",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
"API_TIMEOUT_MS",
]
TIERS = ["opus", "sonnet", "haiku", "fable"]
def load(path, default=None):
if not os.path.exists(path):
return default if default is not None else {}
with open(path, "r", encoding="utf-8") as fh:
text = fh.read().strip()
if not text:
return default if default is not None else {}
return json.loads(text)
def save(path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2)
fh.write("\n")
os.replace(tmp, path)
def cmd_apply(argv):
settings_path, state_path, mode = argv[0], argv[1], argv[2]
preset_path = argv[3] if len(argv) > 3 and argv[3] else None
helper = argv[4] if len(argv) > 4 and argv[4] else None
settings = load(settings_path)
state = load(state_path)
# Clear the baseline keys plus whatever the previous switch actually wrote,
# so a preset's custom extraEnv key cannot outlive the preset that added it.
doomed = set(BASE_MANAGED) | set(state.get("writtenEnvKeys") or [])
env = settings.get("env")
if isinstance(env, dict):
for k in doomed:
env.pop(k, None)
if not env:
settings.pop("env", None)
settings.pop("apiKeyHelper", None)
written = []
if mode != "anthropic":
if not preset_path:
raise SystemExit("apply: a gateway mode needs a preset")
preset = load(preset_path)
if preset.get("provider") != mode:
raise SystemExit(
"preset declares provider '%s', not '%s'" % (preset.get("provider"), mode)
)
models = preset.get("models") or {}
# Cost guard. Gateways resell Anthropic models at full list price with no
# subscription discount, so routing a tier there is almost never intended.
# Opt in per preset with "allowAnthropicModels": true.
if not preset.get("allowAnthropicModels"):
offenders = []
for tier in TIERS:
if is_anthropic_model(models.get(tier)):
offenders.append("%s -> %s" % (tier, models[tier]))
if is_anthropic_model(preset.get("subagentModel")):
offenders.append("subagent -> %s" % preset["subagentModel"])
if offenders:
raise SystemExit(
"refusing to switch: preset routes a tier at an Anthropic model "
"through '%s' (%s). Gateways bill these at full price. Add "
'"allowAnthropicModels": true to the preset if deliberate.'
% (mode, "; ".join(offenders))
)
block = {}
block["ANTHROPIC_BASE_URL"] = str(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.
block["ANTHROPIC_API_KEY"] = ""
for tier in TIERS:
val = models.get(tier)
if val:
block["ANTHROPIC_DEFAULT_%s_MODEL" % tier.upper()] = str(val)
if preset.get("subagentModel"):
block["CLAUDE_CODE_SUBAGENT_MODEL"] = str(preset["subagentModel"])
if preset.get("gatewayModelDiscovery"):
block["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 guesses low and auto-compacts early.
ctx = preset.get("contextTokens")
if ctx:
block["CLAUDE_CODE_MAX_CONTEXT_TOKENS"] = str(int(ctx))
block["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = str(int(ctx))
for k, v in (preset.get("extraEnv") or {}).items():
block[k] = str(v)
auth = preset.get("auth") or {"mode": "vault", "keyRef": "openrouter"}
if auth.get("mode") == "vault":
if not helper:
raise SystemExit("apply: vault auth needs the key-helper path")
settings["apiKeyHelper"] = helper
else:
block["ANTHROPIC_AUTH_TOKEN"] = str(auth.get("token") or "lmstudio")
env = settings.get("env")
if isinstance(env, dict):
env.update(block)
else:
settings["env"] = block
written = list(block.keys())
save(settings_path, settings)
state["mode"] = mode
state["preset"] = os.path.splitext(os.path.basename(preset_path))[0] if preset_path else ""
state["writtenEnvKeys"] = written
save(state_path, state)
for k in written:
print(k)
def cmd_summary(argv):
p = load(argv[0])
if p.get("description"):
print(p["description"])
models = p.get("models") or {}
bits = ["%s=%s" % (t, models[t]) for t in TIERS if models.get(t)]
if bits:
print(" ".join(bits))
ctx = p.get("contextTokens")
if ctx:
print("context {:,} tokens base {}".format(int(ctx), p.get("baseUrl", "")))
else:
print("base %s" % p.get("baseUrl", ""))
def cmd_models(argv):
p = load(argv[0])
models = p.get("models") or {}
for t in TIERS:
print("%s\t%s" % (t, models.get(t, "")))
print("subagent\t%s" % (p.get("subagentModel") or ""))
def cmd_set_tier(argv):
path, tier, model = argv[0], argv[1], argv[2]
p = load(path)
if tier == "subagent":
p["subagentModel"] = model
elif tier in TIERS:
p.setdefault("models", {})[tier] = model
else:
raise SystemExit("unknown tier '%s'" % tier)
save(path, p)
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)
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",
}
print(json.dumps(base, indent=2))
def cmd_get(argv):
data = load(argv[0])
cur = data
for part in argv[1].split("."):
if isinstance(cur, dict) and part in cur:
cur = cur[part]
else:
return
if isinstance(cur, (dict, list)):
print(json.dumps(cur))
elif isinstance(cur, bool):
print("true" if cur else "false")
elif cur is not None:
print(cur)
def cmd_presets(argv):
d = argv[0]
if not os.path.isdir(d):
return
for name in sorted(os.listdir(d)):
if not name.endswith(".json"):
continue
try:
p = load(os.path.join(d, name))
except Exception:
continue
print("%s\t%s\t%s" % (name[:-5], p.get("provider", "openrouter"), p.get("description", "")))
def cmd_managed(argv):
state = load(argv[0]) if argv else {}
for k in sorted(set(BASE_MANAGED) | set(state.get("writtenEnvKeys") or [])):
print(k)
def cmd_settings_env(argv):
"""Print 'KEY=VALUE' for every managed key currently in settings.json."""
settings = load(argv[0])
state = load(argv[1]) if len(argv) > 1 else {}
keys = set(BASE_MANAGED) | set(state.get("writtenEnvKeys") or [])
if settings.get("apiKeyHelper"):
print("apiKeyHelper=%s" % settings["apiKeyHelper"])
env = settings.get("env") or {}
for k in sorted(keys):
if k in env:
print("%s=%s" % (k, env[k]))
def cmd_or_models(argv):
"""OpenRouter /v1/models on stdin -> 'id<TAB>ctx<TAB>$in<TAB>$out' lines."""
data = json.loads(sys.stdin.read())
for m in sorted(data.get("data") or [], key=lambda x: x.get("id", "")):
pricing = m.get("pricing") or {}
def per_m(key):
try:
return round(float(pricing.get(key)) * 1e6, 3)
except (TypeError, ValueError):
return ""
print("%s\t%s\t%s\t%s" % (m.get("id", ""), m.get("context_length", ""), per_m("prompt"), per_m("completion")))
def cmd_lms_models(argv):
"""LM Studio /api/v0/models on stdin -> 'id<TAB>state<TAB>ctx' lines."""
data = json.loads(sys.stdin.read())
for m in sorted(data.get("data") or [], key=lambda x: x.get("id", "")):
print("%s\t%s\t%s" % (m.get("id", ""), m.get("state", "unknown"), m.get("max_context_length", "")))
# 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
# after a switch, producing something like
# `~deepseek/deepseek-v4-flash-latest[1m]` that only fails at compaction, because
# compaction re-resolves the model from session state.
TAGGED_MODEL_RE = re.compile(r"\[[0-9]+[a-zA-Z]\]$")
def walk_model_ids(node, out):
"""Collect model-ish strings anywhere in the config.
Claude Code caches resolved models under more than one key - both
clientDataCacheSlots and additionalModelOptionsCache have been observed - so
scan rather than reach for a fixed path.
"""
if isinstance(node, dict):
for k, v in node.items():
if isinstance(v, str):
if k in ("model", "value") and v and (v[0].isalnum() or v[0] == "~"):
out.add(v)
else:
walk_model_ids(v, out)
elif isinstance(node, list):
for item in node:
walk_model_ids(item, out)
def cmd_stale_models(argv):
"""Cached model ids worth flagging, as 'kind<TAB>id' lines.
kind is 'anthropic' (would bill at gateway list price) or 'tagged' (carries a
[1m]-style marker that breaks compaction on a gateway).
"""
ids = set()
walk_model_ids(load(argv[0], {}), ids)
for m in sorted(i for i in ids if is_anthropic_model(i)):
print("anthropic\t" + m)
for m in sorted(i for i in ids if TAGGED_MODEL_RE.search(i)):
print("tagged\t" + m)
def cmd_strip_tags(argv):
"""Remove [1m]-style suffixes from cached model ids.
The tag is NOT junk everywhere: on an Anthropic id it is how Claude Code
selects the 1M variant, so stripping it silently downgrades that choice to
200k. On a gateway id the same tag is meaningless and breaks compaction.
Default is gateway ids only; pass 'all' as argv[1] to include Anthropic ids.
Prints 'strip<TAB>id' and 'keep<TAB>id' lines.
"""
path = argv[0]
strip_all = len(argv) > 1 and argv[1] == "all"
raw = open(path, encoding="utf-8").read()
tagged = sorted(set(re.findall(r'"([^"]*\[[0-9]+[a-zA-Z]\])"', raw)))
if not tagged:
return
target = [t for t in tagged if strip_all or not is_anthropic_model(t)]
kept = [t for t in tagged if t not in target]
for k in kept:
print("keep\t" + k)
if not target:
return
fixed = raw
for t in target:
fixed = fixed.replace('"' + t + '"', '"' + re.sub(r"\[[0-9]+[a-zA-Z]\]$", "", t) + '"')
json.loads(fixed) # refuse to write anything that is not valid JSON
with open(path, "w", encoding="utf-8") as fh:
fh.write(fixed)
for t in target:
print("strip\t" + t)
def cmd_health(argv):
"""Write health.json - the machine-readable state a fleet reader consumes.
usage: health <root> <claude.json> <mode> <preset> <guardrail> <keyBackend> <version>
Contract, deliberately narrow:
* NO key material. keysConfigured is names only.
* Model lists are [{id, anthropic}] so a reader never re-derives the
Anthropic matcher. A tagged *Anthropic* id is normal (that is how the 1M
variant is selected) and must not render as a fault.
* guardrailStatus is tri-state or null; never collapse it to a boolean.
"""
root, cfg_path, mode, preset, guardrail, key_backend, version = (argv + [""] * 7)[:7]
ids = set()
walk_model_ids(load(cfg_path, {}), ids)
try:
raw = open(cfg_path, encoding="utf-8").read()
ids.update(re.findall(r'"([^"]*\[[0-9]+[a-zA-Z]\])"', raw))
except OSError:
pass
def entries(seq):
return [{"id": i, "anthropic": bool(is_anthropic_model(i))} for i in sorted(seq)]
h = {
"schema": 1,
"tool": "claude-mode",
"version": version or "0.0.0",
"updatedAt": __import__("datetime").datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
"os": "posix",
"mode": mode,
"preset": "" if mode == "anthropic" else preset,
}
tagged = entries(i for i in ids if TAGGED_MODEL_RE.search(i))
if mode == "anthropic":
h["staleModelIds"] = []
h["taggedModelIds"] = tagged
else:
p = load(os.path.join(root, "presets", preset + ".json"), {})
h["provider"] = p.get("provider", "")
h["baseUrl"] = p.get("baseUrl", "")
h["models"] = {t: p.get("models", {}).get(t, "") for t in TIERS if p.get("models", {}).get(t)}
h["subagentModel"] = p.get("subagentModel", "")
h["contextTokens"] = int(p["contextTokens"]) if p.get("contextTokens") else None
h["gatewayDiscovery"] = bool(p.get("gatewayModelDiscovery"))
auth = p.get("auth") or {}
if auth.get("mode", "vault") == "vault":
h["keyBackend"] = key_backend or "unknown"
h["keysConfigured"] = [auth.get("keyRef", "openrouter")]
else:
h["keyBackend"] = "inline"
h["keysConfigured"] = []
h["costGuardPassed"] = True
h["guardrailStatus"] = guardrail or None
h["staleModelIds"] = entries(i for i in ids if is_anthropic_model(i))
h["taggedModelIds"] = tagged
save(os.path.join(root, "health.json"), h)
COMMANDS = {
"health": cmd_health,
"stale-models": cmd_stale_models,
"strip-tags": cmd_strip_tags,
"or-models": cmd_or_models,
"lms-models": cmd_lms_models,
"apply": cmd_apply,
"summary": cmd_summary,
"models": cmd_models,
"set-tier": cmd_set_tier,
"scaffold": cmd_scaffold,
"get": cmd_get,
"presets": cmd_presets,
"managed": cmd_managed,
"settings-env": cmd_settings_env,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
sys.exit("usage: cm-json.py <%s> ..." % "|".join(sorted(COMMANDS)))
try:
COMMANDS[sys.argv[1]](sys.argv[2:])
except SystemExit:
raise
except Exception as exc: # surface a clean message to the shell
sys.exit("cm-json: %s" % exc)
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Secret storage for claude-mode (POSIX port), sourced by claude-mode and by the
# key helper.
#
# Windows uses DPAPI, which binds ciphertext to one Windows user on one machine.
# There is no single equivalent here, so this picks the best available backend:
#
# macOS security login Keychain, unlocked with the session
# Linux secret-tool libsecret / GNOME Keyring, same idea
# Linux pass gpg-backed, agent-cached
# any file 0600 in ~/.claude-mode/vault - PLAINTEXT
#
# The file backend is the honest fallback: it is no worse than the API keys
# people already keep in .bashrc, but it is not encrypted and claude-mode says so
# out loud rather than implying protection it does not provide.
CM_VAULT_DIR="${CM_ROOT:-$HOME/.claude-mode}/vault"
CM_VAULT_SERVICE="claude-mode"
cm_vault_backend() {
if [ -n "${CLAUDE_MODE_VAULT:-}" ]; then printf '%s\n' "$CLAUDE_MODE_VAULT"; return; fi
if [ "$(uname -s)" = "Darwin" ] && command -v security >/dev/null 2>&1; then
printf 'security\n'; return
fi
if command -v secret-tool >/dev/null 2>&1; then printf 'secret-tool\n'; return; fi
if command -v pass >/dev/null 2>&1; then printf 'pass\n'; return; fi
printf 'file\n'
}
cm_vault_backend_label() {
case "$(cm_vault_backend)" in
security) printf 'macOS Keychain\n' ;;
secret-tool) printf 'libsecret (GNOME Keyring)\n' ;;
pass) printf 'pass (gpg)\n' ;;
file) printf 'plain file, 0600 (NOT encrypted)\n' ;;
esac
}
# cm_vault_set <ref> - reads the secret from stdin
cm_vault_set() {
local ref="$1" secret
IFS= read -r secret || true
[ -n "$secret" ] || { echo "empty key, aborted" >&2; return 1; }
case "$(cm_vault_backend)" in
security)
security add-generic-password -U -a "$ref" -s "$CM_VAULT_SERVICE" -w "$secret" >/dev/null
;;
secret-tool)
printf '%s' "$secret" | secret-tool store --label="claude-mode $ref" \
service "$CM_VAULT_SERVICE" ref "$ref" >/dev/null
;;
pass)
printf '%s\n' "$secret" | pass insert -m -f "$CM_VAULT_SERVICE/$ref" >/dev/null
;;
file)
mkdir -p "$CM_VAULT_DIR"
chmod 700 "$CM_VAULT_DIR" 2>/dev/null || true
local f="$CM_VAULT_DIR/$ref.key"
( umask 077; printf '%s' "$secret" > "$f" )
chmod 600 "$f" 2>/dev/null || true
;;
esac
}
# cm_vault_get <ref> - prints the secret, or nothing (exit 1) if absent
cm_vault_get() {
local ref="$1" out=""
case "$(cm_vault_backend)" in
security)
out="$(security find-generic-password -a "$ref" -s "$CM_VAULT_SERVICE" -w 2>/dev/null)" || return 1
;;
secret-tool)
out="$(secret-tool lookup service "$CM_VAULT_SERVICE" ref "$ref" 2>/dev/null)" || return 1
;;
pass)
out="$(pass show "$CM_VAULT_SERVICE/$ref" 2>/dev/null | head -n1)" || return 1
;;
file)
[ -f "$CM_VAULT_DIR/$ref.key" ] || return 1
out="$(cat "$CM_VAULT_DIR/$ref.key")"
;;
esac
[ -n "$out" ] || return 1
printf '%s' "$out"
}
cm_vault_has() { cm_vault_get "$1" >/dev/null 2>&1; }
cm_vault_mask() {
local k="$1"
if [ -z "$k" ]; then printf '(none)\n'; return; fi
if [ "${#k}" -le 12 ]; then printf '****\n'; return; fi
printf '%s...%s\n' "${k:0:8}" "${k: -4}"
}
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# One-time installer for claude-mode (Linux / macOS).
#
# Installs to ~/.claude-mode, symlinks the entry point into ~/.local/bin, and
# adds a marked block to your shell rc for the `claude` wrapper.
#
# Idempotent: safe to re-run to upgrade. Existing presets are kept unless
# --force is passed. ~/.claude/settings.json is NOT touched here - only by an
# actual `claude-mode <mode>`.
set -euo pipefail
FORCE=0
SKIP_KEY=0
for arg in "$@"; do
case "$arg" in
--force) FORCE=1 ;;
--skip-key-prompt) SKIP_KEY=1 ;;
-h|--help) echo "usage: install.sh [--force] [--skip-key-prompt]"; exit 0 ;;
esac
done
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="${CM_ROOT:-$HOME/.claude-mode}"
BINDIR="$HOME/.local/bin"
green() { printf ' \033[32mok \033[0m %s\n' "$*"; }
warn() { printf ' \033[33mwarn\033[0m %s\n' "$*"; }
fail() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; }
printf '\ninstalling claude-mode -> %s\n' "$ROOT"
# --- preflight -------------------------------------------------------------
PY="${CLAUDE_MODE_PYTHON:-python3}"
if ! command -v "$PY" >/dev/null 2>&1; then
fail "python3 not found. claude-mode uses it for JSON handling."
fail "install it (apt install python3 / dnf install python3 / brew install python) and re-run."
exit 1
fi
green "python3: $(command -v "$PY")"
if ! command -v curl >/dev/null 2>&1; then
warn 'curl not found - `models` and `doctor` network checks will not work'
fi
if ! command -v claude >/dev/null 2>&1; then
warn 'claude is not on PATH - claude-mode installs anyway, but nothing uses its config yet'
fi
# --- directories -----------------------------------------------------------
mkdir -p "$ROOT/bin" "$ROOT/presets" "$ROOT/vault" "$ROOT/backups" "$BINDIR"
chmod 700 "$ROOT" "$ROOT/vault" 2>/dev/null || true
# --- payload ---------------------------------------------------------------
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"
green 'copied claude-mode + helpers'
# --- presets (shared with the Windows build) -------------------------------
PRESET_SRC="$SRC/../presets"
[ -d "$PRESET_SRC" ] || PRESET_SRC="$SRC/presets"
if [ -d "$PRESET_SRC" ]; then
for f in "$PRESET_SRC"/*.json; do
[ -e "$f" ] || continue
base="$(basename "$f")"
if [ -e "$ROOT/presets/$base" ] && [ "$FORCE" -eq 0 ]; then
printf ' skip preset %s (exists; --force to overwrite)\n' "${base%.json}"
else
install -m 0644 "$f" "$ROOT/presets/$base"
green "preset ${base%.json}"
fi
done
else
warn 'no presets directory found in the payload'
fi
# --- initial state ---------------------------------------------------------
if [ ! -f "$ROOT/state.json" ]; then
printf '{\n "mode": "anthropic",\n "preset": "",\n "writtenEnvKeys": []\n}\n' > "$ROOT/state.json"
green 'state.json initialised (mode=anthropic)'
fi
# --- PATH entry ------------------------------------------------------------
ln -sf "$ROOT/bin/claude-mode" "$BINDIR/claude-mode"
green "linked $BINDIR/claude-mode"
case ":$PATH:" in
*":$BINDIR:"*) ;;
*) warn "$BINDIR is not on PATH - add it in your shell rc: export PATH=\"\$HOME/.local/bin:\$PATH\"" ;;
esac
# --- shell rc block --------------------------------------------------------
SNIPPET="$SRC/shell-snippet.sh"
START='# >>> claude-mode >>>'
END='# <<< claude-mode <<<'
add_block() {
local rc="$1"
[ -f "$rc" ] || return 0
if grep -qF "$START" "$rc" 2>/dev/null; then
# replace the existing block in place
"$PY" - "$rc" "$SNIPPET" "$START" "$END" <<'PYEOF'
import sys, re
rc, snip, start, end = sys.argv[1:5]
body = open(rc, encoding='utf-8').read()
new = open(snip, encoding='utf-8').read().rstrip('\n')
pattern = re.compile(re.escape(start) + r'.*?' + re.escape(end), re.S)
open(rc, 'w', encoding='utf-8').write(pattern.sub(lambda _: new, body))
PYEOF
green "updated claude-mode block in $rc"
else
printf '\n%s\n' "$(cat "$SNIPPET")" >> "$rc"
green "appended claude-mode block to $rc"
fi
}
RC_TOUCHED=0
for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do
if [ -f "$rc" ]; then add_block "$rc"; RC_TOUCHED=1; fi
done
[ "$RC_TOUCHED" -eq 0 ] && warn 'no ~/.bashrc or ~/.zshrc found - the `claude` wrapper was not installed'
# --- key -------------------------------------------------------------------
if [ "$SKIP_KEY" -eq 0 ]; then
# shellcheck source=/dev/null
. "$ROOT/bin/cm-vault.sh"
if cm_vault_has openrouter && [ "$FORCE" -eq 0 ]; then
green 'OpenRouter key already stored'
else
printf '\n'
"$ROOT/bin/claude-mode" set-key openrouter || warn 'key not stored - run `claude-mode set-key openrouter` later'
fi
fi
printf '\ndone. Open a new shell, then:\n'
printf ' claude-mode\n'
printf ' claude-mode status\n'
printf ' claude-mode doctor\n'
+40
View File
@@ -0,0 +1,40 @@
# >>> claude-mode >>>
# Installed by claude-code-switcher. Do not edit between the markers;
# re-run install.sh instead.
#
# `claude-mode` itself is a normal executable on PATH, so nothing here is needed
# to use it. This block exists only for the wrapper below.
#
# settings.json is the single source of truth for gateway config, so any
# inherited copy of these variables is stripped before launch: a stale
# ANTHROPIC_API_KEY would silently bypass the gateway, and stale
# ANTHROPIC_BASE_URL / *_MODEL values would break native Anthropic auth. Only
# the child process is affected - the rest of your shell is untouched.
claude() {
local real
real="$(command -v claude 2>/dev/null)"
if [ -z "$real" ] || [ "$real" = "claude" ]; then
# `command -v` resolved to this function; find the real binary on PATH.
real="$(type -P claude 2>/dev/null)"
fi
if [ -z "$real" ]; then
echo "claude: not found on PATH" >&2
return 127
fi
env -u ANTHROPIC_API_KEY \
-u ANTHROPIC_AUTH_TOKEN \
-u ANTHROPIC_BASE_URL \
-u ANTHROPIC_DEFAULT_OPUS_MODEL \
-u ANTHROPIC_DEFAULT_SONNET_MODEL \
-u ANTHROPIC_DEFAULT_HAIKU_MODEL \
-u ANTHROPIC_DEFAULT_FABLE_MODEL \
-u CLAUDE_CODE_SUBAGENT_MODEL \
-u CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY \
-u CLAUDE_CODE_ATTRIBUTION_HEADER \
-u CLAUDE_CODE_AUTO_COMPACT_WINDOW \
-u CLAUDE_CODE_MAX_CONTEXT_TOKENS \
-u CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC \
-u API_TIMEOUT_MS \
"$real" "$@"
}
# <<< claude-mode <<<
+18
View File
@@ -0,0 +1,18 @@
{
"provider": "openrouter",
"description": "Minimum cost. Everything on flash-class models with 1M context.",
"baseUrl": "https://openrouter.ai/api",
"auth": {
"mode": "vault",
"keyRef": "openrouter"
},
"models": {
"opus": "deepseek/deepseek-v4-pro",
"sonnet": "deepseek/deepseek-v4-flash-0731",
"haiku": "qwen/qwen3.7-flash",
"fable": "openai/gpt-5.6-luna-pro"
},
"subagentModel": "qwen/qwen3.7-flash",
"gatewayModelDiscovery": true,
"contextTokens": 1000000
}
+18
View File
@@ -0,0 +1,18 @@
{
"provider": "openrouter",
"description": "Daily driver. Cheap flash models on the hot tiers, GLM on fable.",
"baseUrl": "https://openrouter.ai/api",
"auth": {
"mode": "vault",
"keyRef": "openrouter"
},
"models": {
"opus": "deepseek/deepseek-v4-flash-0731",
"sonnet": "qwen/qwen3.7-flash",
"haiku": "openrouter/free",
"fable": "z-ai/glm-5.2"
},
"subagentModel": "inherit",
"gatewayModelDiscovery": true,
"contextTokens": 1000000
}
+21
View File
@@ -0,0 +1,21 @@
{
"provider": "lmstudio",
"description": "Local Qwen3.6-35B-A3B uncensored (heretic, MTP-preserved). Clean chat template - verified working with tools + streaming.",
"baseUrl": "http://127.0.0.1:1234",
"auth": {
"mode": "literal",
"token": "lmstudio"
},
"models": {
"opus": "qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved",
"sonnet": "qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved",
"haiku": "qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved",
"fable": "qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved"
},
"subagentModel": "qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved",
"gatewayModelDiscovery": true,
"extraEnv": {
"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"
},
"contextTokens": 262144
}
+21
View File
@@ -0,0 +1,21 @@
{
"provider": "lmstudio",
"description": "Local KAT-Coder v2.5. NOTE: its chat template asserts message order - if you hit 'Unable to generate parser for this template', use the lmstudio-qwen preset.",
"baseUrl": "http://127.0.0.1:1234",
"auth": {
"mode": "literal",
"token": "lmstudio"
},
"models": {
"opus": "kwaipilot_kat-coder-v2.5-dev",
"sonnet": "kwaipilot_kat-coder-v2.5-dev",
"haiku": "kwaipilot_kat-coder-v2.5-dev",
"fable": "kwaipilot_kat-coder-v2.5-dev"
},
"subagentModel": "kwaipilot_kat-coder-v2.5-dev",
"gatewayModelDiscovery": true,
"extraEnv": {
"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"
},
"contextTokens": 262144
}
+22
View File
@@ -0,0 +1,22 @@
{
"provider": "zai",
"description": "Z.AI GLM coding plan. Same endpoint the official coding helper uses, but with real per-tier model mapping.",
"baseUrl": "https://api.z.ai/api/anthropic",
"auth": {
"mode": "vault",
"keyRef": "zai"
},
"models": {
"opus": "glm-5.2",
"sonnet": "glm-5.2",
"haiku": "glm-4.7",
"fable": "glm-5.2"
},
"subagentModel": "inherit",
"gatewayModelDiscovery": false,
"extraEnv": {
"API_TIMEOUT_MS": "3000000",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
},
"contextTokens": 1000000
}
+51
View File
@@ -0,0 +1,51 @@
# >>> claude-mode >>>
# Installed by claude-code-switcher. Do not edit between the markers;
# re-run install.ps1 instead.
function claude-mode {
& (Join-Path $env:USERPROFILE '.claude-mode\claude-mode.ps1') @args
}
# Wrapper around claude.exe. settings.json is the single source of truth for
# gateway config, so any inherited process-level copy of these variables is
# stripped before launch: a stale ANTHROPIC_API_KEY would silently bypass the
# OpenRouter gateway, and stale ANTHROPIC_BASE_URL / *_MODEL values would break
# native Anthropic auth. Restored afterwards so other tools in the shell are
# unaffected.
function claude {
$exe = Get-Command 'claude.exe' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $exe) { Write-Error 'claude.exe not found on PATH'; return }
$names = @(
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_DEFAULT_FABLE_MODEL',
'CLAUDE_CODE_SUBAGENT_MODEL',
'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY',
'CLAUDE_CODE_ATTRIBUTION_HEADER',
'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
'CLAUDE_CODE_MAX_CONTEXT_TOKENS',
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
'API_TIMEOUT_MS'
)
$saved = @{}
foreach ($n in $names) {
$v = [Environment]::GetEnvironmentVariable($n, 'Process')
if ($null -ne $v) {
$saved[$n] = $v
[Environment]::SetEnvironmentVariable($n, $null, 'Process')
}
}
try {
& $exe.Source @args
} finally {
foreach ($n in $saved.Keys) { [Environment]::SetEnvironmentVariable($n, $saved[$n], 'Process') }
}
}
# <<< claude-mode <<<
+127
View File
@@ -0,0 +1,127 @@
<#
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