# 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} " -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 ' } (Get-Preset $name) | ConvertTo-Json -Depth 20 | Write-Host } 'new' { if (-not $name) { throw 'usage: claude-mode preset new [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 " } 'rm' { if (-not $name) { throw 'usage: claude-mode preset rm ' } $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 ' } Set-PresetTier -Name $name -Tier $Argv[2].ToLower() -Model $Argv[3] } 'all' { if ($Argv.Count -lt 3) { throw 'usage: claude-mode preset all ' } $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']) }