Split the Windows script into modules; drop the Arkylx Index pieces

claude-mode.ps1 was 2,407 lines. It is now 153: the help block, parameters,
paths, the managed-key list, a loader, and the dispatch. The rest moved,
verbatim, into eleven files under lib/ - providers, output, files, core,
vault, switch, guards, health, catalogue, commands, menu - dot-sourced into
the script's scope in their original order, with the same check as the bash
split that every original line landed in exactly one file. Inside a module
$PSScriptRoot is lib\, so the one path beside the main script
(providers.json) now goes through $script:Here.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
smoido
2026-09-15 02:06:45 +03:00
co-authored by Claude Opus 5
parent 441166d003
commit 4091746d4e
21 changed files with 2421 additions and 2527 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
# LF in every working tree, Windows checkouts included. A CRLF shell script # LF in every working tree, Windows checkouts included: a CRLF shell script
# fails its #! line, and the packages are built from a Windows checkout # fails its #! line, and a Windows checkout is exactly where the repository's
# (scripts/build-package.ps1 normalises too, as a backstop). # shell scripts would otherwise pick CRLF up.
* text=auto eol=lf * text=auto eol=lf
*.png binary *.png binary
-1
View File
@@ -1,4 +1,3 @@
dist/
*.bak *.bak
*.bak.* *.bak.*
__pycache__/ __pycache__/
+15 -2269
View File
File diff suppressed because it is too large Load Diff
+35 -13
View File
@@ -46,7 +46,7 @@ Under `~/.claude-mode/`:
| file | written by | read by | | file | written by | read by |
|---|---|---| |---|---|---|
| `bin/claude-mode`, `bin/lib/*.sh`, `bin/cm-json.py`, `bin/cm-vault.sh`, `bin/claude-key-helper.sh` | the installer | — (Windows: `claude-mode.ps1` at the top, `bin/claude-key-helper.ps1` + `.cmd`) | | `bin/claude-mode`, `bin/lib/*.sh`, `bin/cm-json.py`, `bin/cm-vault.sh`, `bin/claude-key-helper.sh` | the installer | — (Windows: `claude-mode.ps1` and `lib\*.ps1` at the top, `bin\claude-key-helper.ps1` + `.cmd`) |
| `providers.json` | the installer, every time | the CLI, the key helper's engine, the widget (via `health.json`) | | `providers.json` | the installer, every time | the CLI, the key helper's engine, the widget (via `health.json`) |
| `presets/*.json` | the installer seeds them; `preset`, `setup`, the panel edit them | everything | | `presets/*.json` | the installer seeds them; `preset`, `setup`, the panel edit them | everything |
| `state.json` | a switch; `preset rename` of the active preset | the CLI, the key helper, the widget | | `state.json` | a switch; `preset rename` of the active preset | the CLI, the key helper, the widget |
@@ -66,14 +66,15 @@ anything else), `~/.claude/projects/*/*.jsonl` (Claude Code's transcripts, which
## The repository ## The repository
``` ```
claude-mode.ps1, install.ps1 the Windows build and its installer claude-mode.ps1, lib/*.ps1 the Windows build: settings + dispatch, and its modules
install.ps1, profile-snippet.ps1 its installer, and the block it adds to the PowerShell profile
bin/ the Windows key helper bin/ the Windows key helper
linux/ the POSIX port: claude-mode (settings + dispatch), linux/ the POSIX port: claude-mode (settings + dispatch),
lib/*.sh (the rest, one file per concern), cm-json.py, lib/*.sh (its modules), cm-json.py, cm-vault.sh,
cm-vault.sh, claude-key-helper.sh, install.sh, bootstrap.sh claude-key-helper.sh, install.sh
omarchy/ the bar widget and its installer omarchy/ the bar widget and its installer
providers.json, presets/ shared by both builds, byte for byte providers.json, presets/ shared by both builds, byte for byte
scripts/ test.sh, bump-version.sh, build-package.ps1 scripts/ test.sh, bump-version.sh
tests/ static, python, cli and windows suites tests/ static, python, cli and windows suites
docs/ this docs/ this
``` ```
@@ -100,6 +101,27 @@ code rather than the installed version's.
| `setup.sh` | `claude-mode setup` | | `setup.sh` | `claude-mode setup` |
| `repair.sh` | `claude-mode repair-session` and dismissals | | `repair.sh` | `claude-mode repair-session` and dismissals |
### The Windows script's modules
`claude-mode.ps1` holds the help block, the parameters, the paths and flags and
the managed-key list, then dot-sources these from `lib\` beside it, in order, and
dispatches. Inside a dot-sourced file `$PSScriptRoot` is `lib\`, so paths beside
the main script go through `$script:Here`.
| `lib/` | what is in it |
|---|---|
| `providers.ps1` | reading `providers.json`; `Get-Provider`, `Resolve-ProviderId`, `Test-ProviderDoctor` |
| `output.ps1` | `Write-Ok`/`Write-Warn2`/`Write-Err2`, mode colours, the banner, `Show-Usage` |
| `files.ps1` | JSON read/write (5.1 has no `-AsHashtable`), `Protect-FileAcl` |
| `core.ps1` | `state.json`, presets, `Resolve-PresetForProvider` |
| `vault.ps1` | the DPAPI vault, `Get-PresetAuth` |
| `switch.ps1` | `Set-ClaudeMode`, settings backups, the `apiKeyHelper` command line |
| `guards.ps1` | the cost guard, the OpenRouter guardrail check, stale cached model ids, `repair` |
| `health.ps1` | `health.json`; persistent environment variables that would override a switch |
| `catalogue.ps1` | `Get-ProviderCatalogue`, LM Studio's model list and template check, the doctor model and context checks |
| `commands.ps1` | `status`, `presets`, `preset`, `models`, `doctor` |
| `menu.ps1` | the interactive menu, its pickers, interactive preset editing, `New-PresetScaffold` |
## Contracts between the pieces ## Contracts between the pieces
These are the interfaces one piece relies on another to keep. Each is pinned by a These are the interfaces one piece relies on another to keep. Each is pinned by a
@@ -142,14 +164,14 @@ test.
|---|---|---| |---|---|---|
| a provider's endpoint, auth template, setup, checks or look | `providers.json` | the same file | | a provider's endpoint, auth template, setup, checks or look | `providers.json` | the same file |
| a shipped preset | `presets/*.json` | the same files | | a shipped preset | `presets/*.json` | the same files |
| what a switch writes to `settings.json` | `cm-json.py` `cmd_apply`, `BASE_MANAGED` | `Set-ClaudeMode`, `$script:BaseManagedEnvKeys` | | what a switch writes to `settings.json` | `cm-json.py` `cmd_apply`, `BASE_MANAGED` | `lib/switch.ps1` `Set-ClaudeMode`; `$script:BaseManagedEnvKeys` in `claude-mode.ps1` |
| a command's arguments | the dispatch at the end of `linux/claude-mode` | the dispatch at the end of `claude-mode.ps1` | | a command's arguments | the dispatch at the end of `linux/claude-mode` | the dispatch at the end of `claude-mode.ps1` |
| what refuses a switch | `lib/preflight.sh` `cm_preflight` | `Set-ClaudeMode` guards | | what refuses a switch | `linux/lib/preflight.sh` `cm_preflight` | the guards in `Set-ClaudeMode` |
| how a model list is fetched | `lib/catalogue.sh` `provider_catalogue` + a parser in `cm-json.py` | `Get-ProviderCatalogue` | | how a model list is fetched | `linux/lib/catalogue.sh` `provider_catalogue` + a parser in `cm-json.py` | `lib/catalogue.ps1` `Get-ProviderCatalogue` |
| a doctor check | `lib/doctor.sh` | `Invoke-Doctor`, `Test-PresetCatalogue`, `Test-OllamaContext` | | a doctor check | `linux/lib/doctor.sh` | `lib/commands.ps1` `Invoke-Doctor`, `lib/catalogue.ps1` |
| setup | `lib/setup.sh` | — | | setup | `linux/lib/setup.sh` | — |
| session detection | `lib/sessions.sh` | — | | session detection | `linux/lib/sessions.sh` | — |
| session repair | `lib/repair.sh`; `cm-json.py` `cmd_scan_sessions`, `cmd_repair_session` | — | | session repair | `linux/lib/repair.sh`; `cm-json.py` `cmd_scan_sessions`, `cmd_repair_session` | — |
| key storage | `cm-vault.sh` | the DPAPI functions in `claude-mode.ps1` | | key storage | `linux/cm-vault.sh` | `lib/vault.ps1` |
| the bar icon and state | `omarchy/smoido.claude-mode/BarWidget.qml` | — | | the bar icon and state | `omarchy/smoido.claude-mode/BarWidget.qml` | — |
| the panel | `omarchy/smoido.claude-mode/Panel.qml` | — | | the panel | `omarchy/smoido.claude-mode/Panel.qml` | — |
+6 -4
View File
@@ -65,7 +65,9 @@ whitespace to `read`, so empty fields merge and later columns shift. Use `cut`
**Windows PowerShell 5.1.** No `?:` or `??`; `"$id:latest"` parses as a **Windows PowerShell 5.1.** No `?:` or `??`; `"$id:latest"` parses as a
drive-qualified variable, so write `"${id}:latest"`. The `.ps1` files are ASCII drive-qualified variable, so write `"${id}:latest"`. The `.ps1` files are ASCII
only, because 5.1 reads a file without a BOM in the ANSI codepage. only, because 5.1 reads a file without a BOM in the ANSI codepage. In the
`lib/*.ps1` modules `$PSScriptRoot` is `lib\`: reach anything beside
`claude-mode.ps1` through `$script:Here`.
**Files are LF everywhere**, `.ps1` and `.cmd` included (`.gitattributes`, **Files are LF everywhere**, `.ps1` and `.cmd` included (`.gitattributes`,
`.editorconfig`). `.editorconfig`).
@@ -112,8 +114,8 @@ unit test that pins it.
3. Commit. 3. Commit.
4. Install on your own machine: `bash linux/install.sh`, `bash omarchy/install.sh`, 4. Install on your own machine: `bash linux/install.sh`, `bash omarchy/install.sh`,
`omarchy restart shell`, and look at the bar. `omarchy restart shell`, and look at the bar.
5. Push. The Windows package is built on a Windows machine with 5. Push. Both installers fetch the repository's own archive (`install.ps1` piped
`scripts/build-package.ps1`, which refuses to package a script that does not through `iex`, `linux/install.sh` through `curl | bash`), so a push to `master`
parse. is the release.
Semver: a minor version adds behaviour, a patch fixes it. Semver: a minor version adds behaviour, a patch fixes it.
+6
View File
@@ -85,6 +85,12 @@ try {
# --- 2. scripts ------------------------------------------------------------- # --- 2. scripts -------------------------------------------------------------
Copy-Item -LiteralPath (Join-Path $src 'claude-mode.ps1') -Destination $root -Force Copy-Item -LiteralPath (Join-Path $src 'claude-mode.ps1') -Destination $root -Force
# The script's modules, dot-sourced from lib\ beside it. Cleared first, so a
# module removed from the source does not linger and get loaded.
$libDest = Join-Path $root 'lib'
New-Item -ItemType Directory -Path $libDest -Force | Out-Null
Remove-Item -Path (Join-Path $libDest '*.ps1') -Force -ErrorAction SilentlyContinue
Copy-Item -Path (Join-Path $src 'lib\*.ps1') -Destination $libDest -Force
# claude-mode reads VERSION from its own directory to stamp health.json. # claude-mode reads VERSION from its own directory to stamp health.json.
if (Test-Path -LiteralPath (Join-Path $src 'VERSION')) { if (Test-Path -LiteralPath (Join-Path $src 'VERSION')) {
Copy-Item -LiteralPath (Join-Path $src 'VERSION') -Destination $root -Force Copy-Item -LiteralPath (Join-Path $src 'VERSION') -Destination $root -Force
+309
View File
@@ -0,0 +1,309 @@
# lib/catalogue.ps1 - provider model lists, and the checks doctor runs against them.
#
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
# the settings at its top. Not meant to run on its own. ASCII only: Windows
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
# lib\, so paths beside the main script go through $script:Here.
# ---------------------------------------------------------------------------
# Provider probes
# ---------------------------------------------------------------------------
# LM Studio's /v1/models lists only *loaded* instances, so an installed model
# that has idle-unloaded disappears from it. /api/v0/models lists everything
# with a load state, which is what we want: LM Studio JIT-loads on first
# request, so "installed but not loaded" is fine - "not installed" is not.
function Get-LmStudioModels {
param([string] $BaseUrl)
$base = $BaseUrl.TrimEnd('/')
try {
return @((Invoke-RestMethod -Uri "$base/api/v0/models" -TimeoutSec 10).data |
ForEach-Object {
[pscustomobject]@{
Id = $_.id
State = $_.state
Ctx = $(if ($_.PSObject.Properties.Name -contains 'max_context_length') { $_.max_context_length } else { $null })
}
})
} catch {
return @((Invoke-RestMethod -Uri "$base/v1/models" -TimeoutSec 10).data |
ForEach-Object { [pscustomobject]@{ Id = $_.id; State = 'unknown'; Ctx = $null } })
}
}
# Some GGUF chat templates hard-assert message ordering, e.g.
# {%- if message.role == "system" %}{%- if not loop.first %}
# {{- raise_exception('System message must be at the beginning.') }}
# Runtimes that auto-generate a tool-call parser probe the template with
# synthetic message sequences; those probes trip the assertion and the request
# dies with "Unable to generate parser for this template". Detect it up front
# instead of letting the user hit a wall of [Server Error] spam.
$script:TemplateAssertions = @(
'System message must be at the beginning',
'No user query found in messages'
)
function Get-LmStudioTemplateReport {
$cache = Join-Path $script:LmStudioDir '.internal\gguf-metadata-cache.json'
if (-not (Test-Path -LiteralPath $cache)) { return @() }
$out = @()
try {
$j = Get-Content -LiteralPath $cache -Raw -Encoding UTF8 | ConvertFrom-Json
foreach ($entry in $j.json.map) {
$path = [string]$entry[0]
$meta = $entry[1]
if (-not $meta -or -not $meta.metadata) { continue }
if ($meta.metadata.PSObject.Properties.Name -notcontains 'chatTemplate') { continue }
$tpl = [string]$meta.metadata.chatTemplate
if (-not $tpl) { continue }
$hits = @()
foreach ($a in $script:TemplateAssertions) { if ($tpl.Contains($a)) { $hits += $a } }
# Derive the id LM Studio serves this file under: the repo folder
# name, lowercased, minus the -GGUF suffix.
$folder = Split-Path -Leaf (Split-Path -Parent ($path -replace '/', '\'))
$key = ($folder -replace '(?i)-GGUF$', '').ToLower()
$out += [pscustomobject]@{ Key = $key; Path = $path; Assertions = $hits }
}
} catch { return @() }
return $out
}
function Test-LmStudioTemplate {
param([string] $ModelId)
$short = ($ModelId -split '/')[-1].ToLower()
$rep = Get-LmStudioTemplateReport | Where-Object { $_.Key -eq $short } | Select-Object -First 1
if (-not $rep) { return $null }
return $rep
}
# The credential a preset would send: its vault key, or its inline token.
function Get-PresetToken {
param($Preset)
$auth = Get-PresetAuth $Preset
if ([string]$auth['mode'] -eq 'vault') {
$ref = $(if ($auth.Contains('keyRef') -and $auth['keyRef']) { [string]$auth['keyRef'] } else { 'openrouter' })
return (Get-VaultKey $ref)
}
return [string]$auth['token']
}
# One catalogue fetch for any provider, by its catalogue.kind in providers.json.
# Every row comes back in one shape - Id, Ctx, State, InM, OutM, Note - so the
# callers (models, the picker, doctor) never branch on the provider itself.
function Get-ProviderCatalogue {
param($Preset)
$prov = Get-Provider ([string]$Preset['provider'])
if (-not $prov) { return @() }
$base = ([string]$Preset['baseUrl']).TrimEnd('/')
switch ([string]$prov.catalogue.kind) {
'openrouter' {
return @((Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/models' -TimeoutSec 30).data | ForEach-Object {
[pscustomobject]@{
Id = $_.id; Ctx = $_.context_length; State = $null; Note = ''
InM = $(if ($_.pricing -and $_.pricing.prompt) { [math]::Round([double]$_.pricing.prompt * 1e6, 3) } else { $null })
OutM = $(if ($_.pricing -and $_.pricing.completion) { [math]::Round([double]$_.pricing.completion * 1e6, 3) } else { $null })
}
})
}
'lmstudio' {
return @(Get-LmStudioModels $base | ForEach-Object {
[pscustomobject]@{ Id = $_.Id; Ctx = $_.Ctx; State = $_.State; InM = $null; OutM = $null; Note = '' }
})
}
'ollama' {
$h = @{}
$tok = Get-PresetToken $Preset
if ($tok) { $h['Authorization'] = "Bearer $tok" }
return @((Invoke-RestMethod -Uri "$base/api/tags" -Headers $h -TimeoutSec 10).models | ForEach-Object {
$d = $_.details
$note = $(if ($d) { (@($d.parameter_size, $d.quantization_level) | Where-Object { $_ }) -join ' ' } else { '' })
[pscustomobject]@{ Id = $_.name; Ctx = $null; State = $null; InM = $null; OutM = $null; Note = $note }
})
}
'openai' {
# Both header styles: a proxy in front of Anthropic wants x-api-key,
# one in front of anything else wants Bearer.
$h = @{ 'anthropic-version' = '2023-06-01' }
$tok = Get-PresetToken $Preset
if ($tok) { $h['Authorization'] = "Bearer $tok"; $h['x-api-key'] = $tok }
return @((Invoke-RestMethod -Uri "$base/v1/models" -Headers $h -TimeoutSec 15).data | ForEach-Object {
[pscustomobject]@{ Id = $_.id; Ctx = $null; State = $null; InM = $null; OutM = $null; Note = '' }
})
}
'static' {
return @($prov.catalogue.static | ForEach-Object {
[pscustomobject]@{ Id = $_.id; Ctx = $null; State = $null; InM = $null; OutM = $null; Note = [string]$_.note }
})
}
}
return @()
}
# The preset's model ids against what the provider actually offers. A server
# provider that does not answer is a failure; a hosted catalogue that cannot be
# fetched is only a warning, since the endpoint may be fine regardless.
function Test-PresetCatalogue {
param([string] $Mode, $Preset)
$prov = Get-Provider $Mode
$kind = [string]$prov.catalogue.kind
$title = [string]$prov.title
$base = ([string]$Preset['baseUrl']).TrimEnd('/')
$server = [bool]$prov.server.editable
$cat = @()
try { $cat = @(Get-ProviderCatalogue $Preset) } catch { $cat = @() }
if ($cat.Count -eq 0) {
if ($server -and [string]$prov.server.probe -eq 'lenient') {
Write-Warn2 "$title at $base lists no models - fine for a proxy, but the ids below cannot be checked"
} elseif ($server) {
$start = [string]$prov.server.start
Write-Err2 ("$title not reachable at $base" + $(if ($start) { " - $start" } else { '' }))
} else {
Write-Warn2 "could not fetch the $title model list"
}
return
}
if ($server) { Write-Ok "$title reachable at $base ($($cat.Count) models)" }
$declared = $(if ($Preset.Contains('contextTokens') -and $Preset['contextTokens']) { [int]$Preset['contextTokens'] } else { 0 })
$tpl = $(if (Test-ProviderDoctor $Mode 'lmstudio-templates') { Get-LmStudioTemplateReport } else { @() })
$warned = @{} # a one-for-all preset names one model four times; say things about it once
foreach ($t in $script:Tiers) {
if (-not ($Preset['models'].Contains($t) -and $Preset['models'][$t])) { continue }
$id = [string]$Preset['models'][$t]
$m = $cat | Where-Object { $_.Id -eq $id } | Select-Object -First 1
# Ollama lists every model with its tag; a bare name means :latest.
if (-not $m -and $kind -eq 'ollama' -and $id -notlike '*:*') {
$m = $cat | Where-Object { $_.Id -eq "${id}:latest" } | Select-Object -First 1
}
if (-not $m) {
# A fixed list is documentation, not the provider's word.
if ($kind -eq 'static') { Write-Ok ("{0,-6} {1} (not in the documented list)" -f $t, $id) }
else { Write-Err2 "$t model NOT available from ${title}: $id" }
continue
}
switch ($kind) {
'lmstudio' {
if ($m.State -eq 'loaded') { Write-Ok ("{0,-6} {1} [loaded, ctx {2}]" -f $t, $id, $m.Ctx) }
else { Write-Ok ("{0,-6} {1} [{2} - LM Studio will JIT-load it on first request, ctx {3}]" -f $t, $id, $m.State, $m.Ctx) }
if (-not $warned.ContainsKey($id)) {
$warned[$id] = $true
if ($m.Ctx -and [int]$m.Ctx -lt 25000) { Write-Warn2 "$id context is $($m.Ctx); LM Studio recommends >25k for Claude Code." }
if ($declared -and $m.Ctx -and [int]$m.Ctx -lt $declared) {
Write-Warn2 ("declared contextTokens {0:N0} exceeds {1}'s {2:N0} - lower it." -f $declared, $id, [int]$m.Ctx)
}
$short = ($id -split '/')[-1].ToLower()
$risk = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1
if ($risk -and $risk.Assertions.Count -gt 0) {
Write-Warn2 "$id chat template hard-asserts message order ($($risk.Assertions -join '; '))."
Write-Warn2 " This can surface as: [Server Error] 'Unable to generate parser for this template'."
Write-Warn2 " If you hit that, switch to a model without this flag - see 'claude-mode models'."
}
}
}
'openrouter' { Write-Ok ("{0,-6} {1} [ctx {2:N0}]" -f $t, $id, [int]$m.Ctx) }
'ollama' { Write-Ok ("{0,-6} {1} [{2}]" -f $t, $id, $m.Note) }
default { Write-Ok ("{0,-6} {1}" -f $t, $id) }
}
}
if ($kind -eq 'openrouter') { Test-ContextWindow -Preset $Preset -Catalogue $cat }
elseif ($declared) { Write-Ok ("declared context window: {0:N0} tokens" -f $declared) }
else { Write-Warn2 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early.' }
}
# Ollama sets the context window on the server, not per request from Claude
# Code: 4096 tokens unless `ollama serve` runs with OLLAMA_CONTEXT_LENGTH, and
# anything past it is cut off without an error. contextTokens only tells Claude
# Code what to expect, so say what the server is actually running where that
# can be seen, and what to set where it cannot.
function Test-OllamaContext {
param($Preset)
if (-not ($Preset.Contains('contextTokens') -and $Preset['contextTokens'])) { return }
$declared = [int]$Preset['contextTokens']
$base = ([string]$Preset['baseUrl']).TrimEnd('/')
$ids = @()
foreach ($t in $script:Tiers) {
$v = [string]$Preset['models'][$t]
if ($v -and $ids -notcontains $v) { $ids += $v }
}
$ps = $null
try { $ps = Invoke-RestMethod -Uri "$base/api/ps" -TimeoutSec 5 } catch { }
$seen = $false; $short = $false
foreach ($id in $ids) {
try {
$show = Invoke-RestMethod -Uri "$base/api/show" -Method Post -ContentType 'application/json' `
-Body (@{ model = $id } | ConvertTo-Json) -TimeoutSec 8
$max = $null
if ($show.model_info) {
foreach ($prop in $show.model_info.PSObject.Properties) {
if ($prop.Name -like '*.context_length') { $max = [int]$prop.Value; break }
}
}
if ($max -and $max -lt $declared) { Write-Warn2 "$id supports at most $max tokens, below the declared $declared - lower contextTokens" }
} catch { }
$want = @($id)
if ($id -notlike '*:*') { $want += "${id}:latest" }
$live = $null
if ($ps -and $ps.models) {
foreach ($m in $ps.models) {
if ((($want -contains [string]$m.name) -or ($want -contains [string]$m.model)) -and $m.context_length) {
$live = [int]$m.context_length; break
}
}
}
if ($null -eq $live) { continue }
$seen = $true
if ($live -lt $declared) {
$short = $true
Write-Warn2 "$id is loaded with a $live-token context, below the declared $declared - requests past it are cut off"
} else {
Write-Ok "$id is loaded with a $live-token context"
}
}
if (-not $seen) {
Write-Warn2 "none of these models is loaded, so the server's context window cannot be checked"
Write-Host " Ollama defaults to 4096 tokens; run it with OLLAMA_CONTEXT_LENGTH=$declared or requests past that are cut off silently"
} elseif ($short) {
Write-Host " restart it with OLLAMA_CONTEXT_LENGTH=$declared (or lower contextTokens to match)"
}
}
# CLAUDE_CODE_MAX_CONTEXT_TOKENS is a single global value, but each tier can
# point at a model with a different window. Declaring more context than a tier's
# model actually has means requests on that tier can overflow, so name the
# offenders rather than silently trusting the preset.
function Test-ContextWindow {
param($Preset, $Catalogue)
if (-not ($Preset.Contains('contextTokens') -and $Preset['contextTokens'])) {
Write-Warn2 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early.'
Write-Warn2 " Fix: add \"contextTokens\": 1000000 to $((Get-PresetPath ([string](Get-State)['preset']))) "
return
}
$declared = [int]$Preset['contextTokens']
Write-Ok ("declared context window: {0:N0} tokens" -f $declared)
foreach ($t in $script:Tiers) {
if (-not $Preset['models'].Contains($t)) { continue }
$id = [string]$Preset['models'][$t]
$m = $Catalogue | Where-Object { $_.Id -eq $id } | Select-Object -First 1
if (-not $m -or -not $m.Ctx) { continue }
if ([int]$m.Ctx -lt $declared) {
Write-Warn2 ("{0} model {1} only has {2:N0} ctx, below the declared {3:N0}." -f $t, $id, [int]$m.Ctx, $declared)
if ($t -eq 'haiku') {
Write-Warn2 ' haiku only runs short background tasks, so this is usually harmless.'
} else {
Write-Warn2 ' This tier can overflow. Lower contextTokens or pick a bigger model.'
}
}
}
}
+350
View File
@@ -0,0 +1,350 @@
# lib/commands.ps1 - status, presets, preset, models and doctor.
#
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
# the settings at its top. Not meant to run on its own. ASCII only: Windows
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
# lib\, so paths beside the main script go through $script:Here.
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
function Invoke-Status {
$state = Get-State
$mode = [string]$state['mode']
Write-Head "claude-mode: $mode"
if ($mode -eq 'anthropic') {
Write-Host ' native Anthropic login/subscription; no gateway env, no apiKeyHelper'
} else {
$name = [string]$state['preset']
Write-Host " preset: $name"
try {
$preset = Get-Preset $name
Write-Host " baseUrl: $($preset['baseUrl'])"
foreach ($tier in $script:Tiers) {
if ($preset['models'].Contains($tier)) {
Write-Host (" {0,-9} {1}" -f ($tier + ':'), $preset['models'][$tier])
}
}
if ($preset.Contains('subagentModel')) { Write-Host " subagent: $($preset['subagentModel'])" }
if ($preset.Contains('contextTokens') -and $preset['contextTokens']) {
Write-Host (" context: {0:N0} tokens" -f [int]$preset['contextTokens'])
}
$auth = Get-PresetAuth $preset
if ([string]$auth['mode'] -eq 'vault') {
$ref = [string]$auth['keyRef']
Write-Host " key: $ref -> $(Format-KeyMask (Get-VaultKey $ref))"
} else {
Write-Host " token: $($auth['token']) (inline, not a secret)"
}
} catch {
Write-Err2 $_.Exception.Message
}
}
Write-Host ''
Write-Host ' settings.json managed keys:'
$settings = Read-JsonFile $script:Settings
$found = 0
if ($settings) {
if ($settings.Contains('apiKeyHelper')) { Write-Host " apiKeyHelper = $($settings['apiKeyHelper'])"; $found++ }
if ($settings.Contains('env') -and $settings['env'] -is [System.Collections.IDictionary]) {
$keys = @($script:BaseManagedEnvKeys) + @($state['writtenEnvKeys']) | Sort-Object -Unique
foreach ($k in $keys) {
if ($k -and $settings['env'].Contains($k)) {
Write-Host " $k = $($settings['env'][$k])"
$found++
}
}
}
}
if ($found -eq 0) { Write-Host ' (none - clean)' }
Write-Host ''
[void](Test-StrayEnvVars -Mode $mode)
}
function Invoke-Presets {
Write-Head 'presets'
$state = Get-State
foreach ($n in Get-PresetNames) {
$mark = ' '
if ($n -eq [string]$state['preset'] -and [string]$state['mode'] -ne 'anthropic') { $mark = '*' }
try {
$p = Get-Preset $n
$bits = @()
foreach ($t in $script:Tiers) {
if ($p['models'].Contains($t)) { $bits += "$t=$($p['models'][$t])" }
}
Write-Host (" {0} {1,-18} [{2,-10}] {3}" -f $mark, $n, $p['provider'], ($bits -join ' '))
} catch {
Write-Host (" {0} {1,-18} <unreadable>" -f $mark, $n)
}
}
}
function Invoke-PresetCmd {
# NOT named $Args - that collides with PowerShell's automatic variable.
param([string[]] $Argv)
if (-not $Argv -or $Argv.Count -lt 1) { Show-Usage; return }
$sub = $Argv[0]
$name = if ($Argv.Count -ge 2) { $Argv[1] } else { $null }
switch ($sub) {
'show' {
if (-not $name) { throw 'usage: claude-mode preset show <name>' }
(Get-Preset $name) | ConvertTo-Json -Depth 20 | Write-Host
}
'new' {
if (-not $name) { throw 'usage: claude-mode preset new <name> [copy-from]' }
$path = Get-PresetPath $name
if (Test-Path -LiteralPath $path) { throw "preset '$name' already exists" }
$from = if ($Argv.Count -ge 3) { $Argv[2] } else { 'default' }
$base = Get-Preset $from
$base['description'] = "copy of '$from'"
Write-JsonFile $path $base
Write-Ok "created $path from '$from' - edit with: claude-mode preset set $name <tier> <model-id>"
}
'rm' {
if (-not $name) { throw 'usage: claude-mode preset rm <name>' }
$path = Get-PresetPath $name
if (-not (Test-Path -LiteralPath $path)) { throw "preset '$name' not found" }
$state = Get-State
if ([string]$state['preset'] -eq $name -and [string]$state['mode'] -ne 'anthropic') {
throw "preset '$name' is active. Switch away first (claude-mode anthropic)."
}
Remove-Item -LiteralPath $path -Force
Write-Ok "deleted preset '$name'"
}
'set' {
if ($Argv.Count -lt 4) { throw 'usage: claude-mode preset set <name> <tier> <model-id>' }
Set-PresetTier -Name $name -Tier $Argv[2].ToLower() -Model $Argv[3]
}
'all' {
if ($Argv.Count -lt 3) { throw 'usage: claude-mode preset all <name> <model-id>' }
$model = $Argv[2]
$preset = Get-Preset $name
if (-not $preset.Contains('models')) { $preset['models'] = [ordered]@{} }
foreach ($t in $script:Tiers) { $preset['models'][$t] = $model }
$preset['subagentModel'] = $model
Write-JsonFile (Get-PresetPath $name) $preset
Write-Ok "$name : all tiers + subagent -> $model"
Update-ActivePreset $name
}
default { Show-Usage }
}
}
function Set-PresetTier {
param([string] $Name, [string] $Tier, [string] $Model)
$preset = Get-Preset $Name
if ($Tier -eq 'subagent') {
$preset['subagentModel'] = $Model
} elseif ($script:Tiers -contains $Tier) {
if (-not $preset.Contains('models')) { $preset['models'] = [ordered]@{} }
$preset['models'][$Tier] = $Model
} else {
throw "unknown tier '$Tier' (use: opus | sonnet | haiku | fable | subagent)"
}
Write-JsonFile (Get-PresetPath $Name) $preset
Write-Ok "$Name : $Tier -> $Model"
Update-ActivePreset $Name
}
# Editing the live preset re-applies it, so there is no second command to run.
function Update-ActivePreset {
param([string] $Name)
$state = Get-State
if ([string]$state['mode'] -ne 'anthropic' -and [string]$state['preset'] -eq $Name) {
Write-Host ' re-applying active preset...'
Set-ClaudeMode -Mode ([string]$state['mode']) -PresetName $Name
}
}
function Invoke-Models {
param([string] $Filter)
$state = Get-State
$mode = [string]$state['mode']
# On anthropic, OpenRouter's catalogue is the one worth browsing, as before.
$preset = $(if ($mode -ne 'anthropic') { Get-Preset ([string]$state['preset']) } else { [ordered]@{ provider = 'openrouter'; baseUrl = '' } })
$prov = Get-Provider ([string]$preset['provider'])
$kind = [string]$prov.catalogue.kind
switch ($kind) {
'static' { Write-Head "$($prov.title) models (from its docs - no public catalogue endpoint)" }
'openrouter' { Write-Head 'fetching https://openrouter.ai/api/v1/models ...' }
default { Write-Head "models on the $($prov.title) server at $($preset['baseUrl'])" }
}
$tpl = $(if ($kind -eq 'lmstudio') { Get-LmStudioTemplateReport } else { @() })
$rows = foreach ($m in (Get-ProviderCatalogue $preset | Sort-Object Id)) {
if ($Filter -and $m.Id -notlike "*$Filter*") { continue }
if ($kind -eq 'openrouter') {
[pscustomobject]@{ Id = $m.Id; Ctx = $m.Ctx; 'In/M$' = $m.InM; 'Out/M$' = $m.OutM }
} elseif ($kind -eq 'lmstudio') {
$short = ($m.Id -split '/')[-1].ToLower()
$t = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1
$flag = $(if ($t -and $t.Assertions.Count -gt 0) { 'TEMPLATE RISK' } else { '' })
[pscustomobject]@{ Id = $m.Id; State = $m.State; Ctx = $m.Ctx; Note = $flag }
} else {
[pscustomobject]@{ Id = $m.Id; Note = $m.Note }
}
}
$rows | Format-Table -AutoSize
if ($kind -eq 'lmstudio') {
Write-Host " 'TEMPLATE RISK' = the model's chat template hard-asserts message order,"
Write-Host " which can break tool-call parser generation. Prefer an unflagged model."
}
if ($kind -eq 'static' -and $prov.catalogue.docs) { Write-Host " Full list: $($prov.catalogue.docs)" }
}
function Invoke-Doctor {
$state = Get-State
$mode = [string]$state['mode']
Write-Head "doctor - mode '$mode'"
try {
$liveSettings = Read-JsonFile $script:Settings
Write-Ok 'settings.json parses'
} catch {
Write-Err2 "settings.json does not parse: $($_.Exception.Message)"
return
}
if (Test-Path -LiteralPath $script:HelperCmd) { Write-Ok "key helper present: $($script:HelperCmd)" }
else { Write-Err2 "key helper missing: $($script:HelperCmd)" }
if ($mode -ne 'anthropic') {
$preset = Get-Preset ([string]$state['preset'])
$auth = Get-PresetAuth $preset
$base = ([string]$preset['baseUrl']).TrimEnd('/')
if ([string]$auth['mode'] -eq 'vault') {
$keyRef = [string]$auth['keyRef']
$key = Get-VaultKey $keyRef
if ($key) {
Write-Ok "vault '$keyRef' decrypts -> $(Format-KeyMask $key)"
if ($key -match '\s' -or $key -like 'claude-mode*') {
Write-Err2 "the stored '$keyRef' value looks like a pasted command, not a key. Re-run: claude-mode set-key $keyRef"
}
}
else { Write-Err2 "vault '$keyRef' missing or undecryptable. Run: claude-mode set-key $keyRef" }
# The stored string is what Claude Code hands to a shell, and a
# path this process can quote correctly is not evidence that the
# recorded one parses. Check the value, then run that value.
$stored = ''
if ($liveSettings.Contains('apiKeyHelper')) { $stored = [string]$liveSettings['apiKeyHelper'] }
$expected = Get-HelperCommandLine
$reSwitch = "claude-mode $mode $([string]$state['preset'])"
if (-not $stored) {
Write-Err2 "settings.json has no apiKeyHelper. Run: $reSwitch"
} elseif ($stored -ne $expected) {
Write-Err2 "apiKeyHelper reads $stored"
Write-Err2 " but should read $expected - run: $reSwitch"
} else {
Write-Ok "apiKeyHelper wired as $stored"
}
if ($stored) {
# Run it through cmd the way a shell would, via a batch file, so
# PowerShell's own native-argument quoting cannot paper over a
# value that a real shell would split.
$probe = Join-Path $env:TEMP ('cm-helper-probe-' + [IO.Path]::GetRandomFileName().Replace('.', '') + '.cmd')
try {
Set-Content -LiteralPath $probe -Value ("@echo off`r`n" + $stored) -Encoding ASCII
$out = (& cmd.exe /c "`"$probe`"" 2>&1 | Out-String).Trim()
if ($out -and $key -and $out -eq $key) { Write-Ok 'apiKeyHelper emits the correct key' }
elseif ($out -match '^\S+$') {
# One unbroken token: a credential, just the wrong one. Never echo it.
Write-Err2 "apiKeyHelper output does not match vault key (got: $(Format-KeyMask $out))"
}
elseif ($out) { Write-Err2 "apiKeyHelper failed: $out" }
else { Write-Err2 'apiKeyHelper produced no output' }
} catch {
Write-Err2 "apiKeyHelper failed to run: $($_.Exception.Message)"
} finally {
Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
}
}
if ($key -and (Test-ProviderDoctor $mode 'openrouter-key')) {
try {
$r = Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/key' -Headers @{ Authorization = "Bearer $key" } -TimeoutSec 20
Write-Ok "OpenRouter key valid (label: $($r.data.label))"
if ($null -ne $r.data.limit) {
Write-Ok ("spend {0:N2} of {1:N2} limit ({2}), {3:N2} remaining" -f `
[double]$r.data.usage, [double]$r.data.limit, $r.data.limit_reset, [double]$r.data.limit_remaining)
} else {
Write-Ok ("spend {0:N2} this month (no key limit set)" -f [double]$r.data.usage)
}
} catch {
Write-Err2 "OpenRouter rejected the key: $($_.Exception.Message)"
}
# The guardrail is not exposed by /api/v1/key, so it can only be
# established by trying a blocked model.
Show-GuardrailStatus -Mode $mode -Key $key
}
if ($key -and (Test-ProviderDoctor $mode 'message-check')) {
# No key-info endpoint; the cheapest real check is a 1-token
# message against the Anthropic-compatible surface.
try {
$body = @{
model = [string]$preset['models']['haiku']
max_tokens = 1
messages = @(@{ role = 'user'; content = 'hi' })
} | ConvertTo-Json -Depth 6
[void](Invoke-RestMethod -Uri "$base/v1/messages" -Method Post -Body $body `
-ContentType 'application/json' -TimeoutSec 45 `
-Headers @{ 'x-api-key' = $key; 'Authorization' = "Bearer $key"; 'anthropic-version' = '2023-06-01' })
Write-Ok "$((Get-Provider $mode).title) endpoint accepted the key ($base/v1/messages)"
} catch {
$detail = ''
if ($_.ErrorDetails) { $detail = ($_.ErrorDetails.Message -replace '\s+', ' ') }
Write-Err2 "$((Get-Provider $mode).title) request failed: $($_.Exception.Message) $detail"
}
}
} else {
Write-Ok "inline token '$($auth['token'])' (no secret in settings.json)"
}
# Which checks run is the provider's call, by name, in providers.json.
if (Test-ProviderDoctor $mode 'catalogue-models') { Test-PresetCatalogue -Mode $mode -Preset $preset }
if (Test-ProviderDoctor $mode 'ollama-context') { Test-OllamaContext -Preset $preset }
}
Write-Host ''
if ((Test-StaleModelSelections -Mode $mode) -eq 0 -and $mode -ne 'anthropic') {
Write-Ok 'no cached Anthropic model ids'
}
Write-Host ''
$bad = Test-StrayEnvVars -Mode $mode
if ($bad -gt 0) {
Write-Host ''
$ans = Read-Host 'Remove the User-scope overrides listed above? [y/N]'
if ($ans -match '^[Yy]') { Repair-StrayEnvVars }
} else {
Write-Ok 'no persistent env-var overrides'
}
Write-Host ''
$exe = Get-Command claude.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if ($exe) {
Write-Host " claude: $((& $exe.Source --version 2>&1 | Out-String).Trim()) [$($exe.Source)]"
} else {
Write-Warn2 'claude.exe not found on PATH'
}
# Refresh the machine-readable state so a fleet reader sees doctor's findings
# (notably guardrailStatus, which only a probe can establish).
Write-HealthFile -Mode $mode -PresetName ([string]$state['preset'])
}
+86
View File
@@ -0,0 +1,86 @@
# lib/core.ps1 - state.json, presets, and which preset a mode resolves to.
#
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
# the settings at its top. Not meant to run on its own. ASCII only: Windows
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
# lib\, so paths beside the main script go through $script:Here.
# ---------------------------------------------------------------------------
# State / presets
# ---------------------------------------------------------------------------
function Initialize-Root {
foreach ($d in @($script:Root, $script:PresetDir, $script:VaultDir, $script:BackupDir, $script:BinDir)) {
if (-not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
}
}
function Get-State {
$s = Read-JsonFile $script:StatePath
if ($null -eq $s) { $s = [ordered]@{} }
if (-not $s.Contains('mode')) { $s['mode'] = 'anthropic' }
if (-not $s.Contains('preset')) { $s['preset'] = '' }
if (-not $s.Contains('writtenEnvKeys')) { $s['writtenEnvKeys'] = @() }
return $s
}
function Set-State {
param([string] $Mode, [string] $PresetName, [string[]] $WrittenKeys)
$s = Get-State
$s['mode'] = $Mode
$s['preset'] = $PresetName
$s['writtenEnvKeys'] = @($WrittenKeys)
$s['updated'] = (Get-Date).ToString('o')
# Written by older builds that picked "most recently used" presets; the
# per-provider default is fixed now, so nothing maintains this.
if ($s.Contains('lastByProvider')) { $s.Remove('lastByProvider') }
Write-JsonFile $script:StatePath $s
}
function Get-PresetPath { param([string] $Name) return (Join-Path $script:PresetDir "$Name.json") }
function Get-Preset {
param([string] $Name)
$p = Read-JsonFile (Get-PresetPath $Name)
if ($null -eq $p) { throw "preset '$Name' not found. Run: claude-mode presets" }
if (-not $p.Contains('provider')) { $p['provider'] = 'openrouter' }
return $p
}
function Get-PresetNames {
if (-not (Test-Path -LiteralPath $script:PresetDir)) { return @() }
return @(Get-ChildItem -LiteralPath $script:PresetDir -Filter '*.json' |
ForEach-Object { $_.BaseName } | Sort-Object)
}
function Get-PresetNamesForProvider {
param([string] $Provider)
$out = @()
foreach ($n in Get-PresetNames) {
try { if ([string](Get-Preset $n)['provider'] -eq $Provider) { $out += $n } } catch { }
}
return $out
}
function Resolve-PresetForProvider {
param([string] $Provider, [string] $Requested)
if ($Requested) {
$p = Get-Preset $Requested
if ([string]$p['provider'] -ne $Provider) {
throw "preset '$Requested' is a '$($p['provider'])' preset, not '$Provider'"
}
return $Requested
}
# Fixed per-provider default, so `claude-mode openrouter` is predictable
# rather than depending on what you last used or on alphabetical order.
$fallback = $script:ProviderDefaultPreset[$Provider]
if ($fallback -and (Test-Path -LiteralPath (Get-PresetPath $fallback))) {
if ([string](Get-Preset $fallback)['provider'] -eq $Provider) { return $fallback }
}
$names = Get-PresetNamesForProvider $Provider
if ($names.Count -gt 0) { return $names[0] }
throw "no preset found for provider '$Provider'"
}
+98
View File
@@ -0,0 +1,98 @@
# lib/files.ps1 - JSON read/write for PowerShell 5.1, and restricting a file to its owner.
#
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
# the settings at its top. Not meant to run on its own. ASCII only: Windows
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
# lib\, so paths beside the main script go through $script:Here.
# ---------------------------------------------------------------------------
# JSON helpers (PS 5.1 has no ConvertFrom-Json -AsHashtable)
# ---------------------------------------------------------------------------
function ConvertTo-DeepHashtable {
param($InputObject)
if ($null -eq $InputObject) { return $null }
if ($InputObject -is [System.Collections.IDictionary]) {
$h = [ordered]@{}
foreach ($k in $InputObject.Keys) { $h[[string]$k] = ConvertTo-DeepHashtable $InputObject[$k] }
return $h
}
if ($InputObject -is [System.Management.Automation.PSCustomObject]) {
$h = [ordered]@{}
foreach ($p in $InputObject.PSObject.Properties) { $h[$p.Name] = ConvertTo-DeepHashtable $p.Value }
return $h
}
if ($InputObject -is [string]) { return $InputObject }
if ($InputObject -is [System.Collections.IEnumerable]) {
$list = New-Object System.Collections.ArrayList
foreach ($item in $InputObject) { [void]$list.Add((ConvertTo-DeepHashtable $item)) }
return , $list.ToArray()
}
return $InputObject
}
function Read-JsonFile {
param([string] $Path)
if (-not (Test-Path -LiteralPath $Path)) { return $null }
$raw = Get-Content -LiteralPath $Path -Raw -Encoding UTF8
if ([string]::IsNullOrWhiteSpace($raw)) { return $null }
return ConvertTo-DeepHashtable (ConvertFrom-Json $raw)
}
function Write-JsonFile {
param([string] $Path, $Data)
$dir = Split-Path -Parent $Path
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
$json = Format-JsonPretty ($Data | ConvertTo-Json -Depth 100)
# UTF-8 without BOM; some JSON readers choke on a BOM.
[System.IO.File]::WriteAllText($Path, $json, (New-Object System.Text.UTF8Encoding($false)))
}
# PowerShell 5.1's ConvertTo-Json indents by aligning values into a column,
# which is valid but painful to hand-edit. Re-indent with node when available.
function Format-JsonPretty {
param([string] $Json)
if ($null -eq $script:NodeExe) {
$c = Get-Command node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if ($c) { $script:NodeExe = $c.Source } else { $script:NodeExe = '' }
}
if (-not $script:NodeExe) { return $Json }
$tmp = [System.IO.Path]::GetTempFileName()
try {
[System.IO.File]::WriteAllText($tmp, $Json, (New-Object System.Text.UTF8Encoding($false)))
$out = & $script:NodeExe -e "const fs=require('fs');process.stdout.write(JSON.stringify(JSON.parse(fs.readFileSync(process.argv[1],'utf8')),null,2)+'\n')" $tmp
if ($out) { return (($out -join "`n") + "`n") }
return $Json
} catch {
return $Json
} finally {
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
}
}
# ---------------------------------------------------------------------------
# ACL hardening - restrict a file to the current user only
# ---------------------------------------------------------------------------
function Protect-FileAcl {
param([string] $Path)
try {
$acl = Get-Acl -LiteralPath $Path
if ($acl.AreAccessRulesProtected) { return }
$acl.SetAccessRuleProtection($true, $false)
foreach ($rule in @($acl.Access)) { [void]$acl.RemoveAccessRule($rule) }
$me = New-Object System.Security.Principal.NTAccount($env:USERDOMAIN, $env:USERNAME)
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule(
$me, 'FullControl', 'None', 'None', 'Allow')))
Set-Acl -LiteralPath $Path -AclObject $acl
} catch {
Write-Warn2 "could not harden ACL on $Path : $($_.Exception.Message)"
}
}
+219
View File
@@ -0,0 +1,219 @@
# lib/guards.ps1 - the Anthropic-model cost guard, the OpenRouter guardrail, stale cached model ids.
#
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
# the settings at its top. Not meant to run on its own. ASCII only: Windows
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
# lib\, so paths beside the main script go through $script:Here.
# ---------------------------------------------------------------------------
# Anthropic-model cost guard
#
# Matches both the qualified gateway id (anthropic/claude-opus-5) and the bare
# internal id Claude Code persists (claude-opus-5, claude-opus-4-8,
# claude-haiku-4-5-20251001). Both have been observed in the wild.
# ---------------------------------------------------------------------------
# Ask OpenRouter whether this key can still reach Anthropic models, by trying the
# cheapest possible request against one. Free when the guardrail blocks it; a
# fraction of a cent when it does not, which is exactly the case worth knowing.
# Returns 'active' | 'open' | 'unknown'.
function Test-OpenRouterGuardrail {
param([string] $Key)
$body = '{"model":"claude-opus-5","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}'
try {
[void](Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/messages' -Method Post -Body $body `
-ContentType 'application/json' -TimeoutSec 25 `
-Headers @{ 'x-api-key' = $Key; 'Authorization' = "Bearer $Key"; 'anthropic-version' = '2023-06-01' })
return 'open'
} catch {
$c = 0
if ($_.Exception.Response) { $c = [int]$_.Exception.Response.StatusCode }
# 403/404 is OpenRouter refusing the model, which is what a guardrail
# looks like. 401 is the KEY being rejected - that says nothing about the
# guardrail and must not read as an all-clear. Anything else (timeout,
# 5xx) is equally uninformative.
if ($c -eq 403 -or $c -eq 404) { return 'active' }
if ($c -eq 401) { return 'badkey' }
return 'unknown'
}
}
# OpenRouter only. Z.AI and LM Studio have no equivalent control, so there is
# nothing actionable to print for them.
function Show-GuardrailStatus {
param([string] $Mode, [string] $Key)
# An OpenRouter feature, flagged per provider rather than by name.
$pv = Get-Provider $Mode
if (-not ($pv -and $pv.guardrail) -or -not $Key) { return }
$label = ' guardrail '
$state = Test-OpenRouterGuardrail -Key $Key
$script:HealthGuardrail = switch ($state) { 'active' { 'active' } 'open' { 'not_set' } default { 'unknown' } }
switch ($state) {
'active' {
Write-Host $label -NoNewline -ForegroundColor DarkGray
Write-Host 'active' -NoNewline -ForegroundColor Green
Write-Host ' - Anthropic models blocked for this key' -ForegroundColor DarkGray
}
'open' {
Write-Host $label -NoNewline -ForegroundColor DarkGray
Write-Host 'NOT SET' -NoNewline -ForegroundColor Red
Write-Host ' - Anthropic models reachable, billed at list price' -ForegroundColor DarkGray
Write-Host ' openrouter.ai -> Guardrails -> new, select this key,' -ForegroundColor DarkGray
Write-Host ' then exclude anthropic models (or allow only the ones you use)' -ForegroundColor DarkGray
}
'badkey' {
Write-Host $label -NoNewline -ForegroundColor DarkGray
Write-Host 'unknown' -NoNewline -ForegroundColor Yellow
Write-Host ' - OpenRouter rejected the key, so it could not be checked' -ForegroundColor DarkGray
}
default {
Write-Host $label -NoNewline -ForegroundColor DarkGray
Write-Host 'unknown' -NoNewline -ForegroundColor Yellow
Write-Host ' - could not reach OpenRouter to check' -ForegroundColor DarkGray
}
}
}
function Test-AnthropicModelId {
param([string] $Id)
if (-not $Id) { return $false }
return ($Id -match '(^|/)claude[-.]' -or $Id -like 'anthropic/*')
}
# Claude Code caches a resolved model per (entrypoint, model, org) in
# ~/.claude.json. A session that was running - or a picker selection made -
# before the switch keeps its old model id, and that id is then sent to whatever
# endpoint is now configured. That is how an Anthropic model ends up billed
# through a gateway. Detect it; we cannot prevent it from here.
# Walk every string in ~/.claude.json and collect model-ish values. Claude Code
# caches resolved models under more than one key - clientDataCacheSlots and
# additionalModelOptionsCache have both been observed - so scan rather than
# reach for a fixed path.
function Get-CachedModelIds {
$cfg = Join-Path $env:USERPROFILE '.claude.json'
if (-not (Test-Path -LiteralPath $cfg)) { return @() }
try { $j = Get-Content -LiteralPath $cfg -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return @() }
$out = New-Object System.Collections.ArrayList
$walk = {
param($o)
if ($null -eq $o) { return }
if ($o -is [string]) { return }
foreach ($p in $o.PSObject.Properties) {
$v = $p.Value
if ($v -is [string]) {
if ($p.Name -match 'model|value' -and $v -match '^[~a-zA-Z0-9]') { [void]$out.Add($v) }
} elseif ($v -is [System.Collections.IEnumerable]) {
foreach ($i in $v) { if ($i -isnot [string]) { & $walk $i } }
} elseif ($null -ne $v) { & $walk $v }
}
}
& $walk $j
# The structural walk only reaches keys named model/value. Tagged ids have
# been found under other keys too, and `repair` matches them by raw text, so
# union that in - otherwise health.json under-reports what repair would act
# on, which is worse than either alone.
try {
$raw = Get-Content -LiteralPath $cfg -Raw -Encoding UTF8
foreach ($m in [regex]::Matches($raw, '"([^"]*\[[0-9]+[a-zA-Z]\])"')) {
[void]$out.Add($m.Groups[1].Value)
}
} catch { }
return @($out | Sort-Object -Unique)
}
# A model id carrying a bracket suffix - claude-mode has seen `claude-fable-5[1m]`
# - is Claude Code's extended-context marker. It belongs to Anthropic's 1M models
# and no gateway recognises it. When a session that had it switches to a gateway,
# the tag can survive onto the new id, producing something like
# `~deepseek/deepseek-v4-flash-latest[1m]` that only fails at compaction time,
# because compaction re-resolves the model from session state.
function Test-TaggedModelId {
param([string] $Id)
return ($Id -match '\[[0-9]+[a-zA-Z]\]$')
}
function Test-StaleModelSelections {
param([string] $Mode)
if ($Mode -eq 'anthropic') { return 0 }
$ids = Get-CachedModelIds
$anth = @($ids | Where-Object { Test-AnthropicModelId $_ })
$tagged = @($ids | Where-Object { Test-TaggedModelId $_ })
$n = $anth.Count + $tagged.Count
if ($n -eq 0) { return 0 }
if ($anth.Count -gt 0) {
Write-Host ' sessions ' -NoNewline -ForegroundColor DarkGray
Write-Host "$($anth.Count) cached Anthropic model ids" -NoNewline -ForegroundColor Yellow
Write-Host ' - restart running claude sessions' -ForegroundColor DarkGray
}
if ($tagged.Count -gt 0) {
Write-Host ' tagged ' -NoNewline -ForegroundColor DarkGray
Write-Host "$($tagged.Count) model id(s) carry a [1m] tag" -NoNewline -ForegroundColor Yellow
Write-Host ' - breaks compaction on gateways; claude-mode repair' -ForegroundColor DarkGray
}
return $n
}
# Strip extended-context tags from cached model ids. Backed up first; the file is
# the user's own Claude Code config, not ours.
# Strip extended-context tags from cached model ids.
#
# The tag is NOT junk everywhere. On an Anthropic id it is how Claude Code
# selects the 1M variant (`qS()` in the CLI literally tests the id string for
# `[1m]`), so stripping `claude-fable-5[1m]` silently downgrades that choice to
# the 200k variant and the user has to re-pick it in /model. On a gateway id the
# same tag is meaningless and breaks compaction.
#
# So the default - and the only thing done automatically - is to strip tags from
# NON-Anthropic ids only. -All includes Anthropic ids and is a deliberate,
# manual choice.
function Invoke-Repair {
param([switch] $All, [switch] $Quiet)
$cfg = Join-Path $env:USERPROFILE '.claude.json'
if (-not (Test-Path -LiteralPath $cfg)) { if (-not $Quiet) { Write-Err2 'no ~/.claude.json' }; return 0 }
$raw = Get-Content -LiteralPath $cfg -Raw -Encoding UTF8
$tagged = @([regex]::Matches($raw, '"([^"]*\[[0-9]+[a-zA-Z]\])"') |
ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
if ($tagged.Count -eq 0) { if (-not $Quiet) { Write-Ok 'no tagged model ids in ~/.claude.json' }; return 0 }
$target = @($tagged | Where-Object { $All -or -not (Test-AnthropicModelId $_) })
$kept = @($tagged | Where-Object { $_ -notin $target })
if ($target.Count -eq 0) {
if (-not $Quiet) {
Write-Ok "nothing to strip - $($kept.Count) tagged id(s) are Anthropic models, where the tag is meaningful"
foreach ($k in $kept) { Write-Host " keeping $k" -ForegroundColor DarkGray }
Write-Host ' use --all to strip those too (downgrades them to the 200k variant)' -ForegroundColor DarkGray
}
return 0
}
Initialize-Root
$bak = Join-Path $script:BackupDir ('claude.json.' + (Get-Date).ToString('yyyyMMdd-HHmmss') + '.bak')
Copy-Item -LiteralPath $cfg -Destination $bak -Force
$fixed = $raw
foreach ($t in $target) {
$clean = $t -replace '\[[0-9]+[a-zA-Z]\]$', ''
$fixed = $fixed.Replace('"' + $t + '"', '"' + $clean + '"')
}
try { [void](ConvertFrom-Json $fixed) } catch { Write-Err2 'repair would produce invalid JSON - aborted'; return 0 }
[System.IO.File]::WriteAllText($cfg, $fixed, (New-Object System.Text.UTF8Encoding($false)))
if ($Quiet) {
Write-Host ' repaired ' -NoNewline -ForegroundColor DarkGray
Write-Host "$($target.Count) gateway model id(s) had a [1m] tag stripped" -ForegroundColor Green
} else {
foreach ($t in $target) { Write-Host " $t" -ForegroundColor DarkGray }
foreach ($k in $kept) { Write-Host " keeping $k (Anthropic - tag is meaningful)" -ForegroundColor DarkGray }
Write-Ok "stripped $($target.Count) tag(s); backup at $bak"
Write-Host ' restart claude for this to take effect' -ForegroundColor DarkGray
}
return $target.Count
}
+148
View File
@@ -0,0 +1,148 @@
# lib/health.ps1 - health.json, and persistent environment variables that would override a switch.
#
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
# the settings at its top. Not meant to run on its own. ASCII only: Windows
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
# lib\, so paths beside the main script go through $script:Here.
# ---------------------------------------------------------------------------
# Stray environment variable detection
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# health.json - the machine-readable state a fleet reader (or anything else)
# reads. Written on every switch and on every `doctor`.
#
# Contract, deliberately narrow:
# * NO key material, ever. `keysConfigured` is names only; `keyBackend` says
# how they are stored, never what they are.
# * Model-id lists are [{id, anthropic}] rather than bare strings, so a reader
# never has to re-derive the Anthropic matcher. A tagged *Anthropic* id is
# normal (that is how the 1M variant is selected) and must not render as a
# fault; a tagged gateway id is the breakage.
# * `guardrailStatus` is tri-state (active|not_set|unknown) or null when not
# probed. Never collapse to a boolean - "unknown" must not read as safe.
# ---------------------------------------------------------------------------
$script:HealthPath = Join-Path $script:Root 'health.json'
$script:HealthGuardrail = $null # set by Show-GuardrailStatus when it probes
# Must serialise as a JSON array for 0, 1 and many entries. PS 5.1 unrolls a
# single-element array on return (rendering it as a bare object) and renders a
# comma-wrapped empty array as [[]]. A generic List survives both: `,` stops the
# unroll, and ConvertTo-Json always emits a List as an array.
function ConvertTo-ModelEntry {
param([string[]] $Ids)
$out = New-Object 'System.Collections.Generic.List[object]'
foreach ($i in @($Ids)) {
$out.Add([ordered]@{ id = $i; anthropic = [bool](Test-AnthropicModelId $i) })
}
return , $out
}
function Write-HealthFile {
param([string] $Mode, [string] $PresetName)
$h = [ordered]@{
schema = 1
tool = 'claude-mode'
version = $script:Version
updatedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
os = 'windows'
mode = $Mode
preset = ''
}
$ids = Get-CachedModelIds
$stale = @($ids | Where-Object { Test-AnthropicModelId $_ })
$tagged = @($ids | Where-Object { Test-TaggedModelId $_ })
if ($Mode -eq 'anthropic') {
# Nothing gateway-shaped is meaningful here, and a cached Anthropic id is
# simply the model in use - not a finding.
$h['staleModelIds'] = (ConvertTo-ModelEntry @())
$h['taggedModelIds'] = ConvertTo-ModelEntry $tagged
} else {
$h['preset'] = $PresetName
try {
$p = Get-Preset $PresetName
$h['provider'] = [string]$p['provider']
$h['baseUrl'] = [string]$p['baseUrl']
$models = [ordered]@{}
foreach ($t in $script:Tiers) { if ($p['models'].Contains($t)) { $models[$t] = [string]$p['models'][$t] } }
$h['models'] = $models
$h['subagentModel'] = [string]$p['subagentModel']
$h['contextTokens'] = if ($p['contextTokens']) { [int]$p['contextTokens'] } else { $null }
$h['gatewayDiscovery'] = [bool]$p['gatewayModelDiscovery']
$auth = Get-PresetAuth $p
if ([string]$auth['mode'] -eq 'vault') {
$ref = [string]$auth['keyRef']
$h['keyBackend'] = 'dpapi'
$kc = New-Object 'System.Collections.Generic.List[object]'
if (Get-VaultKey $ref) { $kc.Add($ref) }
$h['keysConfigured'] = $kc
} else {
$h['keyBackend'] = 'inline'
$h['keysConfigured'] = (New-Object 'System.Collections.Generic.List[object]')
}
# It passed the guard, or Set-ClaudeMode would have thrown.
$h['costGuardPassed'] = $true
} catch {
$h['costGuardPassed'] = $null
}
$h['guardrailStatus'] = $script:HealthGuardrail
$h['staleModelIds'] = ConvertTo-ModelEntry $stale
$h['taggedModelIds'] = ConvertTo-ModelEntry $tagged
}
try { Write-JsonFile $script:HealthPath $h } catch { }
}
function Get-PersistentEnv {
param([string] $Name)
return [pscustomobject]@{
Name = $Name
User = [Environment]::GetEnvironmentVariable($Name, 'User')
Machine = [Environment]::GetEnvironmentVariable($Name, 'Machine')
}
}
function Test-StrayEnvVars {
param([string] $Mode)
$problems = 0
$ak = Get-PersistentEnv 'ANTHROPIC_API_KEY'
if ($Mode -ne 'anthropic') {
if ($ak.User) { Write-Err2 'ANTHROPIC_API_KEY is set at User scope - it will bypass the gateway. Fix: claude-mode doctor'; $problems++ }
if ($ak.Machine) { Write-Err2 'ANTHROPIC_API_KEY is set at Machine scope - it will bypass the gateway. Remove it (admin required).'; $problems++ }
if ($env:ANTHROPIC_API_KEY -and -not $ak.User -and -not $ak.Machine) {
Write-Warn2 'ANTHROPIC_API_KEY is set in THIS shell only. The `claude` wrapper strips it; other shells are unaffected.'
}
}
foreach ($name in $script:BaseManagedEnvKeys) {
if ($name -eq 'ANTHROPIC_API_KEY') { continue }
$v = Get-PersistentEnv $name
if ($v.User) { Write-Err2 "$name is set at User scope - it overrides claude-mode. Fix: claude-mode doctor"; $problems++ }
if ($v.Machine) { Write-Err2 "$name is set at Machine scope - remove it (admin required)."; $problems++ }
}
return $problems
}
function Repair-StrayEnvVars {
$fixed = 0
foreach ($name in $script:BaseManagedEnvKeys) {
$v = [Environment]::GetEnvironmentVariable($name, 'User')
if ($v) {
$bak = Join-Path $script:BackupDir ("userenv-$name-" + (Get-Date).ToString('yyyyMMdd-HHmmss') + '.txt')
Set-Content -LiteralPath $bak -Value $v -Encoding UTF8
Protect-FileAcl $bak
[Environment]::SetEnvironmentVariable($name, $null, 'User')
Write-Ok "removed User-scope $name (old value saved to $bak)"
$fixed++
}
}
if ($fixed -eq 0) { Write-Ok 'no User-scope overrides to remove' }
}
+669
View File
@@ -0,0 +1,669 @@
# lib/menu.ps1 - the interactive menu, its pickers, and interactive preset editing.
#
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
# the settings at its top. Not meant to run on its own. ASCII only: Windows
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
# lib\, so paths beside the main script go through $script:Here.
# ---------------------------------------------------------------------------
# Interactive menu (claude-mode with no arguments)
# ---------------------------------------------------------------------------
function Test-Interactive {
try {
if ([Console]::IsInputRedirected) { return $false }
if ([Console]::IsOutputRedirected) { return $false }
[void][Console]::WindowWidth
return $true
} catch { return $false }
}
# ---------------------------------------------------------------------------
# UI frame - the screen region the interactive menus own.
#
# Successive selectors (mode -> preset, preset -> tier) must REPLACE each other
# rather than stack, otherwise the earlier list stays on screen looking frozen
# and interactive. The frame remembers where the current run of menus began;
# each new selector wipes back to that line and redraws from it.
#
# Output that must persist (a switch summary, doctor results) calls Stop-UiFrame
# first: that erases the menu, then lets the output print in the freed space and
# stay there, with the next menu opening a fresh frame below it.
# ---------------------------------------------------------------------------
$script:UiActive = $false
$script:UiTop = 0
$script:UiQuit = $false # set by the submenu to unwind the whole menu stack
function Start-UiFrame {
if ($script:UiActive) { return }
try { $script:UiTop = [Console]::CursorTop } catch { $script:UiTop = 0 }
$script:UiActive = $true
}
function Clear-UiFrame {
if (-not $script:UiActive) { return }
try {
$w = [Math]::Max(1, [Console]::WindowWidth - 1)
$blank = ' ' * $w
$bottom = [Console]::CursorTop
for ($y = $script:UiTop; $y -le $bottom; $y++) {
[Console]::SetCursorPosition(0, $y)
[Console]::Write($blank)
}
[Console]::SetCursorPosition(0, $script:UiTop)
} catch { }
}
function Stop-UiFrame {
Clear-UiFrame
$script:UiActive = $false
}
# ---------------------------------------------------------------------------
# Show-Select - arrow-key list picker.
#
# Draws the list once, then repaints it in place on every keypress by parking
# the cursor back at the top of the region. Each row is padded to the console
# width so a repaint erases whatever the previous, longer row left behind.
# A fixed-height detail pane under the list shows the highlighted row's info,
# which keeps the geometry constant - variable-height rows would make the
# in-place repaint arithmetic fragile.
#
# Returns the selected index, or -1 if the user cancelled / the console cannot
# support this (callers fall back to Read-Choice).
# ---------------------------------------------------------------------------
function Show-Select {
param(
[string] $Title,
[string] $Status,
[object[]] $Items, # each: Label (string), Detail (string[])
[int] $Default = 0,
[int] $DetailLines = 3
)
if (-not (Test-Interactive)) { return -1 }
$n = @($Items).Count
if ($n -eq 0) { return -1 }
$idx = [Math]::Max(0, [Math]::Min($Default, $n - 1))
$w = [Math]::Max(40, [Console]::WindowWidth - 1)
# Take over the frame: a menu already on screen is wiped so this one lands
# in its place instead of below it.
if ($script:UiActive) { Clear-UiFrame } else { Start-UiFrame }
$headerH = 3
Write-Host ''
if ($Title) {
$headerH = 4
$t = " $Title"
if ($Status) { $t = $t.PadRight(34) + $Status }
Write-Host $t -ForegroundColor Cyan
}
Write-Host ' up/down move enter select esc cancel' -ForegroundColor DarkGray
Write-Host ''
# Reserve the region first so the cursor never has to scroll mid-repaint,
# then rewind to its top.
$regionH = $n + 1 + $DetailLines
for ($i = 0; $i -lt $regionH; $i++) { Write-Host '' }
$top = [Console]::CursorTop - $regionH
# Reserving may have scrolled the buffer, which moves everything up and
# invalidates the remembered frame top. Re-anchor it from where the list
# actually landed.
$script:UiTop = [Math]::Max(0, $top - $headerH)
# Truncate-or-pad a row to exactly the console width, so repainting a short
# row fully erases a longer one underneath it.
$pad = {
param([string] $s)
if ($s.Length -gt $w) { $s = $s.Substring(0, $w) }
return $s.PadRight($w)
}
$cursorWasVisible = $true
try { $cursorWasVisible = [Console]::CursorVisible; [Console]::CursorVisible = $false } catch { }
try {
while ($true) {
[Console]::SetCursorPosition(0, $top)
for ($i = 0; $i -lt $n; $i++) {
$sel = ($i -eq $idx)
$mark = if ($sel) { ' > ' } else { ' ' }
$row = & $pad ($mark + $Items[$i].Label)
if ($sel) {
# An item may carry its own accent (mode rows do), so the
# highlight itself tells you which provider you are on.
$bg = 'Cyan'
if ($Items[$i].PSObject.Properties.Name -contains 'Accent' -and $Items[$i].Accent) {
$bg = [string]$Items[$i].Accent
}
Write-Host $row -ForegroundColor Black -BackgroundColor $bg
}
else { Write-Host $row -ForegroundColor Gray }
}
Write-Host (& $pad '')
$detail = @($Items[$idx].Detail)
for ($k = 0; $k -lt $DetailLines; $k++) {
$line = if ($k -lt $detail.Count) { ' ' + $detail[$k] } else { '' }
Write-Host (& $pad $line) -ForegroundColor DarkGray
}
# if/elseif rather than switch: `break`/`continue` inside a switch
# nested in a loop is ambiguous in PowerShell, and this is the input
# loop - it has to be unambiguous.
$key = [Console]::ReadKey($true)
$k = [string]$key.Key
$ch = $key.KeyChar
if ($k -eq 'UpArrow' -or $k -eq 'K') { $idx = ($idx - 1 + $n) % $n }
elseif ($k -eq 'DownArrow' -or $k -eq 'J') { $idx = ($idx + 1) % $n }
elseif ($k -eq 'Home') { $idx = 0 }
elseif ($k -eq 'End') { $idx = $n - 1 }
elseif ($k -eq 'Enter' -or $k -eq 'Spacebar') { return $idx }
elseif ($k -eq 'Escape') { return -1 }
elseif ($ch -eq 'q' -or $ch -eq 'Q') { return -1 }
elseif ($ch -match '^[1-9]$' -and [int][string]$ch -le $n) {
# Number keys still work as direct shortcuts.
return ([int][string]$ch - 1)
}
}
} finally {
try {
[Console]::SetCursorPosition(0, $top + $regionH)
[Console]::CursorVisible = $cursorWasVisible
} catch { }
}
}
# Numeric fallback, used when the console cannot host Show-Select.
function Read-Choice {
param([string] $Prompt, [int] $Max, [string] $Default = '')
while ($true) {
$raw = Read-Host $Prompt
if ([string]::IsNullOrWhiteSpace($raw)) {
if ($Default) { return $Default }
continue
}
$raw = $raw.Trim()
if ($raw -eq 'q' -or $raw -eq '0') { return 'q' }
$n = 0
if ([int]::TryParse($raw, [ref]$n) -and $n -ge 1 -and $n -le $Max) { return [string]$n }
Write-Host ' (invalid choice)' -ForegroundColor DarkGray
}
}
function Get-PresetSummaryLines {
param($Preset)
$out = @()
if ($Preset.Contains('description')) { $out += [string]$Preset['description'] }
$bits = @()
foreach ($t in $script:Tiers) {
if ($Preset['models'].Contains($t)) { $bits += "$t=$($Preset['models'][$t])" }
}
if ($bits.Count) { $out += ($bits -join ' ') }
if ($Preset.Contains('contextTokens') -and $Preset['contextTokens']) {
$out += ("context {0:N0} tokens base {1}" -f [int]$Preset['contextTokens'], $Preset['baseUrl'])
} else {
$out += "base $($Preset['baseUrl'])"
}
return $out
}
function Invoke-PresetPicker {
param([string] $Provider)
$names = @(Get-PresetNamesForProvider $Provider)
if ($names.Count -eq 0) { throw "no presets defined for provider '$Provider'" }
$default = $script:ProviderDefaultPreset[$Provider]
if (-not ($names -contains $default)) { $default = $names[0] }
$defaultIdx = [Math]::Max(0, [array]::IndexOf($names, $default))
$items = @()
foreach ($n in $names) {
$p = Get-Preset $n
$label = $n
if ($n -eq $default) { $label = "$n (default)" }
$items += [pscustomobject]@{ Label = $label; Detail = (Get-PresetSummaryLines $p) }
}
$sel = Show-Select -Title "preset for $Provider" -Items $items -Default $defaultIdx
if ($sel -ge 0) { return $names[$sel] }
if ($sel -eq -1 -and (Test-Interactive)) { return $null } # cancelled
# console too limited for the picker - fall back to numbers
for ($i = 0; $i -lt $names.Count; $i++) { Write-Host (" {0}) {1}" -f ($i + 1), $names[$i]) }
$c = Read-Choice -Prompt " preset [Enter = $default, q = cancel]" -Max $names.Count -Default 'D'
if ($c -eq 'q') { return $null }
if ($c -eq 'D') { return $default }
return $names[[int]$c - 1]
}
# ---------------------------------------------------------------------------
# Show-SearchSelect - arrow-key picker with a live type-to-filter box.
#
# Show-Select is fine for a handful of rows, but OpenRouter lists 300+ models,
# which is unusable as a flat list. This keeps the same in-place repaint but adds
# a filter line and a scrolling window over the matches.
#
# Returns the index into $Items, or -1 to cancel.
# ---------------------------------------------------------------------------
function Show-SearchSelect {
param(
[string] $Title,
[string] $Status,
[object[]] $Items, # each: Label (string), Detail (string[]), Key (string, searched)
[string] $Query = '',
[int] $MaxRows = 12,
[int] $DetailLines = 2
)
if (-not (Test-Interactive)) { return -1 }
$all = @($Items)
if ($all.Count -eq 0) { return -1 }
$w = [Math]::Max(40, [Console]::WindowWidth - 1)
$MaxRows = [Math]::Max(3, [Math]::Min($MaxRows, [Console]::WindowHeight - 12))
if ($script:UiActive) { Clear-UiFrame } else { Start-UiFrame }
$headerH = 3
Write-Host ''
if ($Title) {
$headerH = 4
$t = " $Title"
if ($Status) { $t = $t.PadRight(34) + $Status }
Write-Host $t -ForegroundColor Cyan
}
Write-Host ' type to filter up/down move enter select esc cancel' -ForegroundColor DarkGray
Write-Host ''
# filter line + blank + rows + count + blank + detail
$regionH = 1 + 1 + $MaxRows + 1 + 1 + $DetailLines
for ($i = 0; $i -lt $regionH; $i++) { Write-Host '' }
$top = [Console]::CursorTop - $regionH
$script:UiTop = [Math]::Max(0, $top - $headerH)
$pad = {
param([string] $s)
if ($s.Length -gt $w) { $s = $s.Substring(0, $w) }
return $s.PadRight($w)
}
$idx = 0
$off = 0
$cursorWasVisible = $true
try { $cursorWasVisible = [Console]::CursorVisible; [Console]::CursorVisible = $false } catch { }
try {
while ($true) {
# Filter on every keystroke. Match against Key when present so a row
# can display extra decoration without breaking search.
$q = $Query.Trim().ToLower()
if ($q) {
$matches = @($all | Where-Object {
$hay = if ($_.PSObject.Properties.Name -contains 'Key' -and $_.Key) { [string]$_.Key } else { [string]$_.Label }
$hay.ToLower().Contains($q)
})
} else {
$matches = $all
}
$m = $matches.Count
if ($idx -ge $m) { $idx = [Math]::Max(0, $m - 1) }
if ($idx -lt $off) { $off = $idx }
if ($idx -ge $off + $MaxRows) { $off = $idx - $MaxRows + 1 }
if ($off -gt [Math]::Max(0, $m - $MaxRows)) { $off = [Math]::Max(0, $m - $MaxRows) }
[Console]::SetCursorPosition(0, $top)
Write-Host (& $pad (" filter: " + $Query + "_")) -ForegroundColor White
Write-Host (& $pad '')
for ($r = 0; $r -lt $MaxRows; $r++) {
$i = $off + $r
if ($i -ge $m) { Write-Host (& $pad ''); continue }
$sel = ($i -eq $idx)
$mark = if ($sel) { ' > ' } else { ' ' }
$row = & $pad ($mark + $matches[$i].Label)
if ($sel) { Write-Host $row -ForegroundColor Black -BackgroundColor Cyan }
else { Write-Host $row -ForegroundColor Gray }
}
$count = if ($m -eq 0) { ' (no match)' } else { " $($idx + 1) of $m" + $(if ($all.Count -ne $m) { " (filtered from $($all.Count))" } else { '' }) }
Write-Host (& $pad $count) -ForegroundColor DarkGray
Write-Host (& $pad '')
$detail = if ($m -gt 0) { @($matches[$idx].Detail) } else { @() }
for ($k = 0; $k -lt $DetailLines; $k++) {
$line = if ($k -lt $detail.Count) { ' ' + $detail[$k] } else { '' }
Write-Host (& $pad $line) -ForegroundColor DarkGray
}
$key = [Console]::ReadKey($true)
$k = [string]$key.Key
$ch = $key.KeyChar
if ($k -eq 'UpArrow') { if ($m) { $idx = ($idx - 1 + $m) % $m } }
elseif ($k -eq 'DownArrow') { if ($m) { $idx = ($idx + 1) % $m } }
elseif ($k -eq 'PageUp') { $idx = [Math]::Max(0, $idx - $MaxRows) }
elseif ($k -eq 'PageDown') { $idx = [Math]::Min([Math]::Max(0, $m - 1), $idx + $MaxRows) }
elseif ($k -eq 'Home') { $idx = 0 }
elseif ($k -eq 'End') { $idx = [Math]::Max(0, $m - 1) }
elseif ($k -eq 'Enter') { if ($m -gt 0) { return [array]::IndexOf($all, $matches[$idx]) } }
elseif ($k -eq 'Escape') { return -1 }
elseif ($k -eq 'Backspace') {
if ($Query.Length -gt 0) { $Query = $Query.Substring(0, $Query.Length - 1); $idx = 0; $off = 0 }
}
elseif ($ch -and [int][char]$ch -ge 32 -and [int][char]$ch -lt 127) {
$Query += $ch; $idx = 0; $off = 0
}
}
} finally {
try {
[Console]::SetCursorPosition(0, $top + $regionH)
[Console]::CursorVisible = $cursorWasVisible
} catch { }
}
}
# Catalogue per provider, shaped for the picker. Cached per process so opening
# the picker repeatedly in one session does not refetch OpenRouter's 300+ models.
$script:ModelCatalogueCache = @{}
function Get-ModelChoices {
param($Preset)
$provider = [string]$Preset['provider']
$cacheKey = $provider + '|' + [string]$Preset['baseUrl']
if ($script:ModelCatalogueCache.ContainsKey($cacheKey)) { return $script:ModelCatalogueCache[$cacheKey] }
$out = @()
$kind = [string](Get-Provider $provider).catalogue.kind
$tpl = $(if ($kind -eq 'lmstudio') { Get-LmStudioTemplateReport } else { @() })
foreach ($m in (Get-ProviderCatalogue $Preset | Sort-Object Id)) {
$flag = ''
if ($kind -eq 'openrouter') {
$det = @(("context {0:N0} `$$($m.InM) in / `$$($m.OutM) out per 1M tokens" -f [int]$m.Ctx))
} elseif ($kind -eq 'lmstudio') {
$det = @("state: $($m.State) max context: $($m.Ctx)")
$short = ($m.Id -split '/')[-1].ToLower()
$risk = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1
if ($risk -and $risk.Assertions.Count -gt 0) {
$flag = ' [TEMPLATE RISK]'
$det += 'chat template asserts message order - can break tool calls'
}
} else {
$det = @([string]$m.Note)
}
$out += [pscustomobject]@{ Id = $m.Id; Key = $m.Id; Label = ($m.Id + $flag); Detail = $det }
}
$script:ModelCatalogueCache[$cacheKey] = $out
return $out
}
function Read-ModelId {
param($Preset, [string] $Tier, [string] $Current)
$choices = @()
$err = $null
try { $choices = @(Get-ModelChoices $Preset) } catch { $err = $_.Exception.Message }
if ($choices.Count -gt 0) {
$items = @()
# First row is always the manual escape hatch - a catalogue can lag
# behind what the provider actually accepts.
$items += [pscustomobject]@{ Id = $null; Key = 'type manually custom'; Label = '<type an id manually>'; Detail = @('enter any model id by hand') }
$items += $choices
$sel = Show-SearchSelect -Title "model for '$Tier'" -Status "current: $Current" -Items $items
if ($sel -lt 0) { return $null }
if ($sel -gt 0) { return $items[$sel].Id }
# fall through to manual entry
}
# A typed prompt cannot live inside the repainting frame - the redraw would
# erase what is being typed. Close the frame first.
Stop-UiFrame
Write-Host ''
if ($err) { Write-Warn2 "could not load the model list: $err" }
Write-Host " current $Tier : $Current" -ForegroundColor DarkGray
$val = Read-Host " new model id for '$Tier' (blank = cancel)"
if ([string]::IsNullOrWhiteSpace($val)) { return $null }
return $val.Trim()
}
# A blank preset per provider, with the endpoint/auth/context bits already right
# so only the model choices are left to make.
function New-PresetScaffold {
param([string] $Provider)
# Built from the provider's own template in providers.json; the POSIX
# build's `cm-json.py scaffold` produces the same thing from the same file.
$p = Get-Provider $Provider
if (-not $p) { throw "unknown provider '$Provider'" }
$tpl = $p.preset
$base = [ordered]@{
provider = $Provider
description = 'new preset'
}
$base['baseUrl'] = [string]$tpl.baseUrl
$auth = [ordered]@{}
if ($tpl.auth) { foreach ($prop in $tpl.auth.PSObject.Properties) { $auth[$prop.Name] = $prop.Value } }
else { $auth['mode'] = 'vault'; $auth['keyRef'] = $Provider }
$base['auth'] = $auth
$base['models'] = [ordered]@{ opus = ''; sonnet = ''; haiku = ''; fable = '' }
$base['subagentModel'] = 'inherit'
$base['gatewayModelDiscovery'] = [bool]$tpl.gatewayModelDiscovery
$base['contextTokens'] = $(if ($tpl.contextTokens) { [int]$tpl.contextTokens } else { 200000 })
if ($tpl.extraEnv) {
$extra = [ordered]@{}
foreach ($prop in $tpl.extraEnv.PSObject.Properties) { $extra[$prop.Name] = [string]$prop.Value }
$base['extraEnv'] = $extra
}
return $base
}
function New-PresetInteractive {
# 1. provider
$provs = @($script:Modes | Where-Object { $_ -ne 'anthropic' })
$items = @()
foreach ($p in $provs) { $items += [pscustomobject]@{ Label = $p; Detail = @($script:ModeLabel[$p]) } }
$sel = Show-Select -Title 'new preset - which provider' -Items $items
if ($sel -lt 0) { return }
$provider = $provs[$sel]
# 2. start from an existing preset of that provider, or blank
$siblings = @(Get-PresetNamesForProvider $provider)
$items = @([pscustomobject]@{ Label = '<blank>'; Detail = @("empty $provider preset - pick every model yourself") })
foreach ($n in $siblings) {
$items += [pscustomobject]@{ Label = "copy of $n"; Detail = (Get-PresetSummaryLines (Get-Preset $n)) }
}
$sel = Show-Select -Title 'start from' -Items $items
if ($sel -lt 0) { return }
$preset = if ($sel -eq 0) { New-PresetScaffold $provider } else { Get-Preset $siblings[$sel - 1] }
# 3. name it
Stop-UiFrame
Write-Host ''
Write-Host " new $provider preset" -ForegroundColor Cyan
$name = Read-Host ' name (letters, digits, dash; blank = cancel)'
if ([string]::IsNullOrWhiteSpace($name)) { return }
$name = $name.Trim()
if ($name -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$') {
Write-Err2 "invalid name '$name' - use letters, digits, dash, dot, underscore"
return
}
if (Test-Path -LiteralPath (Get-PresetPath $name)) {
Write-Err2 "preset '$name' already exists"
return
}
$preset['description'] = if ($sel -eq 0) { "custom $provider preset" } else { "copy of $($siblings[$sel - 1])" }
Write-JsonFile (Get-PresetPath $name) $preset
Write-Ok "created preset '$name' ($provider)"
# 4. straight into editing it
Invoke-PresetEditor -Name $name
}
function Invoke-PresetEditor {
param([string] $Name)
if (-not $Name) {
$names = @(Get-PresetNames)
if ($names.Count -eq 0) { Write-Warn2 'no presets'; return }
$items = @()
foreach ($n in $names) {
$p = Get-Preset $n
$items += [pscustomobject]@{
Label = ("{0,-18} [{1}]" -f $n, $p['provider'])
Detail = (Get-PresetSummaryLines $p)
}
}
$sel = Show-Select -Title 'edit which preset' -Items $items
if ($sel -lt 0) { return }
$Name = $names[$sel]
}
$name = $Name
$preset = Get-Preset $name
while ($true) {
$rows = @()
foreach ($t in $script:Tiers) {
$cur = ''
if ($preset['models'].Contains($t)) { $cur = [string]$preset['models'][$t] }
$rows += [pscustomobject]@{ Tier = $t; Current = $cur }
}
$sub = ''
if ($preset.Contains('subagentModel')) { $sub = [string]$preset['subagentModel'] }
$rows += [pscustomobject]@{ Tier = 'subagent'; Current = $sub }
$items = @()
foreach ($r in $rows) {
$items += [pscustomobject]@{
Label = ("{0,-9} {1}" -f $r.Tier, $r.Current)
Detail = @("change which model backs the '$($r.Tier)' tier", "current: $($r.Current)")
}
}
$c = Show-Select -Title "$name [$($preset['provider'])]" -Status 'esc = done' -Items $items
if ($c -lt 0) { return }
$tier = $rows[$c].Tier
$val = Read-ModelId -Preset $preset -Tier $tier -Current $rows[$c].Current
if (-not $val) { continue }
# Set-PresetTier may re-apply the live preset, which prints a full switch
# summary. Drop the frame so that output survives instead of being wiped
# by the next redraw of the tier list.
Stop-UiFrame
Set-PresetTier -Name $name -Tier $tier -Model $val
$preset = Get-Preset $name
}
}
# The second level: everything that is not switching mode. Loops until the user
# backs out, so running doctor then editing a preset costs one trip in.
function Invoke-MoreMenu {
while ($true) {
$state = Get-State
$current = [string]$state['mode']
$curDesc = $current
if ($current -ne 'anthropic' -and $state['preset']) { $curDesc = "$current / $($state['preset'])" }
$items = @(
[pscustomobject]@{ Label = 'status'; Detail = @('show the full active configuration') }
[pscustomobject]@{ Label = 'edit presets'; Detail = @('pick models per tier from the provider catalogue') }
[pscustomobject]@{ Label = 'new preset'; Detail = @('create a preset - blank or copied from an existing one') }
[pscustomobject]@{ Label = 'doctor'; Detail = @('verify auth, endpoint, model ids, context window') }
[pscustomobject]@{ Label = 'back'; Detail = @('return to the mode menu') }
[pscustomobject]@{ Label = 'quit'; Detail = @() }
)
$sel = Show-Select -Title 'claude-mode - more' -Status "currently: $curDesc" -Items $items
if ($sel -lt 0) { return } # esc = back
switch ($sel) {
0 { Stop-UiFrame; Invoke-Status } # output worth keeping
1 { Invoke-PresetEditor } # more menus - keep the frame
2 { New-PresetInteractive }
3 { Stop-UiFrame; Invoke-Doctor }
4 { return }
5 { Stop-UiFrame; $script:UiQuit = $true; return }
}
}
}
function Invoke-Menu {
if (-not (Test-Interactive)) { Invoke-Status; return }
$script:UiQuit = $false
# Drawn once, above the frame anchor, so the menus repaint underneath it and
# the banner stays put instead of flickering on every keypress.
$st0 = Get-State
Show-Banner -Mode ([string]$st0['mode']) -Preset $(if ([string]$st0['mode'] -ne 'anthropic') { [string]$st0['preset'] } else { '' })
while ($true) {
$state = Get-State
$current = [string]$state['mode']
$curDesc = $current
if ($current -ne 'anthropic' -and $state['preset']) { $curDesc = "$current / $($state['preset'])" }
# The mode you are already in is not offered - nothing to switch to.
$choices = @($script:Modes | Where-Object { $_ -ne $current })
$items = @()
foreach ($m in $choices) {
$detail = @($script:ModeLabel[$m])
try {
$pn = $script:ProviderDefaultPreset[$m]
if ($pn -and (Test-Path -LiteralPath (Get-PresetPath $pn))) {
$detail += (Get-PresetSummaryLines (Get-Preset $pn))[1]
}
} catch { }
$items += [pscustomobject]@{
Label = ("switch to " + $m)
Detail = $detail
Accent = $script:ModeColor[$m]
}
}
# Everything that is not "switch mode" lives one level down, so the three
# things this tool exists to do are the whole first screen.
$items += [pscustomobject]@{ Label = 'more ...'; Detail = @('status, presets, doctor') }
$sel = Show-Select -Title 'claude-mode' -Status "currently: $curDesc" -Items $items
if ($sel -lt 0) { Stop-UiFrame; return }
$nChoices = $choices.Count
if ($sel -eq $nChoices) {
Invoke-MoreMenu
if ($script:UiQuit) { return }
continue
}
$mode = $choices[$sel]
if ($mode -eq 'anthropic') { Stop-UiFrame; Set-ClaudeMode -Mode 'anthropic' -PresetName ''; return }
$preset = Invoke-PresetPicker -Provider $mode
if (-not $preset) { continue }
Stop-UiFrame
Set-ClaudeMode -Mode $mode -PresetName $preset
return
}
}
+90
View File
@@ -0,0 +1,90 @@
# lib/output.ps1 - output helpers, mode colours, the banner and usage.
#
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
# the settings at its top. Not meant to run on its own. ASCII only: Windows
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
# lib\, so paths beside the main script go through $script:Here.
# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------
function Write-Ok ($m) { Write-Host " ok $m" -ForegroundColor Green }
function Write-Warn2 ($m) { Write-Host " warn $m" -ForegroundColor Yellow }
function Write-Err2 ($m) { Write-Host " FAIL $m" -ForegroundColor Red }
function Write-Head ($m) { Write-Host ""; Write-Host $m -ForegroundColor Cyan }
# Each mode gets an identity colour, reused for its menu row, its banner tagline
# and its status line - so "which mode am I in" is answerable at a glance.
$script:ModeColor = @{ 'anthropic' = 'Magenta' }
$__colors = @{ cyan = 'Cyan'; green = 'Green'; yellow = 'Yellow'; magenta = 'Magenta'
white = 'White'; gray = 'Gray'; dkcyan = 'DarkCyan'; red = 'Red' }
foreach ($__p in $script:Providers) {
$__c = $__colors[[string]$__p.color]
$script:ModeColor[[string]$__p.id] = $(if ($__c) { $__c } else { 'Gray' })
}
# Deliberately ASCII-only. This file is read by Windows PowerShell 5.1, which
# assumes the ANSI codepage for a .ps1 without a BOM - box-drawing characters
# would arrive mangled on some machines. Plain ASCII always renders.
$script:Banner = @(
' ____ _ _ __ __ _ ',
' / ___| | __ _ _ _ __| | ___ | \/ | ___ __| | ___ ',
' | | | |/ _` | | | |/ _` |/ _ \ | |\/| |/ _ \ / _` |/ _ \',
' | |___| | (_| | |_| | (_| | __/ | | | | (_) | (_| | __/',
' \____|_|\__,_|\__,_|\__,_|\___| |_| |_|\___/ \__,_|\___|'
)
function Show-Banner {
param([string] $Mode, [string] $Preset)
$shades = @('DarkCyan', 'Cyan', 'Cyan', 'Cyan', 'DarkCyan')
Write-Host ''
for ($i = 0; $i -lt $script:Banner.Count; $i++) {
Write-Host $script:Banner[$i] -ForegroundColor $shades[$i]
}
$accent = $script:ModeColor[$Mode]
if (-not $accent) { $accent = 'Gray' }
$tag = if ($Preset) { "$Mode / $Preset" } else { $Mode }
Write-Host ' ' -NoNewline
Write-Host ('-' * 59) -ForegroundColor DarkGray
Write-Host ' now ' -NoNewline -ForegroundColor DarkGray
Write-Host $tag -NoNewline -ForegroundColor $accent
Write-Host ' ' -NoNewline
Write-Host 'switch Claude Code between providers' -ForegroundColor DarkGray
}
function Show-Usage {
@'
claude-mode - switch Claude Code between Anthropic and gateway providers
claude-mode interactive menu
claude-mode status active mode, preset, model map
claude-mode anthropic native login/subscription (clears all gateway config)
'@ | Write-Host
# One line per provider in providers.json, so a new one documents itself.
foreach ($p in $script:Providers) {
$desc = ([string]$p.label) -replace '^[^-]*-\s*', ''
Write-Host (" claude-mode {0,-26} {1} (default preset: {2})" -f "$($p.id) [preset]", $desc, $script:ProviderDefaultPreset[[string]$p.id])
}
@'
claude-mode presets list presets
claude-mode preset show <name>
claude-mode preset new <name> [from] create a preset (copies 'from')
claude-mode preset set <name> <tier> <model-id>
tier = opus | sonnet | haiku | fable | subagent
claude-mode preset all <name> <model-id>
point every tier at one model
claude-mode preset rm <name>
claude-mode set-key [ref] [key] store an API key (hidden prompt, DPAPI-encrypted)
claude-mode models [filter] models available from the active provider
claude-mode doctor verify auth, endpoint, model ids, stray env vars
claude-mode repair [--all] strip [1m] tags from cached model ids
(default: gateway ids only; --all includes Anthropic)
'@ | Write-Host
}
+60
View File
@@ -0,0 +1,60 @@
# lib/providers.ps1 - every gateway provider, read from providers.json, and lookups into them.
#
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
# the settings at its top. Not meant to run on its own. ASCII only: Windows
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
# lib\, so paths beside the main script go through $script:Here.
# ---------------------------------------------------------------------------
# Providers
#
# Every gateway provider is an entry in providers.json, next to this script and
# shared with the POSIX build: endpoint, auth, how its model list is fetched,
# which doctor checks apply. This script only knows the *kinds* of behaviour
# (Get-ProviderCatalogue, Test-PresetCatalogue) and picks one by name from the
# entry, so a provider that reuses them needs no change here. anthropic is
# built in: it is the native login, not a gateway.
# ---------------------------------------------------------------------------
$script:ProvidersPath = Join-Path $script:Here 'providers.json'
$script:Providers = @()
try {
$__pj = Get-Content -LiteralPath $script:ProvidersPath -Raw -Encoding UTF8 | ConvertFrom-Json
$script:Providers = @($__pj.providers | Where-Object { $_.id })
} catch { }
if ($script:Providers.Count -eq 0) {
Write-Host " FAIL providers.json missing or unreadable at $($script:ProvidersPath) - re-run install.ps1" -ForegroundColor Red
exit 1
}
$script:Modes = @('anthropic') + @($script:Providers | ForEach-Object { [string]$_.id })
$script:ModeLabel = @{ 'anthropic' = 'Anthropic - your subscription login, no gateway' }
# `claude-mode <provider>` with no preset named uses this one. Deliberately a
# fixed choice rather than "most recently used", so the command is predictable.
$script:ProviderDefaultPreset = @{}
foreach ($__p in $script:Providers) {
$script:ModeLabel[[string]$__p.id] = [string]$__p.label
$script:ProviderDefaultPreset[[string]$__p.id] = $(if ($__p.defaultPreset) { [string]$__p.defaultPreset } else { [string]$__p.id })
}
function Get-Provider {
param([string] $Id)
return ($script:Providers | Where-Object { $_.id -eq $Id } | Select-Object -First 1)
}
# An id or an alias (z.ai, z-ai) to the provider id; $null if neither.
function Resolve-ProviderId {
param([string] $Word)
$w = ([string]$Word).ToLower()
foreach ($p in $script:Providers) {
if ([string]$p.id -eq $w -or (@($p.aliases) -contains $w)) { return [string]$p.id }
}
return $null
}
function Test-ProviderDoctor {
param([string] $Id, [string] $Check)
$p = Get-Provider $Id
return [bool]($p -and (@($p.doctor) -contains $Check))
}
+224
View File
@@ -0,0 +1,224 @@
# lib/switch.ps1 - the write into settings.json, and the apiKeyHelper command line.
#
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
# the settings at its top. Not meant to run on its own. ASCII only: Windows
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
# lib\, so paths beside the main script go through $script:Here.
# Claude Code runs apiKeyHelper as a shell command line, not as a bare argv[0],
# so the value in settings.json is parsed by cmd before anything is executed. A
# profile path containing a space therefore has to arrive already quoted:
# C:\Users\Firstname Lastname\... otherwise splits and cmd tries to run
# C:\Users\Firstname. Paths with nothing cmd cares about are written bare,
# exactly as before, so no existing settings.json churns on the next switch.
function Get-HelperCommandLine {
param([string] $Path = $script:HelperCmd)
if ($Path -match '[\s&()^;,]') { return '"' + $Path + '"' }
return $Path
}
# ---------------------------------------------------------------------------
# settings.json rewriting
# ---------------------------------------------------------------------------
function Backup-Settings {
if (-not (Test-Path -LiteralPath $script:Settings)) { return $null }
Initialize-Root
$stamp = (Get-Date).ToString('yyyyMMdd-HHmmss-fff')
$dest = Join-Path $script:BackupDir "settings.$stamp.json"
Copy-Item -LiteralPath $script:Settings -Destination $dest -Force
$old = @(Get-ChildItem -LiteralPath $script:BackupDir -Filter 'settings.*.json' |
Sort-Object Name -Descending | Select-Object -Skip 20)
foreach ($f in $old) { Remove-Item -LiteralPath $f.FullName -Force }
return $dest
}
function Clear-ManagedSettings {
param($Settings)
# Baseline keys plus whatever the previous switch actually wrote, so a
# preset's custom extraEnv key cannot outlive the preset that added it.
$keys = @($script:BaseManagedEnvKeys) + @((Get-State)['writtenEnvKeys']) | Sort-Object -Unique
if ($Settings.Contains('env') -and $Settings['env'] -is [System.Collections.IDictionary]) {
foreach ($k in $keys) {
if ($k -and $Settings['env'].Contains($k)) { $Settings['env'].Remove($k) }
}
if ($Settings['env'].Count -eq 0) { $Settings.Remove('env') }
}
if ($Settings.Contains('apiKeyHelper')) { $Settings.Remove('apiKeyHelper') }
return $Settings
}
function Set-ClaudeMode {
param(
[string] $Mode,
[string] $PresetName
)
Initialize-Root
if (-not (Test-Path -LiteralPath $script:SettingsDir)) {
New-Item -ItemType Directory -Path $script:SettingsDir -Force | Out-Null
}
# Any provider in providers.json, rather than a fixed ValidateSet.
if ($Mode -ne 'anthropic' -and -not (Get-Provider $Mode)) { throw "unknown mode '$Mode'" }
$settings = Read-JsonFile $script:Settings
if ($null -eq $settings) { $settings = [ordered]@{} }
$backup = Backup-Settings
$settings = Clear-ManagedSettings $settings
$written = @()
$preset = $null
if ($Mode -ne 'anthropic') {
$preset = Get-Preset $PresetName
if ([string]$preset['provider'] -ne $Mode) {
throw "preset '$PresetName' declares provider '$($preset['provider'])', not '$Mode'"
}
$models = $preset['models']
if ($null -eq $models) { throw "preset '$PresetName' has no 'models' block" }
# A custom endpoint ships with no address, since there is no sensible
# one to guess. Switching to it would point every session at nothing.
if ([string]::IsNullOrWhiteSpace([string]$preset['baseUrl'])) {
throw "preset '$PresetName' has no server address - set baseUrl in $(Get-PresetPath $PresetName)"
}
# Every tier empty - a fresh blank preset - would switch cleanly and
# leave Claude Code asking for its own default Anthropic models: billed
# at full price through OpenRouter, refused by the other providers.
$anyTier = $false
foreach ($tier in $script:Tiers) { if (-not [string]::IsNullOrWhiteSpace([string]$models[$tier])) { $anyTier = $true } }
if (-not $anyTier) {
throw "preset '$PresetName' has no models set - set a tier first: claude-mode preset set $PresetName <tier> <model-id>"
}
# Cost guard. Gateways resell Anthropic models at full list price, with no
# subscription discount - routing a tier there is almost never intended
# and is expensive enough to be worth blocking outright. Opt in per
# preset with "allowAnthropicModels": true.
if (-not ($preset.Contains('allowAnthropicModels') -and $preset['allowAnthropicModels'])) {
$offenders = @()
foreach ($tier in $script:Tiers) {
$id = [string]$models[$tier]
if ($id -and (Test-AnthropicModelId $id)) { $offenders += "$tier -> $id" }
}
$sub = [string]$preset['subagentModel']
if ($sub -and (Test-AnthropicModelId $sub)) { $offenders += "subagent -> $sub" }
if ($offenders.Count -gt 0) {
Write-Err2 "preset '$PresetName' routes a tier at an Anthropic model through '$Mode':"
foreach ($o in $offenders) { Write-Err2 " $o" }
throw "refusing to switch - gateways bill Anthropic models at full price. Add `"allowAnthropicModels`": true to the preset if this is deliberate."
}
}
$envBlock = [ordered]@{}
$envBlock['ANTHROPIC_BASE_URL'] = [string]$preset['baseUrl']
# Explicitly empty, not absent: a cached Anthropic login can otherwise
# override the gateway config and surface as a model-not-found error.
# Removed entirely when switching back to anthropic.
$envBlock['ANTHROPIC_API_KEY'] = ''
foreach ($tier in $script:Tiers) {
if ($models.Contains($tier) -and -not [string]::IsNullOrWhiteSpace([string]$models[$tier])) {
$envBlock["ANTHROPIC_DEFAULT_$($tier.ToUpper())_MODEL"] = [string]$models[$tier]
}
}
if ($preset.Contains('subagentModel') -and -not [string]::IsNullOrWhiteSpace([string]$preset['subagentModel'])) {
$envBlock['CLAUDE_CODE_SUBAGENT_MODEL'] = [string]$preset['subagentModel']
}
if ($preset.Contains('gatewayModelDiscovery') -and $preset['gatewayModelDiscovery']) {
$envBlock['CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY'] = '1'
}
# Behind a custom base URL, Claude Code cannot resolve a third-party
# model id to a context length, so it falls back to a conservative
# default and starts auto-compacting long before the model is actually
# full. State the real window explicitly.
if ($preset.Contains('contextTokens') -and $preset['contextTokens']) {
$ctx = [string][int]$preset['contextTokens']
$envBlock['CLAUDE_CODE_MAX_CONTEXT_TOKENS'] = $ctx
$envBlock['CLAUDE_CODE_AUTO_COMPACT_WINDOW'] = $ctx
}
if ($preset.Contains('extraEnv') -and $preset['extraEnv'] -is [System.Collections.IDictionary]) {
foreach ($k in $preset['extraEnv'].Keys) { $envBlock[$k] = [string]$preset['extraEnv'][$k] }
}
# Auth. 'vault' keeps the secret out of settings.json entirely and hands
# it over at runtime; 'literal' is for non-secrets like LM Studio's
# placeholder token.
$auth = Get-PresetAuth $preset
if ([string]$auth['mode'] -eq 'vault') {
$keyRef = 'openrouter'
if ($auth.Contains('keyRef') -and $auth['keyRef']) { $keyRef = [string]$auth['keyRef'] }
if (-not (Get-VaultKey $keyRef)) {
throw "no key stored for ref '$keyRef'. Run: claude-mode set-key $keyRef"
}
if (-not (Test-Path -LiteralPath $script:HelperCmd)) {
throw "key helper missing at $($script:HelperCmd). Re-run install.ps1"
}
$settings['apiKeyHelper'] = Get-HelperCommandLine
} else {
# The provider's own placeholder (lmstudio, ollama), not LM Studio's.
$pv = Get-Provider ([string]$preset['provider'])
$tok = $(if ($pv -and $pv.preset.auth.token) { [string]$pv.preset.auth.token } else { 'lmstudio' })
if ($auth.Contains('token') -and $auth['token']) { $tok = [string]$auth['token'] }
$envBlock['ANTHROPIC_AUTH_TOKEN'] = $tok
}
if ($settings.Contains('env') -and $settings['env'] -is [System.Collections.IDictionary]) {
foreach ($k in $envBlock.Keys) { $settings['env'][$k] = $envBlock[$k] }
} else {
$settings['env'] = $envBlock
}
$written = @($envBlock.Keys)
}
# settings.json deliberately keeps its default ACL: it never holds a real
# secret (apiKeyHelper supplies those), and other tools read it.
Write-JsonFile $script:Settings $settings
Set-State -Mode $Mode -PresetName $PresetName -WrittenKeys $written
if ($Mode -eq 'anthropic') { Write-Head 'switched to: anthropic' }
else { Write-Head "switched to: $Mode / preset '$PresetName'" }
if ($backup) { Write-Ok "settings.json backed up to $backup" }
if ($Mode -eq 'anthropic') {
Write-Ok 'all gateway env + apiKeyHelper removed; native Anthropic login is authoritative'
} else {
Write-Ok "base url $($preset['baseUrl'])"
foreach ($tier in $script:Tiers) {
if ($preset['models'].Contains($tier)) {
Write-Ok ("{0,-7} -> {1}" -f $tier, $preset['models'][$tier])
}
}
if ($preset.Contains('contextTokens') -and $preset['contextTokens']) {
Write-Ok ("context -> {0:N0} tokens (max + auto-compact window)" -f [int]$preset['contextTokens'])
} else {
Write-Warn2 'no contextTokens in this preset - Claude Code will guess a small window and compact early'
}
$auth = Get-PresetAuth $preset
if ([string]$auth['mode'] -eq 'vault') {
Write-Ok 'auth via apiKeyHelper (key stays DPAPI-encrypted on disk)'
} else {
Write-Ok "auth inline placeholder token '$($auth['token'])' (not a secret)"
}
}
[void](Test-StrayEnvVars -Mode $Mode)
if ($Mode -ne 'anthropic') {
$auth = Get-PresetAuth $preset
if ([string]$auth['mode'] -eq 'vault') {
Show-GuardrailStatus -Mode $Mode -Key (Get-VaultKey ([string]$auth['keyRef']))
}
}
[void](Test-StaleModelSelections -Mode $Mode)
Write-HealthFile -Mode $Mode -PresetName $PresetName
Write-Host ''
Write-Host ' restart claude (and reload the VS Code window) to pick this up' -ForegroundColor DarkGray
}
+85
View File
@@ -0,0 +1,85 @@
# lib/vault.ps1 - the DPAPI key vault, and a preset's auth.
#
# Part of claude-mode.ps1, which dot-sources it into its own script scope after
# the settings at its top. Not meant to run on its own. ASCII only: Windows
# PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be
# lib\, so paths beside the main script go through $script:Here.
# ---------------------------------------------------------------------------
# Vault (DPAPI: CurrentUser scope)
# ---------------------------------------------------------------------------
function Get-VaultPath { param([string] $Ref) return (Join-Path $script:VaultDir "$Ref.cred") }
function ConvertFrom-SecureStringPlain {
param([System.Security.SecureString] $Secure)
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure)
try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
}
function Set-VaultKey {
param([string] $Ref, [string] $Key = '')
Initialize-Root
if ($Key) {
Write-Warn2 'the key was given on the command line, so it is in this shell history - the hidden prompt leaves no trace'
$plain = $Key
# The vault is written from a SecureString (DPAPI), so the inline form
# has to produce one too.
$secure = ConvertTo-SecureString -String $Key -AsPlainText -Force
} else {
Write-Host "Paste the API key for ref '$Ref' (input hidden):"
$secure = Read-Host -AsSecureString
$plain = ConvertFrom-SecureStringPlain $secure
}
if ([string]::IsNullOrWhiteSpace($plain)) { throw 'empty key, aborted' }
# A hidden prompt will happily swallow a mis-paste. Guard the two shapes that
# are never a real key, because the failure is otherwise invisible until the
# provider answers 401 and the UI just spins.
if ($plain -match '\s') {
throw "that value contains whitespace, so it is not an API key (a pasted command line?). Nothing was stored."
}
if ($plain -like 'claude-mode*') {
throw "that value is a claude-mode command, not an API key. Nothing was stored."
}
if ($plain.Length -lt 16) {
$unit = if ($plain.Length -eq 1) { 'character' } else { 'characters' }
Write-Warn2 "that key is only $($plain.Length) $unit - unusually short. Storing anyway."
}
if ($Ref -eq 'openrouter' -and $plain -notlike 'sk-or-*') {
Write-Warn2 "key does not start with 'sk-or-' - storing anyway"
}
$path = Get-VaultPath $Ref
# ConvertFrom-SecureString with no -Key uses DPAPI, CurrentUser scope.
ConvertFrom-SecureString -SecureString $secure |
Set-Content -LiteralPath $path -Encoding ASCII -NoNewline
Protect-FileAcl $path
Write-Ok "stored DPAPI-encrypted key at $path"
}
function Get-VaultKey {
param([string] $Ref)
$path = Get-VaultPath $Ref
if (-not (Test-Path -LiteralPath $path)) { return $null }
$blob = (Get-Content -LiteralPath $path -Raw).Trim()
if ([string]::IsNullOrWhiteSpace($blob)) { return $null }
try { return ConvertFrom-SecureStringPlain (ConvertTo-SecureString $blob) }
catch { return $null }
}
function Format-KeyMask {
param([string] $Key)
if ([string]::IsNullOrEmpty($Key)) { return '(none)' }
if ($Key.Length -le 12) { return '****' }
return ($Key.Substring(0, 8) + '...' + $Key.Substring($Key.Length - 4))
}
function Get-PresetAuth {
param($Preset)
$auth = $Preset['auth']
if ($null -eq $auth) { $auth = [ordered]@{ mode = 'vault'; keyRef = 'openrouter' } }
if (-not $auth.Contains('mode')) { $auth['mode'] = 'vault' }
return $auth
}
-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env bash
# claude-mode bootstrap installer for Linux and macOS.
#
# Served by the Arkylx Index and run as a one-liner:
#
# curl -fsSL https://index.arkylx.com/tools/claude-mode/install.sh | bash
#
# Downloads the payload, verifies its SHA-256 against the manifest, unpacks it to
# a temp directory and runs the bundled install.sh. Nothing is written outside
# ~/.claude-mode, ~/.local/bin and your shell rc.
#
# Environment overrides:
# ARKYLX_CLAUDE_MODE_BASE base URL (default: index.arkylx.com)
# ARKYLX_CLAUDE_MODE_VERSION pin a version instead of "latest"
# ARKYLX_CODE enrolment code; reports the install to the Index
set -euo pipefail
BASE="${ARKYLX_CLAUDE_MODE_BASE:-https://index.arkylx.com/tools/claude-mode}"
BASE="${BASE%/}"
VERSION="${ARKYLX_CLAUDE_MODE_VERSION:-latest}"
green() { printf ' \033[32mok \033[0m %s\n' "$*"; }
warn() { printf ' \033[33mwarn\033[0m %s\n' "$*"; }
fail() { printf ' \033[31mFAIL\033[0m %s\n' "$*" >&2; }
printf '\n \033[36mclaude-mode installer\033[0m\n'
printf ' \033[90msource: %s (%s)\033[0m\n\n' "$BASE" "$VERSION"
# --- preflight -------------------------------------------------------------
need() { command -v "$1" >/dev/null 2>&1 || { fail "$1 is required but not installed"; exit 1; }; }
need curl
need tar
PY="${CLAUDE_MODE_PYTHON:-python3}"
command -v "$PY" >/dev/null 2>&1 || {
fail "python3 is required (claude-mode uses it for JSON handling)"
fail " Debian/Ubuntu: sudo apt install python3"
fail " Fedora/RHEL: sudo dnf install python3"
fail " macOS: xcode-select --install (or brew install python)"
exit 1
}
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1
elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1
else fail 'no sha256sum or shasum available - cannot verify the download'; exit 1
fi
}
WORK="$(mktemp -d 2>/dev/null || mktemp -d -t claude-mode)"
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT
# --- manifest --------------------------------------------------------------
curl -fsSL --max-time 30 "$BASE/$VERSION/manifest.posix.json" -o "$WORK/manifest.json" || {
fail "could not fetch $BASE/$VERSION/manifest.posix.json"; exit 1; }
read_field() { "$PY" -c "import json,sys;print(json.load(open(sys.argv[1])).get(sys.argv[2],''))" "$WORK/manifest.json" "$1"; }
PKG="$(read_field package)"
EXPECTED="$(read_field sha256)"
PKGVER="$(read_field version)"
[ -n "$PKG" ] && [ -n "$EXPECTED" ] || { fail 'manifest is missing package/sha256'; exit 1; }
# --- payload ---------------------------------------------------------------
printf ' downloading %s (%s)\n' "$PKG" "$PKGVER"
curl -fsSL --max-time 120 "$BASE/$VERSION/$PKG" -o "$WORK/$PKG" || { fail 'download failed'; exit 1; }
ACTUAL="$(sha256_of "$WORK/$PKG")"
if [ "$ACTUAL" != "$EXPECTED" ]; then
fail 'checksum mismatch - refusing to install'
fail "expected $EXPECTED"
fail "actual $ACTUAL"
exit 1
fi
green 'checksum verified'
mkdir -p "$WORK/src"
tar -xzf "$WORK/$PKG" -C "$WORK/src"
[ -f "$WORK/src/linux/install.sh" ] || { fail 'package does not contain linux/install.sh'; exit 1; }
chmod +x "$WORK/src/linux/install.sh"
printf '\n'
"$WORK/src/linux/install.sh" "$@"
# --- optional report -------------------------------------------------------
# Best-effort: a failure here must never make a good install look broken.
if [ -n "${ARKYLX_CODE:-}" ] && [ "${ARKYLX_CLAUDE_MODE_REPORT:-1}" != "0" ]; then
if curl -fsS --max-time 10 -X POST "$BASE/report" \
-H 'content-type: application/json' \
-d "{\"code\":\"$ARKYLX_CODE\",\"tool\":\"claude-mode\",\"version\":\"$PKGVER\",\"hostname\":\"$(hostname 2>/dev/null || echo unknown)\",\"os\":\"$(uname -sr 2>/dev/null || echo unknown)\"}" \
>/dev/null 2>&1; then
green 'reported install to the Arkylx Index'
else
warn 'could not report to the Index (install is fine)'
fi
fi
-131
View File
@@ -1,131 +0,0 @@
<#
Build the distributable claude-mode package.
Produces, under dist/<version>/ :
claude-mode-<version>.zip the payload (script, key helper, presets, installer)
manifest.json version + package name + SHA-256
The Arkylx Index serves that directory, plus dist/bootstrap.ps1 as
/tools/claude-mode/install.ps1. See ARKYLX_INDEX_INTEGRATION.md.
#>
[CmdletBinding()]
param([string] $OutRoot)
Set-StrictMode -Version 1.0
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
if (-not $OutRoot) { $OutRoot = Join-Path $root 'dist' }
$version = (Get-Content (Join-Path $root 'VERSION') -Raw).Trim()
if (-not $version) { throw 'VERSION file is empty' }
Write-Host "building claude-mode $version" -ForegroundColor Cyan
$stage = Join-Path ([System.IO.Path]::GetTempPath()) ("cm-stage-" + [Guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path (Join-Path $stage 'bin') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $stage 'presets') -Force | Out-Null
# Everything the installer needs, and nothing else - no README, no docs, no
# .git. The payload is what lands on a user's machine.
Copy-Item (Join-Path $root 'claude-mode.ps1') $stage -Force
Copy-Item (Join-Path $root 'install.ps1') $stage -Force
Copy-Item (Join-Path $root 'profile-snippet.ps1') $stage -Force
Copy-Item (Join-Path $root 'VERSION') $stage -Force
Copy-Item (Join-Path $root 'providers.json') $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
# Recursive: the CLI's modules live in linux/lib/, and a flat copy would ship a
# package that installs a claude-mode unable to start.
Copy-Item (Join-Path $root 'linux\*') (Join-Path $posixStage 'linux') -Recurse -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
Copy-Item (Join-Path $root 'providers.json') $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 -Recurse | 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' 'providers.json'
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
+3 -2
View File
@@ -13,10 +13,11 @@ ssh_() { ssh -o BatchMode=yes -o LogLevel=ERROR -o ConnectTimeout=10 "$host" "$@
parent="$(mktemp -d)" parent="$(mktemp -d)"
trap 'rm -rf "$parent"' EXIT trap 'rm -rf "$parent"' EXIT
stage="$parent/$name" stage="$parent/$name"
mkdir -p "$stage/presets" "$stage/bin" mkdir -p "$stage/presets" "$stage/bin" "$stage/lib"
cp claude-mode.ps1 providers.json VERSION tests/fake_server.py tests/windows/run.ps1 "$stage/" cp claude-mode.ps1 install.ps1 providers.json VERSION tests/fake_server.py tests/windows/run.ps1 "$stage/"
cp presets/*.json "$stage/presets/" cp presets/*.json "$stage/presets/"
cp bin/* "$stage/bin/" cp bin/* "$stage/bin/"
cp lib/*.ps1 "$stage/lib/"
rtemp="$(ssh_ 'Write-Output $env:TEMP' | tr -d '\r')" || { echo "cannot reach $host" >&2; exit 1; } rtemp="$(ssh_ 'Write-Output $env:TEMP' | tr -d '\r')" || { echo "cannot reach $host" >&2; exit 1; }
[ -n "$rtemp" ] || { echo "no %TEMP% on $host" >&2; exit 1; } [ -n "$rtemp" ] || { echo "no %TEMP% on $host" >&2; exit 1; }
+12 -5
View File
@@ -28,16 +28,23 @@ function Expect-Eq($got, $want, [string] $what) {
if ("$got" -eq "$want") { Pass } else { Fail "$what (got '$got', wanted '$want')" $null } if ("$got" -eq "$want") { Pass } else { Fail "$what (got '$got', wanted '$want')" $null }
} }
New-Item -ItemType Directory -Force -Path "$root\presets", "$root\bin", "$sb\.claude" | Out-Null New-Item -ItemType Directory -Force -Path "$root\presets", "$root\bin", "$root\lib", "$sb\.claude" | Out-Null
Copy-Item "$src\claude-mode.ps1", "$src\providers.json", "$src\VERSION" $root Copy-Item "$src\claude-mode.ps1", "$src\providers.json", "$src\VERSION" $root
Copy-Item "$src\presets\*.json" "$root\presets" Copy-Item "$src\presets\*.json" "$root\presets"
Copy-Item "$src\bin\*" "$root\bin" Copy-Item "$src\bin\*" "$root\bin"
Copy-Item "$src\lib\*.ps1" "$root\lib"
$cm = Join-Path $root 'claude-mode.ps1' $cm = Join-Path $root 'claude-mode.ps1'
$tokens = $null; $errors = $null # install.ps1 is parsed but never run: it edits the real PowerShell profile and
[void][System.Management.Automation.Language.Parser]::ParseFile($cm, [ref]$tokens, [ref]$errors) # the User PATH, which no USERPROFILE sandbox contains.
Expect-Eq $errors.Count 0 'claude-mode.ps1 parses' $parse = @($cm) + @(Get-ChildItem "$root\lib\*.ps1" | ForEach-Object { $_.FullName })
foreach ($e in $errors) { Write-Host " | line $($e.Extent.StartLineNumber): $($e.Message)" } if (Test-Path "$src\install.ps1") { $parse += "$src\install.ps1" }
foreach ($file in $parse) {
$tokens = $null; $errors = $null
[void][System.Management.Automation.Language.Parser]::ParseFile($file, [ref]$tokens, [ref]$errors)
Expect-Eq $errors.Count 0 "$(Split-Path -Leaf $file) parses"
foreach ($e in $errors) { Write-Host " | $(Split-Path -Leaf $file) line $($e.Extent.StartLineNumber): $($e.Message)" }
}
$portsFile = Join-Path $sb 'ports.json' $portsFile = Join-Path $sb 'ports.json'
$fake = Start-Process python -ArgumentList ('"' + (Join-Path $src 'fake_server.py') + '"') ` $fake = Start-Process python -ArgumentList ('"' + (Join-Path $src 'fake_server.py') + '"') `