Files
claude-mode/claude-mode.ps1
T
smoidoandClaude Code 5bf5a6d36e set-key takes the ref it was given, and a key inline
`claude-mode set-key openrouter sk-or-...` did the wrong thing on the
POSIX port: the dispatcher loop kept the *last* positional, so the key
became the ref name and the prompt asked for "the key for ref
'sk-or-...'". Storing to that ref succeeds, so nothing looks wrong until
the switch later reports no key for 'openrouter' - which is exactly what
happened on macOS.

Both ports now take `set-key [ref] [key]`: the first positional is the
ref unless it is key-shaped (sk-* or longer than a ref name would
plausibly be), in which case it is the key for the default ref, and a
second positional is always the key. An inline key warns that it is now
in the shell history, since the hidden prompt leaves no trace. Extra
arguments are refused instead of ignored. The Windows dispatch already
read the first positional correctly, so it only gains the inline form.

The shipped default preset moves to the models asked for: DeepSeek 4.1
Flash on opus, GLM Flash on sonnet. Existing installs keep their own
presets - the installer never overwrites one without --force - so an
upgrade needs `claude-mode preset set default opus ...` and the same for
sonnet, or a --force install.

Verified in the bash 3.2 container: ref+key stores under the ref, a bare
key stores under openrouter, an empty prompt aborts, a custom ref still
works, the stored value is byte-identical, and four positionals are
refused.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-12 23:26:19 +03:00

2232 lines
95 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'
# Claude Code runs apiKeyHelper as a shell command line, not as a bare argv[0],
# so the value in settings.json is parsed by cmd before anything is executed. A
# profile path containing a space therefore has to arrive already quoted:
# C:\Users\Firstname Lastname\... otherwise splits and cmd tries to run
# C:\Users\Firstname. Paths with nothing cmd cares about are written bare,
# exactly as before, so no existing settings.json churns on the next switch.
function Get-HelperCommandLine {
param([string] $Path = $script:HelperCmd)
if ($Path -match '[\s&()^;,]') { return '"' + $Path + '"' }
return $Path
}
# 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] [key] 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, [string] $Key = '')
Initialize-Root
if ($Key) {
Write-Warn2 'the key was given on the command line, so it is in this shell history - the hidden prompt leaves no trace'
$plain = $Key
} else {
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) {
$unit = if ($plain.Length -eq 1) { 'character' } else { 'characters' }
Write-Warn2 "that key is only $($plain.Length) $unit - 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'] = Get-HelperCommandLine
} 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 {
$liveSettings = Read-JsonFile $script:Settings
Write-Ok 'settings.json parses'
} catch {
Write-Err2 "settings.json does not parse: $($_.Exception.Message)"
return
}
if (Test-Path -LiteralPath $script:HelperCmd) { Write-Ok "key helper present: $($script:HelperCmd)" }
else { Write-Err2 "key helper missing: $($script:HelperCmd)" }
if ($mode -ne 'anthropic') {
$preset = Get-Preset ([string]$state['preset'])
$auth = Get-PresetAuth $preset
$base = ([string]$preset['baseUrl']).TrimEnd('/')
if ([string]$auth['mode'] -eq 'vault') {
$keyRef = [string]$auth['keyRef']
$key = Get-VaultKey $keyRef
if ($key) {
Write-Ok "vault '$keyRef' decrypts -> $(Format-KeyMask $key)"
if ($key -match '\s' -or $key -like 'claude-mode*') {
Write-Err2 "the stored '$keyRef' value looks like a pasted command, not a key. Re-run: claude-mode set-key $keyRef"
}
}
else { Write-Err2 "vault '$keyRef' missing or undecryptable. Run: claude-mode set-key $keyRef" }
# The stored string is what Claude Code hands to a shell, and a
# path this process can quote correctly is not evidence that the
# recorded one parses. Check the value, then run that value.
$stored = ''
if ($liveSettings.Contains('apiKeyHelper')) { $stored = [string]$liveSettings['apiKeyHelper'] }
$expected = Get-HelperCommandLine
$reSwitch = "claude-mode $mode $([string]$state['preset'])"
if (-not $stored) {
Write-Err2 "settings.json has no apiKeyHelper. Run: $reSwitch"
} elseif ($stored -ne $expected) {
Write-Err2 "apiKeyHelper reads $stored"
Write-Err2 " but should read $expected - run: $reSwitch"
} else {
Write-Ok "apiKeyHelper wired as $stored"
}
if ($stored) {
# Run it through cmd the way a shell would, via a batch file, so
# PowerShell's own native-argument quoting cannot paper over a
# value that a real shell would split.
$probe = Join-Path $env:TEMP ('cm-helper-probe-' + [IO.Path]::GetRandomFileName().Replace('.', '') + '.cmd')
try {
Set-Content -LiteralPath $probe -Value ("@echo off`r`n" + $stored) -Encoding ASCII
$out = (& cmd.exe /c "`"$probe`"" 2>&1 | Out-String).Trim()
if ($out -and $key -and $out -eq $key) { Write-Ok 'apiKeyHelper emits the correct key' }
elseif ($out -match '^\S+$') {
# One unbroken token: a credential, just the wrong one. Never echo it.
Write-Err2 "apiKeyHelper output does not match vault key (got: $(Format-KeyMask $out))"
}
elseif ($out) { Write-Err2 "apiKeyHelper failed: $out" }
else { Write-Err2 'apiKeyHelper produced no output' }
} catch {
Write-Err2 "apiKeyHelper failed to run: $($_.Exception.Message)"
} finally {
Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
}
}
if ($key -and $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-key [ref] [key]`. The key is normally typed at the hidden
# prompt; anything key-shaped in the ref position is taken as the
# key for the default ref, which is the easy mistake to make.
$kRef = ''; $kKey = ''
foreach ($a in $Rest) {
if (-not $kRef -and -not ($a -like 'sk-*' -or $a.Length -gt 24)) { $kRef = $a; continue }
if (-not $kKey) { $kKey = $a; continue }
throw "unexpected extra argument '$a'"
}
if (-not $kRef) { $kRef = 'openrouter' }
Set-VaultKey -Ref $kRef -Key $kKey
}
'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
}