Files
claude-mode/claude-mode.ps1
T
smoido 35690bcd8d Move GLM 5.2 to 5.3
GLM 5.3 supersedes 5.2, so every reference to the older id follows it: the zai
preset's opus, sonnet and fable tiers, and the fable tier of `default`, which
routes to the same model through OpenRouter.

Also the hardcoded Z.AI entries in both builds' model pickers, which are the one
place a model id is not read from a preset - leaving those would have gone on
offering a superseded model from the menu while the presets used the new one.

haiku stays on glm-4.7: that is the fast/cheap tier, not a 5.x.
2026-08-30 21:28:45 +03:00

2172 lines
94 KiB
PowerShell

<#
.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 <provider>` 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 <name>
claude-mode preset new <name> [from] create a preset (copies 'from')
claude-mode preset set <name> <tier> <model-id>
tier = opus | sonnet | haiku | fable | subagent
claude-mode preset all <name> <model-id>
point every tier at one model
claude-mode preset rm <name>
claude-mode set-key [ref] 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} <unreadable>" -f $mark, $n)
}
}
}
function Invoke-PresetCmd {
# NOT named $Args - that collides with PowerShell's automatic variable.
param([string[]] $Argv)
if (-not $Argv -or $Argv.Count -lt 1) { Show-Usage; return }
$sub = $Argv[0]
$name = if ($Argv.Count -ge 2) { $Argv[1] } else { $null }
switch ($sub) {
'show' {
if (-not $name) { throw 'usage: claude-mode preset show <name>' }
(Get-Preset $name) | ConvertTo-Json -Depth 20 | Write-Host
}
'new' {
if (-not $name) { throw 'usage: claude-mode preset new <name> [copy-from]' }
$path = Get-PresetPath $name
if (Test-Path -LiteralPath $path) { throw "preset '$name' already exists" }
$from = if ($Argv.Count -ge 3) { $Argv[2] } else { 'default' }
$base = Get-Preset $from
$base['description'] = "copy of '$from'"
Write-JsonFile $path $base
Write-Ok "created $path from '$from' - edit with: claude-mode preset set $name <tier> <model-id>"
}
'rm' {
if (-not $name) { throw 'usage: claude-mode preset rm <name>' }
$path = Get-PresetPath $name
if (-not (Test-Path -LiteralPath $path)) { throw "preset '$name' not found" }
$state = Get-State
if ([string]$state['preset'] -eq $name -and [string]$state['mode'] -ne 'anthropic') {
throw "preset '$name' is active. Switch away first (claude-mode anthropic)."
}
Remove-Item -LiteralPath $path -Force
Write-Ok "deleted preset '$name'"
}
'set' {
if ($Argv.Count -lt 4) { throw 'usage: claude-mode preset set <name> <tier> <model-id>' }
Set-PresetTier -Name $name -Tier $Argv[2].ToLower() -Model $Argv[3]
}
'all' {
if ($Argv.Count -lt 3) { throw 'usage: claude-mode preset all <name> <model-id>' }
$model = $Argv[2]
$preset = Get-Preset $name
if (-not $preset.Contains('models')) { $preset['models'] = [ordered]@{} }
foreach ($t in $script:Tiers) { $preset['models'][$t] = $model }
$preset['subagentModel'] = $model
Write-JsonFile (Get-PresetPath $name) $preset
Write-Ok "$name : all tiers + subagent -> $model"
Update-ActivePreset $name
}
default { Show-Usage }
}
}
function Set-PresetTier {
param([string] $Name, [string] $Tier, [string] $Model)
$preset = Get-Preset $Name
if ($Tier -eq 'subagent') {
$preset['subagentModel'] = $Model
} elseif ($script:Tiers -contains $Tier) {
if (-not $preset.Contains('models')) { $preset['models'] = [ordered]@{} }
$preset['models'][$Tier] = $Model
} else {
throw "unknown tier '$Tier' (use: opus | sonnet | haiku | fable | subagent)"
}
Write-JsonFile (Get-PresetPath $Name) $preset
Write-Ok "$Name : $Tier -> $Model"
Update-ActivePreset $Name
}
# Editing the live preset re-applies it, so there is no second command to run.
function Update-ActivePreset {
param([string] $Name)
$state = Get-State
if ([string]$state['mode'] -ne 'anthropic' -and [string]$state['preset'] -eq $Name) {
Write-Host ' re-applying active preset...'
Set-ClaudeMode -Mode ([string]$state['mode']) -PresetName $Name
}
}
function Invoke-Models {
param([string] $Filter)
$state = Get-State
$mode = [string]$state['mode']
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.3 - 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.3'; Key = 'glm-5.3'; Label = 'glm-5.3'; 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 = '<type an id manually>'; Detail = @('enter any model id by hand') }
$items += $choices
$sel = Show-SearchSelect -Title "model for '$Tier'" -Status "current: $Current" -Items $items
if ($sel -lt 0) { return $null }
if ($sel -gt 0) { return $items[$sel].Id }
# fall through to manual entry
}
# A typed prompt cannot live inside the repainting frame - the redraw would
# erase what is being typed. Close the frame first.
Stop-UiFrame
Write-Host ''
if ($err) { Write-Warn2 "could not load the model list: $err" }
Write-Host " current $Tier : $Current" -ForegroundColor DarkGray
$val = Read-Host " new model id for '$Tier' (blank = cancel)"
if ([string]::IsNullOrWhiteSpace($val)) { return $null }
return $val.Trim()
}
# A blank preset per provider, with the endpoint/auth/context bits already right
# so only the model choices are left to make.
function New-PresetScaffold {
param([string] $Provider)
$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 = '<blank>'; Detail = @("empty $provider preset - pick every model yourself") })
foreach ($n in $siblings) {
$items += [pscustomobject]@{ Label = "copy of $n"; Detail = (Get-PresetSummaryLines (Get-Preset $n)) }
}
$sel = Show-Select -Title 'start from' -Items $items
if ($sel -lt 0) { return }
$preset = if ($sel -eq 0) { New-PresetScaffold $provider } else { Get-Preset $siblings[$sel - 1] }
# 3. name it
Stop-UiFrame
Write-Host ''
Write-Host " new $provider preset" -ForegroundColor Cyan
$name = Read-Host ' name (letters, digits, dash; blank = cancel)'
if ([string]::IsNullOrWhiteSpace($name)) { return }
$name = $name.Trim()
if ($name -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$') {
Write-Err2 "invalid name '$name' - use letters, digits, dash, dot, underscore"
return
}
if (Test-Path -LiteralPath (Get-PresetPath $name)) {
Write-Err2 "preset '$name' already exists"
return
}
$preset['description'] = if ($sel -eq 0) { "custom $provider preset" } else { "copy of $($siblings[$sel - 1])" }
Write-JsonFile (Get-PresetPath $name) $preset
Write-Ok "created preset '$name' ($provider)"
# 4. straight into editing it
Invoke-PresetEditor -Name $name
}
function Invoke-PresetEditor {
param([string] $Name)
if (-not $Name) {
$names = @(Get-PresetNames)
if ($names.Count -eq 0) { Write-Warn2 'no presets'; return }
$items = @()
foreach ($n in $names) {
$p = Get-Preset $n
$items += [pscustomobject]@{
Label = ("{0,-18} [{1}]" -f $n, $p['provider'])
Detail = (Get-PresetSummaryLines $p)
}
}
$sel = Show-Select -Title 'edit which preset' -Items $items
if ($sel -lt 0) { return }
$Name = $names[$sel]
}
$name = $Name
$preset = Get-Preset $name
while ($true) {
$rows = @()
foreach ($t in $script:Tiers) {
$cur = ''
if ($preset['models'].Contains($t)) { $cur = [string]$preset['models'][$t] }
$rows += [pscustomobject]@{ Tier = $t; Current = $cur }
}
$sub = ''
if ($preset.Contains('subagentModel')) { $sub = [string]$preset['subagentModel'] }
$rows += [pscustomobject]@{ Tier = 'subagent'; Current = $sub }
$items = @()
foreach ($r in $rows) {
$items += [pscustomobject]@{
Label = ("{0,-9} {1}" -f $r.Tier, $r.Current)
Detail = @("change which model backs the '$($r.Tier)' tier", "current: $($r.Current)")
}
}
$c = Show-Select -Title "$name [$($preset['provider'])]" -Status 'esc = done' -Items $items
if ($c -lt 0) { return }
$tier = $rows[$c].Tier
$val = Read-ModelId -Preset $preset -Tier $tier -Current $rows[$c].Current
if (-not $val) { continue }
# Set-PresetTier may re-apply the live preset, which prints a full switch
# summary. Drop the frame so that output survives instead of being wiped
# by the next redraw of the tier list.
Stop-UiFrame
Set-PresetTier -Name $name -Tier $tier -Model $val
$preset = Get-Preset $name
}
}
# The second level: everything that is not switching mode. Loops until the user
# backs out, so running doctor then editing a preset costs one trip in.
function Invoke-MoreMenu {
while ($true) {
$state = Get-State
$current = [string]$state['mode']
$curDesc = $current
if ($current -ne 'anthropic' -and $state['preset']) { $curDesc = "$current / $($state['preset'])" }
$items = @(
[pscustomobject]@{ Label = 'status'; Detail = @('show the full active configuration') }
[pscustomobject]@{ Label = 'edit presets'; Detail = @('pick models per tier from the provider catalogue') }
[pscustomobject]@{ Label = 'new preset'; Detail = @('create a preset - blank or copied from an existing one') }
[pscustomobject]@{ Label = 'doctor'; Detail = @('verify auth, endpoint, model ids, context window') }
[pscustomobject]@{ Label = 'back'; Detail = @('return to the mode menu') }
[pscustomobject]@{ Label = 'quit'; Detail = @() }
)
$sel = Show-Select -Title 'claude-mode - more' -Status "currently: $curDesc" -Items $items
if ($sel -lt 0) { return } # esc = back
switch ($sel) {
0 { Stop-UiFrame; Invoke-Status } # output worth keeping
1 { Invoke-PresetEditor } # more menus - keep the frame
2 { New-PresetInteractive }
3 { Stop-UiFrame; Invoke-Doctor }
4 { return }
5 { Stop-UiFrame; $script:UiQuit = $true; return }
}
}
}
function Invoke-Menu {
if (-not (Test-Interactive)) { Invoke-Status; return }
$script:UiQuit = $false
# Drawn once, above the frame anchor, so the menus repaint underneath it and
# the banner stays put instead of flickering on every keypress.
$st0 = Get-State
Show-Banner -Mode ([string]$st0['mode']) -Preset $(if ([string]$st0['mode'] -ne 'anthropic') { [string]$st0['preset'] } else { '' })
while ($true) {
$state = Get-State
$current = [string]$state['mode']
$curDesc = $current
if ($current -ne 'anthropic' -and $state['preset']) { $curDesc = "$current / $($state['preset'])" }
# The mode you are already in is not offered - nothing to switch to.
$choices = @($script:Modes | Where-Object { $_ -ne $current })
$items = @()
foreach ($m in $choices) {
$detail = @($script:ModeLabel[$m])
try {
$pn = $script:ProviderDefaultPreset[$m]
if ($pn -and (Test-Path -LiteralPath (Get-PresetPath $pn))) {
$detail += (Get-PresetSummaryLines (Get-Preset $pn))[1]
}
} catch { }
$items += [pscustomobject]@{
Label = ("switch to " + $m)
Detail = $detail
Accent = $script:ModeColor[$m]
}
}
# Everything that is not "switch mode" lives one level down, so the three
# things this tool exists to do are the whole first screen.
$items += [pscustomobject]@{ Label = 'more ...'; Detail = @('status, presets, doctor') }
$sel = Show-Select -Title 'claude-mode' -Status "currently: $curDesc" -Items $items
if ($sel -lt 0) { Stop-UiFrame; return }
$nChoices = $choices.Count
if ($sel -eq $nChoices) {
Invoke-MoreMenu
if ($script:UiQuit) { return }
continue
}
$mode = $choices[$sel]
if ($mode -eq 'anthropic') { Stop-UiFrame; Set-ClaudeMode -Mode 'anthropic' -PresetName ''; return }
$preset = Invoke-PresetPicker -Provider $mode
if (-not $preset) { continue }
Stop-UiFrame
Set-ClaudeMode -Mode $mode -PresetName $preset
return
}
}
# ---------------------------------------------------------------------------
# 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
}