diff --git a/README.md b/README.md index 0ea345a..7b2a969 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ claude-mode preset set claude-mode preset all point every tier at one model claude-mode preset rm -claude-mode set-key [ref] store a key (hidden prompt, DPAPI) +claude-mode set-key [ref] [key] store a key (hidden prompt; [key] for scripts) claude-mode models [filter] models available from the active provider claude-mode doctor verify auth, endpoint, model ids, stray env vars @@ -209,7 +209,7 @@ menu to read before the thing you asked for happens. Build more with | preset | provider | opus | sonnet | haiku | fable | |---|---|---|---|---|---| -| `default` | openrouter | `z-ai/glm-5.3-flash` | `deepseek/deepseek-v4-flash-0731` | `openrouter/free` | `z-ai/glm-5.3` | +| `default` | openrouter | `deepseek/deepseek-v4.1-flash` | `z-ai/glm-5.3-flash` | `openrouter/free` | `z-ai/glm-5.3` | | `zai` | zai | `glm-5.3` | `glm-5.3` | `glm-4.7` | `glm-5.3` | | `lmstudio` | lmstudio | whatever setup finds on your server (all tiers) | | | | diff --git a/VERSION b/VERSION index 8fdcf38..77fee73 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.9.2 +1.9.3 diff --git a/claude-mode.ps1 b/claude-mode.ps1 index ded2313..f27bd0b 100644 --- a/claude-mode.ps1 +++ b/claude-mode.ps1 @@ -1,2213 +1,2231 @@ -<# -.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 ` with no preset named uses this one. Deliberately a -# fixed choice rather than "most recently used", so the command is predictable. -$script:ProviderDefaultPreset = @{ - 'openrouter' = 'default' - 'zai' = 'zai' - 'lmstudio' = 'lmstudio' -} - -# --------------------------------------------------------------------------- -# Output helpers -# --------------------------------------------------------------------------- - -function Write-Ok ($m) { Write-Host " ok $m" -ForegroundColor Green } -function Write-Warn2 ($m) { Write-Host " warn $m" -ForegroundColor Yellow } -function Write-Err2 ($m) { Write-Host " FAIL $m" -ForegroundColor Red } -function Write-Head ($m) { Write-Host ""; Write-Host $m -ForegroundColor Cyan } - -# Each mode gets an identity colour, reused for its menu row, its banner tagline -# and its status line - so "which mode am I in" is answerable at a glance. -$script:ModeColor = @{ - 'anthropic' = 'Magenta' - 'openrouter' = 'Cyan' - 'zai' = 'Green' - 'lmstudio' = 'Yellow' -} - -# Deliberately ASCII-only. This file is read by Windows PowerShell 5.1, which -# assumes the ANSI codepage for a .ps1 without a BOM - box-drawing characters -# would arrive mangled on some machines. Plain ASCII always renders. -$script:Banner = @( - ' ____ _ _ __ __ _ ', - ' / ___| | __ _ _ _ __| | ___ | \/ | ___ __| | ___ ', - ' | | | |/ _` | | | |/ _` |/ _ \ | |\/| |/ _ \ / _` |/ _ \', - ' | |___| | (_| | |_| | (_| | __/ | | | | (_) | (_| | __/', - ' \____|_|\__,_|\__,_|\__,_|\___| |_| |_|\___/ \__,_|\___|' -) - -function Show-Banner { - param([string] $Mode, [string] $Preset) - - $shades = @('DarkCyan', 'Cyan', 'Cyan', 'Cyan', 'DarkCyan') - Write-Host '' - for ($i = 0; $i -lt $script:Banner.Count; $i++) { - Write-Host $script:Banner[$i] -ForegroundColor $shades[$i] - } - - $accent = $script:ModeColor[$Mode] - if (-not $accent) { $accent = 'Gray' } - $tag = if ($Preset) { "$Mode / $Preset" } else { $Mode } - - Write-Host ' ' -NoNewline - Write-Host ('-' * 59) -ForegroundColor DarkGray - Write-Host ' now ' -NoNewline -ForegroundColor DarkGray - Write-Host $tag -NoNewline -ForegroundColor $accent - Write-Host ' ' -NoNewline - Write-Host 'switch Claude Code between providers' -ForegroundColor DarkGray -} - -function Show-Usage { - @' -claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio - - claude-mode interactive menu - claude-mode status active mode, preset, model map - - claude-mode anthropic native login/subscription (clears all gateway config) - claude-mode openrouter [preset] remote gateway (default preset: default) - claude-mode zai [preset] Z.AI GLM coding plan (default preset: zai) - claude-mode lmstudio [preset] local LM Studio (default preset: lmstudio) - - claude-mode presets list presets - claude-mode preset show - claude-mode preset new [from] create a preset (copies 'from') - claude-mode preset set - tier = opus | sonnet | haiku | fable | subagent - claude-mode preset all - point every tier at one model - claude-mode preset rm - - claude-mode set-key [ref] store an API key (hidden prompt, DPAPI-encrypted) - claude-mode models [filter] models available from the active provider - claude-mode doctor verify auth, endpoint, model ids, stray env vars - claude-mode repair [--all] strip [1m] tags from cached model ids - (default: gateway ids only; --all includes Anthropic) -'@ | Write-Host -} - -# --------------------------------------------------------------------------- -# JSON helpers (PS 5.1 has no ConvertFrom-Json -AsHashtable) -# --------------------------------------------------------------------------- - -function ConvertTo-DeepHashtable { - param($InputObject) - - if ($null -eq $InputObject) { return $null } - - if ($InputObject -is [System.Collections.IDictionary]) { - $h = [ordered]@{} - foreach ($k in $InputObject.Keys) { $h[[string]$k] = ConvertTo-DeepHashtable $InputObject[$k] } - return $h - } - - if ($InputObject -is [System.Management.Automation.PSCustomObject]) { - $h = [ordered]@{} - foreach ($p in $InputObject.PSObject.Properties) { $h[$p.Name] = ConvertTo-DeepHashtable $p.Value } - return $h - } - - if ($InputObject -is [string]) { return $InputObject } - - if ($InputObject -is [System.Collections.IEnumerable]) { - $list = New-Object System.Collections.ArrayList - foreach ($item in $InputObject) { [void]$list.Add((ConvertTo-DeepHashtable $item)) } - return , $list.ToArray() - } - - return $InputObject -} - -function Read-JsonFile { - param([string] $Path) - if (-not (Test-Path -LiteralPath $Path)) { return $null } - $raw = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 - if ([string]::IsNullOrWhiteSpace($raw)) { return $null } - return ConvertTo-DeepHashtable (ConvertFrom-Json $raw) -} - -function Write-JsonFile { - param([string] $Path, $Data) - $dir = Split-Path -Parent $Path - if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } - $json = Format-JsonPretty ($Data | ConvertTo-Json -Depth 100) - # UTF-8 without BOM; some JSON readers choke on a BOM. - [System.IO.File]::WriteAllText($Path, $json, (New-Object System.Text.UTF8Encoding($false))) -} - -# PowerShell 5.1's ConvertTo-Json indents by aligning values into a column, -# which is valid but painful to hand-edit. Re-indent with node when available. -function Format-JsonPretty { - param([string] $Json) - if ($null -eq $script:NodeExe) { - $c = Get-Command node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($c) { $script:NodeExe = $c.Source } else { $script:NodeExe = '' } - } - if (-not $script:NodeExe) { return $Json } - - $tmp = [System.IO.Path]::GetTempFileName() - try { - [System.IO.File]::WriteAllText($tmp, $Json, (New-Object System.Text.UTF8Encoding($false))) - $out = & $script:NodeExe -e "const fs=require('fs');process.stdout.write(JSON.stringify(JSON.parse(fs.readFileSync(process.argv[1],'utf8')),null,2)+'\n')" $tmp - if ($out) { return (($out -join "`n") + "`n") } - return $Json - } catch { - return $Json - } finally { - Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue - } -} - -# --------------------------------------------------------------------------- -# ACL hardening - restrict a file to the current user only -# --------------------------------------------------------------------------- - -function Protect-FileAcl { - param([string] $Path) - try { - $acl = Get-Acl -LiteralPath $Path - if ($acl.AreAccessRulesProtected) { return } - $acl.SetAccessRuleProtection($true, $false) - foreach ($rule in @($acl.Access)) { [void]$acl.RemoveAccessRule($rule) } - $me = New-Object System.Security.Principal.NTAccount($env:USERDOMAIN, $env:USERNAME) - $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule( - $me, 'FullControl', 'None', 'None', 'Allow'))) - Set-Acl -LiteralPath $Path -AclObject $acl - } catch { - Write-Warn2 "could not harden ACL on $Path : $($_.Exception.Message)" - } -} - -# --------------------------------------------------------------------------- -# State / presets -# --------------------------------------------------------------------------- - -function Initialize-Root { - foreach ($d in @($script:Root, $script:PresetDir, $script:VaultDir, $script:BackupDir, $script:BinDir)) { - if (-not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null } - } -} - -function Get-State { - $s = Read-JsonFile $script:StatePath - if ($null -eq $s) { $s = [ordered]@{} } - if (-not $s.Contains('mode')) { $s['mode'] = 'anthropic' } - if (-not $s.Contains('preset')) { $s['preset'] = '' } - if (-not $s.Contains('writtenEnvKeys')) { $s['writtenEnvKeys'] = @() } - return $s -} - -function Set-State { - param([string] $Mode, [string] $PresetName, [string[]] $WrittenKeys) - $s = Get-State - $s['mode'] = $Mode - $s['preset'] = $PresetName - $s['writtenEnvKeys'] = @($WrittenKeys) - $s['updated'] = (Get-Date).ToString('o') - # Written by older builds that picked "most recently used" presets; the - # per-provider default is fixed now, so nothing maintains this. - if ($s.Contains('lastByProvider')) { $s.Remove('lastByProvider') } - Write-JsonFile $script:StatePath $s -} - -function Get-PresetPath { param([string] $Name) return (Join-Path $script:PresetDir "$Name.json") } - -function Get-Preset { - param([string] $Name) - $p = Read-JsonFile (Get-PresetPath $Name) - if ($null -eq $p) { throw "preset '$Name' not found. Run: claude-mode presets" } - if (-not $p.Contains('provider')) { $p['provider'] = 'openrouter' } - return $p -} - -function Get-PresetNames { - if (-not (Test-Path -LiteralPath $script:PresetDir)) { return @() } - return @(Get-ChildItem -LiteralPath $script:PresetDir -Filter '*.json' | - ForEach-Object { $_.BaseName } | Sort-Object) -} - -function Get-PresetNamesForProvider { - param([string] $Provider) - $out = @() - foreach ($n in Get-PresetNames) { - try { if ([string](Get-Preset $n)['provider'] -eq $Provider) { $out += $n } } catch { } - } - return $out -} - -function Resolve-PresetForProvider { - param([string] $Provider, [string] $Requested) - - if ($Requested) { - $p = Get-Preset $Requested - if ([string]$p['provider'] -ne $Provider) { - throw "preset '$Requested' is a '$($p['provider'])' preset, not '$Provider'" - } - return $Requested - } - - # Fixed per-provider default, so `claude-mode openrouter` is predictable - # rather than depending on what you last used or on alphabetical order. - $fallback = $script:ProviderDefaultPreset[$Provider] - if ($fallback -and (Test-Path -LiteralPath (Get-PresetPath $fallback))) { - if ([string](Get-Preset $fallback)['provider'] -eq $Provider) { return $fallback } - } - - $names = Get-PresetNamesForProvider $Provider - if ($names.Count -gt 0) { return $names[0] } - throw "no preset found for provider '$Provider'" -} - -# --------------------------------------------------------------------------- -# Vault (DPAPI: CurrentUser scope) -# --------------------------------------------------------------------------- - -function Get-VaultPath { param([string] $Ref) return (Join-Path $script:VaultDir "$Ref.cred") } - -function ConvertFrom-SecureStringPlain { - param([System.Security.SecureString] $Secure) - $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) - try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } - finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } -} - -function Set-VaultKey { - param([string] $Ref) - Initialize-Root - Write-Host "Paste the API key for ref '$Ref' (input hidden):" - $secure = Read-Host -AsSecureString - $plain = ConvertFrom-SecureStringPlain $secure - if ([string]::IsNullOrWhiteSpace($plain)) { throw 'empty key, aborted' } - - # A hidden prompt will happily swallow a mis-paste. Guard the two shapes that - # are never a real key, because the failure is otherwise invisible until the - # provider answers 401 and the UI just spins. - if ($plain -match '\s') { - throw "that value contains whitespace, so it is not an API key (a pasted command line?). Nothing was stored." - } - if ($plain -like 'claude-mode*') { - throw "that value is a claude-mode command, not an API key. Nothing was stored." - } - if ($plain.Length -lt 16) { - Write-Warn2 "that key is only $($plain.Length) characters - unusually short. Storing anyway." - } - if ($Ref -eq 'openrouter' -and $plain -notlike 'sk-or-*') { - Write-Warn2 "key does not start with 'sk-or-' - storing anyway" - } - - $path = Get-VaultPath $Ref - # ConvertFrom-SecureString with no -Key uses DPAPI, CurrentUser scope. - ConvertFrom-SecureString -SecureString $secure | - Set-Content -LiteralPath $path -Encoding ASCII -NoNewline - Protect-FileAcl $path - Write-Ok "stored DPAPI-encrypted key at $path" -} - -function Get-VaultKey { - param([string] $Ref) - $path = Get-VaultPath $Ref - if (-not (Test-Path -LiteralPath $path)) { return $null } - $blob = (Get-Content -LiteralPath $path -Raw).Trim() - if ([string]::IsNullOrWhiteSpace($blob)) { return $null } - try { return ConvertFrom-SecureStringPlain (ConvertTo-SecureString $blob) } - catch { return $null } -} - -function Format-KeyMask { - param([string] $Key) - if ([string]::IsNullOrEmpty($Key)) { return '(none)' } - if ($Key.Length -le 12) { return '****' } - return ($Key.Substring(0, 8) + '...' + $Key.Substring($Key.Length - 4)) -} - -function Get-PresetAuth { - param($Preset) - $auth = $Preset['auth'] - if ($null -eq $auth) { $auth = [ordered]@{ mode = 'vault'; keyRef = 'openrouter' } } - if (-not $auth.Contains('mode')) { $auth['mode'] = 'vault' } - return $auth -} - -# --------------------------------------------------------------------------- -# settings.json rewriting -# --------------------------------------------------------------------------- - -function Backup-Settings { - if (-not (Test-Path -LiteralPath $script:Settings)) { return $null } - Initialize-Root - $stamp = (Get-Date).ToString('yyyyMMdd-HHmmss-fff') - $dest = Join-Path $script:BackupDir "settings.$stamp.json" - Copy-Item -LiteralPath $script:Settings -Destination $dest -Force - $old = @(Get-ChildItem -LiteralPath $script:BackupDir -Filter 'settings.*.json' | - Sort-Object Name -Descending | Select-Object -Skip 20) - foreach ($f in $old) { Remove-Item -LiteralPath $f.FullName -Force } - return $dest -} - -function Clear-ManagedSettings { - param($Settings) - # Baseline keys plus whatever the previous switch actually wrote, so a - # preset's custom extraEnv key cannot outlive the preset that added it. - $keys = @($script:BaseManagedEnvKeys) + @((Get-State)['writtenEnvKeys']) | Sort-Object -Unique - - if ($Settings.Contains('env') -and $Settings['env'] -is [System.Collections.IDictionary]) { - foreach ($k in $keys) { - if ($k -and $Settings['env'].Contains($k)) { $Settings['env'].Remove($k) } - } - if ($Settings['env'].Count -eq 0) { $Settings.Remove('env') } - } - if ($Settings.Contains('apiKeyHelper')) { $Settings.Remove('apiKeyHelper') } - return $Settings -} - -function Set-ClaudeMode { - param( - [ValidateSet('anthropic', 'openrouter', 'zai', 'lmstudio')] [string] $Mode, - [string] $PresetName - ) - - Initialize-Root - if (-not (Test-Path -LiteralPath $script:SettingsDir)) { - New-Item -ItemType Directory -Path $script:SettingsDir -Force | Out-Null - } - - $settings = Read-JsonFile $script:Settings - if ($null -eq $settings) { $settings = [ordered]@{} } - - $backup = Backup-Settings - $settings = Clear-ManagedSettings $settings - $written = @() - $preset = $null - - if ($Mode -ne 'anthropic') { - $preset = Get-Preset $PresetName - if ([string]$preset['provider'] -ne $Mode) { - throw "preset '$PresetName' declares provider '$($preset['provider'])', not '$Mode'" - } - - $models = $preset['models'] - if ($null -eq $models) { throw "preset '$PresetName' has no 'models' block" } - - # Cost guard. Gateways resell Anthropic models at full list price, with no - # subscription discount - routing a tier there is almost never intended - # and is expensive enough to be worth blocking outright. Opt in per - # preset with "allowAnthropicModels": true. - if (-not ($preset.Contains('allowAnthropicModels') -and $preset['allowAnthropicModels'])) { - $offenders = @() - foreach ($tier in $script:Tiers) { - $id = [string]$models[$tier] - if ($id -and (Test-AnthropicModelId $id)) { $offenders += "$tier -> $id" } - } - $sub = [string]$preset['subagentModel'] - if ($sub -and (Test-AnthropicModelId $sub)) { $offenders += "subagent -> $sub" } - if ($offenders.Count -gt 0) { - Write-Err2 "preset '$PresetName' routes a tier at an Anthropic model through '$Mode':" - foreach ($o in $offenders) { Write-Err2 " $o" } - throw "refusing to switch - gateways bill Anthropic models at full price. Add `"allowAnthropicModels`": true to the preset if this is deliberate." - } - } - - $envBlock = [ordered]@{} - $envBlock['ANTHROPIC_BASE_URL'] = [string]$preset['baseUrl'] - # Explicitly empty, not absent: a cached Anthropic login can otherwise - # override the gateway config and surface as a model-not-found error. - # Removed entirely when switching back to anthropic. - $envBlock['ANTHROPIC_API_KEY'] = '' - - foreach ($tier in $script:Tiers) { - if ($models.Contains($tier) -and -not [string]::IsNullOrWhiteSpace([string]$models[$tier])) { - $envBlock["ANTHROPIC_DEFAULT_$($tier.ToUpper())_MODEL"] = [string]$models[$tier] - } - } - if ($preset.Contains('subagentModel') -and -not [string]::IsNullOrWhiteSpace([string]$preset['subagentModel'])) { - $envBlock['CLAUDE_CODE_SUBAGENT_MODEL'] = [string]$preset['subagentModel'] - } - if ($preset.Contains('gatewayModelDiscovery') -and $preset['gatewayModelDiscovery']) { - $envBlock['CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY'] = '1' - } - - # Behind a custom base URL, Claude Code cannot resolve a third-party - # model id to a context length, so it falls back to a conservative - # default and starts auto-compacting long before the model is actually - # full. State the real window explicitly. - if ($preset.Contains('contextTokens') -and $preset['contextTokens']) { - $ctx = [string][int]$preset['contextTokens'] - $envBlock['CLAUDE_CODE_MAX_CONTEXT_TOKENS'] = $ctx - $envBlock['CLAUDE_CODE_AUTO_COMPACT_WINDOW'] = $ctx - } - if ($preset.Contains('extraEnv') -and $preset['extraEnv'] -is [System.Collections.IDictionary]) { - foreach ($k in $preset['extraEnv'].Keys) { $envBlock[$k] = [string]$preset['extraEnv'][$k] } - } - - # Auth. 'vault' keeps the secret out of settings.json entirely and hands - # it over at runtime; 'literal' is for non-secrets like LM Studio's - # placeholder token. - $auth = Get-PresetAuth $preset - if ([string]$auth['mode'] -eq 'vault') { - $keyRef = 'openrouter' - if ($auth.Contains('keyRef') -and $auth['keyRef']) { $keyRef = [string]$auth['keyRef'] } - if (-not (Get-VaultKey $keyRef)) { - throw "no key stored for ref '$keyRef'. Run: claude-mode set-key $keyRef" - } - if (-not (Test-Path -LiteralPath $script:HelperCmd)) { - throw "key helper missing at $($script:HelperCmd). Re-run install.ps1" - } - $settings['apiKeyHelper'] = 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} " -f $mark, $n) - } - } -} - -function Invoke-PresetCmd { - # NOT named $Args - that collides with PowerShell's automatic variable. - param([string[]] $Argv) - if (-not $Argv -or $Argv.Count -lt 1) { Show-Usage; return } - $sub = $Argv[0] - $name = if ($Argv.Count -ge 2) { $Argv[1] } else { $null } - - switch ($sub) { - 'show' { - if (-not $name) { throw 'usage: claude-mode preset show ' } - (Get-Preset $name) | ConvertTo-Json -Depth 20 | Write-Host - } - 'new' { - if (-not $name) { throw 'usage: claude-mode preset new [copy-from]' } - $path = Get-PresetPath $name - if (Test-Path -LiteralPath $path) { throw "preset '$name' already exists" } - $from = if ($Argv.Count -ge 3) { $Argv[2] } else { 'default' } - $base = Get-Preset $from - $base['description'] = "copy of '$from'" - Write-JsonFile $path $base - Write-Ok "created $path from '$from' - edit with: claude-mode preset set $name " - } - 'rm' { - if (-not $name) { throw 'usage: claude-mode preset rm ' } - $path = Get-PresetPath $name - if (-not (Test-Path -LiteralPath $path)) { throw "preset '$name' not found" } - $state = Get-State - if ([string]$state['preset'] -eq $name -and [string]$state['mode'] -ne 'anthropic') { - throw "preset '$name' is active. Switch away first (claude-mode anthropic)." - } - Remove-Item -LiteralPath $path -Force - Write-Ok "deleted preset '$name'" - } - 'set' { - if ($Argv.Count -lt 4) { throw 'usage: claude-mode preset set ' } - Set-PresetTier -Name $name -Tier $Argv[2].ToLower() -Model $Argv[3] - } - 'all' { - if ($Argv.Count -lt 3) { throw 'usage: claude-mode preset all ' } - $model = $Argv[2] - $preset = Get-Preset $name - if (-not $preset.Contains('models')) { $preset['models'] = [ordered]@{} } - foreach ($t in $script:Tiers) { $preset['models'][$t] = $model } - $preset['subagentModel'] = $model - Write-JsonFile (Get-PresetPath $name) $preset - Write-Ok "$name : all tiers + subagent -> $model" - Update-ActivePreset $name - } - default { Show-Usage } - } -} - -function Set-PresetTier { - param([string] $Name, [string] $Tier, [string] $Model) - $preset = Get-Preset $Name - - if ($Tier -eq 'subagent') { - $preset['subagentModel'] = $Model - } elseif ($script:Tiers -contains $Tier) { - if (-not $preset.Contains('models')) { $preset['models'] = [ordered]@{} } - $preset['models'][$Tier] = $Model - } else { - throw "unknown tier '$Tier' (use: opus | sonnet | haiku | fable | subagent)" - } - - Write-JsonFile (Get-PresetPath $Name) $preset - Write-Ok "$Name : $Tier -> $Model" - Update-ActivePreset $Name -} - -# Editing the live preset re-applies it, so there is no second command to run. -function Update-ActivePreset { - param([string] $Name) - $state = Get-State - if ([string]$state['mode'] -ne 'anthropic' -and [string]$state['preset'] -eq $Name) { - Write-Host ' re-applying active preset...' - Set-ClaudeMode -Mode ([string]$state['mode']) -PresetName $Name - } -} - -function Invoke-Models { - param([string] $Filter) - $state = Get-State - $mode = [string]$state['mode'] - - if ($mode -eq 'lmstudio') { - $preset = Get-Preset ([string]$state['preset']) - Write-Head "models installed in LM Studio at $($preset['baseUrl'])" - $tpl = Get-LmStudioTemplateReport - Get-LmStudioModels ([string]$preset['baseUrl']) | - Where-Object { -not $Filter -or $_.Id -like "*$Filter*" } | - Sort-Object Id | - ForEach-Object { - $short = ($_.Id -split '/')[-1].ToLower() - $t = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1 - $flag = '' - if ($t -and $t.Assertions.Count -gt 0) { $flag = 'TEMPLATE RISK' } - [pscustomobject]@{ Id = $_.Id; State = $_.State; Ctx = $_.Ctx; Note = $flag } - } | Format-Table -AutoSize - Write-Host " 'TEMPLATE RISK' = the model's chat template hard-asserts message order," - Write-Host " which can break tool-call parser generation. Prefer an unflagged model." - return - } - - if ($mode -eq 'zai') { - Write-Head 'Z.AI GLM models (from Z.AI docs - no public catalogue endpoint)' - @('glm-5.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 = ''; Detail = @('enter any model id by hand') } - $items += $choices - - $sel = Show-SearchSelect -Title "model for '$Tier'" -Status "current: $Current" -Items $items - if ($sel -lt 0) { return $null } - if ($sel -gt 0) { return $items[$sel].Id } - # fall through to manual entry - } - - # A typed prompt cannot live inside the repainting frame - the redraw would - # erase what is being typed. Close the frame first. - Stop-UiFrame - Write-Host '' - if ($err) { Write-Warn2 "could not load the model list: $err" } - Write-Host " current $Tier : $Current" -ForegroundColor DarkGray - $val = Read-Host " new model id for '$Tier' (blank = cancel)" - if ([string]::IsNullOrWhiteSpace($val)) { return $null } - return $val.Trim() -} - -# A blank preset per provider, with the endpoint/auth/context bits already right -# so only the model choices are left to make. -function New-PresetScaffold { - param([string] $Provider) - - $base = [ordered]@{ - provider = $Provider - description = 'new preset' - } - - switch ($Provider) { - 'openrouter' { - $base['baseUrl'] = 'https://openrouter.ai/api' - $base['auth'] = [ordered]@{ mode = 'vault'; keyRef = 'openrouter' } - } - 'zai' { - $base['baseUrl'] = 'https://api.z.ai/api/anthropic' - $base['auth'] = [ordered]@{ mode = 'vault'; keyRef = 'zai' } - } - 'lmstudio' { - $base['baseUrl'] = 'http://127.0.0.1:1234' - $base['auth'] = [ordered]@{ mode = 'literal'; token = 'lmstudio' } - } - } - - $base['models'] = [ordered]@{ opus = ''; sonnet = ''; haiku = ''; fable = '' } - $base['subagentModel'] = 'inherit' - $base['gatewayModelDiscovery'] = ($Provider -eq 'openrouter') - $base['contextTokens'] = if ($Provider -eq 'lmstudio') { 262144 } else { 1000000 } - if ($Provider -eq 'lmstudio') { $base['extraEnv'] = [ordered]@{ CLAUDE_CODE_ATTRIBUTION_HEADER = '0' } } - if ($Provider -eq 'zai') { - $base['extraEnv'] = [ordered]@{ - API_TIMEOUT_MS = '3000000' - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1' - } - } - return $base -} - -function New-PresetInteractive { - # 1. provider - $provs = @($script:Modes | Where-Object { $_ -ne 'anthropic' }) - $items = @() - foreach ($p in $provs) { $items += [pscustomobject]@{ Label = $p; Detail = @($script:ModeLabel[$p]) } } - $sel = Show-Select -Title 'new preset - which provider' -Items $items - if ($sel -lt 0) { return } - $provider = $provs[$sel] - - # 2. start from an existing preset of that provider, or blank - $siblings = @(Get-PresetNamesForProvider $provider) - $items = @([pscustomobject]@{ Label = ''; Detail = @("empty $provider preset - pick every model yourself") }) - foreach ($n in $siblings) { - $items += [pscustomobject]@{ Label = "copy of $n"; Detail = (Get-PresetSummaryLines (Get-Preset $n)) } - } - $sel = Show-Select -Title 'start from' -Items $items - if ($sel -lt 0) { return } - - $preset = if ($sel -eq 0) { New-PresetScaffold $provider } else { Get-Preset $siblings[$sel - 1] } - - # 3. name it - Stop-UiFrame - Write-Host '' - Write-Host " new $provider preset" -ForegroundColor Cyan - $name = Read-Host ' name (letters, digits, dash; blank = cancel)' - if ([string]::IsNullOrWhiteSpace($name)) { return } - $name = $name.Trim() - if ($name -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$') { - Write-Err2 "invalid name '$name' - use letters, digits, dash, dot, underscore" - return - } - if (Test-Path -LiteralPath (Get-PresetPath $name)) { - Write-Err2 "preset '$name' already exists" - return - } - - $preset['description'] = if ($sel -eq 0) { "custom $provider preset" } else { "copy of $($siblings[$sel - 1])" } - Write-JsonFile (Get-PresetPath $name) $preset - Write-Ok "created preset '$name' ($provider)" - - # 4. straight into editing it - Invoke-PresetEditor -Name $name -} - -function Invoke-PresetEditor { - param([string] $Name) - - if (-not $Name) { - $names = @(Get-PresetNames) - if ($names.Count -eq 0) { Write-Warn2 'no presets'; return } - - $items = @() - foreach ($n in $names) { - $p = Get-Preset $n - $items += [pscustomobject]@{ - Label = ("{0,-18} [{1}]" -f $n, $p['provider']) - Detail = (Get-PresetSummaryLines $p) - } - } - - $sel = Show-Select -Title 'edit which preset' -Items $items - if ($sel -lt 0) { return } - $Name = $names[$sel] - } - - $name = $Name - $preset = Get-Preset $name - - while ($true) { - $rows = @() - foreach ($t in $script:Tiers) { - $cur = '' - if ($preset['models'].Contains($t)) { $cur = [string]$preset['models'][$t] } - $rows += [pscustomobject]@{ Tier = $t; Current = $cur } - } - $sub = '' - if ($preset.Contains('subagentModel')) { $sub = [string]$preset['subagentModel'] } - $rows += [pscustomobject]@{ Tier = 'subagent'; Current = $sub } - - $items = @() - foreach ($r in $rows) { - $items += [pscustomobject]@{ - Label = ("{0,-9} {1}" -f $r.Tier, $r.Current) - Detail = @("change which model backs the '$($r.Tier)' tier", "current: $($r.Current)") - } - } - - $c = Show-Select -Title "$name [$($preset['provider'])]" -Status 'esc = done' -Items $items - if ($c -lt 0) { return } - - $tier = $rows[$c].Tier - $val = Read-ModelId -Preset $preset -Tier $tier -Current $rows[$c].Current - if (-not $val) { continue } - - # Set-PresetTier may re-apply the live preset, which prints a full switch - # summary. Drop the frame so that output survives instead of being wiped - # by the next redraw of the tier list. - Stop-UiFrame - Set-PresetTier -Name $name -Tier $tier -Model $val - $preset = Get-Preset $name - } -} - -# The second level: everything that is not switching mode. Loops until the user -# backs out, so running doctor then editing a preset costs one trip in. -function Invoke-MoreMenu { - while ($true) { - $state = Get-State - $current = [string]$state['mode'] - $curDesc = $current - if ($current -ne 'anthropic' -and $state['preset']) { $curDesc = "$current / $($state['preset'])" } - - $items = @( - [pscustomobject]@{ Label = 'status'; Detail = @('show the full active configuration') } - [pscustomobject]@{ Label = 'edit presets'; Detail = @('pick models per tier from the provider catalogue') } - [pscustomobject]@{ Label = 'new preset'; Detail = @('create a preset - blank or copied from an existing one') } - [pscustomobject]@{ Label = 'doctor'; Detail = @('verify auth, endpoint, model ids, context window') } - [pscustomobject]@{ Label = 'back'; Detail = @('return to the mode menu') } - [pscustomobject]@{ Label = 'quit'; Detail = @() } - ) - - $sel = Show-Select -Title 'claude-mode - more' -Status "currently: $curDesc" -Items $items - if ($sel -lt 0) { return } # esc = back - - switch ($sel) { - 0 { Stop-UiFrame; Invoke-Status } # output worth keeping - 1 { Invoke-PresetEditor } # more menus - keep the frame - 2 { New-PresetInteractive } - 3 { Stop-UiFrame; Invoke-Doctor } - 4 { return } - 5 { Stop-UiFrame; $script:UiQuit = $true; return } - } - } -} - -function Invoke-Menu { - if (-not (Test-Interactive)) { Invoke-Status; return } - $script:UiQuit = $false - - # Drawn once, above the frame anchor, so the menus repaint underneath it and - # the banner stays put instead of flickering on every keypress. - $st0 = Get-State - Show-Banner -Mode ([string]$st0['mode']) -Preset $(if ([string]$st0['mode'] -ne 'anthropic') { [string]$st0['preset'] } else { '' }) - - while ($true) { - $state = Get-State - $current = [string]$state['mode'] - $curDesc = $current - if ($current -ne 'anthropic' -and $state['preset']) { $curDesc = "$current / $($state['preset'])" } - - # The mode you are already in is not offered - nothing to switch to. - $choices = @($script:Modes | Where-Object { $_ -ne $current }) - - $items = @() - foreach ($m in $choices) { - $detail = @($script:ModeLabel[$m]) - try { - $pn = $script:ProviderDefaultPreset[$m] - if ($pn -and (Test-Path -LiteralPath (Get-PresetPath $pn))) { - $detail += (Get-PresetSummaryLines (Get-Preset $pn))[1] - } - } catch { } - $items += [pscustomobject]@{ - Label = ("switch to " + $m) - Detail = $detail - Accent = $script:ModeColor[$m] - } - } - # Everything that is not "switch mode" lives one level down, so the three - # things this tool exists to do are the whole first screen. - $items += [pscustomobject]@{ Label = 'more ...'; Detail = @('status, presets, doctor') } - - $sel = Show-Select -Title 'claude-mode' -Status "currently: $curDesc" -Items $items - if ($sel -lt 0) { Stop-UiFrame; return } - - $nChoices = $choices.Count - if ($sel -eq $nChoices) { - Invoke-MoreMenu - if ($script:UiQuit) { return } - continue - } - - $mode = $choices[$sel] - if ($mode -eq 'anthropic') { Stop-UiFrame; Set-ClaudeMode -Mode 'anthropic' -PresetName ''; return } - - $preset = Invoke-PresetPicker -Provider $mode - if (-not $preset) { continue } - Stop-UiFrame - Set-ClaudeMode -Mode $mode -PresetName $preset - return - } -} - -# --------------------------------------------------------------------------- -# Dispatch -# --------------------------------------------------------------------------- - -Initialize-Root - -try { - $cmd = $Command.ToLower() - if ($cmd -eq 'z.ai' -or $cmd -eq 'z-ai') { $cmd = 'zai' } - - switch ($cmd) { - '' { Invoke-Menu } - 'menu' { Invoke-Menu } - 'status' { Invoke-Status } - 'anthropic' { Set-ClaudeMode -Mode 'anthropic' -PresetName '' } - 'openrouter' { - $req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null } - Set-ClaudeMode -Mode 'openrouter' -PresetName (Resolve-PresetForProvider 'openrouter' $req) - } - 'zai' { - $req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null } - Set-ClaudeMode -Mode 'zai' -PresetName (Resolve-PresetForProvider 'zai' $req) - } - 'lmstudio' { - $req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null } - Set-ClaudeMode -Mode 'lmstudio' -PresetName (Resolve-PresetForProvider 'lmstudio' $req) - } - 'presets' { Invoke-Presets } - 'preset' { Invoke-PresetCmd -Argv $Rest } - 'set-key' { Set-VaultKey -Ref $(if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { 'openrouter' }) } - 'models' { Invoke-Models -Filter $(if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null }) } - 'doctor' { Invoke-Doctor } - 'repair' { [void](Invoke-Repair -All:([bool]($Rest -and ($Rest -contains '--all')))) } - 'help' { Show-Usage } - '--help' { Show-Usage } - '-h' { Show-Usage } - default { Write-Err2 "unknown command '$Command'"; Show-Usage; exit 1 } - } -} catch { - Write-Err2 $_.Exception.Message - exit 1 -} +<# +.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 ` with no preset named uses this one. Deliberately a +# fixed choice rather than "most recently used", so the command is predictable. +$script:ProviderDefaultPreset = @{ + 'openrouter' = 'default' + 'zai' = 'zai' + 'lmstudio' = 'lmstudio' +} + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- + +function Write-Ok ($m) { Write-Host " ok $m" -ForegroundColor Green } +function Write-Warn2 ($m) { Write-Host " warn $m" -ForegroundColor Yellow } +function Write-Err2 ($m) { Write-Host " FAIL $m" -ForegroundColor Red } +function Write-Head ($m) { Write-Host ""; Write-Host $m -ForegroundColor Cyan } + +# Each mode gets an identity colour, reused for its menu row, its banner tagline +# and its status line - so "which mode am I in" is answerable at a glance. +$script:ModeColor = @{ + 'anthropic' = 'Magenta' + 'openrouter' = 'Cyan' + 'zai' = 'Green' + 'lmstudio' = 'Yellow' +} + +# Deliberately ASCII-only. This file is read by Windows PowerShell 5.1, which +# assumes the ANSI codepage for a .ps1 without a BOM - box-drawing characters +# would arrive mangled on some machines. Plain ASCII always renders. +$script:Banner = @( + ' ____ _ _ __ __ _ ', + ' / ___| | __ _ _ _ __| | ___ | \/ | ___ __| | ___ ', + ' | | | |/ _` | | | |/ _` |/ _ \ | |\/| |/ _ \ / _` |/ _ \', + ' | |___| | (_| | |_| | (_| | __/ | | | | (_) | (_| | __/', + ' \____|_|\__,_|\__,_|\__,_|\___| |_| |_|\___/ \__,_|\___|' +) + +function Show-Banner { + param([string] $Mode, [string] $Preset) + + $shades = @('DarkCyan', 'Cyan', 'Cyan', 'Cyan', 'DarkCyan') + Write-Host '' + for ($i = 0; $i -lt $script:Banner.Count; $i++) { + Write-Host $script:Banner[$i] -ForegroundColor $shades[$i] + } + + $accent = $script:ModeColor[$Mode] + if (-not $accent) { $accent = 'Gray' } + $tag = if ($Preset) { "$Mode / $Preset" } else { $Mode } + + Write-Host ' ' -NoNewline + Write-Host ('-' * 59) -ForegroundColor DarkGray + Write-Host ' now ' -NoNewline -ForegroundColor DarkGray + Write-Host $tag -NoNewline -ForegroundColor $accent + Write-Host ' ' -NoNewline + Write-Host 'switch Claude Code between providers' -ForegroundColor DarkGray +} + +function Show-Usage { + @' +claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio + + claude-mode interactive menu + claude-mode status active mode, preset, model map + + claude-mode anthropic native login/subscription (clears all gateway config) + claude-mode openrouter [preset] remote gateway (default preset: default) + claude-mode zai [preset] Z.AI GLM coding plan (default preset: zai) + claude-mode lmstudio [preset] local LM Studio (default preset: lmstudio) + + claude-mode presets list presets + claude-mode preset show + claude-mode preset new [from] create a preset (copies 'from') + claude-mode preset set + tier = opus | sonnet | haiku | fable | subagent + claude-mode preset all + point every tier at one model + claude-mode preset rm + + claude-mode set-key [ref] [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} " -f $mark, $n) + } + } +} + +function Invoke-PresetCmd { + # NOT named $Args - that collides with PowerShell's automatic variable. + param([string[]] $Argv) + if (-not $Argv -or $Argv.Count -lt 1) { Show-Usage; return } + $sub = $Argv[0] + $name = if ($Argv.Count -ge 2) { $Argv[1] } else { $null } + + switch ($sub) { + 'show' { + if (-not $name) { throw 'usage: claude-mode preset show ' } + (Get-Preset $name) | ConvertTo-Json -Depth 20 | Write-Host + } + 'new' { + if (-not $name) { throw 'usage: claude-mode preset new [copy-from]' } + $path = Get-PresetPath $name + if (Test-Path -LiteralPath $path) { throw "preset '$name' already exists" } + $from = if ($Argv.Count -ge 3) { $Argv[2] } else { 'default' } + $base = Get-Preset $from + $base['description'] = "copy of '$from'" + Write-JsonFile $path $base + Write-Ok "created $path from '$from' - edit with: claude-mode preset set $name " + } + 'rm' { + if (-not $name) { throw 'usage: claude-mode preset rm ' } + $path = Get-PresetPath $name + if (-not (Test-Path -LiteralPath $path)) { throw "preset '$name' not found" } + $state = Get-State + if ([string]$state['preset'] -eq $name -and [string]$state['mode'] -ne 'anthropic') { + throw "preset '$name' is active. Switch away first (claude-mode anthropic)." + } + Remove-Item -LiteralPath $path -Force + Write-Ok "deleted preset '$name'" + } + 'set' { + if ($Argv.Count -lt 4) { throw 'usage: claude-mode preset set ' } + Set-PresetTier -Name $name -Tier $Argv[2].ToLower() -Model $Argv[3] + } + 'all' { + if ($Argv.Count -lt 3) { throw 'usage: claude-mode preset all ' } + $model = $Argv[2] + $preset = Get-Preset $name + if (-not $preset.Contains('models')) { $preset['models'] = [ordered]@{} } + foreach ($t in $script:Tiers) { $preset['models'][$t] = $model } + $preset['subagentModel'] = $model + Write-JsonFile (Get-PresetPath $name) $preset + Write-Ok "$name : all tiers + subagent -> $model" + Update-ActivePreset $name + } + default { Show-Usage } + } +} + +function Set-PresetTier { + param([string] $Name, [string] $Tier, [string] $Model) + $preset = Get-Preset $Name + + if ($Tier -eq 'subagent') { + $preset['subagentModel'] = $Model + } elseif ($script:Tiers -contains $Tier) { + if (-not $preset.Contains('models')) { $preset['models'] = [ordered]@{} } + $preset['models'][$Tier] = $Model + } else { + throw "unknown tier '$Tier' (use: opus | sonnet | haiku | fable | subagent)" + } + + Write-JsonFile (Get-PresetPath $Name) $preset + Write-Ok "$Name : $Tier -> $Model" + Update-ActivePreset $Name +} + +# Editing the live preset re-applies it, so there is no second command to run. +function Update-ActivePreset { + param([string] $Name) + $state = Get-State + if ([string]$state['mode'] -ne 'anthropic' -and [string]$state['preset'] -eq $Name) { + Write-Host ' re-applying active preset...' + Set-ClaudeMode -Mode ([string]$state['mode']) -PresetName $Name + } +} + +function Invoke-Models { + param([string] $Filter) + $state = Get-State + $mode = [string]$state['mode'] + + if ($mode -eq 'lmstudio') { + $preset = Get-Preset ([string]$state['preset']) + Write-Head "models installed in LM Studio at $($preset['baseUrl'])" + $tpl = Get-LmStudioTemplateReport + Get-LmStudioModels ([string]$preset['baseUrl']) | + Where-Object { -not $Filter -or $_.Id -like "*$Filter*" } | + Sort-Object Id | + ForEach-Object { + $short = ($_.Id -split '/')[-1].ToLower() + $t = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1 + $flag = '' + if ($t -and $t.Assertions.Count -gt 0) { $flag = 'TEMPLATE RISK' } + [pscustomobject]@{ Id = $_.Id; State = $_.State; Ctx = $_.Ctx; Note = $flag } + } | Format-Table -AutoSize + Write-Host " 'TEMPLATE RISK' = the model's chat template hard-asserts message order," + Write-Host " which can break tool-call parser generation. Prefer an unflagged model." + return + } + + if ($mode -eq 'zai') { + Write-Head 'Z.AI GLM models (from Z.AI docs - no public catalogue endpoint)' + @('glm-5.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 = ''; Detail = @('enter any model id by hand') } + $items += $choices + + $sel = Show-SearchSelect -Title "model for '$Tier'" -Status "current: $Current" -Items $items + if ($sel -lt 0) { return $null } + if ($sel -gt 0) { return $items[$sel].Id } + # fall through to manual entry + } + + # A typed prompt cannot live inside the repainting frame - the redraw would + # erase what is being typed. Close the frame first. + Stop-UiFrame + Write-Host '' + if ($err) { Write-Warn2 "could not load the model list: $err" } + Write-Host " current $Tier : $Current" -ForegroundColor DarkGray + $val = Read-Host " new model id for '$Tier' (blank = cancel)" + if ([string]::IsNullOrWhiteSpace($val)) { return $null } + return $val.Trim() +} + +# A blank preset per provider, with the endpoint/auth/context bits already right +# so only the model choices are left to make. +function New-PresetScaffold { + param([string] $Provider) + + $base = [ordered]@{ + provider = $Provider + description = 'new preset' + } + + switch ($Provider) { + 'openrouter' { + $base['baseUrl'] = 'https://openrouter.ai/api' + $base['auth'] = [ordered]@{ mode = 'vault'; keyRef = 'openrouter' } + } + 'zai' { + $base['baseUrl'] = 'https://api.z.ai/api/anthropic' + $base['auth'] = [ordered]@{ mode = 'vault'; keyRef = 'zai' } + } + 'lmstudio' { + $base['baseUrl'] = 'http://127.0.0.1:1234' + $base['auth'] = [ordered]@{ mode = 'literal'; token = 'lmstudio' } + } + } + + $base['models'] = [ordered]@{ opus = ''; sonnet = ''; haiku = ''; fable = '' } + $base['subagentModel'] = 'inherit' + $base['gatewayModelDiscovery'] = ($Provider -eq 'openrouter') + $base['contextTokens'] = if ($Provider -eq 'lmstudio') { 262144 } else { 1000000 } + if ($Provider -eq 'lmstudio') { $base['extraEnv'] = [ordered]@{ CLAUDE_CODE_ATTRIBUTION_HEADER = '0' } } + if ($Provider -eq 'zai') { + $base['extraEnv'] = [ordered]@{ + API_TIMEOUT_MS = '3000000' + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1' + } + } + return $base +} + +function New-PresetInteractive { + # 1. provider + $provs = @($script:Modes | Where-Object { $_ -ne 'anthropic' }) + $items = @() + foreach ($p in $provs) { $items += [pscustomobject]@{ Label = $p; Detail = @($script:ModeLabel[$p]) } } + $sel = Show-Select -Title 'new preset - which provider' -Items $items + if ($sel -lt 0) { return } + $provider = $provs[$sel] + + # 2. start from an existing preset of that provider, or blank + $siblings = @(Get-PresetNamesForProvider $provider) + $items = @([pscustomobject]@{ Label = ''; Detail = @("empty $provider preset - pick every model yourself") }) + foreach ($n in $siblings) { + $items += [pscustomobject]@{ Label = "copy of $n"; Detail = (Get-PresetSummaryLines (Get-Preset $n)) } + } + $sel = Show-Select -Title 'start from' -Items $items + if ($sel -lt 0) { return } + + $preset = if ($sel -eq 0) { New-PresetScaffold $provider } else { Get-Preset $siblings[$sel - 1] } + + # 3. name it + Stop-UiFrame + Write-Host '' + Write-Host " new $provider preset" -ForegroundColor Cyan + $name = Read-Host ' name (letters, digits, dash; blank = cancel)' + if ([string]::IsNullOrWhiteSpace($name)) { return } + $name = $name.Trim() + if ($name -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$') { + Write-Err2 "invalid name '$name' - use letters, digits, dash, dot, underscore" + return + } + if (Test-Path -LiteralPath (Get-PresetPath $name)) { + Write-Err2 "preset '$name' already exists" + return + } + + $preset['description'] = if ($sel -eq 0) { "custom $provider preset" } else { "copy of $($siblings[$sel - 1])" } + Write-JsonFile (Get-PresetPath $name) $preset + Write-Ok "created preset '$name' ($provider)" + + # 4. straight into editing it + Invoke-PresetEditor -Name $name +} + +function Invoke-PresetEditor { + param([string] $Name) + + if (-not $Name) { + $names = @(Get-PresetNames) + if ($names.Count -eq 0) { Write-Warn2 'no presets'; return } + + $items = @() + foreach ($n in $names) { + $p = Get-Preset $n + $items += [pscustomobject]@{ + Label = ("{0,-18} [{1}]" -f $n, $p['provider']) + Detail = (Get-PresetSummaryLines $p) + } + } + + $sel = Show-Select -Title 'edit which preset' -Items $items + if ($sel -lt 0) { return } + $Name = $names[$sel] + } + + $name = $Name + $preset = Get-Preset $name + + while ($true) { + $rows = @() + foreach ($t in $script:Tiers) { + $cur = '' + if ($preset['models'].Contains($t)) { $cur = [string]$preset['models'][$t] } + $rows += [pscustomobject]@{ Tier = $t; Current = $cur } + } + $sub = '' + if ($preset.Contains('subagentModel')) { $sub = [string]$preset['subagentModel'] } + $rows += [pscustomobject]@{ Tier = 'subagent'; Current = $sub } + + $items = @() + foreach ($r in $rows) { + $items += [pscustomobject]@{ + Label = ("{0,-9} {1}" -f $r.Tier, $r.Current) + Detail = @("change which model backs the '$($r.Tier)' tier", "current: $($r.Current)") + } + } + + $c = Show-Select -Title "$name [$($preset['provider'])]" -Status 'esc = done' -Items $items + if ($c -lt 0) { return } + + $tier = $rows[$c].Tier + $val = Read-ModelId -Preset $preset -Tier $tier -Current $rows[$c].Current + if (-not $val) { continue } + + # Set-PresetTier may re-apply the live preset, which prints a full switch + # summary. Drop the frame so that output survives instead of being wiped + # by the next redraw of the tier list. + Stop-UiFrame + Set-PresetTier -Name $name -Tier $tier -Model $val + $preset = Get-Preset $name + } +} + +# The second level: everything that is not switching mode. Loops until the user +# backs out, so running doctor then editing a preset costs one trip in. +function Invoke-MoreMenu { + while ($true) { + $state = Get-State + $current = [string]$state['mode'] + $curDesc = $current + if ($current -ne 'anthropic' -and $state['preset']) { $curDesc = "$current / $($state['preset'])" } + + $items = @( + [pscustomobject]@{ Label = 'status'; Detail = @('show the full active configuration') } + [pscustomobject]@{ Label = 'edit presets'; Detail = @('pick models per tier from the provider catalogue') } + [pscustomobject]@{ Label = 'new preset'; Detail = @('create a preset - blank or copied from an existing one') } + [pscustomobject]@{ Label = 'doctor'; Detail = @('verify auth, endpoint, model ids, context window') } + [pscustomobject]@{ Label = 'back'; Detail = @('return to the mode menu') } + [pscustomobject]@{ Label = 'quit'; Detail = @() } + ) + + $sel = Show-Select -Title 'claude-mode - more' -Status "currently: $curDesc" -Items $items + if ($sel -lt 0) { return } # esc = back + + switch ($sel) { + 0 { Stop-UiFrame; Invoke-Status } # output worth keeping + 1 { Invoke-PresetEditor } # more menus - keep the frame + 2 { New-PresetInteractive } + 3 { Stop-UiFrame; Invoke-Doctor } + 4 { return } + 5 { Stop-UiFrame; $script:UiQuit = $true; return } + } + } +} + +function Invoke-Menu { + if (-not (Test-Interactive)) { Invoke-Status; return } + $script:UiQuit = $false + + # Drawn once, above the frame anchor, so the menus repaint underneath it and + # the banner stays put instead of flickering on every keypress. + $st0 = Get-State + Show-Banner -Mode ([string]$st0['mode']) -Preset $(if ([string]$st0['mode'] -ne 'anthropic') { [string]$st0['preset'] } else { '' }) + + while ($true) { + $state = Get-State + $current = [string]$state['mode'] + $curDesc = $current + if ($current -ne 'anthropic' -and $state['preset']) { $curDesc = "$current / $($state['preset'])" } + + # The mode you are already in is not offered - nothing to switch to. + $choices = @($script:Modes | Where-Object { $_ -ne $current }) + + $items = @() + foreach ($m in $choices) { + $detail = @($script:ModeLabel[$m]) + try { + $pn = $script:ProviderDefaultPreset[$m] + if ($pn -and (Test-Path -LiteralPath (Get-PresetPath $pn))) { + $detail += (Get-PresetSummaryLines (Get-Preset $pn))[1] + } + } catch { } + $items += [pscustomobject]@{ + Label = ("switch to " + $m) + Detail = $detail + Accent = $script:ModeColor[$m] + } + } + # Everything that is not "switch mode" lives one level down, so the three + # things this tool exists to do are the whole first screen. + $items += [pscustomobject]@{ Label = 'more ...'; Detail = @('status, presets, doctor') } + + $sel = Show-Select -Title 'claude-mode' -Status "currently: $curDesc" -Items $items + if ($sel -lt 0) { Stop-UiFrame; return } + + $nChoices = $choices.Count + if ($sel -eq $nChoices) { + Invoke-MoreMenu + if ($script:UiQuit) { return } + continue + } + + $mode = $choices[$sel] + if ($mode -eq 'anthropic') { Stop-UiFrame; Set-ClaudeMode -Mode 'anthropic' -PresetName ''; return } + + $preset = Invoke-PresetPicker -Provider $mode + if (-not $preset) { continue } + Stop-UiFrame + Set-ClaudeMode -Mode $mode -PresetName $preset + return + } +} + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + +Initialize-Root + +try { + $cmd = $Command.ToLower() + if ($cmd -eq 'z.ai' -or $cmd -eq 'z-ai') { $cmd = 'zai' } + + switch ($cmd) { + '' { Invoke-Menu } + 'menu' { Invoke-Menu } + 'status' { Invoke-Status } + 'anthropic' { Set-ClaudeMode -Mode 'anthropic' -PresetName '' } + 'openrouter' { + $req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null } + Set-ClaudeMode -Mode 'openrouter' -PresetName (Resolve-PresetForProvider 'openrouter' $req) + } + 'zai' { + $req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null } + Set-ClaudeMode -Mode 'zai' -PresetName (Resolve-PresetForProvider 'zai' $req) + } + 'lmstudio' { + $req = if ($Rest -and $Rest.Count -ge 1) { $Rest[0] } else { $null } + Set-ClaudeMode -Mode 'lmstudio' -PresetName (Resolve-PresetForProvider 'lmstudio' $req) + } + 'presets' { Invoke-Presets } + 'preset' { Invoke-PresetCmd -Argv $Rest } + 'set-key' { + # `set-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 +} diff --git a/linux/claude-mode b/linux/claude-mode index ab56cad..8b1478f 100755 --- a/linux/claude-mode +++ b/linux/claude-mode @@ -269,7 +269,7 @@ claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio claude-mode preset auth none|key [ref] whether that server needs a key claude-mode preset rm - claude-mode set-key [ref] [--terminal] store an API key (hidden prompt) + claude-mode set-key [ref] [key] store an API key (hidden prompt; key for scripts) claude-mode models [filter] models available from the active provider claude-mode doctor verify auth, endpoint, model ids, env claude-mode repair strip [1m] tags from cached model ids @@ -1242,15 +1242,20 @@ cmd_models() { } cmd_set_key() { - local ref="${1:-openrouter}" secret + local ref="${1:-openrouter}" inline="${2:-}" secret unit init_root printf ' storage backend: %s\n' "$(cm_vault_backend_label)" if [ "$(cm_vault_backend)" = "file" ]; then warn 'no keyring available - the key will be stored in a 0600 file, NOT encrypted.' warn 'install libsecret-tools (secret-tool) or pass for encrypted storage.' fi - printf ' paste the API key for ref '\''%s'\'' (input hidden): ' "$ref" - IFS= read -rs secret; printf '\n' + if [ -n "$inline" ]; then + secret="$inline" + warn 'the key was given on the command line, so it is in this shell history - the hidden prompt leaves no trace' + else + printf ' paste the API key for ref '\''%s'\'' (input hidden): ' "$ref" + IFS= read -rs secret; printf '\n' + fi [ -n "$secret" ] || { err 'empty key, aborted'; return 1; } # A hidden prompt will happily swallow a mis-paste. Guard the two shapes that @@ -1260,7 +1265,10 @@ cmd_set_key() { *[[:space:]]*) err 'that value contains whitespace, so it is not an API key (a pasted command line?). Nothing was stored.'; return 1 ;; claude-mode*) err 'that value is a claude-mode command, not an API key. Nothing was stored.'; return 1 ;; esac - [ "${#secret}" -lt 16 ] && warn "that key is only ${#secret} characters - unusually short. Storing anyway." + if [ "${#secret}" -lt 16 ]; then + unit=characters; [ "${#secret}" -eq 1 ] && unit=character + warn "that key is only ${#secret} $unit - unusually short. Storing anyway." + fi if [ "$ref" = "openrouter" ] && [ "${secret#sk-or-}" = "$secret" ]; then warn "key does not start with 'sk-or-' - storing anyway" fi @@ -2432,13 +2440,30 @@ case "$cmd" in # --terminal is for callers with no stdin to offer: the bar # widget cannot host a hidden password prompt, so it asks the # terminal to host one instead. - _ref=openrouter; _term=0 + # `set-key [ref] [key]`. The key is normally typed at the + # hidden prompt; a second word is taken as the key itself, for + # scripting. Anything key-shaped in the *ref* position is also + # taken as the key for the default ref - it used to become the + # ref name instead, which stored the wrong thing under a ref + # nobody would ever look up, and only failed later and quietly. + _ref=''; _term=0; _secret='' for _a in "$@"; do case "$_a" in --terminal) _term=1 ;; - *) _ref="$_a" ;; + *) + if [ -z "$_ref" ]; then + case "$_a" in + sk-*|sk_*) _secret="$_a" ;; + *) if [ "${#_a}" -gt 24 ]; then _secret="$_a"; else _ref="$_a"; fi ;; + esac + elif [ -z "$_secret" ]; then + _secret="$_a" + else + err "unexpected extra argument '$_a'"; exit 1 + fi ;; esac done + [ -n "$_ref" ] || _ref=openrouter if [ "$_term" -eq 1 ]; then _t="$(cm_terminal_cmd)" setsid nohup "$_t" -e bash -lc \ @@ -2446,7 +2471,7 @@ case "$cmd" in >/dev/null 2>&1 & ok "opened $_t to store the '$_ref' key" else - cmd_set_key "$_ref" + cmd_set_key "$_ref" "$_secret" fi ;; models) cmd_models "${1:-}" ;; doctor) cmd_doctor ;; diff --git a/presets/default.json b/presets/default.json index be85d27..e5e289d 100644 --- a/presets/default.json +++ b/presets/default.json @@ -1,14 +1,14 @@ { "provider": "openrouter", - "description": "Daily driver. Flash-class models on the hot tiers, GLM on fable.", + "description": "Daily driver. DeepSeek 4.1 Flash on opus, GLM Flash on sonnet, GLM on fable.", "baseUrl": "https://openrouter.ai/api", "auth": { "mode": "vault", "keyRef": "openrouter" }, "models": { - "opus": "z-ai/glm-5.3-flash", - "sonnet": "deepseek/deepseek-v4-flash-0731", + "opus": "deepseek/deepseek-v4.1-flash", + "sonnet": "z-ai/glm-5.3-flash", "haiku": "openrouter/free", "fable": "z-ai/glm-5.3" },