From 112068314c8935aab40c10be85788578b6c1b0d8 Mon Sep 17 00:00:00 2001 From: smoido Date: Sun, 30 Aug 2026 21:05:48 +0300 Subject: [PATCH] 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. --- README.md | 326 ++++++ VERSION | 1 + bin/claude-key-helper.cmd | 4 + bin/claude-key-helper.ps1 | 48 + claude-mode.ps1 | 2171 ++++++++++++++++++++++++++++++++++++ install.ps1 | 144 +++ linux/bootstrap.sh | 96 ++ linux/claude-key-helper.sh | 38 + linux/claude-mode | 1133 +++++++++++++++++++ linux/cm-json.py | 481 ++++++++ linux/cm-vault.sh | 95 ++ linux/install.sh | 139 +++ linux/shell-snippet.sh | 40 + presets/cheap.json | 18 + presets/default.json | 18 + presets/lmstudio-qwen.json | 21 + presets/lmstudio.json | 21 + presets/zai.json | 22 + profile-snippet.ps1 | 51 + scripts/build-package.ps1 | 127 +++ 20 files changed, 4994 insertions(+) create mode 100644 README.md create mode 100644 VERSION create mode 100755 bin/claude-key-helper.cmd create mode 100644 bin/claude-key-helper.ps1 create mode 100644 claude-mode.ps1 create mode 100644 install.ps1 create mode 100644 linux/bootstrap.sh create mode 100644 linux/claude-key-helper.sh create mode 100755 linux/claude-mode create mode 100644 linux/cm-json.py create mode 100644 linux/cm-vault.sh create mode 100755 linux/install.sh create mode 100644 linux/shell-snippet.sh create mode 100644 presets/cheap.json create mode 100644 presets/default.json create mode 100644 presets/lmstudio-qwen.json create mode 100644 presets/lmstudio.json create mode 100644 presets/zai.json create mode 100644 profile-snippet.ps1 create mode 100644 scripts/build-package.ps1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce79f83 --- /dev/null +++ b/README.md @@ -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 +claude-mode preset new [from] copy an existing preset +claude-mode preset set +claude-mode preset all point every tier at one model +claude-mode preset rm + +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 = +CLAUDE_CODE_AUTO_COMPACT_WINDOW = +``` + +| 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/.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 +``` diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..27f9cd3 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.8.0 diff --git a/bin/claude-key-helper.cmd b/bin/claude-key-helper.cmd new file mode 100755 index 0000000..73f6781 --- /dev/null +++ b/bin/claude-key-helper.cmd @@ -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" diff --git a/bin/claude-key-helper.ps1 b/bin/claude-key-helper.ps1 new file mode 100644 index 0000000..44f71aa --- /dev/null +++ b/bin/claude-key-helper.ps1 @@ -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 +} diff --git a/claude-mode.ps1 b/claude-mode.ps1 new file mode 100644 index 0000000..1fa413e --- /dev/null +++ b/claude-mode.ps1 @@ -0,0 +1,2171 @@ +<# +.SYNOPSIS + claude-mode - switch Claude Code system-wide between native Anthropic auth, + OpenRouter, Z.AI, and a local LM Studio server, with named model presets. + +.DESCRIPTION + State lives in %USERPROFILE%\.claude-mode. Switching rewrites the managed keys + inside %USERPROFILE%\.claude\settings.json, which Claude Code re-reads at every + startup - so a switch applies to every new `claude` invocation from any shell, + the VS Code extension, and the desktop app, with nothing to re-source. + + Secrets are never written to settings.json. A remote provider's API key is + stored DPAPI-encrypted (bound to this Windows user + machine) and handed to + Claude Code at runtime via the `apiKeyHelper` hook. LM Studio's placeholder + token is not a secret and is written inline. + + Run with no arguments for an interactive menu. + +.NOTES + Windows PowerShell 5.1 compatible. No external dependencies. +#> + +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [string] $Command = '', + + [Parameter(Position = 1, ValueFromRemainingArguments = $true)] + [string[]] $Rest +) + +# v1 only (uninitialised variables). v2 would throw on absent JSON properties, +# which is normal here - presets and settings.json are both partially shaped. +Set-StrictMode -Version 1.0 +$ErrorActionPreference = 'Stop' +try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { } + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +$script:Root = Join-Path $env:USERPROFILE '.claude-mode' +$script:PresetDir = Join-Path $script:Root 'presets' +$script:VaultDir = Join-Path $script:Root 'vault' +$script:BackupDir = Join-Path $script:Root 'backups' +$script:BinDir = Join-Path $script:Root 'bin' +$script:StatePath = Join-Path $script:Root 'state.json' +$script:HelperCmd = Join-Path $script:BinDir 'claude-key-helper.cmd' +$script:SettingsDir = Join-Path $env:USERPROFILE '.claude' +$script:Settings = Join-Path $script:SettingsDir 'settings.json' +$script:LmStudioDir = Join-Path $env:USERPROFILE '.lmstudio' + +# Baseline set of settings.json env keys this tool owns. On every switch these +# are deleted first, together with whatever a previous switch actually wrote +# (tracked in state.json), so no value can survive a mode change. +$script:BaseManagedEnvKeys = @( + '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 are read by the CLI (21 and 37 references in 2.1.221) and both pin a + # concrete model outside the tier mapping. A stale value in either survives a + # switch and is a known cause of "works normally, dies on compaction", + # because background summarisation uses the small/fast slot. + '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' +) + +$script:Tiers = @('opus', 'sonnet', 'haiku', 'fable') +$script:Version = '0.0.0' +try { $__v = Join-Path $PSScriptRoot 'VERSION'; if (Test-Path $__v) { $script:Version = (Get-Content $__v -Raw).Trim() } } catch { } +$script:Modes = @('anthropic', 'openrouter', 'zai', 'lmstudio') +$script:NodeExe = $null # resolved lazily by Format-JsonPretty + +$script:ModeLabel = @{ + 'anthropic' = 'Anthropic - your subscription login, no gateway' + 'openrouter' = 'OpenRouter - remote, pay-per-token, any vendor' + 'zai' = 'Z.AI - GLM coding plan' + 'lmstudio' = 'LM Studio - local server, offline, free' +} + +# `claude-mode ` with no preset named uses this one. Deliberately a +# fixed choice rather than "most recently used", so the command is predictable. +$script:ProviderDefaultPreset = @{ + 'openrouter' = 'default' + 'zai' = 'zai' + 'lmstudio' = 'lmstudio' +} + +# --------------------------------------------------------------------------- +# 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' + 'openrouter' = 'Cyan' + 'zai' = 'Green' + 'lmstudio' = 'Yellow' +} + +# 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, OpenRouter, Z.AI, LM Studio + + claude-mode interactive menu + claude-mode status active mode, preset, model map + + claude-mode anthropic native login/subscription (clears all gateway config) + claude-mode openrouter [preset] remote gateway (default preset: default) + claude-mode zai [preset] Z.AI GLM coding plan (default preset: zai) + claude-mode lmstudio [preset] local LM Studio (default preset: lmstudio) + + claude-mode presets list presets + claude-mode preset show + claude-mode preset new [from] create a preset (copies 'from') + claude-mode preset set + tier = opus | sonnet | haiku | fable | subagent + claude-mode preset all + point every tier at one model + claude-mode preset rm + + claude-mode set-key [ref] 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 +} + +# --------------------------------------------------------------------------- +# 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)" + } +} + +# --------------------------------------------------------------------------- +# 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'" +} + +# --------------------------------------------------------------------------- +# 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) + Initialize-Root + 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) { + Write-Warn2 "that key is only $($plain.Length) characters - 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 +} + +# --------------------------------------------------------------------------- +# 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( + [ValidateSet('anthropic', 'openrouter', 'zai', 'lmstudio')] [string] $Mode, + [string] $PresetName + ) + + Initialize-Root + if (-not (Test-Path -LiteralPath $script:SettingsDir)) { + New-Item -ItemType Directory -Path $script:SettingsDir -Force | Out-Null + } + + $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" } + + # 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'] = $script:HelperCmd + } else { + $tok = '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 +} + +# --------------------------------------------------------------------------- +# Stray environment variable detection +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# 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) + if ($Mode -ne 'openrouter' -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 +} + +# --------------------------------------------------------------------------- +# health.json - the machine-readable state the Arkylx Index (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' } +} + +# --------------------------------------------------------------------------- +# 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 +} + +# 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) { continue } + if ([int]$m.context_length -lt $declared) { + Write-Warn2 ("{0} model {1} only has {2:N0} ctx, below the declared {3:N0}." -f $t, $id, $m.context_length, $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.' + } + } + } +} + +# --------------------------------------------------------------------------- +# 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} " -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 ' } + (Get-Preset $name) | ConvertTo-Json -Depth 20 | Write-Host + } + 'new' { + if (-not $name) { throw 'usage: claude-mode preset new [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 " + } + 'rm' { + if (-not $name) { throw 'usage: claude-mode preset rm ' } + $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 ' } + Set-PresetTier -Name $name -Tier $Argv[2].ToLower() -Model $Argv[3] + } + 'all' { + if ($Argv.Count -lt 3) { throw 'usage: claude-mode preset all ' } + $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'] + + if ($mode -eq 'lmstudio') { + $preset = Get-Preset ([string]$state['preset']) + Write-Head "models installed in LM Studio at $($preset['baseUrl'])" + $tpl = Get-LmStudioTemplateReport + Get-LmStudioModels ([string]$preset['baseUrl']) | + Where-Object { -not $Filter -or $_.Id -like "*$Filter*" } | + Sort-Object Id | + ForEach-Object { + $short = ($_.Id -split '/')[-1].ToLower() + $t = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1 + $flag = '' + if ($t -and $t.Assertions.Count -gt 0) { $flag = 'TEMPLATE RISK' } + [pscustomobject]@{ Id = $_.Id; State = $_.State; Ctx = $_.Ctx; Note = $flag } + } | Format-Table -AutoSize + 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." + return + } + + if ($mode -eq 'zai') { + Write-Head 'Z.AI GLM models (from Z.AI docs - no public catalogue endpoint)' + @('glm-5.2 - flagship coding model (opus/sonnet tier)', + 'glm-4.7 - fast/cheap tier (haiku tier)') | ForEach-Object { Write-Host " $_" } + Write-Host ' Full list: https://docs.z.ai/devpack/tool/claude' + return + } + + Write-Head 'fetching https://openrouter.ai/api/v1/models ...' + $resp = Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/models' -Method Get -TimeoutSec 30 + $rows = foreach ($m in $resp.data) { + if ($Filter -and $m.id -notlike "*$Filter*") { continue } + [pscustomobject]@{ + Id = $m.id + Ctx = $m.context_length + 'In/M$' = if ($m.pricing -and $m.pricing.prompt) { [math]::Round([double]$m.pricing.prompt * 1e6, 3) } else { $null } + 'Out/M$' = if ($m.pricing -and $m.pricing.completion) { [math]::Round([double]$m.pricing.completion * 1e6, 3) } else { $null } + } + } + $rows | Sort-Object Id | Format-Table -AutoSize +} + +function Invoke-Doctor { + $state = Get-State + $mode = [string]$state['mode'] + Write-Head "doctor - mode '$mode'" + + try { + [void](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" } + + try { + $out = (& cmd.exe /c "`"$($script:HelperCmd)`"" 2>&1 | Out-String).Trim() + if ($out -and $key -and $out -eq $key) { Write-Ok 'apiKeyHelper emits the correct key' } + elseif ($out) { Write-Err2 "apiKeyHelper output does not match vault key (got: $(Format-KeyMask $out))" } + else { Write-Err2 'apiKeyHelper produced no output' } + } catch { + Write-Err2 "apiKeyHelper failed to run: $($_.Exception.Message)" + } + + if ($key -and $mode -eq 'openrouter') { + 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 $mode -eq 'zai') { + # 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 "Z.AI endpoint accepted the key ($base/v1/messages)" + } catch { + $detail = '' + if ($_.ErrorDetails) { $detail = ($_.ErrorDetails.Message -replace '\s+', ' ') } + Write-Err2 "Z.AI request failed: $($_.Exception.Message) $detail" + } + } + } else { + Write-Ok "inline token '$($auth['token'])' (no secret in settings.json)" + } + + if ($mode -eq 'lmstudio') { + $catalogue = $null + try { + $catalogue = Get-LmStudioModels $base + Write-Ok "LM Studio server reachable at $base ($($catalogue.Count) models installed)" + } catch { + Write-Err2 "LM Studio not reachable at $base - start the server (LM Studio > Developer > Start Server)" + } + + if ($catalogue) { + $wanted = @() + foreach ($t in $script:Tiers) { + if ($preset['models'].Contains($t)) { $wanted += [string]$preset['models'][$t] } + } + if ($preset.Contains('subagentModel')) { $wanted += [string]$preset['subagentModel'] } + + foreach ($id in ($wanted | Sort-Object -Unique)) { + $m = $catalogue | Where-Object { $_.Id -eq $id } | Select-Object -First 1 + if (-not $m) { + Write-Err2 "model NOT installed in LM Studio: $id (run 'claude-mode models' for valid ids)" + continue + } + if ($m.State -eq 'loaded') { Write-Ok "$id [loaded, ctx $($m.Ctx)]" } + else { Write-Ok "$id [$($m.State) - LM Studio will JIT-load it on first request, ctx $($m.Ctx)]" } + + if ($m.Ctx -and [int]$m.Ctx -lt 25000) { + Write-Warn2 "$id context is $($m.Ctx); LM Studio recommends >25k for Claude Code." + } + + if ($preset.Contains('contextTokens') -and $preset['contextTokens'] -and $m.Ctx) { + $declared = [int]$preset['contextTokens'] + if ([int]$m.Ctx -lt $declared) { + Write-Warn2 ("declared contextTokens {0:N0} exceeds {1}'s {2:N0} - lower it." -f $declared, $id, $m.Ctx) + } else { + Write-Ok ("declared context window: {0:N0} tokens" -f $declared) + } + } elseif (-not $preset.Contains('contextTokens')) { + Write-Warn2 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early.' + } + + $tpl = Test-LmStudioTemplate $id + if ($tpl -and $tpl.Assertions.Count -gt 0) { + Write-Warn2 "$id chat template hard-asserts message order ($($tpl.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'." + } + } + } + } elseif ($mode -eq 'openrouter') { + try { + $cat = (Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/models' -TimeoutSec 30).data + foreach ($t in $script:Tiers) { + if (-not $preset['models'].Contains($t)) { continue } + $id = [string]$preset['models'][$t] + $m = $cat | Where-Object { $_.id -eq $id } | Select-Object -First 1 + if ($m) { Write-Ok ("{0,-6} {1} [ctx {2:N0}]" -f $t, $id, $m.context_length) } + else { Write-Err2 "$t model NOT available from this provider: $id" } + } + Test-ContextWindow -Preset $preset -Catalogue $cat + } catch { + Write-Warn2 "could not verify model ids: $($_.Exception.Message)" + } + } + } + + 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']) +} + +# --------------------------------------------------------------------------- +# 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 = @() + + if ($provider -eq 'lmstudio') { + $models = @(Get-LmStudioModels ([string]$Preset['baseUrl'])) + $tpl = Get-LmStudioTemplateReport + foreach ($m in ($models | Sort-Object Id)) { + $short = ($m.Id -split '/')[-1].ToLower() + $risk = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1 + $flag = '' + $det = @("state: $($m.State) max context: $($m.Ctx)") + if ($risk -and $risk.Assertions.Count -gt 0) { + $flag = ' [TEMPLATE RISK]' + $det += 'chat template asserts message order - can break tool calls' + } + $out += [pscustomobject]@{ Id = $m.Id; Key = $m.Id; Label = ($m.Id + $flag); Detail = $det } + } + } + elseif ($provider -eq 'openrouter') { + $cat = (Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/models' -TimeoutSec 30).data + foreach ($m in ($cat | Sort-Object id)) { + $inp = if ($m.pricing -and $m.pricing.prompt) { [math]::Round([double]$m.pricing.prompt * 1e6, 3) } else { $null } + $outp= if ($m.pricing -and $m.pricing.completion) { [math]::Round([double]$m.pricing.completion * 1e6, 3) } else { $null } + $det = @(("context {0:N0} `$$inp in / `$$outp out per 1M tokens" -f [int]$m.context_length)) + $out += [pscustomobject]@{ Id = $m.id; Key = $m.id; Label = $m.id; Detail = $det } + } + } + elseif ($provider -eq 'zai') { + # Z.AI publishes no public catalogue endpoint; these are the documented + # coding-plan models. Anything else can still be typed manually. + $out += [pscustomobject]@{ Id = 'glm-5.2'; Key = 'glm-5.2'; Label = 'glm-5.2'; Detail = @('flagship coding model - opus/sonnet tier') } + $out += [pscustomobject]@{ Id = 'glm-4.7'; Key = 'glm-4.7'; Label = 'glm-4.7'; Detail = @('fast/cheap tier - haiku') } + } + + $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 = ''; 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) + + $base = [ordered]@{ + provider = $Provider + description = 'new preset' + } + + switch ($Provider) { + 'openrouter' { + $base['baseUrl'] = 'https://openrouter.ai/api' + $base['auth'] = [ordered]@{ mode = 'vault'; keyRef = 'openrouter' } + } + 'zai' { + $base['baseUrl'] = 'https://api.z.ai/api/anthropic' + $base['auth'] = [ordered]@{ mode = 'vault'; keyRef = 'zai' } + } + 'lmstudio' { + $base['baseUrl'] = 'http://127.0.0.1:1234' + $base['auth'] = [ordered]@{ mode = 'literal'; token = 'lmstudio' } + } + } + + $base['models'] = [ordered]@{ opus = ''; sonnet = ''; haiku = ''; fable = '' } + $base['subagentModel'] = 'inherit' + $base['gatewayModelDiscovery'] = ($Provider -eq 'openrouter') + $base['contextTokens'] = if ($Provider -eq 'lmstudio') { 262144 } else { 1000000 } + if ($Provider -eq 'lmstudio') { $base['extraEnv'] = [ordered]@{ CLAUDE_CODE_ATTRIBUTION_HEADER = '0' } } + if ($Provider -eq 'zai') { + $base['extraEnv'] = [ordered]@{ + API_TIMEOUT_MS = '3000000' + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1' + } + } + 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 = ''; 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 + } +} + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + +Initialize-Root + +try { + $cmd = $Command.ToLower() + if ($cmd -eq 'z.ai' -or $cmd -eq 'z-ai') { $cmd = 'zai' } + + switch ($cmd) { + '' { Invoke-Menu } + 'menu' { Invoke-Menu } + 'status' { Invoke-Status } + 'anthropic' { Set-ClaudeMode -Mode 'anthropic' -PresetName '' } + 'openrouter' { + $req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null } + Set-ClaudeMode -Mode 'openrouter' -PresetName (Resolve-PresetForProvider 'openrouter' $req) + } + 'zai' { + $req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null } + Set-ClaudeMode -Mode 'zai' -PresetName (Resolve-PresetForProvider 'zai' $req) + } + 'lmstudio' { + $req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null } + Set-ClaudeMode -Mode 'lmstudio' -PresetName (Resolve-PresetForProvider 'lmstudio' $req) + } + 'presets' { Invoke-Presets } + 'preset' { Invoke-PresetCmd -Argv $Rest } + 'set-key' { Set-VaultKey -Ref $(if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { 'openrouter' }) } + 'models' { Invoke-Models -Filter $(if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null }) } + 'doctor' { Invoke-Doctor } + 'repair' { [void](Invoke-Repair -All:([bool]($Rest -and ($Rest -contains '--all')))) } + 'help' { Show-Usage } + '--help' { Show-Usage } + '-h' { Show-Usage } + default { Write-Err2 "unknown command '$Command'"; Show-Usage; exit 1 } + } +} catch { + Write-Err2 $_.Exception.Message + exit 1 +} diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..6f380b0 --- /dev/null +++ b/install.ps1 @@ -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' diff --git a/linux/bootstrap.sh b/linux/bootstrap.sh new file mode 100644 index 0000000..559a070 --- /dev/null +++ b/linux/bootstrap.sh @@ -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 diff --git a/linux/claude-key-helper.sh b/linux/claude-key-helper.sh new file mode 100644 index 0000000..5c76a79 --- /dev/null +++ b/linux/claude-key-helper.sh @@ -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 diff --git a/linux/claude-mode b/linux/claude-mode new file mode 100755 index 0000000..32a9eef --- /dev/null +++ b/linux/claude-mode @@ -0,0 +1,1133 @@ +#!/usr/bin/env bash +# claude-mode - switch Claude Code system-wide between Anthropic, OpenRouter, +# Z.AI and a local LM Studio server, with named model presets. +# +# POSIX port of the Windows/PowerShell build. Same design: the switch rewrites +# the managed keys inside ~/.claude/settings.json, which Claude Code re-reads at +# every startup, so it applies to every new `claude` invocation - CLI, VS Code +# extension, desktop app - with nothing to re-source. +# +# Secrets never enter settings.json; see cm-vault.sh for the storage backends. + +set -uo pipefail + +CM_ROOT="${CM_ROOT:-$HOME/.claude-mode}" +CM_BIN="$CM_ROOT/bin" +CM_PRESETS="$CM_ROOT/presets" +CM_BACKUPS="$CM_ROOT/backups" +CM_STATE="$CM_ROOT/state.json" +CM_SETTINGS_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +CM_SETTINGS="$CM_SETTINGS_DIR/settings.json" +CM_HELPER="$CM_BIN/claude-key-helper.sh" + +PY="${CLAUDE_MODE_PYTHON:-python3}" +JSON="$CM_BIN/cm-json.py" + +# shellcheck source=/dev/null +. "$CM_BIN/cm-vault.sh" + +MODES=(anthropic openrouter zai lmstudio) +TIERS=(opus sonnet haiku fable) + +# --------------------------------------------------------------------------- +# Colour / output +# --------------------------------------------------------------------------- + +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + C_RESET=$'\033[0m'; C_DIM=$'\033[90m'; C_CYAN=$'\033[36m'; C_GREEN=$'\033[32m' + C_YELLOW=$'\033[33m'; C_RED=$'\033[31m'; C_MAGENTA=$'\033[35m'; C_WHITE=$'\033[97m' + C_GRAY=$'\033[37m'; C_DKCYAN=$'\033[36;2m' +else + C_RESET=''; C_DIM=''; C_CYAN=''; C_GREEN=''; C_YELLOW=''; C_RED='' + C_MAGENTA=''; C_WHITE=''; C_GRAY=''; C_DKCYAN='' +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_RED" "$C_RESET" "$*" >&2; } +head_() { printf '\n%s%s%s\n' "$C_CYAN" "$*" "$C_RESET"; } + +mode_color() { + case "$1" in + anthropic) printf '%s' "$C_MAGENTA" ;; + openrouter) printf '%s' "$C_CYAN" ;; + zai) printf '%s' "$C_GREEN" ;; + lmstudio) printf '%s' "$C_YELLOW" ;; + *) printf '%s' "$C_GRAY" ;; + esac +} + +mode_label() { + case "$1" in + anthropic) printf 'Anthropic - your subscription login, no gateway' ;; + openrouter) printf 'OpenRouter - remote, pay-per-token, any vendor' ;; + zai) printf 'Z.AI - GLM coding plan' ;; + lmstudio) printf 'LM Studio - local server, offline, free' ;; + esac +} + +# 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, OpenRouter, Z.AI, LM Studio + + claude-mode interactive menu + claude-mode status active mode, preset, model map + + claude-mode anthropic native login (clears all gateway config) + claude-mode openrouter [preset] remote gateway (default: default) + claude-mode zai [preset] Z.AI GLM coding plan (default: zai) + claude-mode lmstudio [preset] local LM Studio (default: lmstudio) + + claude-mode presets list presets + claude-mode preset show + claude-mode preset new [from] create a preset (copies 'from') + claude-mode preset set + claude-mode preset all + claude-mode preset rm + + claude-mode set-key [ref] store an API key (hidden prompt) + claude-mode models [filter] models available from the active provider + claude-mode doctor verify auth, endpoint, model ids, env + claude-mode repair strip [1m] tags from cached model ids +EOF +} + +# --------------------------------------------------------------------------- +# 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() { "$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"; } + +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" +} + +default_preset_for() { + case "$1" in + openrouter) printf 'default' ;; + zai) printf 'zai' ;; + lmstudio) printf 'lmstudio' ;; + esac +} + +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" +} + +# --------------------------------------------------------------------------- +# 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")" + [ -f "$preset_file" ] || { err "preset '$preset_name' not found"; return 1; } + + local auth_mode key_ref + auth_mode="$(jget "$preset_file" auth.mode)"; [ -z "$auth_mode" ] && auth_mode=vault + if [ "$auth_mode" = "vault" ]; then + key_ref="$(jget "$preset_file" auth.keyRef)"; [ -z "$key_ref" ] && key_ref=openrouter + if ! cm_vault_has "$key_ref"; then + err "no key stored for ref '$key_ref'. Run: claude-mode set-key $key_ref" + return 1 + fi + [ -x "$CM_HELPER" ] || { err "key helper missing/not executable: $CM_HELPER"; return 1; } + fi + fi + + 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" + + 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 + [ "$mode" = "openrouter" ] || 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 +} + +# --------------------------------------------------------------------------- +# Provider catalogues +# --------------------------------------------------------------------------- + +or_catalogue() { + curl -fsS --max-time 30 https://openrouter.ai/api/v1/models 2>/dev/null | "$PY" "$JSON" or-models 2>/dev/null +} + +lms_catalogue() { + local base="${1%/}" + curl -fsS --max-time 10 "$base/api/v0/models" 2>/dev/null | "$PY" "$JSON" lms-models 2>/dev/null +} + +# --------------------------------------------------------------------------- +# 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 +} + +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="${1:-}" mode preset pf + mode="$(state_mode)"; preset="$(state_preset)"; pf="$(preset_path "$preset")" + + case "$mode" in + lmstudio) + head_ "models installed in LM Studio at $(jget "$pf" baseUrl)" + lms_catalogue "$(jget "$pf" baseUrl)" | while IFS=$'\t' read -r id st ctx; do + [ -z "$filter" ] || case "$id" in *"$filter"*) ;; *) continue ;; esac + printf ' %-58s %-11s %s\n' "$id" "$st" "$ctx" + done + ;; + zai) + head_ 'Z.AI GLM models (from Z.AI docs - no public catalogue endpoint)' + say 'glm-5.2 - flagship coding model (opus/sonnet tier)' + say 'glm-4.7 - fast/cheap tier (haiku tier)' + ;; + *) + head_ 'fetching https://openrouter.ai/api/v1/models ...' + or_catalogue | while IFS=$'\t' read -r id ctx pin pout; do + [ -z "$filter" ] || case "$id" in *"$filter"*) ;; *) continue ;; esac + printf ' %-52s %10s $%-8s $%s\n' "$id" "$ctx" "$pin" "$pout" + done + ;; + esac +} + +cmd_set_key() { + local ref="${1:-openrouter}" secret + 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 + printf ' paste the API key for ref '\''%s'\'' (input hidden): ' "$ref" + IFS= read -rs secret; printf '\n' + [ -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 + [ "${#secret}" -lt 16 ] && warn "that key is only ${#secret} characters - unusually short. Storing anyway." + 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)" +} + +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 + out="$("$CM_HELPER" 2>/dev/null)" + if [ -n "$out" ] && [ "$out" = "$key" ]; then ok 'apiKeyHelper emits the correct key' + elif [ -n "$out" ]; then err 'apiKeyHelper output does not match the vault' + else err 'apiKeyHelper produced no output'; fi + + if [ -n "$key" ] && [ "$mode" = "openrouter" ]; 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 + if [ -n "$key" ] && [ "$mode" = "zai" ]; 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 "Z.AI endpoint accepted the key ($base/v1/messages)" + else + err 'Z.AI request failed' + fi + fi + else + ok "inline token '$(jget "$pf" auth.token)' (no secret in settings.json)" + fi + + if [ "$mode" = "lmstudio" ]; then + local cat; cat="$(lms_catalogue "$base")" + if [ -n "$cat" ]; then + ok "LM Studio reachable at $base ($(printf '%s\n' "$cat" | wc -l | tr -d ' ') models installed)" + local t id row st ctx + for t in "${TIERS[@]}"; do + id="$(jget "$pf" "models.$t")"; [ -n "$id" ] || continue + row="$(printf '%s\n' "$cat" | tsv_find "$id")" || row='' + if [ -z "$row" ]; then err "$t model NOT installed in LM Studio: $id"; continue; fi + st="$(tsv_field "$row" 2)"; ctx="$(tsv_field "$row" 3)" + ok "$t $id [$st, ctx $ctx]" + done + else + err "LM Studio not reachable at $base - start the server (Developer > Start Server)" + fi + elif [ "$mode" = "openrouter" ]; then + local cat; cat="$(or_catalogue)" + if [ -n "$cat" ]; then + local t id row ctx declared + declared="$(jget "$pf" contextTokens)" + for t in "${TIERS[@]}"; do + id="$(jget "$pf" "models.$t")"; [ -n "$id" ] || continue + row="$(printf '%s\n' "$cat" | tsv_find "$id")" || row='' + if [ -z "$row" ]; then err "$t model NOT available from OpenRouter: $id"; continue; fi + ctx="$(tsv_field "$row" 2)" + ok "$(printf '%-6s %s [ctx %s]' "$t" "$id" "$ctx")" + if [ -n "$declared" ] && [ -n "$ctx" ] && [ "$ctx" -lt "$declared" ] 2>/dev/null; then + 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 + fi + 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' + else + warn 'could not fetch the OpenRouter catalogue' + fi + fi + 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 +} + +cmd_preset() { + local sub="${1:-}" name="${2:-}" + case "$sub" in + show) + [ -n "$name" ] || { err 'usage: claude-mode preset show '; return 1; } + cat "$(preset_path "$name")" + ;; + new) + [ -n "$name" ] || { err 'usage: claude-mode preset new [copy-from]'; return 1; } + [ -f "$(preset_path "$name")" ] && { err "preset '$name' already exists"; return 1; } + local from="${3:-default}" + [ -f "$(preset_path "$from")" ] || { err "source preset '$from' not found"; return 1; } + cp "$(preset_path "$from")" "$(preset_path "$name")" + ok "created $(preset_path "$name") from '$from'" + ;; + rm) + [ -n "$name" ] || { err 'usage: claude-mode preset rm '; 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 + rm -f "$(preset_path "$name")"; ok "deleted preset '$name'" + ;; + set) + local tier="${3:-}" model="${4:-}" + [ -n "$name" ] && [ -n "$tier" ] && [ -n "$model" ] || { err 'usage: claude-mode preset set '; return 1; } + "$PY" "$JSON" set-tier "$(preset_path "$name")" "$tier" "$model" || return 1 + ok "$name : $tier -> $model" + reapply_if_active "$name" + ;; + all) + local model="${3:-}" + [ -n "$name" ] && [ -n "$model" ] || { err 'usage: claude-mode preset all '; 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" + ;; + *) usage ;; + esac +} + +reapply_if_active() { + local name="$1" + if [ "$(state_mode)" != "anthropic" ] && [ "$(state_preset)" = "$name" ]; then + say 're-applying active preset...' + set_mode "$(state_mode)" "$name" + fi +} + +# --------------------------------------------------------------------------- +# Interactive UI +# --------------------------------------------------------------------------- + +ui_interactive() { [ -t 0 ] && [ -t 1 ]; } + +# Read one keypress, normalised to a word. +read_key() { + local k rest extra + IFS= read -rsn1 k 2>/dev/null || return 1 + if [ "$k" = $'\033' ]; then + IFS= read -rsn2 -t 0.05 rest 2>/dev/null || rest='' + case "$rest" in + '[A') printf 'up' ;; + '[B') printf 'down' ;; + '[C') printf 'right' ;; + '[D') printf 'left' ;; + '[H') printf 'home' ;; + '[F') printf 'end' ;; + '[5') IFS= read -rsn1 -t 0.05 extra 2>/dev/null; printf 'pgup' ;; + '[6') IFS= read -rsn1 -t 0.05 extra 2>/dev/null; printf 'pgdn' ;; + '') 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 <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_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_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='' + def="$(default_preset_for "$provider")" + mapfile -t names < <(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 + case "$provider" in + openrouter) + while IFS=$'\t' read -r id ctx a b; do + [ -n "$id" ] || continue + ids+=("$id"); ui_add_item "$id" "context $ctx \$$a in / \$$b out per 1M" + done < <(or_catalogue) + ;; + lmstudio) + while IFS=$'\t' read -r id st ctx; do + [ -n "$id" ] || continue + ids+=("$id"); ui_add_item "$id" "state: $st max context: $ctx" + done < <(lms_catalogue "$base") + ;; + zai) + ids+=('glm-5.2'); ui_add_item 'glm-5.2' 'flagship coding model - opus/sonnet tier' + ids+=('glm-4.7'); ui_add_item 'glm-4.7' 'fast/cheap tier - haiku' + ;; + esac + + 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; mapfile -t names < <(preset_names) + [ "${#names[@]}" -gt 0 ] || { warn 'no presets'; return; } + ui_reset_items + local n + 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=(openrouter zai lmstudio) 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; mapfile -t sibs < <(presets_for_provider "$provider") + ui_reset_items + ui_add_item '<blank>' "empty $provider preset - pick every model yourself" + local s + 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 + case "$name" in + *[!A-Za-z0-9._-]*) err "invalid name '$name'"; return ;; + esac + [ -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 +} + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + +command -v "$PY" >/dev/null 2>&1 || { echo "claude-mode: $PY not found (set CLAUDE_MODE_PYTHON)" >&2; exit 1; } +init_root + +cmd="${1:-}"; [ $# -gt 0 ] && shift +case "$cmd" in + ''|menu) ui_menu ;; + status) cmd_status ;; + anthropic) set_mode anthropic ;; + openrouter|zai|lmstudio) + p="$(resolve_preset "$cmd" "${1:-}")" || exit 1 + set_mode "$cmd" "$p" ;; + z.ai|z-ai) p="$(resolve_preset zai "${1:-}")" || exit 1; set_mode zai "$p" ;; + presets) cmd_presets ;; + preset) cmd_preset "$@" ;; + set-key) cmd_set_key "${1:-openrouter}" ;; + models) cmd_models "${1:-}" ;; + doctor) cmd_doctor ;; + repair) + scope='' + for a in "$@"; do [ "$a" = "--all" ] && scope=all; done + cmd_repair "$scope" ;; + help|--help|-h) usage ;; + *) err "unknown command '$cmd'"; usage; exit 1 ;; +esac diff --git a/linux/cm-json.py b/linux/cm-json.py new file mode 100644 index 0000000..9396e0e --- /dev/null +++ b/linux/cm-json.py @@ -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) diff --git a/linux/cm-vault.sh b/linux/cm-vault.sh new file mode 100644 index 0000000..0e63332 --- /dev/null +++ b/linux/cm-vault.sh @@ -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}" +} diff --git a/linux/install.sh b/linux/install.sh new file mode 100755 index 0000000..d815a2b --- /dev/null +++ b/linux/install.sh @@ -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' diff --git a/linux/shell-snippet.sh b/linux/shell-snippet.sh new file mode 100644 index 0000000..32a850b --- /dev/null +++ b/linux/shell-snippet.sh @@ -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 <<< diff --git a/presets/cheap.json b/presets/cheap.json new file mode 100644 index 0000000..0372ff4 --- /dev/null +++ b/presets/cheap.json @@ -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 +} diff --git a/presets/default.json b/presets/default.json new file mode 100644 index 0000000..5c96685 --- /dev/null +++ b/presets/default.json @@ -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 +} diff --git a/presets/lmstudio-qwen.json b/presets/lmstudio-qwen.json new file mode 100644 index 0000000..01a2363 --- /dev/null +++ b/presets/lmstudio-qwen.json @@ -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 +} diff --git a/presets/lmstudio.json b/presets/lmstudio.json new file mode 100644 index 0000000..dfc3aff --- /dev/null +++ b/presets/lmstudio.json @@ -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 +} diff --git a/presets/zai.json b/presets/zai.json new file mode 100644 index 0000000..3796341 --- /dev/null +++ b/presets/zai.json @@ -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 +} diff --git a/profile-snippet.ps1 b/profile-snippet.ps1 new file mode 100644 index 0000000..4ae25b3 --- /dev/null +++ b/profile-snippet.ps1 @@ -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 <<< diff --git a/scripts/build-package.ps1 b/scripts/build-package.ps1 new file mode 100644 index 0000000..12b9f78 --- /dev/null +++ b/scripts/build-package.ps1 @@ -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