# lib/switch.ps1 - the write into settings.json, and the apiKeyHelper command line. # # Part of claude-mode.ps1, which dot-sources it into its own script scope after # the settings at its top. Not meant to run on its own. ASCII only: Windows # PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be # lib\, so paths beside the main script go through $script:Here. # 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 } # --------------------------------------------------------------------------- # 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( [string] $Mode, [string] $PresetName ) Initialize-Root if (-not (Test-Path -LiteralPath $script:SettingsDir)) { New-Item -ItemType Directory -Path $script:SettingsDir -Force | Out-Null } # Any provider in providers.json, rather than a fixed ValidateSet. if ($Mode -ne 'anthropic' -and -not (Get-Provider $Mode)) { throw "unknown mode '$Mode'" } $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" } # A custom endpoint ships with no address, since there is no sensible # one to guess. Switching to it would point every session at nothing. if ([string]::IsNullOrWhiteSpace([string]$preset['baseUrl'])) { throw "preset '$PresetName' has no server address - set baseUrl in $(Get-PresetPath $PresetName)" } # Every tier empty - a fresh blank preset - would switch cleanly and # leave Claude Code asking for its own default Anthropic models: billed # at full price through OpenRouter, refused by the other providers. $anyTier = $false foreach ($tier in $script:Tiers) { if (-not [string]::IsNullOrWhiteSpace([string]$models[$tier])) { $anyTier = $true } } if (-not $anyTier) { throw "preset '$PresetName' has no models set - set a tier first: claude-mode preset set $PresetName " } # 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 { # The provider's own placeholder (lmstudio, ollama), not LM Studio's. $pv = Get-Provider ([string]$preset['provider']) $tok = $(if ($pv -and $pv.preset.auth.token) { [string]$pv.preset.auth.token } else { '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 }