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>
225 lines
11 KiB
PowerShell
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
|
|
}
|