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:
+3
-3
@@ -1,6 +1,6 @@
|
||||
# LF in every working tree, Windows checkouts included. A CRLF shell script
|
||||
# fails its #! line, and the packages are built from a Windows checkout
|
||||
# (scripts/build-package.ps1 normalises too, as a backstop).
|
||||
# LF in every working tree, Windows checkouts included: a CRLF shell script
|
||||
# fails its #! line, and a Windows checkout is exactly where the repository's
|
||||
# shell scripts would otherwise pick CRLF up.
|
||||
* text=auto eol=lf
|
||||
|
||||
*.png binary
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
dist/
|
||||
*.bak
|
||||
*.bak.*
|
||||
__pycache__/
|
||||
|
||||
+18
-2272
File diff suppressed because it is too large
Load Diff
+35
-13
@@ -46,7 +46,7 @@ Under `~/.claude-mode/`:
|
||||
|
||||
| file | written by | read by |
|
||||
|---|---|---|
|
||||
| `bin/claude-mode`, `bin/lib/*.sh`, `bin/cm-json.py`, `bin/cm-vault.sh`, `bin/claude-key-helper.sh` | the installer | — (Windows: `claude-mode.ps1` 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`) |
|
||||
| `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 |
|
||||
@@ -66,14 +66,15 @@ anything else), `~/.claude/projects/*/*.jsonl` (Claude Code's transcripts, which
|
||||
## 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
|
||||
linux/ the POSIX port: claude-mode (settings + dispatch),
|
||||
lib/*.sh (the rest, one file per concern), cm-json.py,
|
||||
cm-vault.sh, claude-key-helper.sh, install.sh, bootstrap.sh
|
||||
lib/*.sh (its modules), cm-json.py, cm-vault.sh,
|
||||
claude-key-helper.sh, install.sh
|
||||
omarchy/ the bar widget and its installer
|
||||
providers.json, presets/ shared by both builds, byte for byte
|
||||
scripts/ test.sh, bump-version.sh, build-package.ps1
|
||||
scripts/ test.sh, bump-version.sh
|
||||
tests/ static, python, cli and windows suites
|
||||
docs/ this
|
||||
```
|
||||
@@ -100,6 +101,27 @@ code rather than the installed version's.
|
||||
| `setup.sh` | `claude-mode setup` |
|
||||
| `repair.sh` | `claude-mode repair-session` and dismissals |
|
||||
|
||||
### The Windows script's modules
|
||||
|
||||
`claude-mode.ps1` holds the help block, the parameters, the paths and flags and
|
||||
the managed-key list, then dot-sources these from `lib\` beside it, in order, and
|
||||
dispatches. Inside a dot-sourced file `$PSScriptRoot` is `lib\`, so paths beside
|
||||
the main script go through `$script:Here`.
|
||||
|
||||
| `lib/` | what is in it |
|
||||
|---|---|
|
||||
| `providers.ps1` | reading `providers.json`; `Get-Provider`, `Resolve-ProviderId`, `Test-ProviderDoctor` |
|
||||
| `output.ps1` | `Write-Ok`/`Write-Warn2`/`Write-Err2`, mode colours, the banner, `Show-Usage` |
|
||||
| `files.ps1` | JSON read/write (5.1 has no `-AsHashtable`), `Protect-FileAcl` |
|
||||
| `core.ps1` | `state.json`, presets, `Resolve-PresetForProvider` |
|
||||
| `vault.ps1` | the DPAPI vault, `Get-PresetAuth` |
|
||||
| `switch.ps1` | `Set-ClaudeMode`, settings backups, the `apiKeyHelper` command line |
|
||||
| `guards.ps1` | the cost guard, the OpenRouter guardrail check, stale cached model ids, `repair` |
|
||||
| `health.ps1` | `health.json`; persistent environment variables that would override a switch |
|
||||
| `catalogue.ps1` | `Get-ProviderCatalogue`, LM Studio's model list and template check, the doctor model and context checks |
|
||||
| `commands.ps1` | `status`, `presets`, `preset`, `models`, `doctor` |
|
||||
| `menu.ps1` | the interactive menu, its pickers, interactive preset editing, `New-PresetScaffold` |
|
||||
|
||||
## Contracts between the pieces
|
||||
|
||||
These are the interfaces one piece relies on another to keep. Each is pinned by a
|
||||
@@ -142,14 +164,14 @@ test.
|
||||
|---|---|---|
|
||||
| a provider's endpoint, auth template, setup, checks or look | `providers.json` | the same file |
|
||||
| a shipped preset | `presets/*.json` | the same files |
|
||||
| what a switch writes to `settings.json` | `cm-json.py` `cmd_apply`, `BASE_MANAGED` | `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` |
|
||||
| what refuses a switch | `lib/preflight.sh` `cm_preflight` | `Set-ClaudeMode` guards |
|
||||
| how a model list is fetched | `lib/catalogue.sh` `provider_catalogue` + a parser in `cm-json.py` | `Get-ProviderCatalogue` |
|
||||
| a doctor check | `lib/doctor.sh` | `Invoke-Doctor`, `Test-PresetCatalogue`, `Test-OllamaContext` |
|
||||
| setup | `lib/setup.sh` | — |
|
||||
| session detection | `lib/sessions.sh` | — |
|
||||
| session repair | `lib/repair.sh`; `cm-json.py` `cmd_scan_sessions`, `cmd_repair_session` | — |
|
||||
| key storage | `cm-vault.sh` | the DPAPI functions in `claude-mode.ps1` |
|
||||
| what refuses a switch | `linux/lib/preflight.sh` `cm_preflight` | the guards in `Set-ClaudeMode` |
|
||||
| how a model list is fetched | `linux/lib/catalogue.sh` `provider_catalogue` + a parser in `cm-json.py` | `lib/catalogue.ps1` `Get-ProviderCatalogue` |
|
||||
| a doctor check | `linux/lib/doctor.sh` | `lib/commands.ps1` `Invoke-Doctor`, `lib/catalogue.ps1` |
|
||||
| setup | `linux/lib/setup.sh` | — |
|
||||
| session detection | `linux/lib/sessions.sh` | — |
|
||||
| session repair | `linux/lib/repair.sh`; `cm-json.py` `cmd_scan_sessions`, `cmd_repair_session` | — |
|
||||
| key storage | `linux/cm-vault.sh` | `lib/vault.ps1` |
|
||||
| the bar icon and state | `omarchy/smoido.claude-mode/BarWidget.qml` | — |
|
||||
| the panel | `omarchy/smoido.claude-mode/Panel.qml` | — |
|
||||
|
||||
+6
-4
@@ -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
|
||||
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`,
|
||||
`.editorconfig`).
|
||||
@@ -112,8 +114,8 @@ unit test that pins it.
|
||||
3. Commit.
|
||||
4. Install on your own machine: `bash linux/install.sh`, `bash omarchy/install.sh`,
|
||||
`omarchy restart shell`, and look at the bar.
|
||||
5. Push. The Windows package is built on a Windows machine with
|
||||
`scripts/build-package.ps1`, which refuses to package a script that does not
|
||||
parse.
|
||||
5. Push. Both installers fetch the repository's own archive (`install.ps1` piped
|
||||
through `iex`, `linux/install.sh` through `curl | bash`), so a push to `master`
|
||||
is the release.
|
||||
|
||||
Semver: a minor version adds behaviour, a patch fixes it.
|
||||
|
||||
@@ -85,6 +85,12 @@ try {
|
||||
|
||||
# --- 2. scripts -------------------------------------------------------------
|
||||
Copy-Item -LiteralPath (Join-Path $src 'claude-mode.ps1') -Destination $root -Force
|
||||
# The script's modules, dot-sourced from lib\ beside it. Cleared first, so a
|
||||
# module removed from the source does not linger and get loaded.
|
||||
$libDest = Join-Path $root 'lib'
|
||||
New-Item -ItemType Directory -Path $libDest -Force | Out-Null
|
||||
Remove-Item -Path (Join-Path $libDest '*.ps1') -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item -Path (Join-Path $src 'lib\*.ps1') -Destination $libDest -Force
|
||||
# claude-mode reads VERSION from its own directory to stamp health.json.
|
||||
if (Test-Path -LiteralPath (Join-Path $src 'VERSION')) {
|
||||
Copy-Item -LiteralPath (Join-Path $src 'VERSION') -Destination $root -Force
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'])
|
||||
}
|
||||
@@ -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'"
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -13,10 +13,11 @@ ssh_() { ssh -o BatchMode=yes -o LogLevel=ERROR -o ConnectTimeout=10 "$host" "$@
|
||||
parent="$(mktemp -d)"
|
||||
trap 'rm -rf "$parent"' EXIT
|
||||
stage="$parent/$name"
|
||||
mkdir -p "$stage/presets" "$stage/bin"
|
||||
cp claude-mode.ps1 providers.json VERSION tests/fake_server.py tests/windows/run.ps1 "$stage/"
|
||||
mkdir -p "$stage/presets" "$stage/bin" "$stage/lib"
|
||||
cp claude-mode.ps1 install.ps1 providers.json VERSION tests/fake_server.py tests/windows/run.ps1 "$stage/"
|
||||
cp presets/*.json "$stage/presets/"
|
||||
cp bin/* "$stage/bin/"
|
||||
cp lib/*.ps1 "$stage/lib/"
|
||||
|
||||
rtemp="$(ssh_ 'Write-Output $env:TEMP' | tr -d '\r')" || { echo "cannot reach $host" >&2; exit 1; }
|
||||
[ -n "$rtemp" ] || { echo "no %TEMP% on $host" >&2; exit 1; }
|
||||
|
||||
+12
-5
@@ -28,16 +28,23 @@ function Expect-Eq($got, $want, [string] $what) {
|
||||
if ("$got" -eq "$want") { Pass } else { Fail "$what (got '$got', wanted '$want')" $null }
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path "$root\presets", "$root\bin", "$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\presets\*.json" "$root\presets"
|
||||
Copy-Item "$src\bin\*" "$root\bin"
|
||||
Copy-Item "$src\lib\*.ps1" "$root\lib"
|
||||
$cm = Join-Path $root 'claude-mode.ps1'
|
||||
|
||||
$tokens = $null; $errors = $null
|
||||
[void][System.Management.Automation.Language.Parser]::ParseFile($cm, [ref]$tokens, [ref]$errors)
|
||||
Expect-Eq $errors.Count 0 'claude-mode.ps1 parses'
|
||||
foreach ($e in $errors) { Write-Host " | line $($e.Extent.StartLineNumber): $($e.Message)" }
|
||||
# install.ps1 is parsed but never run: it edits the real PowerShell profile and
|
||||
# the User PATH, which no USERPROFILE sandbox contains.
|
||||
$parse = @($cm) + @(Get-ChildItem "$root\lib\*.ps1" | ForEach-Object { $_.FullName })
|
||||
if (Test-Path "$src\install.ps1") { $parse += "$src\install.ps1" }
|
||||
foreach ($file in $parse) {
|
||||
$tokens = $null; $errors = $null
|
||||
[void][System.Management.Automation.Language.Parser]::ParseFile($file, [ref]$tokens, [ref]$errors)
|
||||
Expect-Eq $errors.Count 0 "$(Split-Path -Leaf $file) parses"
|
||||
foreach ($e in $errors) { Write-Host " | $(Split-Path -Leaf $file) line $($e.Extent.StartLineNumber): $($e.Message)" }
|
||||
}
|
||||
|
||||
$portsFile = Join-Path $sb 'ports.json'
|
||||
$fake = Start-Process python -ArgumentList ('"' + (Join-Path $src 'fake_server.py') + '"') `
|
||||
|
||||
Reference in New Issue
Block a user