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>
351 lines
16 KiB
PowerShell
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'])
|
|
}
|