<# .SYNOPSIS claude-mode - switch Claude Code system-wide between native Anthropic auth, OpenRouter, Z.AI, and a local LM Studio server, with named model presets. .DESCRIPTION State lives in %USERPROFILE%\.claude-mode. Switching rewrites the managed keys inside %USERPROFILE%\.claude\settings.json, which Claude Code re-reads at every startup - so a switch applies to every new `claude` invocation from any shell, the VS Code extension, and the desktop app, with nothing to re-source. Secrets are never written to settings.json. A remote provider's API key is stored DPAPI-encrypted (bound to this Windows user + machine) and handed to Claude Code at runtime via the `apiKeyHelper` hook. LM Studio's placeholder token is not a secret and is written inline. Run with no arguments for an interactive menu. .NOTES Windows PowerShell 5.1 compatible. No external dependencies. #> [CmdletBinding()] param( [Parameter(Position = 0)] [string] $Command = '', [Parameter(Position = 1, ValueFromRemainingArguments = $true)] [string[]] $Rest ) # v1 only (uninitialised variables). v2 would throw on absent JSON properties, # which is normal here - presets and settings.json are both partially shaped. Set-StrictMode -Version 1.0 $ErrorActionPreference = 'Stop' try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { } # --------------------------------------------------------------------------- # Paths # --------------------------------------------------------------------------- $script:Root = Join-Path $env:USERPROFILE '.claude-mode' $script:PresetDir = Join-Path $script:Root 'presets' $script:VaultDir = Join-Path $script:Root 'vault' $script:BackupDir = Join-Path $script:Root 'backups' $script:BinDir = Join-Path $script:Root 'bin' $script:StatePath = Join-Path $script:Root 'state.json' $script:HelperCmd = Join-Path $script:BinDir 'claude-key-helper.cmd' $script:SettingsDir = Join-Path $env:USERPROFILE '.claude' $script:Settings = Join-Path $script:SettingsDir 'settings.json' $script:LmStudioDir = Join-Path $env:USERPROFILE '.lmstudio' # Baseline set of settings.json env keys this tool owns. On every switch these # are deleted first, together with whatever a previous switch actually wrote # (tracked in state.json), so no value can survive a mode change. $script:BaseManagedEnvKeys = @( 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_FABLE_MODEL', # Both are read by the CLI (21 and 37 references in 2.1.221) and both pin a # concrete model outside the tier mapping. A stale value in either survives a # switch and is a known cause of "works normally, dies on compaction", # because background summarisation uses the small/fast slot. 'ANTHROPIC_MODEL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL', 'CLAUDE_CODE_DISABLE_1M_CONTEXT', 'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY', 'CLAUDE_CODE_ATTRIBUTION_HEADER', 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'API_TIMEOUT_MS' ) $script:Tiers = @('opus', 'sonnet', 'haiku', 'fable') $script:Version = '0.0.0' try { $__v = Join-Path $PSScriptRoot 'VERSION'; if (Test-Path $__v) { $script:Version = (Get-Content $__v -Raw).Trim() } } catch { } $script:Modes = @('anthropic', 'openrouter', 'zai', 'lmstudio') $script:NodeExe = $null # resolved lazily by Format-JsonPretty $script:ModeLabel = @{ 'anthropic' = 'Anthropic - your subscription login, no gateway' 'openrouter' = 'OpenRouter - remote, pay-per-token, any vendor' 'zai' = 'Z.AI - GLM coding plan' 'lmstudio' = 'LM Studio - local server, offline, free' } # `claude-mode ` with no preset named uses this one. Deliberately a # fixed choice rather than "most recently used", so the command is predictable. $script:ProviderDefaultPreset = @{ 'openrouter' = 'default' 'zai' = 'zai' 'lmstudio' = 'lmstudio' } # --------------------------------------------------------------------------- # 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' 'openrouter' = 'Cyan' 'zai' = 'Green' 'lmstudio' = 'Yellow' } # 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, OpenRouter, Z.AI, LM Studio claude-mode interactive menu claude-mode status active mode, preset, model map claude-mode anthropic native login/subscription (clears all gateway config) claude-mode openrouter [preset] remote gateway (default preset: default) claude-mode zai [preset] Z.AI GLM coding plan (default preset: zai) claude-mode lmstudio [preset] local LM Studio (default preset: lmstudio) claude-mode presets list presets claude-mode preset show claude-mode preset new [from] create a preset (copies 'from') claude-mode preset set tier = opus | sonnet | haiku | fable | subagent claude-mode preset all point every tier at one model claude-mode preset rm claude-mode set-key [ref] 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 } # --------------------------------------------------------------------------- # 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)" } } # --------------------------------------------------------------------------- # 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'" } # --------------------------------------------------------------------------- # 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) Initialize-Root 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) { Write-Warn2 "that key is only $($plain.Length) characters - 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 } # --------------------------------------------------------------------------- # 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( [ValidateSet('anthropic', 'openrouter', 'zai', 'lmstudio')] [string] $Mode, [string] $PresetName ) Initialize-Root if (-not (Test-Path -LiteralPath $script:SettingsDir)) { New-Item -ItemType Directory -Path $script:SettingsDir -Force | Out-Null } $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" } # 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'] = $script:HelperCmd } else { $tok = '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 } # --------------------------------------------------------------------------- # Stray environment variable detection # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # 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) if ($Mode -ne 'openrouter' -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 } # --------------------------------------------------------------------------- # health.json - the machine-readable state the Arkylx Index (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' } } # --------------------------------------------------------------------------- # 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 } # 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) { continue } if ([int]$m.context_length -lt $declared) { Write-Warn2 ("{0} model {1} only has {2:N0} ctx, below the declared {3:N0}." -f $t, $id, $m.context_length, $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.' } } } } # --------------------------------------------------------------------------- # 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'] if ($mode -eq 'lmstudio') { $preset = Get-Preset ([string]$state['preset']) Write-Head "models installed in LM Studio at $($preset['baseUrl'])" $tpl = Get-LmStudioTemplateReport Get-LmStudioModels ([string]$preset['baseUrl']) | Where-Object { -not $Filter -or $_.Id -like "*$Filter*" } | Sort-Object Id | ForEach-Object { $short = ($_.Id -split '/')[-1].ToLower() $t = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1 $flag = '' if ($t -and $t.Assertions.Count -gt 0) { $flag = 'TEMPLATE RISK' } [pscustomobject]@{ Id = $_.Id; State = $_.State; Ctx = $_.Ctx; Note = $flag } } | Format-Table -AutoSize 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." return } if ($mode -eq 'zai') { Write-Head 'Z.AI GLM models (from Z.AI docs - no public catalogue endpoint)' @('glm-5.2 - flagship coding model (opus/sonnet tier)', 'glm-4.7 - fast/cheap tier (haiku tier)') | ForEach-Object { Write-Host " $_" } Write-Host ' Full list: https://docs.z.ai/devpack/tool/claude' return } Write-Head 'fetching https://openrouter.ai/api/v1/models ...' $resp = Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/models' -Method Get -TimeoutSec 30 $rows = foreach ($m in $resp.data) { if ($Filter -and $m.id -notlike "*$Filter*") { continue } [pscustomobject]@{ Id = $m.id Ctx = $m.context_length 'In/M$' = if ($m.pricing -and $m.pricing.prompt) { [math]::Round([double]$m.pricing.prompt * 1e6, 3) } else { $null } 'Out/M$' = if ($m.pricing -and $m.pricing.completion) { [math]::Round([double]$m.pricing.completion * 1e6, 3) } else { $null } } } $rows | Sort-Object Id | Format-Table -AutoSize } function Invoke-Doctor { $state = Get-State $mode = [string]$state['mode'] Write-Head "doctor - mode '$mode'" try { [void](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" } try { $out = (& cmd.exe /c "`"$($script:HelperCmd)`"" 2>&1 | Out-String).Trim() if ($out -and $key -and $out -eq $key) { Write-Ok 'apiKeyHelper emits the correct key' } elseif ($out) { Write-Err2 "apiKeyHelper output does not match vault key (got: $(Format-KeyMask $out))" } else { Write-Err2 'apiKeyHelper produced no output' } } catch { Write-Err2 "apiKeyHelper failed to run: $($_.Exception.Message)" } if ($key -and $mode -eq 'openrouter') { 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 $mode -eq 'zai') { # 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 "Z.AI endpoint accepted the key ($base/v1/messages)" } catch { $detail = '' if ($_.ErrorDetails) { $detail = ($_.ErrorDetails.Message -replace '\s+', ' ') } Write-Err2 "Z.AI request failed: $($_.Exception.Message) $detail" } } } else { Write-Ok "inline token '$($auth['token'])' (no secret in settings.json)" } if ($mode -eq 'lmstudio') { $catalogue = $null try { $catalogue = Get-LmStudioModels $base Write-Ok "LM Studio server reachable at $base ($($catalogue.Count) models installed)" } catch { Write-Err2 "LM Studio not reachable at $base - start the server (LM Studio > Developer > Start Server)" } if ($catalogue) { $wanted = @() foreach ($t in $script:Tiers) { if ($preset['models'].Contains($t)) { $wanted += [string]$preset['models'][$t] } } if ($preset.Contains('subagentModel')) { $wanted += [string]$preset['subagentModel'] } foreach ($id in ($wanted | Sort-Object -Unique)) { $m = $catalogue | Where-Object { $_.Id -eq $id } | Select-Object -First 1 if (-not $m) { Write-Err2 "model NOT installed in LM Studio: $id (run 'claude-mode models' for valid ids)" continue } if ($m.State -eq 'loaded') { Write-Ok "$id [loaded, ctx $($m.Ctx)]" } else { Write-Ok "$id [$($m.State) - LM Studio will JIT-load it on first request, ctx $($m.Ctx)]" } if ($m.Ctx -and [int]$m.Ctx -lt 25000) { Write-Warn2 "$id context is $($m.Ctx); LM Studio recommends >25k for Claude Code." } if ($preset.Contains('contextTokens') -and $preset['contextTokens'] -and $m.Ctx) { $declared = [int]$preset['contextTokens'] if ([int]$m.Ctx -lt $declared) { Write-Warn2 ("declared contextTokens {0:N0} exceeds {1}'s {2:N0} - lower it." -f $declared, $id, $m.Ctx) } else { Write-Ok ("declared context window: {0:N0} tokens" -f $declared) } } elseif (-not $preset.Contains('contextTokens')) { Write-Warn2 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early.' } $tpl = Test-LmStudioTemplate $id if ($tpl -and $tpl.Assertions.Count -gt 0) { Write-Warn2 "$id chat template hard-asserts message order ($($tpl.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'." } } } } elseif ($mode -eq 'openrouter') { try { $cat = (Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/models' -TimeoutSec 30).data foreach ($t in $script:Tiers) { if (-not $preset['models'].Contains($t)) { continue } $id = [string]$preset['models'][$t] $m = $cat | Where-Object { $_.id -eq $id } | Select-Object -First 1 if ($m) { Write-Ok ("{0,-6} {1} [ctx {2:N0}]" -f $t, $id, $m.context_length) } else { Write-Err2 "$t model NOT available from this provider: $id" } } Test-ContextWindow -Preset $preset -Catalogue $cat } catch { Write-Warn2 "could not verify model ids: $($_.Exception.Message)" } } } 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']) } # --------------------------------------------------------------------------- # 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 = @() if ($provider -eq 'lmstudio') { $models = @(Get-LmStudioModels ([string]$Preset['baseUrl'])) $tpl = Get-LmStudioTemplateReport foreach ($m in ($models | Sort-Object Id)) { $short = ($m.Id -split '/')[-1].ToLower() $risk = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1 $flag = '' $det = @("state: $($m.State) max context: $($m.Ctx)") if ($risk -and $risk.Assertions.Count -gt 0) { $flag = ' [TEMPLATE RISK]' $det += 'chat template asserts message order - can break tool calls' } $out += [pscustomobject]@{ Id = $m.Id; Key = $m.Id; Label = ($m.Id + $flag); Detail = $det } } } elseif ($provider -eq 'openrouter') { $cat = (Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/models' -TimeoutSec 30).data foreach ($m in ($cat | Sort-Object id)) { $inp = if ($m.pricing -and $m.pricing.prompt) { [math]::Round([double]$m.pricing.prompt * 1e6, 3) } else { $null } $outp= if ($m.pricing -and $m.pricing.completion) { [math]::Round([double]$m.pricing.completion * 1e6, 3) } else { $null } $det = @(("context {0:N0} `$$inp in / `$$outp out per 1M tokens" -f [int]$m.context_length)) $out += [pscustomobject]@{ Id = $m.id; Key = $m.id; Label = $m.id; Detail = $det } } } elseif ($provider -eq 'zai') { # Z.AI publishes no public catalogue endpoint; these are the documented # coding-plan models. Anything else can still be typed manually. $out += [pscustomobject]@{ Id = 'glm-5.2'; Key = 'glm-5.2'; Label = 'glm-5.2'; Detail = @('flagship coding model - opus/sonnet tier') } $out += [pscustomobject]@{ Id = 'glm-4.7'; Key = 'glm-4.7'; Label = 'glm-4.7'; Detail = @('fast/cheap tier - haiku') } } $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 = ''; 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) $base = [ordered]@{ provider = $Provider description = 'new preset' } switch ($Provider) { 'openrouter' { $base['baseUrl'] = 'https://openrouter.ai/api' $base['auth'] = [ordered]@{ mode = 'vault'; keyRef = 'openrouter' } } 'zai' { $base['baseUrl'] = 'https://api.z.ai/api/anthropic' $base['auth'] = [ordered]@{ mode = 'vault'; keyRef = 'zai' } } 'lmstudio' { $base['baseUrl'] = 'http://127.0.0.1:1234' $base['auth'] = [ordered]@{ mode = 'literal'; token = 'lmstudio' } } } $base['models'] = [ordered]@{ opus = ''; sonnet = ''; haiku = ''; fable = '' } $base['subagentModel'] = 'inherit' $base['gatewayModelDiscovery'] = ($Provider -eq 'openrouter') $base['contextTokens'] = if ($Provider -eq 'lmstudio') { 262144 } else { 1000000 } if ($Provider -eq 'lmstudio') { $base['extraEnv'] = [ordered]@{ CLAUDE_CODE_ATTRIBUTION_HEADER = '0' } } if ($Provider -eq 'zai') { $base['extraEnv'] = [ordered]@{ API_TIMEOUT_MS = '3000000' CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1' } } 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 = ''; 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 } } # --------------------------------------------------------------------------- # Dispatch # --------------------------------------------------------------------------- Initialize-Root try { $cmd = $Command.ToLower() if ($cmd -eq 'z.ai' -or $cmd -eq 'z-ai') { $cmd = 'zai' } switch ($cmd) { '' { Invoke-Menu } 'menu' { Invoke-Menu } 'status' { Invoke-Status } 'anthropic' { Set-ClaudeMode -Mode 'anthropic' -PresetName '' } 'openrouter' { $req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null } Set-ClaudeMode -Mode 'openrouter' -PresetName (Resolve-PresetForProvider 'openrouter' $req) } 'zai' { $req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null } Set-ClaudeMode -Mode 'zai' -PresetName (Resolve-PresetForProvider 'zai' $req) } 'lmstudio' { $req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null } Set-ClaudeMode -Mode 'lmstudio' -PresetName (Resolve-PresetForProvider 'lmstudio' $req) } 'presets' { Invoke-Presets } 'preset' { Invoke-PresetCmd -Argv $Rest } 'set-key' { Set-VaultKey -Ref $(if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { 'openrouter' }) } 'models' { Invoke-Models -Filter $(if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null }) } 'doctor' { Invoke-Doctor } 'repair' { [void](Invoke-Repair -All:([bool]($Rest -and ($Rest -contains '--all')))) } 'help' { Show-Usage } '--help' { Show-Usage } '-h' { Show-Usage } default { Write-Err2 "unknown command '$Command'"; Show-Usage; exit 1 } } } catch { Write-Err2 $_.Exception.Message exit 1 }