Files
smoidoandClaude Opus 5 4091746d4e 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>
2026-09-15 02:06:45 +03:00

351 lines
16 KiB
PowerShell

# 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'])
}