Files
claude-mode/lib/switch.ps1
T
smoidoandClaude Opus 5 4091746d4e Split the Windows script into modules; drop the Arkylx Index pieces
claude-mode.ps1 was 2,407 lines. It is now 153: the help block, parameters,
paths, the managed-key list, a loader, and the dispatch. The rest moved,
verbatim, into eleven files under lib/ - providers, output, files, core,
vault, switch, guards, health, catalogue, commands, menu - dot-sourced into
the script's scope in their original order, with the same check as the bash
split that every original line landed in exactly one file. Inside a module
$PSScriptRoot is lib\, so the one path beside the main script
(providers.json) now goes through $script:Here.

install.ps1 ships lib\, clearing old modules first. The Windows suite
parses every module and install.ps1 (36 checks, all green on Windows
PowerShell 5.1); install.ps1 is parsed but never run, since it edits the
real profile and User PATH.

linux/bootstrap.sh and scripts/build-package.ps1 existed only to build and
serve packages for the Arkylx Index. Both installers fetch the repository's
own archive, so a push to master is the release; the two scripts, the dist/
ignore and their mentions in the docs are gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 02:06:45 +03:00

225 lines
11 KiB
PowerShell

# 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 <tier> <model-id>"
}
# 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
}