diff --git a/.gitattributes b/.gitattributes index 01454e7..dd0be8e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,6 @@ -# LF in every working tree, Windows checkouts included. A CRLF shell script -# fails its #! line, and the packages are built from a Windows checkout -# (scripts/build-package.ps1 normalises too, as a backstop). +# LF in every working tree, Windows checkouts included: a CRLF shell script +# fails its #! line, and a Windows checkout is exactly where the repository's +# shell scripts would otherwise pick CRLF up. * text=auto eol=lf *.png binary diff --git a/.gitignore b/.gitignore index e3abeac..680f2de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -dist/ *.bak *.bak.* __pycache__/ diff --git a/claude-mode.ps1 b/claude-mode.ps1 index 261c158..bc3d626 100644 --- a/claude-mode.ps1 +++ b/claude-mode.ps1 @@ -1,7 +1,8 @@ <# .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. + claude-mode - switch Claude Code system-wide between native Anthropic auth and + the gateway providers in providers.json (OpenRouter, Z.AI, LM Studio, Ollama, + custom endpoints), with named model presets. .DESCRIPTION State lives in %USERPROFILE%\.claude-mode. Switching rewrites the managed keys @@ -50,18 +51,6 @@ $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. @@ -94,2264 +83,21 @@ $script:Version = '0.0.0' try { $__v = Join-Path $PSScriptRoot 'VERSION'; if (Test-Path $__v) { $script:Version = (Get-Content $__v -Raw).Trim() } } catch { } $script:NodeExe = $null # resolved lazily by Format-JsonPretty -# --------------------------------------------------------------------------- -# Providers -# -# Every gateway provider is an entry in providers.json, next to this script and -# shared with the POSIX build: endpoint, auth, how its model list is fetched, -# which doctor checks apply. This script only knows the *kinds* of behaviour -# (Get-ProviderCatalogue, Test-PresetCatalogue) and picks one by name from the -# entry, so a provider that reuses them needs no change here. anthropic is -# built in: it is the native login, not a gateway. -# --------------------------------------------------------------------------- - -$script:ProvidersPath = Join-Path $PSScriptRoot 'providers.json' -$script:Providers = @() -try { - $__pj = Get-Content -LiteralPath $script:ProvidersPath -Raw -Encoding UTF8 | ConvertFrom-Json - $script:Providers = @($__pj.providers | Where-Object { $_.id }) -} catch { } -if ($script:Providers.Count -eq 0) { - Write-Host " FAIL providers.json missing or unreadable at $($script:ProvidersPath) - re-run install.ps1" -ForegroundColor Red - exit 1 -} - -$script:Modes = @('anthropic') + @($script:Providers | ForEach-Object { [string]$_.id }) -$script:ModeLabel = @{ 'anthropic' = 'Anthropic - your subscription login, no gateway' } - -# `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 = @{} -foreach ($__p in $script:Providers) { - $script:ModeLabel[[string]$__p.id] = [string]$__p.label - $script:ProviderDefaultPreset[[string]$__p.id] = $(if ($__p.defaultPreset) { [string]$__p.defaultPreset } else { [string]$__p.id }) -} - -function Get-Provider { - param([string] $Id) - return ($script:Providers | Where-Object { $_.id -eq $Id } | Select-Object -First 1) -} - -# An id or an alias (z.ai, z-ai) to the provider id; $null if neither. -function Resolve-ProviderId { - param([string] $Word) - $w = ([string]$Word).ToLower() - foreach ($p in $script:Providers) { - if ([string]$p.id -eq $w -or (@($p.aliases) -contains $w)) { return [string]$p.id } - } - return $null -} - -function Test-ProviderDoctor { - param([string] $Id, [string] $Check) - $p = Get-Provider $Id - return [bool]($p -and (@($p.doctor) -contains $Check)) -} - -# --------------------------------------------------------------------------- -# 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' } -$__colors = @{ cyan = 'Cyan'; green = 'Green'; yellow = 'Yellow'; magenta = 'Magenta' - white = 'White'; gray = 'Gray'; dkcyan = 'DarkCyan'; red = 'Red' } -foreach ($__p in $script:Providers) { - $__c = $__colors[[string]$__p.color] - $script:ModeColor[[string]$__p.id] = $(if ($__c) { $__c } else { 'Gray' }) -} - -# 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 and gateway providers - - claude-mode interactive menu - claude-mode status active mode, preset, model map - - claude-mode anthropic native login/subscription (clears all gateway config) -'@ | Write-Host - # One line per provider in providers.json, so a new one documents itself. - foreach ($p in $script:Providers) { - $desc = ([string]$p.label) -replace '^[^-]*-\s*', '' - Write-Host (" claude-mode {0,-26} {1} (default preset: {2})" -f "$($p.id) [preset]", $desc, $script:ProviderDefaultPreset[[string]$p.id]) - } - @' - - 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 - # The vault is written from a SecureString (DPAPI), so the inline form - # has to produce one too. - $secure = ConvertTo-SecureString -String $Key -AsPlainText -Force - } 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( - [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 -} - -# --------------------------------------------------------------------------- -# 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) - # An OpenRouter feature, flagged per provider rather than by name. - $pv = Get-Provider $Mode - if (-not ($pv -and $pv.guardrail) -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 -} - -# The credential a preset would send: its vault key, or its inline token. -function Get-PresetToken { - param($Preset) - $auth = Get-PresetAuth $Preset - if ([string]$auth['mode'] -eq 'vault') { - $ref = $(if ($auth.Contains('keyRef') -and $auth['keyRef']) { [string]$auth['keyRef'] } else { 'openrouter' }) - return (Get-VaultKey $ref) - } - return [string]$auth['token'] -} - -# One catalogue fetch for any provider, by its catalogue.kind in providers.json. -# Every row comes back in one shape - Id, Ctx, State, InM, OutM, Note - so the -# callers (models, the picker, doctor) never branch on the provider itself. -function Get-ProviderCatalogue { - param($Preset) - $prov = Get-Provider ([string]$Preset['provider']) - if (-not $prov) { return @() } - $base = ([string]$Preset['baseUrl']).TrimEnd('/') - switch ([string]$prov.catalogue.kind) { - 'openrouter' { - return @((Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/models' -TimeoutSec 30).data | ForEach-Object { - [pscustomobject]@{ - Id = $_.id; Ctx = $_.context_length; State = $null; Note = '' - InM = $(if ($_.pricing -and $_.pricing.prompt) { [math]::Round([double]$_.pricing.prompt * 1e6, 3) } else { $null }) - OutM = $(if ($_.pricing -and $_.pricing.completion) { [math]::Round([double]$_.pricing.completion * 1e6, 3) } else { $null }) - } - }) - } - 'lmstudio' { - return @(Get-LmStudioModels $base | ForEach-Object { - [pscustomobject]@{ Id = $_.Id; Ctx = $_.Ctx; State = $_.State; InM = $null; OutM = $null; Note = '' } - }) - } - 'ollama' { - $h = @{} - $tok = Get-PresetToken $Preset - if ($tok) { $h['Authorization'] = "Bearer $tok" } - return @((Invoke-RestMethod -Uri "$base/api/tags" -Headers $h -TimeoutSec 10).models | ForEach-Object { - $d = $_.details - $note = $(if ($d) { (@($d.parameter_size, $d.quantization_level) | Where-Object { $_ }) -join ' ' } else { '' }) - [pscustomobject]@{ Id = $_.name; Ctx = $null; State = $null; InM = $null; OutM = $null; Note = $note } - }) - } - 'openai' { - # Both header styles: a proxy in front of Anthropic wants x-api-key, - # one in front of anything else wants Bearer. - $h = @{ 'anthropic-version' = '2023-06-01' } - $tok = Get-PresetToken $Preset - if ($tok) { $h['Authorization'] = "Bearer $tok"; $h['x-api-key'] = $tok } - return @((Invoke-RestMethod -Uri "$base/v1/models" -Headers $h -TimeoutSec 15).data | ForEach-Object { - [pscustomobject]@{ Id = $_.id; Ctx = $null; State = $null; InM = $null; OutM = $null; Note = '' } - }) - } - 'static' { - return @($prov.catalogue.static | ForEach-Object { - [pscustomobject]@{ Id = $_.id; Ctx = $null; State = $null; InM = $null; OutM = $null; Note = [string]$_.note } - }) - } - } - return @() -} - -# The preset's model ids against what the provider actually offers. A server -# provider that does not answer is a failure; a hosted catalogue that cannot be -# fetched is only a warning, since the endpoint may be fine regardless. -function Test-PresetCatalogue { - param([string] $Mode, $Preset) - $prov = Get-Provider $Mode - $kind = [string]$prov.catalogue.kind - $title = [string]$prov.title - $base = ([string]$Preset['baseUrl']).TrimEnd('/') - $server = [bool]$prov.server.editable - - $cat = @() - try { $cat = @(Get-ProviderCatalogue $Preset) } catch { $cat = @() } - if ($cat.Count -eq 0) { - if ($server -and [string]$prov.server.probe -eq 'lenient') { - Write-Warn2 "$title at $base lists no models - fine for a proxy, but the ids below cannot be checked" - } elseif ($server) { - $start = [string]$prov.server.start - Write-Err2 ("$title not reachable at $base" + $(if ($start) { " - $start" } else { '' })) - } else { - Write-Warn2 "could not fetch the $title model list" - } - return - } - if ($server) { Write-Ok "$title reachable at $base ($($cat.Count) models)" } - - $declared = $(if ($Preset.Contains('contextTokens') -and $Preset['contextTokens']) { [int]$Preset['contextTokens'] } else { 0 }) - $tpl = $(if (Test-ProviderDoctor $Mode 'lmstudio-templates') { Get-LmStudioTemplateReport } else { @() }) - $warned = @{} # a one-for-all preset names one model four times; say things about it once - - foreach ($t in $script:Tiers) { - if (-not ($Preset['models'].Contains($t) -and $Preset['models'][$t])) { continue } - $id = [string]$Preset['models'][$t] - $m = $cat | Where-Object { $_.Id -eq $id } | Select-Object -First 1 - # Ollama lists every model with its tag; a bare name means :latest. - if (-not $m -and $kind -eq 'ollama' -and $id -notlike '*:*') { - $m = $cat | Where-Object { $_.Id -eq "${id}:latest" } | Select-Object -First 1 - } - if (-not $m) { - # A fixed list is documentation, not the provider's word. - if ($kind -eq 'static') { Write-Ok ("{0,-6} {1} (not in the documented list)" -f $t, $id) } - else { Write-Err2 "$t model NOT available from ${title}: $id" } - continue - } - switch ($kind) { - 'lmstudio' { - if ($m.State -eq 'loaded') { Write-Ok ("{0,-6} {1} [loaded, ctx {2}]" -f $t, $id, $m.Ctx) } - else { Write-Ok ("{0,-6} {1} [{2} - LM Studio will JIT-load it on first request, ctx {3}]" -f $t, $id, $m.State, $m.Ctx) } - if (-not $warned.ContainsKey($id)) { - $warned[$id] = $true - if ($m.Ctx -and [int]$m.Ctx -lt 25000) { Write-Warn2 "$id context is $($m.Ctx); LM Studio recommends >25k for Claude Code." } - if ($declared -and $m.Ctx -and [int]$m.Ctx -lt $declared) { - Write-Warn2 ("declared contextTokens {0:N0} exceeds {1}'s {2:N0} - lower it." -f $declared, $id, [int]$m.Ctx) - } - $short = ($id -split '/')[-1].ToLower() - $risk = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1 - if ($risk -and $risk.Assertions.Count -gt 0) { - Write-Warn2 "$id chat template hard-asserts message order ($($risk.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'." - } - } - } - 'openrouter' { Write-Ok ("{0,-6} {1} [ctx {2:N0}]" -f $t, $id, [int]$m.Ctx) } - 'ollama' { Write-Ok ("{0,-6} {1} [{2}]" -f $t, $id, $m.Note) } - default { Write-Ok ("{0,-6} {1}" -f $t, $id) } - } - } - - if ($kind -eq 'openrouter') { Test-ContextWindow -Preset $Preset -Catalogue $cat } - elseif ($declared) { Write-Ok ("declared context window: {0:N0} tokens" -f $declared) } - else { Write-Warn2 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early.' } -} - -# Ollama sets the context window on the server, not per request from Claude -# Code: 4096 tokens unless `ollama serve` runs with OLLAMA_CONTEXT_LENGTH, and -# anything past it is cut off without an error. contextTokens only tells Claude -# Code what to expect, so say what the server is actually running where that -# can be seen, and what to set where it cannot. -function Test-OllamaContext { - param($Preset) - if (-not ($Preset.Contains('contextTokens') -and $Preset['contextTokens'])) { return } - $declared = [int]$Preset['contextTokens'] - $base = ([string]$Preset['baseUrl']).TrimEnd('/') - $ids = @() - foreach ($t in $script:Tiers) { - $v = [string]$Preset['models'][$t] - if ($v -and $ids -notcontains $v) { $ids += $v } - } - $ps = $null - try { $ps = Invoke-RestMethod -Uri "$base/api/ps" -TimeoutSec 5 } catch { } - - $seen = $false; $short = $false - foreach ($id in $ids) { - try { - $show = Invoke-RestMethod -Uri "$base/api/show" -Method Post -ContentType 'application/json' ` - -Body (@{ model = $id } | ConvertTo-Json) -TimeoutSec 8 - $max = $null - if ($show.model_info) { - foreach ($prop in $show.model_info.PSObject.Properties) { - if ($prop.Name -like '*.context_length') { $max = [int]$prop.Value; break } - } - } - if ($max -and $max -lt $declared) { Write-Warn2 "$id supports at most $max tokens, below the declared $declared - lower contextTokens" } - } catch { } - - $want = @($id) - if ($id -notlike '*:*') { $want += "${id}:latest" } - $live = $null - if ($ps -and $ps.models) { - foreach ($m in $ps.models) { - if ((($want -contains [string]$m.name) -or ($want -contains [string]$m.model)) -and $m.context_length) { - $live = [int]$m.context_length; break - } - } - } - if ($null -eq $live) { continue } - $seen = $true - if ($live -lt $declared) { - $short = $true - Write-Warn2 "$id is loaded with a $live-token context, below the declared $declared - requests past it are cut off" - } else { - Write-Ok "$id is loaded with a $live-token context" - } - } - if (-not $seen) { - Write-Warn2 "none of these models is loaded, so the server's context window cannot be checked" - Write-Host " Ollama defaults to 4096 tokens; run it with OLLAMA_CONTEXT_LENGTH=$declared or requests past that are cut off silently" - } elseif ($short) { - Write-Host " restart it with OLLAMA_CONTEXT_LENGTH=$declared (or lower contextTokens to match)" - } -} - -# 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 -or -not $m.Ctx) { continue } - if ([int]$m.Ctx -lt $declared) { - Write-Warn2 ("{0} model {1} only has {2:N0} ctx, below the declared {3:N0}." -f $t, $id, [int]$m.Ctx, $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'] - - # On anthropic, OpenRouter's catalogue is the one worth browsing, as before. - $preset = $(if ($mode -ne 'anthropic') { Get-Preset ([string]$state['preset']) } else { [ordered]@{ provider = 'openrouter'; baseUrl = '' } }) - $prov = Get-Provider ([string]$preset['provider']) - $kind = [string]$prov.catalogue.kind - switch ($kind) { - 'static' { Write-Head "$($prov.title) models (from its docs - no public catalogue endpoint)" } - 'openrouter' { Write-Head 'fetching https://openrouter.ai/api/v1/models ...' } - default { Write-Head "models on the $($prov.title) server at $($preset['baseUrl'])" } - } - - $tpl = $(if ($kind -eq 'lmstudio') { Get-LmStudioTemplateReport } else { @() }) - $rows = foreach ($m in (Get-ProviderCatalogue $preset | Sort-Object Id)) { - if ($Filter -and $m.Id -notlike "*$Filter*") { continue } - if ($kind -eq 'openrouter') { - [pscustomobject]@{ Id = $m.Id; Ctx = $m.Ctx; 'In/M$' = $m.InM; 'Out/M$' = $m.OutM } - } elseif ($kind -eq 'lmstudio') { - $short = ($m.Id -split '/')[-1].ToLower() - $t = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1 - $flag = $(if ($t -and $t.Assertions.Count -gt 0) { 'TEMPLATE RISK' } else { '' }) - [pscustomobject]@{ Id = $m.Id; State = $m.State; Ctx = $m.Ctx; Note = $flag } - } else { - [pscustomobject]@{ Id = $m.Id; Note = $m.Note } - } - } - $rows | Format-Table -AutoSize - - if ($kind -eq 'lmstudio') { - 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." - } - if ($kind -eq 'static' -and $prov.catalogue.docs) { Write-Host " Full list: $($prov.catalogue.docs)" } -} - -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 (Test-ProviderDoctor $mode 'openrouter-key')) { - 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 (Test-ProviderDoctor $mode 'message-check')) { - # 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 "$((Get-Provider $mode).title) endpoint accepted the key ($base/v1/messages)" - } catch { - $detail = '' - if ($_.ErrorDetails) { $detail = ($_.ErrorDetails.Message -replace '\s+', ' ') } - Write-Err2 "$((Get-Provider $mode).title) request failed: $($_.Exception.Message) $detail" - } - } - } else { - Write-Ok "inline token '$($auth['token'])' (no secret in settings.json)" - } - - # Which checks run is the provider's call, by name, in providers.json. - if (Test-ProviderDoctor $mode 'catalogue-models') { Test-PresetCatalogue -Mode $mode -Preset $preset } - if (Test-ProviderDoctor $mode 'ollama-context') { Test-OllamaContext -Preset $preset } - } - - 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 = @() - $kind = [string](Get-Provider $provider).catalogue.kind - $tpl = $(if ($kind -eq 'lmstudio') { Get-LmStudioTemplateReport } else { @() }) - - foreach ($m in (Get-ProviderCatalogue $Preset | Sort-Object Id)) { - $flag = '' - if ($kind -eq 'openrouter') { - $det = @(("context {0:N0} `$$($m.InM) in / `$$($m.OutM) out per 1M tokens" -f [int]$m.Ctx)) - } elseif ($kind -eq 'lmstudio') { - $det = @("state: $($m.State) max context: $($m.Ctx)") - $short = ($m.Id -split '/')[-1].ToLower() - $risk = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1 - if ($risk -and $risk.Assertions.Count -gt 0) { - $flag = ' [TEMPLATE RISK]' - $det += 'chat template asserts message order - can break tool calls' - } - } else { - $det = @([string]$m.Note) - } - $out += [pscustomobject]@{ Id = $m.Id; Key = $m.Id; Label = ($m.Id + $flag); Detail = $det } - } - - $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) - - # Built from the provider's own template in providers.json; the POSIX - # build's `cm-json.py scaffold` produces the same thing from the same file. - $p = Get-Provider $Provider - if (-not $p) { throw "unknown provider '$Provider'" } - $tpl = $p.preset - - $base = [ordered]@{ - provider = $Provider - description = 'new preset' - } - $base['baseUrl'] = [string]$tpl.baseUrl - $auth = [ordered]@{} - if ($tpl.auth) { foreach ($prop in $tpl.auth.PSObject.Properties) { $auth[$prop.Name] = $prop.Value } } - else { $auth['mode'] = 'vault'; $auth['keyRef'] = $Provider } - $base['auth'] = $auth - - $base['models'] = [ordered]@{ opus = ''; sonnet = ''; haiku = ''; fable = '' } - $base['subagentModel'] = 'inherit' - $base['gatewayModelDiscovery'] = [bool]$tpl.gatewayModelDiscovery - $base['contextTokens'] = $(if ($tpl.contextTokens) { [int]$tpl.contextTokens } else { 200000 }) - if ($tpl.extraEnv) { - $extra = [ordered]@{} - foreach ($prop in $tpl.extraEnv.PSObject.Properties) { $extra[$prop.Name] = [string]$prop.Value } - $base['extraEnv'] = $extra - } - 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 - } +# Where this script lives. The modules below are dot-sourced, and inside them +# $PSScriptRoot is lib\ - so anything beside this script (providers.json) +# is reached through $script:Here instead. +$script:Here = $PSScriptRoot + +# The rest of the script, one file per concern, loaded in the order it was +# written: a few modules set script-scope tables as they load (the providers, +# mode colours, UI state), and every function exists before the dispatch below. +foreach ($__m in @('providers', 'output', 'files', 'core', 'vault', 'switch', 'guards', 'health', 'catalogue', 'commands', 'menu')) { + $__f = Join-Path $PSScriptRoot "lib\$__m.ps1" + if (-not (Test-Path -LiteralPath $__f)) { + Write-Host " FAIL $__f is missing - re-run install.ps1" -ForegroundColor Red + exit 1 + } + . $__f } # --------------------------------------------------------------------------- diff --git a/docs/architecture.md b/docs/architecture.md index 2f9b7a3..757b192 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,7 +46,7 @@ Under `~/.claude-mode/`: | file | written by | read by | |---|---|---| -| `bin/claude-mode`, `bin/lib/*.sh`, `bin/cm-json.py`, `bin/cm-vault.sh`, `bin/claude-key-helper.sh` | the installer | — (Windows: `claude-mode.ps1` at the top, `bin/claude-key-helper.ps1` + `.cmd`) | +| `bin/claude-mode`, `bin/lib/*.sh`, `bin/cm-json.py`, `bin/cm-vault.sh`, `bin/claude-key-helper.sh` | the installer | — (Windows: `claude-mode.ps1` and `lib\*.ps1` at the top, `bin\claude-key-helper.ps1` + `.cmd`) | | `providers.json` | the installer, every time | the CLI, the key helper's engine, the widget (via `health.json`) | | `presets/*.json` | the installer seeds them; `preset`, `setup`, the panel edit them | everything | | `state.json` | a switch; `preset rename` of the active preset | the CLI, the key helper, the widget | @@ -66,14 +66,15 @@ anything else), `~/.claude/projects/*/*.jsonl` (Claude Code's transcripts, which ## The repository ``` -claude-mode.ps1, install.ps1 the Windows build and its installer +claude-mode.ps1, lib/*.ps1 the Windows build: settings + dispatch, and its modules +install.ps1, profile-snippet.ps1 its installer, and the block it adds to the PowerShell profile bin/ the Windows key helper linux/ the POSIX port: claude-mode (settings + dispatch), - lib/*.sh (the rest, one file per concern), cm-json.py, - cm-vault.sh, claude-key-helper.sh, install.sh, bootstrap.sh + lib/*.sh (its modules), cm-json.py, cm-vault.sh, + claude-key-helper.sh, install.sh omarchy/ the bar widget and its installer providers.json, presets/ shared by both builds, byte for byte -scripts/ test.sh, bump-version.sh, build-package.ps1 +scripts/ test.sh, bump-version.sh tests/ static, python, cli and windows suites docs/ this ``` @@ -100,6 +101,27 @@ code rather than the installed version's. | `setup.sh` | `claude-mode setup` | | `repair.sh` | `claude-mode repair-session` and dismissals | +### The Windows script's modules + +`claude-mode.ps1` holds the help block, the parameters, the paths and flags and +the managed-key list, then dot-sources these from `lib\` beside it, in order, and +dispatches. Inside a dot-sourced file `$PSScriptRoot` is `lib\`, so paths beside +the main script go through `$script:Here`. + +| `lib/` | what is in it | +|---|---| +| `providers.ps1` | reading `providers.json`; `Get-Provider`, `Resolve-ProviderId`, `Test-ProviderDoctor` | +| `output.ps1` | `Write-Ok`/`Write-Warn2`/`Write-Err2`, mode colours, the banner, `Show-Usage` | +| `files.ps1` | JSON read/write (5.1 has no `-AsHashtable`), `Protect-FileAcl` | +| `core.ps1` | `state.json`, presets, `Resolve-PresetForProvider` | +| `vault.ps1` | the DPAPI vault, `Get-PresetAuth` | +| `switch.ps1` | `Set-ClaudeMode`, settings backups, the `apiKeyHelper` command line | +| `guards.ps1` | the cost guard, the OpenRouter guardrail check, stale cached model ids, `repair` | +| `health.ps1` | `health.json`; persistent environment variables that would override a switch | +| `catalogue.ps1` | `Get-ProviderCatalogue`, LM Studio's model list and template check, the doctor model and context checks | +| `commands.ps1` | `status`, `presets`, `preset`, `models`, `doctor` | +| `menu.ps1` | the interactive menu, its pickers, interactive preset editing, `New-PresetScaffold` | + ## Contracts between the pieces These are the interfaces one piece relies on another to keep. Each is pinned by a @@ -142,14 +164,14 @@ test. |---|---|---| | a provider's endpoint, auth template, setup, checks or look | `providers.json` | the same file | | a shipped preset | `presets/*.json` | the same files | -| what a switch writes to `settings.json` | `cm-json.py` `cmd_apply`, `BASE_MANAGED` | `Set-ClaudeMode`, `$script:BaseManagedEnvKeys` | +| what a switch writes to `settings.json` | `cm-json.py` `cmd_apply`, `BASE_MANAGED` | `lib/switch.ps1` `Set-ClaudeMode`; `$script:BaseManagedEnvKeys` in `claude-mode.ps1` | | a command's arguments | the dispatch at the end of `linux/claude-mode` | the dispatch at the end of `claude-mode.ps1` | -| what refuses a switch | `lib/preflight.sh` `cm_preflight` | `Set-ClaudeMode` guards | -| how a model list is fetched | `lib/catalogue.sh` `provider_catalogue` + a parser in `cm-json.py` | `Get-ProviderCatalogue` | -| a doctor check | `lib/doctor.sh` | `Invoke-Doctor`, `Test-PresetCatalogue`, `Test-OllamaContext` | -| setup | `lib/setup.sh` | — | -| session detection | `lib/sessions.sh` | — | -| session repair | `lib/repair.sh`; `cm-json.py` `cmd_scan_sessions`, `cmd_repair_session` | — | -| key storage | `cm-vault.sh` | the DPAPI functions in `claude-mode.ps1` | +| what refuses a switch | `linux/lib/preflight.sh` `cm_preflight` | the guards in `Set-ClaudeMode` | +| how a model list is fetched | `linux/lib/catalogue.sh` `provider_catalogue` + a parser in `cm-json.py` | `lib/catalogue.ps1` `Get-ProviderCatalogue` | +| a doctor check | `linux/lib/doctor.sh` | `lib/commands.ps1` `Invoke-Doctor`, `lib/catalogue.ps1` | +| setup | `linux/lib/setup.sh` | — | +| session detection | `linux/lib/sessions.sh` | — | +| session repair | `linux/lib/repair.sh`; `cm-json.py` `cmd_scan_sessions`, `cmd_repair_session` | — | +| key storage | `linux/cm-vault.sh` | `lib/vault.ps1` | | the bar icon and state | `omarchy/smoido.claude-mode/BarWidget.qml` | — | | the panel | `omarchy/smoido.claude-mode/Panel.qml` | — | diff --git a/docs/development.md b/docs/development.md index 2013316..400de4c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -65,7 +65,9 @@ whitespace to `read`, so empty fields merge and later columns shift. Use `cut` **Windows PowerShell 5.1.** No `?:` or `??`; `"$id:latest"` parses as a drive-qualified variable, so write `"${id}:latest"`. The `.ps1` files are ASCII -only, because 5.1 reads a file without a BOM in the ANSI codepage. +only, because 5.1 reads a file without a BOM in the ANSI codepage. In the +`lib/*.ps1` modules `$PSScriptRoot` is `lib\`: reach anything beside +`claude-mode.ps1` through `$script:Here`. **Files are LF everywhere**, `.ps1` and `.cmd` included (`.gitattributes`, `.editorconfig`). @@ -112,8 +114,8 @@ unit test that pins it. 3. Commit. 4. Install on your own machine: `bash linux/install.sh`, `bash omarchy/install.sh`, `omarchy restart shell`, and look at the bar. -5. Push. The Windows package is built on a Windows machine with - `scripts/build-package.ps1`, which refuses to package a script that does not - parse. +5. Push. Both installers fetch the repository's own archive (`install.ps1` piped + through `iex`, `linux/install.sh` through `curl | bash`), so a push to `master` + is the release. Semver: a minor version adds behaviour, a patch fixes it. diff --git a/install.ps1 b/install.ps1 index d2ce112..8fa2ca2 100644 --- a/install.ps1 +++ b/install.ps1 @@ -85,6 +85,12 @@ try { # --- 2. scripts ------------------------------------------------------------- Copy-Item -LiteralPath (Join-Path $src 'claude-mode.ps1') -Destination $root -Force +# The script's modules, dot-sourced from lib\ beside it. Cleared first, so a +# module removed from the source does not linger and get loaded. +$libDest = Join-Path $root 'lib' +New-Item -ItemType Directory -Path $libDest -Force | Out-Null +Remove-Item -Path (Join-Path $libDest '*.ps1') -Force -ErrorAction SilentlyContinue +Copy-Item -Path (Join-Path $src 'lib\*.ps1') -Destination $libDest -Force # claude-mode reads VERSION from its own directory to stamp health.json. if (Test-Path -LiteralPath (Join-Path $src 'VERSION')) { Copy-Item -LiteralPath (Join-Path $src 'VERSION') -Destination $root -Force diff --git a/lib/catalogue.ps1 b/lib/catalogue.ps1 new file mode 100644 index 0000000..bd86dfc --- /dev/null +++ b/lib/catalogue.ps1 @@ -0,0 +1,309 @@ +# lib/catalogue.ps1 - provider model lists, and the checks doctor runs against them. +# +# 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. + +# --------------------------------------------------------------------------- +# 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 +} + +# The credential a preset would send: its vault key, or its inline token. +function Get-PresetToken { + param($Preset) + $auth = Get-PresetAuth $Preset + if ([string]$auth['mode'] -eq 'vault') { + $ref = $(if ($auth.Contains('keyRef') -and $auth['keyRef']) { [string]$auth['keyRef'] } else { 'openrouter' }) + return (Get-VaultKey $ref) + } + return [string]$auth['token'] +} + +# One catalogue fetch for any provider, by its catalogue.kind in providers.json. +# Every row comes back in one shape - Id, Ctx, State, InM, OutM, Note - so the +# callers (models, the picker, doctor) never branch on the provider itself. +function Get-ProviderCatalogue { + param($Preset) + $prov = Get-Provider ([string]$Preset['provider']) + if (-not $prov) { return @() } + $base = ([string]$Preset['baseUrl']).TrimEnd('/') + switch ([string]$prov.catalogue.kind) { + 'openrouter' { + return @((Invoke-RestMethod -Uri 'https://openrouter.ai/api/v1/models' -TimeoutSec 30).data | ForEach-Object { + [pscustomobject]@{ + Id = $_.id; Ctx = $_.context_length; State = $null; Note = '' + InM = $(if ($_.pricing -and $_.pricing.prompt) { [math]::Round([double]$_.pricing.prompt * 1e6, 3) } else { $null }) + OutM = $(if ($_.pricing -and $_.pricing.completion) { [math]::Round([double]$_.pricing.completion * 1e6, 3) } else { $null }) + } + }) + } + 'lmstudio' { + return @(Get-LmStudioModels $base | ForEach-Object { + [pscustomobject]@{ Id = $_.Id; Ctx = $_.Ctx; State = $_.State; InM = $null; OutM = $null; Note = '' } + }) + } + 'ollama' { + $h = @{} + $tok = Get-PresetToken $Preset + if ($tok) { $h['Authorization'] = "Bearer $tok" } + return @((Invoke-RestMethod -Uri "$base/api/tags" -Headers $h -TimeoutSec 10).models | ForEach-Object { + $d = $_.details + $note = $(if ($d) { (@($d.parameter_size, $d.quantization_level) | Where-Object { $_ }) -join ' ' } else { '' }) + [pscustomobject]@{ Id = $_.name; Ctx = $null; State = $null; InM = $null; OutM = $null; Note = $note } + }) + } + 'openai' { + # Both header styles: a proxy in front of Anthropic wants x-api-key, + # one in front of anything else wants Bearer. + $h = @{ 'anthropic-version' = '2023-06-01' } + $tok = Get-PresetToken $Preset + if ($tok) { $h['Authorization'] = "Bearer $tok"; $h['x-api-key'] = $tok } + return @((Invoke-RestMethod -Uri "$base/v1/models" -Headers $h -TimeoutSec 15).data | ForEach-Object { + [pscustomobject]@{ Id = $_.id; Ctx = $null; State = $null; InM = $null; OutM = $null; Note = '' } + }) + } + 'static' { + return @($prov.catalogue.static | ForEach-Object { + [pscustomobject]@{ Id = $_.id; Ctx = $null; State = $null; InM = $null; OutM = $null; Note = [string]$_.note } + }) + } + } + return @() +} + +# The preset's model ids against what the provider actually offers. A server +# provider that does not answer is a failure; a hosted catalogue that cannot be +# fetched is only a warning, since the endpoint may be fine regardless. +function Test-PresetCatalogue { + param([string] $Mode, $Preset) + $prov = Get-Provider $Mode + $kind = [string]$prov.catalogue.kind + $title = [string]$prov.title + $base = ([string]$Preset['baseUrl']).TrimEnd('/') + $server = [bool]$prov.server.editable + + $cat = @() + try { $cat = @(Get-ProviderCatalogue $Preset) } catch { $cat = @() } + if ($cat.Count -eq 0) { + if ($server -and [string]$prov.server.probe -eq 'lenient') { + Write-Warn2 "$title at $base lists no models - fine for a proxy, but the ids below cannot be checked" + } elseif ($server) { + $start = [string]$prov.server.start + Write-Err2 ("$title not reachable at $base" + $(if ($start) { " - $start" } else { '' })) + } else { + Write-Warn2 "could not fetch the $title model list" + } + return + } + if ($server) { Write-Ok "$title reachable at $base ($($cat.Count) models)" } + + $declared = $(if ($Preset.Contains('contextTokens') -and $Preset['contextTokens']) { [int]$Preset['contextTokens'] } else { 0 }) + $tpl = $(if (Test-ProviderDoctor $Mode 'lmstudio-templates') { Get-LmStudioTemplateReport } else { @() }) + $warned = @{} # a one-for-all preset names one model four times; say things about it once + + foreach ($t in $script:Tiers) { + if (-not ($Preset['models'].Contains($t) -and $Preset['models'][$t])) { continue } + $id = [string]$Preset['models'][$t] + $m = $cat | Where-Object { $_.Id -eq $id } | Select-Object -First 1 + # Ollama lists every model with its tag; a bare name means :latest. + if (-not $m -and $kind -eq 'ollama' -and $id -notlike '*:*') { + $m = $cat | Where-Object { $_.Id -eq "${id}:latest" } | Select-Object -First 1 + } + if (-not $m) { + # A fixed list is documentation, not the provider's word. + if ($kind -eq 'static') { Write-Ok ("{0,-6} {1} (not in the documented list)" -f $t, $id) } + else { Write-Err2 "$t model NOT available from ${title}: $id" } + continue + } + switch ($kind) { + 'lmstudio' { + if ($m.State -eq 'loaded') { Write-Ok ("{0,-6} {1} [loaded, ctx {2}]" -f $t, $id, $m.Ctx) } + else { Write-Ok ("{0,-6} {1} [{2} - LM Studio will JIT-load it on first request, ctx {3}]" -f $t, $id, $m.State, $m.Ctx) } + if (-not $warned.ContainsKey($id)) { + $warned[$id] = $true + if ($m.Ctx -and [int]$m.Ctx -lt 25000) { Write-Warn2 "$id context is $($m.Ctx); LM Studio recommends >25k for Claude Code." } + if ($declared -and $m.Ctx -and [int]$m.Ctx -lt $declared) { + Write-Warn2 ("declared contextTokens {0:N0} exceeds {1}'s {2:N0} - lower it." -f $declared, $id, [int]$m.Ctx) + } + $short = ($id -split '/')[-1].ToLower() + $risk = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1 + if ($risk -and $risk.Assertions.Count -gt 0) { + Write-Warn2 "$id chat template hard-asserts message order ($($risk.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'." + } + } + } + 'openrouter' { Write-Ok ("{0,-6} {1} [ctx {2:N0}]" -f $t, $id, [int]$m.Ctx) } + 'ollama' { Write-Ok ("{0,-6} {1} [{2}]" -f $t, $id, $m.Note) } + default { Write-Ok ("{0,-6} {1}" -f $t, $id) } + } + } + + if ($kind -eq 'openrouter') { Test-ContextWindow -Preset $Preset -Catalogue $cat } + elseif ($declared) { Write-Ok ("declared context window: {0:N0} tokens" -f $declared) } + else { Write-Warn2 'preset has no contextTokens - Claude Code will guess a small window and auto-compact early.' } +} + +# Ollama sets the context window on the server, not per request from Claude +# Code: 4096 tokens unless `ollama serve` runs with OLLAMA_CONTEXT_LENGTH, and +# anything past it is cut off without an error. contextTokens only tells Claude +# Code what to expect, so say what the server is actually running where that +# can be seen, and what to set where it cannot. +function Test-OllamaContext { + param($Preset) + if (-not ($Preset.Contains('contextTokens') -and $Preset['contextTokens'])) { return } + $declared = [int]$Preset['contextTokens'] + $base = ([string]$Preset['baseUrl']).TrimEnd('/') + $ids = @() + foreach ($t in $script:Tiers) { + $v = [string]$Preset['models'][$t] + if ($v -and $ids -notcontains $v) { $ids += $v } + } + $ps = $null + try { $ps = Invoke-RestMethod -Uri "$base/api/ps" -TimeoutSec 5 } catch { } + + $seen = $false; $short = $false + foreach ($id in $ids) { + try { + $show = Invoke-RestMethod -Uri "$base/api/show" -Method Post -ContentType 'application/json' ` + -Body (@{ model = $id } | ConvertTo-Json) -TimeoutSec 8 + $max = $null + if ($show.model_info) { + foreach ($prop in $show.model_info.PSObject.Properties) { + if ($prop.Name -like '*.context_length') { $max = [int]$prop.Value; break } + } + } + if ($max -and $max -lt $declared) { Write-Warn2 "$id supports at most $max tokens, below the declared $declared - lower contextTokens" } + } catch { } + + $want = @($id) + if ($id -notlike '*:*') { $want += "${id}:latest" } + $live = $null + if ($ps -and $ps.models) { + foreach ($m in $ps.models) { + if ((($want -contains [string]$m.name) -or ($want -contains [string]$m.model)) -and $m.context_length) { + $live = [int]$m.context_length; break + } + } + } + if ($null -eq $live) { continue } + $seen = $true + if ($live -lt $declared) { + $short = $true + Write-Warn2 "$id is loaded with a $live-token context, below the declared $declared - requests past it are cut off" + } else { + Write-Ok "$id is loaded with a $live-token context" + } + } + if (-not $seen) { + Write-Warn2 "none of these models is loaded, so the server's context window cannot be checked" + Write-Host " Ollama defaults to 4096 tokens; run it with OLLAMA_CONTEXT_LENGTH=$declared or requests past that are cut off silently" + } elseif ($short) { + Write-Host " restart it with OLLAMA_CONTEXT_LENGTH=$declared (or lower contextTokens to match)" + } +} + +# 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 -or -not $m.Ctx) { continue } + if ([int]$m.Ctx -lt $declared) { + Write-Warn2 ("{0} model {1} only has {2:N0} ctx, below the declared {3:N0}." -f $t, $id, [int]$m.Ctx, $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.' + } + } + } +} diff --git a/lib/commands.ps1 b/lib/commands.ps1 new file mode 100644 index 0000000..8c0bcec --- /dev/null +++ b/lib/commands.ps1 @@ -0,0 +1,350 @@ +# lib/commands.ps1 - status, presets, preset, models and doctor. +# +# 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. + +# --------------------------------------------------------------------------- +# 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'] + + # On anthropic, OpenRouter's catalogue is the one worth browsing, as before. + $preset = $(if ($mode -ne 'anthropic') { Get-Preset ([string]$state['preset']) } else { [ordered]@{ provider = 'openrouter'; baseUrl = '' } }) + $prov = Get-Provider ([string]$preset['provider']) + $kind = [string]$prov.catalogue.kind + switch ($kind) { + 'static' { Write-Head "$($prov.title) models (from its docs - no public catalogue endpoint)" } + 'openrouter' { Write-Head 'fetching https://openrouter.ai/api/v1/models ...' } + default { Write-Head "models on the $($prov.title) server at $($preset['baseUrl'])" } + } + + $tpl = $(if ($kind -eq 'lmstudio') { Get-LmStudioTemplateReport } else { @() }) + $rows = foreach ($m in (Get-ProviderCatalogue $preset | Sort-Object Id)) { + if ($Filter -and $m.Id -notlike "*$Filter*") { continue } + if ($kind -eq 'openrouter') { + [pscustomobject]@{ Id = $m.Id; Ctx = $m.Ctx; 'In/M$' = $m.InM; 'Out/M$' = $m.OutM } + } elseif ($kind -eq 'lmstudio') { + $short = ($m.Id -split '/')[-1].ToLower() + $t = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1 + $flag = $(if ($t -and $t.Assertions.Count -gt 0) { 'TEMPLATE RISK' } else { '' }) + [pscustomobject]@{ Id = $m.Id; State = $m.State; Ctx = $m.Ctx; Note = $flag } + } else { + [pscustomobject]@{ Id = $m.Id; Note = $m.Note } + } + } + $rows | Format-Table -AutoSize + + if ($kind -eq 'lmstudio') { + 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." + } + if ($kind -eq 'static' -and $prov.catalogue.docs) { Write-Host " Full list: $($prov.catalogue.docs)" } +} + +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 (Test-ProviderDoctor $mode 'openrouter-key')) { + 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 (Test-ProviderDoctor $mode 'message-check')) { + # 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 "$((Get-Provider $mode).title) endpoint accepted the key ($base/v1/messages)" + } catch { + $detail = '' + if ($_.ErrorDetails) { $detail = ($_.ErrorDetails.Message -replace '\s+', ' ') } + Write-Err2 "$((Get-Provider $mode).title) request failed: $($_.Exception.Message) $detail" + } + } + } else { + Write-Ok "inline token '$($auth['token'])' (no secret in settings.json)" + } + + # Which checks run is the provider's call, by name, in providers.json. + if (Test-ProviderDoctor $mode 'catalogue-models') { Test-PresetCatalogue -Mode $mode -Preset $preset } + if (Test-ProviderDoctor $mode 'ollama-context') { Test-OllamaContext -Preset $preset } + } + + 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']) +} diff --git a/lib/core.ps1 b/lib/core.ps1 new file mode 100644 index 0000000..b395bfd --- /dev/null +++ b/lib/core.ps1 @@ -0,0 +1,86 @@ +# lib/core.ps1 - state.json, presets, and which preset a mode resolves to. +# +# 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. + +# --------------------------------------------------------------------------- +# 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'" +} diff --git a/lib/files.ps1 b/lib/files.ps1 new file mode 100644 index 0000000..e6dc38f --- /dev/null +++ b/lib/files.ps1 @@ -0,0 +1,98 @@ +# lib/files.ps1 - JSON read/write for PowerShell 5.1, and restricting a file to its owner. +# +# 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. + +# --------------------------------------------------------------------------- +# 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)" + } +} diff --git a/lib/guards.ps1 b/lib/guards.ps1 new file mode 100644 index 0000000..d42aa03 --- /dev/null +++ b/lib/guards.ps1 @@ -0,0 +1,219 @@ +# lib/guards.ps1 - the Anthropic-model cost guard, the OpenRouter guardrail, stale cached model ids. +# +# 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. + +# --------------------------------------------------------------------------- +# 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) + # An OpenRouter feature, flagged per provider rather than by name. + $pv = Get-Provider $Mode + if (-not ($pv -and $pv.guardrail) -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 +} diff --git a/lib/health.ps1 b/lib/health.ps1 new file mode 100644 index 0000000..a890cf4 --- /dev/null +++ b/lib/health.ps1 @@ -0,0 +1,148 @@ +# lib/health.ps1 - health.json, and persistent environment variables that would override a switch. +# +# 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. + +# --------------------------------------------------------------------------- +# Stray environment variable detection +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# health.json - the machine-readable state a fleet reader (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' } +} diff --git a/lib/menu.ps1 b/lib/menu.ps1 new file mode 100644 index 0000000..55f406b --- /dev/null +++ b/lib/menu.ps1 @@ -0,0 +1,669 @@ +# lib/menu.ps1 - the interactive menu, its pickers, and interactive preset editing. +# +# 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. + +# --------------------------------------------------------------------------- +# 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 = @() + $kind = [string](Get-Provider $provider).catalogue.kind + $tpl = $(if ($kind -eq 'lmstudio') { Get-LmStudioTemplateReport } else { @() }) + + foreach ($m in (Get-ProviderCatalogue $Preset | Sort-Object Id)) { + $flag = '' + if ($kind -eq 'openrouter') { + $det = @(("context {0:N0} `$$($m.InM) in / `$$($m.OutM) out per 1M tokens" -f [int]$m.Ctx)) + } elseif ($kind -eq 'lmstudio') { + $det = @("state: $($m.State) max context: $($m.Ctx)") + $short = ($m.Id -split '/')[-1].ToLower() + $risk = $tpl | Where-Object { $_.Key -eq $short } | Select-Object -First 1 + if ($risk -and $risk.Assertions.Count -gt 0) { + $flag = ' [TEMPLATE RISK]' + $det += 'chat template asserts message order - can break tool calls' + } + } else { + $det = @([string]$m.Note) + } + $out += [pscustomobject]@{ Id = $m.Id; Key = $m.Id; Label = ($m.Id + $flag); Detail = $det } + } + + $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) + + # Built from the provider's own template in providers.json; the POSIX + # build's `cm-json.py scaffold` produces the same thing from the same file. + $p = Get-Provider $Provider + if (-not $p) { throw "unknown provider '$Provider'" } + $tpl = $p.preset + + $base = [ordered]@{ + provider = $Provider + description = 'new preset' + } + $base['baseUrl'] = [string]$tpl.baseUrl + $auth = [ordered]@{} + if ($tpl.auth) { foreach ($prop in $tpl.auth.PSObject.Properties) { $auth[$prop.Name] = $prop.Value } } + else { $auth['mode'] = 'vault'; $auth['keyRef'] = $Provider } + $base['auth'] = $auth + + $base['models'] = [ordered]@{ opus = ''; sonnet = ''; haiku = ''; fable = '' } + $base['subagentModel'] = 'inherit' + $base['gatewayModelDiscovery'] = [bool]$tpl.gatewayModelDiscovery + $base['contextTokens'] = $(if ($tpl.contextTokens) { [int]$tpl.contextTokens } else { 200000 }) + if ($tpl.extraEnv) { + $extra = [ordered]@{} + foreach ($prop in $tpl.extraEnv.PSObject.Properties) { $extra[$prop.Name] = [string]$prop.Value } + $base['extraEnv'] = $extra + } + 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 + } +} diff --git a/lib/output.ps1 b/lib/output.ps1 new file mode 100644 index 0000000..8474027 --- /dev/null +++ b/lib/output.ps1 @@ -0,0 +1,90 @@ +# lib/output.ps1 - output helpers, mode colours, the banner and usage. +# +# 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. + +# --------------------------------------------------------------------------- +# 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' } +$__colors = @{ cyan = 'Cyan'; green = 'Green'; yellow = 'Yellow'; magenta = 'Magenta' + white = 'White'; gray = 'Gray'; dkcyan = 'DarkCyan'; red = 'Red' } +foreach ($__p in $script:Providers) { + $__c = $__colors[[string]$__p.color] + $script:ModeColor[[string]$__p.id] = $(if ($__c) { $__c } else { 'Gray' }) +} + +# 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 and gateway providers + + claude-mode interactive menu + claude-mode status active mode, preset, model map + + claude-mode anthropic native login/subscription (clears all gateway config) +'@ | Write-Host + # One line per provider in providers.json, so a new one documents itself. + foreach ($p in $script:Providers) { + $desc = ([string]$p.label) -replace '^[^-]*-\s*', '' + Write-Host (" claude-mode {0,-26} {1} (default preset: {2})" -f "$($p.id) [preset]", $desc, $script:ProviderDefaultPreset[[string]$p.id]) + } + @' + + 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 +} diff --git a/lib/providers.ps1 b/lib/providers.ps1 new file mode 100644 index 0000000..3cd0260 --- /dev/null +++ b/lib/providers.ps1 @@ -0,0 +1,60 @@ +# lib/providers.ps1 - every gateway provider, read from providers.json, and lookups into them. +# +# 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. + +# --------------------------------------------------------------------------- +# Providers +# +# Every gateway provider is an entry in providers.json, next to this script and +# shared with the POSIX build: endpoint, auth, how its model list is fetched, +# which doctor checks apply. This script only knows the *kinds* of behaviour +# (Get-ProviderCatalogue, Test-PresetCatalogue) and picks one by name from the +# entry, so a provider that reuses them needs no change here. anthropic is +# built in: it is the native login, not a gateway. +# --------------------------------------------------------------------------- + +$script:ProvidersPath = Join-Path $script:Here 'providers.json' +$script:Providers = @() +try { + $__pj = Get-Content -LiteralPath $script:ProvidersPath -Raw -Encoding UTF8 | ConvertFrom-Json + $script:Providers = @($__pj.providers | Where-Object { $_.id }) +} catch { } +if ($script:Providers.Count -eq 0) { + Write-Host " FAIL providers.json missing or unreadable at $($script:ProvidersPath) - re-run install.ps1" -ForegroundColor Red + exit 1 +} + +$script:Modes = @('anthropic') + @($script:Providers | ForEach-Object { [string]$_.id }) +$script:ModeLabel = @{ 'anthropic' = 'Anthropic - your subscription login, no gateway' } + +# `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 = @{} +foreach ($__p in $script:Providers) { + $script:ModeLabel[[string]$__p.id] = [string]$__p.label + $script:ProviderDefaultPreset[[string]$__p.id] = $(if ($__p.defaultPreset) { [string]$__p.defaultPreset } else { [string]$__p.id }) +} + +function Get-Provider { + param([string] $Id) + return ($script:Providers | Where-Object { $_.id -eq $Id } | Select-Object -First 1) +} + +# An id or an alias (z.ai, z-ai) to the provider id; $null if neither. +function Resolve-ProviderId { + param([string] $Word) + $w = ([string]$Word).ToLower() + foreach ($p in $script:Providers) { + if ([string]$p.id -eq $w -or (@($p.aliases) -contains $w)) { return [string]$p.id } + } + return $null +} + +function Test-ProviderDoctor { + param([string] $Id, [string] $Check) + $p = Get-Provider $Id + return [bool]($p -and (@($p.doctor) -contains $Check)) +} diff --git a/lib/switch.ps1 b/lib/switch.ps1 new file mode 100644 index 0000000..8b2fb06 --- /dev/null +++ b/lib/switch.ps1 @@ -0,0 +1,224 @@ +# 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 +} diff --git a/lib/vault.ps1 b/lib/vault.ps1 new file mode 100644 index 0000000..036c63c --- /dev/null +++ b/lib/vault.ps1 @@ -0,0 +1,85 @@ +# lib/vault.ps1 - the DPAPI key vault, and a preset's auth. +# +# 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. + +# --------------------------------------------------------------------------- +# 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 + # The vault is written from a SecureString (DPAPI), so the inline form + # has to produce one too. + $secure = ConvertTo-SecureString -String $Key -AsPlainText -Force + } 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 +} diff --git a/linux/bootstrap.sh b/linux/bootstrap.sh deleted file mode 100644 index 559a070..0000000 --- a/linux/bootstrap.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env bash -# claude-mode bootstrap installer for Linux and macOS. -# -# Served by the Arkylx Index and run as a one-liner: -# -# curl -fsSL https://index.arkylx.com/tools/claude-mode/install.sh | bash -# -# Downloads the payload, verifies its SHA-256 against the manifest, unpacks it to -# a temp directory and runs the bundled install.sh. Nothing is written outside -# ~/.claude-mode, ~/.local/bin and your shell rc. -# -# Environment overrides: -# ARKYLX_CLAUDE_MODE_BASE base URL (default: index.arkylx.com) -# ARKYLX_CLAUDE_MODE_VERSION pin a version instead of "latest" -# ARKYLX_CODE enrolment code; reports the install to the Index - -set -euo pipefail - -BASE="${ARKYLX_CLAUDE_MODE_BASE:-https://index.arkylx.com/tools/claude-mode}" -BASE="${BASE%/}" -VERSION="${ARKYLX_CLAUDE_MODE_VERSION:-latest}" - -green() { printf ' \033[32mok \033[0m %s\n' "$*"; } -warn() { printf ' \033[33mwarn\033[0m %s\n' "$*"; } -fail() { printf ' \033[31mFAIL\033[0m %s\n' "$*" >&2; } - -printf '\n \033[36mclaude-mode installer\033[0m\n' -printf ' \033[90msource: %s (%s)\033[0m\n\n' "$BASE" "$VERSION" - -# --- preflight ------------------------------------------------------------- -need() { command -v "$1" >/dev/null 2>&1 || { fail "$1 is required but not installed"; exit 1; }; } -need curl -need tar -PY="${CLAUDE_MODE_PYTHON:-python3}" -command -v "$PY" >/dev/null 2>&1 || { - fail "python3 is required (claude-mode uses it for JSON handling)" - fail " Debian/Ubuntu: sudo apt install python3" - fail " Fedora/RHEL: sudo dnf install python3" - fail " macOS: xcode-select --install (or brew install python)" - exit 1 -} - -sha256_of() { - if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 - elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1 - else fail 'no sha256sum or shasum available - cannot verify the download'; exit 1 - fi -} - -WORK="$(mktemp -d 2>/dev/null || mktemp -d -t claude-mode)" -cleanup() { rm -rf "$WORK"; } -trap cleanup EXIT - -# --- manifest -------------------------------------------------------------- -curl -fsSL --max-time 30 "$BASE/$VERSION/manifest.posix.json" -o "$WORK/manifest.json" || { - fail "could not fetch $BASE/$VERSION/manifest.posix.json"; exit 1; } - -read_field() { "$PY" -c "import json,sys;print(json.load(open(sys.argv[1])).get(sys.argv[2],''))" "$WORK/manifest.json" "$1"; } -PKG="$(read_field package)" -EXPECTED="$(read_field sha256)" -PKGVER="$(read_field version)" -[ -n "$PKG" ] && [ -n "$EXPECTED" ] || { fail 'manifest is missing package/sha256'; exit 1; } - -# --- payload --------------------------------------------------------------- -printf ' downloading %s (%s)\n' "$PKG" "$PKGVER" -curl -fsSL --max-time 120 "$BASE/$VERSION/$PKG" -o "$WORK/$PKG" || { fail 'download failed'; exit 1; } - -ACTUAL="$(sha256_of "$WORK/$PKG")" -if [ "$ACTUAL" != "$EXPECTED" ]; then - fail 'checksum mismatch - refusing to install' - fail "expected $EXPECTED" - fail "actual $ACTUAL" - exit 1 -fi -green 'checksum verified' - -mkdir -p "$WORK/src" -tar -xzf "$WORK/$PKG" -C "$WORK/src" -[ -f "$WORK/src/linux/install.sh" ] || { fail 'package does not contain linux/install.sh'; exit 1; } -chmod +x "$WORK/src/linux/install.sh" - -printf '\n' -"$WORK/src/linux/install.sh" "$@" - -# --- optional report ------------------------------------------------------- -# Best-effort: a failure here must never make a good install look broken. -if [ -n "${ARKYLX_CODE:-}" ] && [ "${ARKYLX_CLAUDE_MODE_REPORT:-1}" != "0" ]; then - if curl -fsS --max-time 10 -X POST "$BASE/report" \ - -H 'content-type: application/json' \ - -d "{\"code\":\"$ARKYLX_CODE\",\"tool\":\"claude-mode\",\"version\":\"$PKGVER\",\"hostname\":\"$(hostname 2>/dev/null || echo unknown)\",\"os\":\"$(uname -sr 2>/dev/null || echo unknown)\"}" \ - >/dev/null 2>&1; then - green 'reported install to the Arkylx Index' - else - warn 'could not report to the Index (install is fine)' - fi -fi diff --git a/scripts/build-package.ps1 b/scripts/build-package.ps1 deleted file mode 100644 index e2739be..0000000 --- a/scripts/build-package.ps1 +++ /dev/null @@ -1,131 +0,0 @@ -<# - Build the distributable claude-mode package. - - Produces, under dist// : - claude-mode-.zip the payload (script, key helper, presets, installer) - manifest.json version + package name + SHA-256 - - The Arkylx Index serves that directory, plus dist/bootstrap.ps1 as - /tools/claude-mode/install.ps1. See ARKYLX_INDEX_INTEGRATION.md. -#> - -[CmdletBinding()] -param([string] $OutRoot) - -Set-StrictMode -Version 1.0 -$ErrorActionPreference = 'Stop' - -$root = Split-Path -Parent $PSScriptRoot -if (-not $OutRoot) { $OutRoot = Join-Path $root 'dist' } - -$version = (Get-Content (Join-Path $root 'VERSION') -Raw).Trim() -if (-not $version) { throw 'VERSION file is empty' } - -Write-Host "building claude-mode $version" -ForegroundColor Cyan - -$stage = Join-Path ([System.IO.Path]::GetTempPath()) ("cm-stage-" + [Guid]::NewGuid().ToString('N').Substring(0, 8)) -New-Item -ItemType Directory -Path (Join-Path $stage 'bin') -Force | Out-Null -New-Item -ItemType Directory -Path (Join-Path $stage 'presets') -Force | Out-Null - -# Everything the installer needs, and nothing else - no README, no docs, no -# .git. The payload is what lands on a user's machine. -Copy-Item (Join-Path $root 'claude-mode.ps1') $stage -Force -Copy-Item (Join-Path $root 'install.ps1') $stage -Force -Copy-Item (Join-Path $root 'profile-snippet.ps1') $stage -Force -Copy-Item (Join-Path $root 'VERSION') $stage -Force -Copy-Item (Join-Path $root 'providers.json') $stage -Force -Get-ChildItem (Join-Path $root 'bin') -File | ForEach-Object { Copy-Item $_.FullName (Join-Path $stage 'bin') -Force } -Get-ChildItem (Join-Path $root 'presets') -File | ForEach-Object { Copy-Item $_.FullName (Join-Path $stage 'presets') -Force } - -# Refuse to ship a package whose main script does not parse - a broken payload -# would be installed by every user before anyone noticed. -$errs = $null -[void][System.Management.Automation.Language.Parser]::ParseFile((Join-Path $stage 'claude-mode.ps1'), [ref]$null, [ref]$errs) -if ($errs.Count -gt 0) { - $errs | ForEach-Object { Write-Host " L$($_.Extent.StartLineNumber): $($_.Message)" -ForegroundColor Red } - throw 'claude-mode.ps1 does not parse - refusing to package' -} -foreach ($p in (Get-ChildItem (Join-Path $stage 'presets') -File)) { - try { [void](Get-Content $p.FullName -Raw | ConvertFrom-Json) } - catch { throw "preset $($p.Name) is not valid JSON - refusing to package" } -} -Write-Host ' ok payload validated' -ForegroundColor Green - -$outDir = Join-Path $OutRoot $version -if (-not (Test-Path -LiteralPath $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null } - -$zipName = "claude-mode-$version.zip" -$zipPath = Join-Path $outDir $zipName -if (Test-Path -LiteralPath $zipPath) { Remove-Item -LiteralPath $zipPath -Force } -Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zipPath -Force - -$sha = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLower() - -$manifest = [ordered]@{ - tool = 'claude-mode' - version = $version - package = $zipName - sha256 = $sha - size = (Get-Item $zipPath).Length - requires = [ordered]@{ powershell = '5.1'; os = 'windows' } - entry = 'install.ps1' -} -$manifest | ConvertTo-Json -Depth 5 | - Set-Content -LiteralPath (Join-Path $outDir 'manifest.json') -Encoding UTF8 - -# --- POSIX payload (Linux + macOS) ----------------------------------------- -# Shipped as a tar.gz because zip does not preserve the executable bit, and the -# installer must be runnable straight out of the archive. -$posixStage = Join-Path ([System.IO.Path]::GetTempPath()) ("cm-posix-" + [Guid]::NewGuid().ToString('N').Substring(0, 8)) -New-Item -ItemType Directory -Path (Join-Path $posixStage 'linux') -Force | Out-Null -New-Item -ItemType Directory -Path (Join-Path $posixStage 'presets') -Force | Out-Null -# Recursive: the CLI's modules live in linux/lib/, and a flat copy would ship a -# package that installs a claude-mode unable to start. -Copy-Item (Join-Path $root 'linux\*') (Join-Path $posixStage 'linux') -Recurse -Force -Get-ChildItem (Join-Path $root 'presets') -File | ForEach-Object { Copy-Item $_.FullName (Join-Path $posixStage 'presets') -Force } -Copy-Item (Join-Path $root 'VERSION') $posixStage -Force -Copy-Item (Join-Path $root 'providers.json') $posixStage -Force - -# Shell scripts authored on Windows carry CRLF, which makes the kernel reject -# the "#!/usr/bin/env bash" line. Normalise before packaging. -Get-ChildItem (Join-Path $posixStage 'linux') -File -Recurse | ForEach-Object { - $text = [System.IO.File]::ReadAllText($_.FullName) -replace "`r`n", "`n" - [System.IO.File]::WriteAllText($_.FullName, $text, (New-Object System.Text.UTF8Encoding($false))) -} - -$tarName = "claude-mode-$version-posix.tar.gz" -$tarPath = Join-Path $outDir $tarName -if (Test-Path -LiteralPath $tarPath) { Remove-Item -LiteralPath $tarPath -Force } -& tar.exe -czf $tarPath -C $posixStage 'linux' 'presets' 'VERSION' 'providers.json' -if ($LASTEXITCODE -ne 0) { throw 'tar failed - cannot build the POSIX package' } - -$shaPosix = (Get-FileHash -LiteralPath $tarPath -Algorithm SHA256).Hash.ToLower() -[ordered]@{ - tool = 'claude-mode' - version = $version - package = $tarName - sha256 = $shaPosix - size = (Get-Item $tarPath).Length - requires = [ordered]@{ bash = '4.0'; python3 = '3.6'; os = 'linux,darwin' } - entry = 'linux/install.sh' -} | ConvertTo-Json -Depth 5 | - Set-Content -LiteralPath (Join-Path $outDir 'manifest.posix.json') -Encoding UTF8 - -Remove-Item -LiteralPath $posixStage -Recurse -Force -ErrorAction SilentlyContinue -Write-Host " ok $tarPath" -ForegroundColor Green -Write-Host " ok sha256 $shaPosix" -ForegroundColor Green - -# "latest" is a copy rather than a symlink so a plain static file server can -# serve it with no extra configuration. -$latest = Join-Path $OutRoot 'latest' -if (-not (Test-Path -LiteralPath $latest)) { New-Item -ItemType Directory -Path $latest -Force | Out-Null } -Copy-Item $zipPath (Join-Path $latest $zipName) -Force -Copy-Item (Join-Path $outDir 'manifest.json') (Join-Path $latest 'manifest.json') -Force -Copy-Item $tarPath (Join-Path $latest $tarName) -Force -Copy-Item (Join-Path $outDir 'manifest.posix.json') (Join-Path $latest 'manifest.posix.json') -Force - -Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue - -Write-Host " ok $zipPath" -ForegroundColor Green -Write-Host " ok sha256 $sha" -ForegroundColor Green -Write-Host " ok also copied to dist/latest/" -ForegroundColor Green diff --git a/tests/windows/run-remote.sh b/tests/windows/run-remote.sh index ba34865..c43c970 100755 --- a/tests/windows/run-remote.sh +++ b/tests/windows/run-remote.sh @@ -13,10 +13,11 @@ ssh_() { ssh -o BatchMode=yes -o LogLevel=ERROR -o ConnectTimeout=10 "$host" "$@ parent="$(mktemp -d)" trap 'rm -rf "$parent"' EXIT stage="$parent/$name" -mkdir -p "$stage/presets" "$stage/bin" -cp claude-mode.ps1 providers.json VERSION tests/fake_server.py tests/windows/run.ps1 "$stage/" +mkdir -p "$stage/presets" "$stage/bin" "$stage/lib" +cp claude-mode.ps1 install.ps1 providers.json VERSION tests/fake_server.py tests/windows/run.ps1 "$stage/" cp presets/*.json "$stage/presets/" cp bin/* "$stage/bin/" +cp lib/*.ps1 "$stage/lib/" rtemp="$(ssh_ 'Write-Output $env:TEMP' | tr -d '\r')" || { echo "cannot reach $host" >&2; exit 1; } [ -n "$rtemp" ] || { echo "no %TEMP% on $host" >&2; exit 1; } diff --git a/tests/windows/run.ps1 b/tests/windows/run.ps1 index d11f25f..4c6d04b 100644 --- a/tests/windows/run.ps1 +++ b/tests/windows/run.ps1 @@ -28,16 +28,23 @@ function Expect-Eq($got, $want, [string] $what) { if ("$got" -eq "$want") { Pass } else { Fail "$what (got '$got', wanted '$want')" $null } } -New-Item -ItemType Directory -Force -Path "$root\presets", "$root\bin", "$sb\.claude" | Out-Null +New-Item -ItemType Directory -Force -Path "$root\presets", "$root\bin", "$root\lib", "$sb\.claude" | Out-Null Copy-Item "$src\claude-mode.ps1", "$src\providers.json", "$src\VERSION" $root Copy-Item "$src\presets\*.json" "$root\presets" Copy-Item "$src\bin\*" "$root\bin" +Copy-Item "$src\lib\*.ps1" "$root\lib" $cm = Join-Path $root 'claude-mode.ps1' -$tokens = $null; $errors = $null -[void][System.Management.Automation.Language.Parser]::ParseFile($cm, [ref]$tokens, [ref]$errors) -Expect-Eq $errors.Count 0 'claude-mode.ps1 parses' -foreach ($e in $errors) { Write-Host " | line $($e.Extent.StartLineNumber): $($e.Message)" } +# install.ps1 is parsed but never run: it edits the real PowerShell profile and +# the User PATH, which no USERPROFILE sandbox contains. +$parse = @($cm) + @(Get-ChildItem "$root\lib\*.ps1" | ForEach-Object { $_.FullName }) +if (Test-Path "$src\install.ps1") { $parse += "$src\install.ps1" } +foreach ($file in $parse) { + $tokens = $null; $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile($file, [ref]$tokens, [ref]$errors) + Expect-Eq $errors.Count 0 "$(Split-Path -Leaf $file) parses" + foreach ($e in $errors) { Write-Host " | $(Split-Path -Leaf $file) line $($e.Extent.StartLineNumber): $($e.Message)" } +} $portsFile = Join-Path $sb 'ports.json' $fake = Start-Process python -ArgumentList ('"' + (Join-Path $src 'fake_server.py') + '"') `