Import claude-code-switcher from the Windows build
Source of truth so far has been c:\Users\smoido\projects\cli on the Windows box, which has no git history of its own. This is that tree copied verbatim over SSH, minus dist/ - the PowerShell build, the POSIX port under linux/, and the presets both share. Recorded as its own commit so that everything after it is a reviewable diff rather than an undifferentiated first drop.
This commit is contained in:
@@ -0,0 +1,326 @@
|
|||||||
|
# claude-mode
|
||||||
|
|
||||||
|
Switch Claude Code system-wide between **Anthropic**, **OpenRouter**, **Z.AI**, and a
|
||||||
|
local **LM Studio** server — with named per-tier model presets.
|
||||||
|
Windows / PowerShell 5.1, no external dependencies.
|
||||||
|
|
||||||
|
```
|
||||||
|
claude-mode # interactive menu
|
||||||
|
claude-mode anthropic # subscription login
|
||||||
|
claude-mode openrouter # remote gateway (preset: default)
|
||||||
|
claude-mode zai # Z.AI GLM coding plan (preset: zai)
|
||||||
|
claude-mode lmstudio # local server (preset: lmstudio)
|
||||||
|
```
|
||||||
|
|
||||||
|
Works for the CLI, the VS Code extension, and the desktop app from a single
|
||||||
|
switch. Restart Claude Code afterwards — nothing else.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The interactive menu
|
||||||
|
|
||||||
|
Run `claude-mode` with no arguments. The mode you're already in is omitted —
|
||||||
|
there's nothing to switch to:
|
||||||
|
|
||||||
|
```
|
||||||
|
claude-mode (currently: openrouter / default)
|
||||||
|
switch mode:
|
||||||
|
1) Anthropic - your subscription login, no gateway
|
||||||
|
2) Z.AI - GLM coding plan
|
||||||
|
3) LM Studio - local server, offline, free
|
||||||
|
|
||||||
|
4) show full status
|
||||||
|
5) edit presets
|
||||||
|
6) run doctor
|
||||||
|
0) quit
|
||||||
|
```
|
||||||
|
|
||||||
|
Pick a provider and it lists that provider's presets with the default marked;
|
||||||
|
**Enter** accepts it. Option 5 walks preset → tier → new model ID, and if you
|
||||||
|
edit the preset that's currently live it re-applies immediately.
|
||||||
|
|
||||||
|
When stdin is redirected (scripts, CI) the menu is skipped and `status` prints
|
||||||
|
instead, so `claude-mode` is still safe in a pipeline.
|
||||||
|
|
||||||
|
## Why settings.json and not a profile export
|
||||||
|
|
||||||
|
Three ways to make this persist. I picked the second.
|
||||||
|
|
||||||
|
**1. Export the variables from `$PROFILE`.**
|
||||||
|
The obvious move, and the wrong one here. It only covers processes launched from
|
||||||
|
a PowerShell session that loaded the profile — which is exactly *not* how you use
|
||||||
|
Claude Code. The VS Code extension is spawned by VS Code, not by your shell, so
|
||||||
|
it would never see the exports. Same for the desktop app, `cmd.exe`, and any
|
||||||
|
terminal opened before the switch. Worse, the failure is silent: you switch to
|
||||||
|
`anthropic`, a shell opened five minutes ago still has `ANTHROPIC_BASE_URL` set,
|
||||||
|
and that session quietly keeps billing OpenRouter.
|
||||||
|
|
||||||
|
**2. Rewrite the `env` block in `~/.claude/settings.json`.** ← chosen
|
||||||
|
Claude Code reads this file on every startup, from every launch context. One
|
||||||
|
write, and the next `claude` — CLI, extension, desktop — picks it up. A switch is
|
||||||
|
atomic: one file, one source of truth. `claude-mode anthropic` *deletes* the
|
||||||
|
managed keys rather than blanking them, so nothing can linger and break native
|
||||||
|
auth. The cost is that config is global rather than per-terminal.
|
||||||
|
|
||||||
|
**3. Persistent User-scope environment variables (`setx`).**
|
||||||
|
Also global and reboot-proof, but strictly worse: new processes only, values sit
|
||||||
|
in the registry in plaintext, and a stale entry silently outranks whatever
|
||||||
|
`claude-mode` writes. This tool treats them as a fault condition — `status` and
|
||||||
|
`doctor` flag them and offer removal, backing the old value up first.
|
||||||
|
|
||||||
|
The `claude` wrapper in the profile is a **safety net, not the mechanism**. It
|
||||||
|
strips inherited process-level copies of all thirteen managed variables before
|
||||||
|
launching `claude.exe`. Everything still works without it — including in VS Code,
|
||||||
|
which never loads the profile.
|
||||||
|
|
||||||
|
## Why API keys are not in settings.json
|
||||||
|
|
||||||
|
`settings.json` is a config file you'll hand-edit, diff, and possibly paste into
|
||||||
|
a bug report. A `sk-or-` or Z.AI token does not belong there.
|
||||||
|
|
||||||
|
Keys are stored **DPAPI-encrypted** in `~/.claude-mode/vault/*.cred` — encrypted
|
||||||
|
against your Windows account on this machine, so copying the file elsewhere or
|
||||||
|
reading it as another user yields nothing — with the file ACL restricted to you.
|
||||||
|
Claude Code receives the key at runtime through `apiKeyHelper`, which decrypts
|
||||||
|
and prints it. `settings.json` holds only the base URL and model IDs.
|
||||||
|
|
||||||
|
In `anthropic` mode the helper is removed from settings.json *and* returns
|
||||||
|
nothing when state says `anthropic` — belt and braces. LM Studio's `lmstudio`
|
||||||
|
token is a placeholder, not a secret, so it's written inline and the helper stays
|
||||||
|
out of it.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```
|
||||||
|
claude-mode interactive menu
|
||||||
|
claude-mode status active mode, preset, model map
|
||||||
|
|
||||||
|
claude-mode anthropic native login (deletes all managed keys)
|
||||||
|
claude-mode openrouter [preset] default preset: default
|
||||||
|
claude-mode zai [preset] default preset: zai (alias: z.ai, z-ai)
|
||||||
|
claude-mode lmstudio [preset] default preset: lmstudio
|
||||||
|
|
||||||
|
claude-mode presets list presets (* = active)
|
||||||
|
claude-mode preset show <name>
|
||||||
|
claude-mode preset new <name> [from] copy an existing preset
|
||||||
|
claude-mode preset set <name> <tier> <model-id>
|
||||||
|
claude-mode preset all <name> <model-id> point every tier at one model
|
||||||
|
claude-mode preset rm <name>
|
||||||
|
|
||||||
|
claude-mode set-key [ref] store a key (hidden prompt, DPAPI)
|
||||||
|
claude-mode models [filter] models available from the active provider
|
||||||
|
claude-mode doctor verify auth, endpoint, model ids, stray env vars
|
||||||
|
```
|
||||||
|
|
||||||
|
Omitting the preset uses a **fixed** per-provider default, not "most recently
|
||||||
|
used" — so `claude-mode openrouter` always means `default`.
|
||||||
|
|
||||||
|
## Presets shipped
|
||||||
|
|
||||||
|
| preset | provider | opus | sonnet | haiku | fable |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `default` | openrouter | `deepseek/deepseek-v4-flash` | `qwen/qwen3.7-flash` | `openrouter/free` | `z-ai/glm-5.2` |
|
||||||
|
| `cheap` | openrouter | `deepseek/deepseek-v4-pro` | `deepseek/deepseek-v4-flash` | `qwen/qwen3.7-flash` | `openai/gpt-5.6-luna-pro` |
|
||||||
|
| `claude-via-or` | openrouter | `anthropic/claude-opus-5` | `anthropic/claude-sonnet-5` | `anthropic/claude-haiku-4.5` | `anthropic/claude-fable-5` |
|
||||||
|
| `zai` | zai | `glm-5.2` | `glm-5.2` | `glm-4.7` | `glm-5.2` |
|
||||||
|
| `lmstudio` | lmstudio | `kwaipilot_kat-coder-v2.5-dev` (all tiers) | | | |
|
||||||
|
| `lmstudio-qwen` | lmstudio | `qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved` (all tiers) | | | |
|
||||||
|
|
||||||
|
Presets are plain JSON in `~/.claude-mode/presets/`. A preset declares its
|
||||||
|
`provider`; `claude-mode lmstudio default` is rejected rather than silently
|
||||||
|
pointing a local URL at remote model IDs.
|
||||||
|
|
||||||
|
## Context windows and early auto-compaction
|
||||||
|
|
||||||
|
**Symptom:** switch to a gateway and the session starts auto-compacting almost
|
||||||
|
immediately, even though every model involved has a huge context window.
|
||||||
|
|
||||||
|
**Cause:** behind a custom `ANTHROPIC_BASE_URL`, Claude Code has no way to
|
||||||
|
resolve a third-party model ID like `deepseek/deepseek-v4-flash` to a context
|
||||||
|
length. It falls back to a conservative default and starts compacting against
|
||||||
|
*that*, not against the model's real 1M window. Z.AI's own docs work around this
|
||||||
|
by setting `CLAUDE_CODE_AUTO_COMPACT_WINDOW=1000000` — they hit the same thing.
|
||||||
|
|
||||||
|
**Fix:** every preset carries a `contextTokens` field, which writes both knobs
|
||||||
|
(confirmed present in CLI 2.1.221):
|
||||||
|
|
||||||
|
```
|
||||||
|
CLAUDE_CODE_MAX_CONTEXT_TOKENS = <contextTokens>
|
||||||
|
CLAUDE_CODE_AUTO_COMPACT_WINDOW = <contextTokens>
|
||||||
|
```
|
||||||
|
|
||||||
|
| preset | contextTokens |
|
||||||
|
|---|---|
|
||||||
|
| `default`, `cheap`, `claude-via-or`, `zai` | 1,000,000 |
|
||||||
|
| `lmstudio`, `lmstudio-qwen` | 262,144 |
|
||||||
|
|
||||||
|
`doctor` cross-checks the declared window against each tier's *actual* model
|
||||||
|
window and names any tier that falls short — `default` maps haiku to
|
||||||
|
`openrouter/free` (200k), which it flags as harmless since haiku only runs short
|
||||||
|
background tasks. Switching without `contextTokens` prints a warning.
|
||||||
|
|
||||||
|
Adjust per preset:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# edit ~/.claude-mode/presets/<name>.json -> "contextTokens": 262144
|
||||||
|
claude-mode doctor # re-checks declared vs actual
|
||||||
|
```
|
||||||
|
|
||||||
|
## Z.AI mode
|
||||||
|
|
||||||
|
Replaces `npx @z_ai/coding-helper` — and does something it doesn't: **maps a
|
||||||
|
distinct model to each Anthropic tier** instead of forcing one model everywhere.
|
||||||
|
|
||||||
|
Per [Z.AI's Claude Code docs](https://docs.z.ai/devpack/tool/claude):
|
||||||
|
|
||||||
|
| setting | value |
|
||||||
|
|---|---|
|
||||||
|
| `ANTHROPIC_BASE_URL` | `https://api.z.ai/api/anthropic` |
|
||||||
|
| auth | your Z.AI API key — kept in the DPAPI vault, delivered via `apiKeyHelper` |
|
||||||
|
| `API_TIMEOUT_MS` | `3000000` |
|
||||||
|
| `CLAUDE_CODE_AUTO_COMPACT_WINDOW` | `1000000` |
|
||||||
|
| `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | `1` |
|
||||||
|
|
||||||
|
All three extra variables are confirmed present in CLI 2.1.221. Setup:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
claude-mode set-key zai # paste your key from https://z.ai/manage-apikey/apikey-list
|
||||||
|
claude-mode zai
|
||||||
|
claude-mode doctor # sends a 1-token request to prove the key works
|
||||||
|
```
|
||||||
|
|
||||||
|
## LM Studio mode
|
||||||
|
|
||||||
|
Per [LM Studio's docs](https://lmstudio.ai/docs/integrations/claude-code): base
|
||||||
|
URL `http://127.0.0.1:1234` (**base only** — Claude Code appends `/v1/messages`),
|
||||||
|
token `lmstudio`, plus `CLAUDE_CODE_ATTRIBUTION_HEADER=0`. Gateway discovery
|
||||||
|
stays off; the Anthropic surface is `/v1/messages` only.
|
||||||
|
|
||||||
|
### Model IDs are not what the UI shows
|
||||||
|
|
||||||
|
LM Studio's `/v1/models` lists only **loaded** instances under their display
|
||||||
|
alias. `kat-coder-v2.5` is such an alias — once the model idle-unloads it
|
||||||
|
vanishes, and a request using that name returns `400 No models loaded`. The
|
||||||
|
JIT-loadable ID is the model key, `kwaipilot_kat-coder-v2.5-dev`. `claude-mode`
|
||||||
|
reads `/api/v0/models` instead, which lists every installed model with its load
|
||||||
|
state, so `models` and `doctor` show IDs that actually work.
|
||||||
|
|
||||||
|
### The `[Server Error] ... Unable to generate parser for this template` spam
|
||||||
|
|
||||||
|
Cause: some GGUF chat templates hard-assert message ordering —
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
{%- if message.role == "system" %}
|
||||||
|
{%- if not loop.first %}
|
||||||
|
{{- raise_exception('System message must be at the beginning.') }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Runtimes that auto-generate a tool-call parser probe the template with synthetic
|
||||||
|
message sequences; those probes trip the assertion and the request dies. It's a
|
||||||
|
model-template bug, not a Claude Code or claude-mode bug — it's been reported
|
||||||
|
against several models
|
||||||
|
([LM Studio #1999](https://github.com/lmstudio-ai/lmstudio-bug-tracker/issues/1999),
|
||||||
|
[llama.cpp #20733](https://github.com/ggml-org/llama.cpp/issues/20733)).
|
||||||
|
|
||||||
|
Scanning your installed models' templates:
|
||||||
|
|
||||||
|
| model | template |
|
||||||
|
|---|---|
|
||||||
|
| `qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved` | clean |
|
||||||
|
| `qwen3.6-35b-a3b` | clean |
|
||||||
|
| `qwen2.5-coder-7b-instruct`, `google/gemma-4-12b-qat` | clean |
|
||||||
|
| **`kwaipilot_kat-coder-v2.5-dev`** | **asserts** |
|
||||||
|
| **`qwen/qwen3.5-9b`**, **`prism-ml/bonsai-27b`** | **assert** |
|
||||||
|
|
||||||
|
`doctor` now reports this per model, and `models` flags affected entries with
|
||||||
|
`TEMPLATE RISK`. **The fix is to use a model without the flag** — which is
|
||||||
|
exactly the `lmstudio-qwen` preset, verified end-to-end with a cold JIT load,
|
||||||
|
streaming, and tool calls.
|
||||||
|
|
||||||
|
Honest caveat: KAT-Coder's template *does* contain the assertion, but I could not
|
||||||
|
reproduce the failure against it here — cold JIT, streaming, tools, system
|
||||||
|
blocks, and multi-turn `tool_result` all succeeded. Whether it trips seems to
|
||||||
|
depend on which parser strategy the runtime picks. If it spams, switch:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
claude-mode lmstudio lmstudio-qwen
|
||||||
|
```
|
||||||
|
|
||||||
|
Other notes: use a model with **>25k context** (`doctor` warns below that), and
|
||||||
|
the model must be **installed** — JIT loading handles "not loaded" fine.
|
||||||
|
|
||||||
|
If you enable authentication in LM Studio, move that preset to a vault key:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
claude-mode set-key lmstudio
|
||||||
|
# then in ~/.claude-mode/presets/lmstudio.json:
|
||||||
|
# "auth": { "mode": "vault", "keyRef": "lmstudio" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI version
|
||||||
|
|
||||||
|
Verified against `claude.exe` **2.1.221** by scanning the binary — every variable
|
||||||
|
this tool writes is referenced by it:
|
||||||
|
|
||||||
|
`ANTHROPIC_BASE_URL` · `ANTHROPIC_AUTH_TOKEN` · `ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL` ·
|
||||||
|
`CLAUDE_CODE_SUBAGENT_MODEL` · `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` ·
|
||||||
|
`CLAUDE_CODE_ATTRIBUTION_HEADER` · `CLAUDE_CODE_AUTO_COMPACT_WINDOW` ·
|
||||||
|
`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` · `API_TIMEOUT_MS` · `apiKeyHelper`
|
||||||
|
|
||||||
|
(On 2.1.89 the fable and gateway-discovery vars did not exist; the update to
|
||||||
|
2.1.221 added both.)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd c:\Users\smoido\Projects\cli\claude-code-switcher
|
||||||
|
.\install.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Installs to `~/.claude-mode/` (ACL: you only), drops `claude-mode.cmd` into
|
||||||
|
`~/.local/bin` (already on your User PATH, next to `claude.exe`), and adds a
|
||||||
|
marked block to `~/Documents/WindowsPowerShell/profile.ps1`.
|
||||||
|
`~/.claude/settings.json` is **not** touched by the installer — only by an actual
|
||||||
|
mode switch, which backs it up to `~/.claude-mode/backups/` first (last 20 kept).
|
||||||
|
|
||||||
|
If the profile doesn't load: `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`.
|
||||||
|
|
||||||
|
## Restarting sessions
|
||||||
|
|
||||||
|
Claude Code reads all of this **once at startup**. A switch does not affect a
|
||||||
|
running session — that's how the process loads config, not something scripting
|
||||||
|
can change.
|
||||||
|
|
||||||
|
- **CLI** — exit and relaunch `claude`
|
||||||
|
- **VS Code** — `Ctrl+Shift+P` → *Developer: Reload Window*
|
||||||
|
- **Desktop app** — quit and reopen
|
||||||
|
|
||||||
|
`claude-mode status` shows what the *next* launch will use.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.claude-mode/
|
||||||
|
claude-mode.ps1 main script
|
||||||
|
state.json mode, active preset, and the exact env keys last written
|
||||||
|
presets/*.json provider + model maps
|
||||||
|
vault/*.cred DPAPI-encrypted keys (openrouter, zai, ...)
|
||||||
|
backups/ settings.json snapshots + removed env-var values
|
||||||
|
bin/claude-key-helper.cmd apiKeyHelper shim
|
||||||
|
~/.local/bin/claude-mode.cmd PATH entry point (works from any shell)
|
||||||
|
~/Documents/WindowsPowerShell/profile.ps1
|
||||||
|
claude-mode + claude functions, between markers
|
||||||
|
```
|
||||||
|
|
||||||
|
`state.json` records which env keys the last switch actually wrote, so a custom
|
||||||
|
`extraEnv` key (Z.AI's timeouts, LM Studio's attribution header) is removed when
|
||||||
|
you switch away — even though no other preset knows that key exists.
|
||||||
|
|
||||||
|
## Uninstall
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
claude-mode anthropic # clean settings.json first
|
||||||
|
Remove-Item ~\.claude-mode -Recurse -Force
|
||||||
|
Remove-Item ~\.local\bin\claude-mode.cmd
|
||||||
|
# then delete the block between the >>> claude-mode >>> markers in profile.ps1
|
||||||
|
```
|
||||||
Executable
+4
@@ -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"
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# claude-key-helper.ps1
|
||||||
|
#
|
||||||
|
# Invoked by Claude Code via the `apiKeyHelper` setting. Prints the API key for
|
||||||
|
# the currently active preset to stdout and nothing else.
|
||||||
|
#
|
||||||
|
# Only presets whose auth mode is "vault" have a secret to emit. In anthropic
|
||||||
|
# mode, or for a preset using an inline placeholder token (LM Studio), this
|
||||||
|
# exits silently so no token can leak into a context that must not have one.
|
||||||
|
#
|
||||||
|
# The key is stored DPAPI-encrypted (CurrentUser scope), so decryption only
|
||||||
|
# succeeds for the Windows account that ran `claude-mode set-key`. Reading the
|
||||||
|
# .cred file as another user - or copying it to another machine - yields nothing.
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
try {
|
||||||
|
$root = Join-Path $env:USERPROFILE '.claude-mode'
|
||||||
|
$state = Get-Content -LiteralPath (Join-Path $root 'state.json') -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
|
||||||
|
if ($state.mode -eq 'anthropic') { exit 0 }
|
||||||
|
if (-not $state.preset) { exit 0 }
|
||||||
|
|
||||||
|
$presetPath = Join-Path $root ('presets\' + $state.preset + '.json')
|
||||||
|
if (-not (Test-Path -LiteralPath $presetPath)) { exit 1 }
|
||||||
|
$preset = Get-Content -LiteralPath $presetPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
|
||||||
|
$authMode = 'vault'
|
||||||
|
$keyRef = 'openrouter'
|
||||||
|
if ($preset.PSObject.Properties.Name -contains 'auth' -and $preset.auth) {
|
||||||
|
if ($preset.auth.PSObject.Properties.Name -contains 'mode' -and $preset.auth.mode) { $authMode = [string]$preset.auth.mode }
|
||||||
|
if ($preset.auth.PSObject.Properties.Name -contains 'keyRef' -and $preset.auth.keyRef) { $keyRef = [string]$preset.auth.keyRef }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($authMode -ne 'vault') { exit 0 } # inline token: nothing for us to emit
|
||||||
|
|
||||||
|
$credPath = Join-Path $root ('vault\' + $keyRef + '.cred')
|
||||||
|
if (-not (Test-Path -LiteralPath $credPath)) { exit 1 }
|
||||||
|
|
||||||
|
$secure = ConvertTo-SecureString (Get-Content -LiteralPath $credPath -Raw).Trim()
|
||||||
|
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
|
||||||
|
try {
|
||||||
|
[Console]::Out.Write([Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr))
|
||||||
|
} finally {
|
||||||
|
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
+2171
File diff suppressed because it is too large
Load Diff
+144
@@ -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'
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+1133
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
@@ -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}"
|
||||||
|
}
|
||||||
Executable
+139
@@ -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'
|
||||||
@@ -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 <<<
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 <<<
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user