# ============================================================ # switch-api.ps1 - tukar provider API untuk Claude Code, OpenCode # dan Hermes Agent (PowerShell) # Versi 2.3 (2026-11-02) # # Fail ini TIDAK mengandungi apa-apa API key. # Selamat dikongsi - setiap user isi key sendiri. # # Pasang (sekali sahaja), dalam folder fail ini: # powershell -ExecutionPolicy Bypass -File .\switch-api.ps1 install # # Lepas pasang, buka terminal baru dan taip: # switch-api setup mwapi # switch-api claude use mwapi # switch-api opencode use bedrock # switch-api hermes use mwapi # switch-api current # switch-api uninstall # ============================================================ param( [Parameter(Position = 0)][string]$A1 = "", [Parameter(Position = 1)][string]$A2 = "", [Parameter(Position = 2)][string]$A3 = "" ) $ErrorActionPreference = "Stop" $ClaudeHome = Join-Path $env:USERPROFILE ".claude" $Settings = if ($env:CLAUDE_SETTINGS) { $env:CLAUDE_SETTINGS } else { Join-Path $ClaudeHome "settings.json" } $DataHome = if ($env:SWITCH_API_DATA) { $env:SWITCH_API_DATA } else { Join-Path $env:USERPROFILE ".switch-api" } $ProvidersHome = Join-Path $DataHome "providers" $InstallDir = if ($env:SWITCH_API_HOME) { $env:SWITCH_API_HOME } else { Join-Path $env:LOCALAPPDATA "switch-api" } $CoreName = "switch-api-core.ps1" $ShimName = "switch-api.cmd" $XdgConfig = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $env:USERPROFILE ".config" } $OpenCodeDir = Join-Path $XdgConfig "opencode" # opencode.json diutamakan; kalau tiada, guna opencode.jsonc (lalai OpenCode 2) $OpenCodeConfig = Join-Path $OpenCodeDir "opencode.json" if (-not (Test-Path -LiteralPath $OpenCodeConfig) -and (Test-Path -LiteralPath (Join-Path $OpenCodeDir "opencode.jsonc"))) { $OpenCodeConfig = Join-Path $OpenCodeDir "opencode.jsonc" } $Apps = @("claude", "opencode", "hermes") $Providers = @("mwapi", "bedrock", "mirai") $ProviderBase = @{ mwapi = "https://api.mwapi.dev" bedrock = "https://api.bedrock.com.de" mirai = "https://api.miraiapi.com" } # Claude Code: mwapi & bedrock guna AUTH_TOKEN, mirai guna API_KEY $ProviderTokenVar = @{ mwapi = "ANTHROPIC_AUTH_TOKEN" bedrock = "ANTHROPIC_AUTH_TOKEN" mirai = "ANTHROPIC_API_KEY" } $ProviderModel = @{ mwapi = @{ opus = "claude-opus-5-5"; sonnet = "claude-sonnet-5-5"; haiku = "claude-haiku-4-5-20251001" } bedrock = @{ opus = "claude-opus-5-5"; sonnet = "claude-sonnet-5-5"; haiku = "" } mirai = @{ opus = "claude-opus-5-5"; sonnet = "claude-sonnet-5-5"; haiku = "" } } $Utf8NoBom = New-Object System.Text.UTF8Encoding($false) function Die([string]$msg) { [Console]::Error.WriteLine("ralat: $msg") exit 1 } function Info([string]$msg) { Write-Host $msg } function Assert-Provider([string]$p) { if (-not $p) { Die "nama provider diperlukan. Pilihan: $($Providers -join ' ')" } if ($Providers -notcontains $p) { Die "provider tidak dikenali: $p (pilihan: $($Providers -join ' '))" } } function Conf-Get([string]$file, [string]$key) { if (-not (Test-Path -LiteralPath $file)) { return "" } $val = "" foreach ($line in [IO.File]::ReadAllLines($file)) { if ($line -match "^\s*$key\s*=\s*(.*)$") { $val = $Matches[1].Trim() } } return $val } function Ask-Yes([string]$question) { $ans = Read-Host "$question [y/N]" return @("y", "ya", "yes") -contains $ans.Trim().ToLower() } function Stamp { Get-Date -Format "yyyyMMdd-HHmmss" } # Rentetan berpetik dua untuk YAML: escape \ dan " function Quote-Str([string]$s) { return '"' + ($s -replace '\\', '\\' -replace '"', '\"') + '"' } # Baca key & model dari fail .conf provider. $required = $false -> pulang # $null (tanpa ralat) kalau key belum diset. function Get-ProviderConf([string]$p, [bool]$required = $true) { $conf = Join-Path $ProvidersHome "$p.conf" if (-not (Test-Path -LiteralPath $conf)) { if ($required) { Die "fail key belum ada: $conf - jalankan: switch-api setup $p" } return $null } $token = Conf-Get $conf "TOKEN" if (-not $token) { if ($required) { Die "TOKEN kosong dalam $conf - jalankan semula: switch-api setup $p" } return $null } $m = $ProviderModel[$p] $c = @{ Token = $token } $c.Base = Conf-Get $conf "BASE"; if (-not $c.Base) { $c.Base = $ProviderBase[$p] } $c.Opus = Conf-Get $conf "MODEL_OPUS"; if (-not $c.Opus) { $c.Opus = $m.opus } $c.Sonnet = Conf-Get $conf "MODEL_SONNET"; if (-not $c.Sonnet) { $c.Sonnet = $m.sonnet } $c.Haiku = Conf-Get $conf "MODEL_HAIKU"; if (-not $c.Haiku) { $c.Haiku = $m.haiku } $c.Hermes = Conf-Get $conf "MODEL_HERMES"; if (-not $c.Hermes) { $c.Hermes = $c.Opus } $c.Base = $c.Base.TrimEnd("/") return $c } function Backup-File([string]$path, [string]$tag) { if (-not (Test-Path -LiteralPath $path)) { return "(tiada - fail baru dicipta)" } $backup = "$path.bak.$tag.$(Stamp)" Copy-Item -LiteralPath $path -Destination $backup return $backup } # --------------------------------------------------------------- # JSON: baca & tulis dengan PowerShell tulen (tanpa Node). # ConvertTo-Json PowerShell 5.1 rosakkan format (indentasi pelik, # ' jadi '), jadi baca dengan parser .NET dan tulis sendiri # dalam format 4 ruang. Tetapan lain tidak diusik. # --------------------------------------------------------------- $AnthropicKeys = @( "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL" ) function ConvertFrom-JsonText([string]$raw) { if ($PSVersionTable.PSVersion.Major -ge 6) { return ConvertFrom-Json -InputObject $raw -AsHashtable } Add-Type -AssemblyName System.Web.Extensions $ser = New-Object System.Web.Script.Serialization.JavaScriptSerializer $ser.MaxJsonLength = [int]::MaxValue return $ser.DeserializeObject($raw) } function Quote-Json([string]$s) { $sb = New-Object System.Text.StringBuilder [void]$sb.Append('"') foreach ($ch in $s.ToCharArray()) { $c = [int]$ch if ($c -eq 34) { [void]$sb.Append('\"') } elseif ($c -eq 92) { [void]$sb.Append('\\') } elseif ($c -eq 10) { [void]$sb.Append('\n') } elseif ($c -eq 13) { [void]$sb.Append('\r') } elseif ($c -eq 9) { [void]$sb.Append('\t') } elseif ($c -eq 8) { [void]$sb.Append('\b') } elseif ($c -eq 12) { [void]$sb.Append('\f') } elseif ($c -lt 32) { [void]$sb.Append(('\u{0:x4}' -f $c)) } else { [void]$sb.Append($ch) } } [void]$sb.Append('"') return $sb.ToString() } function ConvertTo-JsonText($v, [int]$level) { $pad = " " * $level $pad2 = " " * ($level + 1) if ($null -eq $v) { return "null" } if ($v -is [string]) { return (Quote-Json $v) } if ($v -is [char]) { return (Quote-Json ([string]$v)) } if ($v -is [bool]) { if ($v) { return "true" } else { return "false" } } if ($v -is [System.Collections.IDictionary]) { # psbase: elak kunci bernama "Keys"/"Count" mengatasi property sebenar if ($v.psbase.Count -eq 0) { return "{}" } $items = New-Object System.Collections.Generic.List[string] foreach ($k in $v.psbase.Keys) { $items.Add($pad2 + (Quote-Json ([string]$k)) + ": " + (ConvertTo-JsonText $v[$k] ($level + 1))) } return "{`n" + ($items -join ",`n") + "`n$pad}" } if ($v -is [System.Collections.IEnumerable]) { $items = New-Object System.Collections.Generic.List[string] foreach ($x in $v) { $items.Add($pad2 + (ConvertTo-JsonText $x ($level + 1))) } if ($items.Count -eq 0) { return "[]" } return "[`n" + ($items -join ",`n") + "`n$pad]" } if ($v -is [System.IFormattable]) { return $v.ToString($null, [System.Globalization.CultureInfo]::InvariantCulture) } return (Quote-Json ([string]$v)) } function Read-TextFile([string]$path) { try { $bytes = [IO.File]::ReadAllBytes($path) } catch { Die "tidak boleh baca ${path}: $($_.Exception.Message)" } # Notepad / PowerShell kadang-kadang tambah BOM - kekalkan bila tulis semula $hadBom = ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) $start = 0 if ($hadBom) { $start = 3 } return @{ Text = $Utf8NoBom.GetString($bytes, $start, $bytes.Length - $start); HadBom = $hadBom } } # JSONC -> JSON: buang komen // dan /* */ serta koma di hujung (trailing # comma), tanpa mengusik isi string. function Remove-JsoncExtras([string]$text) { $sb = New-Object System.Text.StringBuilder $n = $text.Length $i = 0 $inStr = $false while ($i -lt $n) { $ch = $text[$i] if ($inStr) { [void]$sb.Append($ch) if ($ch -eq '\' -and $i + 1 -lt $n) { [void]$sb.Append($text[$i + 1]); $i += 2; continue } if ($ch -eq '"') { $inStr = $false } $i++; continue } if ($ch -eq '"') { $inStr = $true; [void]$sb.Append($ch); $i++; continue } if ($ch -eq '/' -and $i + 1 -lt $n -and $text[$i + 1] -eq '/') { while ($i -lt $n -and $text[$i] -ne "`n") { $i++ } continue } if ($ch -eq '/' -and $i + 1 -lt $n -and $text[$i + 1] -eq '*') { $end = $text.IndexOf("*/", $i + 2) if ($end -lt 0) { $i = $n } else { $i = $end + 2 } [void]$sb.Append(' ') continue } if ($ch -eq ',') { # koma diikuti (selepas ruang/komen) oleh } atau ] - buang $j = $i + 1 while ($j -lt $n) { if ([char]::IsWhiteSpace($text[$j])) { $j++; continue } if ($text[$j] -eq '/' -and $j + 1 -lt $n -and $text[$j + 1] -eq '/') { while ($j -lt $n -and $text[$j] -ne "`n") { $j++ } continue } if ($text[$j] -eq '/' -and $j + 1 -lt $n -and $text[$j + 1] -eq '*') { $end = $text.IndexOf("*/", $j + 2) if ($end -lt 0) { $j = $n } else { $j = $end + 2 } continue } break } if ($j -lt $n -and ($text[$j] -eq '}' -or $text[$j] -eq ']')) { $i++; continue } } [void]$sb.Append($ch) $i++ } return $sb.ToString() } function Read-JsonFile([string]$path) { $f = Read-TextFile $path $raw = $f.Text if ($path.EndsWith(".jsonc", [StringComparison]::OrdinalIgnoreCase)) { $stripped = Remove-JsoncExtras $raw $f.Lossy = ($stripped.Trim() -ne $raw.Trim()) $raw = $stripped } if (-not $raw.Trim()) { $raw = "{}" } $name = Split-Path -Leaf $path try { $cfg = ConvertFrom-JsonText $raw } catch { # Parser .NET sertakan SELURUH kandungan fail (termasuk key) dalam mesej # ralat - potong supaya hanya sebab + kedudukan dipaparkan. $e = $_.Exception while ($e.InnerException) { $e = $e.InnerException } $why = ($e.Message -split "`n")[0] if ($why -match '^(.*?)\s*\((\d+)\):') { $why = "$($Matches[1]) (aksara ke-$($Matches[2]))" } Die "$name tidak sah: $why. Betulkan, atau pulihkan dari fail $name.bak*" } if (-not ($cfg -is [System.Collections.IDictionary])) { Die "$name tidak sah: kandungan mesti objek JSON { ... }" } return @{ Cfg = $cfg; HadBom = $f.HadBom; Lossy = [bool]$f.Lossy } } function Write-JsonFile([string]$path, $cfg, [bool]$hadBom) { $text = (ConvertTo-JsonText $cfg 0) + "`n" [IO.File]::WriteAllText($path, $text, (New-Object System.Text.UTF8Encoding($hadBom))) } function Ensure-File([string]$path, [string]$content) { if (Test-Path -LiteralPath $path) { return } New-Item -ItemType Directory -Force -Path (Split-Path -Parent $path) | Out-Null [IO.File]::WriteAllText($path, $content, $Utf8NoBom) } function Has-Key($d, [string]$key) { foreach ($k in $d.psbase.Keys) { if ($k -ceq $key) { return $true } } return $false } function Get-Dict($d, [string]$key) { if ((Has-Key $d $key) -and ($d[$key] -is [System.Collections.IDictionary])) { return $d[$key] } return $null } function New-Dict { return (New-Object System.Collections.Specialized.OrderedDictionary) } # =============================================================== # CLAUDE CODE (~/.claude/settings.json, blok "env") # =============================================================== # $values = $null -> buang semua ANTHROPIC_* sahaja (off) function Update-ClaudeSettings([System.Collections.IDictionary]$values) { $s = Read-JsonFile $Settings $cfg = $s.Cfg $oldEnv = Get-Dict $cfg "env" # Bina semula "env": salin kunci bukan ANTHROPIC_* ikut susunan asal, # kemudian tambah yang baru di hujung. OrderedDictionary biasa (bukan # [ordered]) supaya nama kunci kekal sensitif huruf besar/kecil. $envBlock = New-Dict if ($null -ne $oldEnv) { foreach ($k in $oldEnv.psbase.Keys) { if ($AnthropicKeys -cnotcontains $k) { $envBlock[$k] = $oldEnv[$k] } } } $cfg["env"] = $envBlock if ($null -ne $values) { foreach ($k in $values.Keys) { if ($values[$k]) { $envBlock[$k] = [string]$values[$k] } } } if ($envBlock.psbase.Count -eq 0) { [void]$cfg.Remove("env") } Write-JsonFile $Settings $cfg $s.HadBom } function Claude-Use([string]$p) { Assert-Provider $p $c = Get-ProviderConf $p $tokenVar = $ProviderTokenVar[$p] # settings.json belum wujud (Claude Code belum pernah dibuka) - cipta baru $backup = Backup-File $Settings $p Ensure-File $Settings "{}`n" $values = [ordered]@{ ANTHROPIC_BASE_URL = $c.Base $tokenVar = $c.Token ANTHROPIC_DEFAULT_OPUS_MODEL = $c.Opus ANTHROPIC_DEFAULT_SONNET_MODEL = $c.Sonnet ANTHROPIC_DEFAULT_HAIKU_MODEL = $c.Haiku } try { Update-ClaudeSettings $values } catch { Die "gagal mengemas kini settings.json: $($_.Exception.Message) (backup: $backup)" } Info "Claude Code ditukar ke: $p" Info " backup : $backup" Info " base : $($c.Base)" Info " auth : $tokenVar" if ($c.Opus) { Info " opus : $($c.Opus)" } if ($c.Sonnet) { Info " sonnet : $($c.Sonnet)" } if ($c.Haiku) { Info " haiku : $($c.Haiku)" } Info "" Info "RESTART Claude Code, kemudian taip /status untuk sahkan." } function Claude-Off { if (-not (Test-Path -LiteralPath $Settings)) { Die "fail tetapan tidak dijumpai: $Settings" } $backup = Backup-File $Settings "off" try { Update-ClaudeSettings $null } catch { Die "gagal mengemas kini settings.json: $($_.Exception.Message) (backup: $backup)" } Info "Claude Code: semua tetapan API dibuang - akan guna claude.ai." Info "backup: $backup" Info "RESTART Claude Code, kemudian pilih akaun claude.ai." } function Claude-Current { Info "[Claude Code] $Settings" if (-not (Test-Path -LiteralPath $Settings)) { Info " (fail belum wujud - guna claude.ai)"; return } $envBlock = Get-Dict (Read-JsonFile $Settings).Cfg "env" if ($null -eq $envBlock) { $envBlock = @{} } $get = { param($k) if (Has-Key $envBlock $k) { [string]$envBlock[$k] } else { "" } } $base = & $get "ANTHROPIC_BASE_URL" if (-not $base) { $base = "(tiada - guna claude.ai)" } $auth = "(tiada)" if (& $get "ANTHROPIC_AUTH_TOKEN") { $auth = "ANTHROPIC_AUTH_TOKEN (ada)" } elseif (& $get "ANTHROPIC_API_KEY") { $auth = "ANTHROPIC_API_KEY (ada)" } Info " base url : $base" Info " auth : $auth" foreach ($row in @(@("opus ", "ANTHROPIC_DEFAULT_OPUS_MODEL"), @("sonnet", "ANTHROPIC_DEFAULT_SONNET_MODEL"), @("haiku ", "ANTHROPIC_DEFAULT_HAIKU_MODEL"))) { $val = & $get $row[1] if (-not $val) { $val = "(lalai Claude Code)" } Info " $($row[0]) : $val" } } # =============================================================== # OPENCODE (~/.config/opencode/opencode.json atau opencode.jsonc) # Semua provider yang ada key didaftarkan sekaligus (id: mwapi, # bedrock, mirai). Entry milik switch-api dikenal pasti dengan # name "... (switch-api)". Entry milik user tidak diusik. # # OpenCode 1 : "provider" { npm, options { baseURL, apiKey, headers } } # OpenCode 2 : "providers" { package, settings { baseURL, apiKey }, headers } # OpenCode 2 masih baca format 1, tapi format native 2 diutamakan. # Format dipilih ikut "opencode --version"; kalau opencode tiada dalam # PATH, ikut fail sedia ada (ada "providers" = format 2). # Paksa dengan env SWITCH_API_OPENCODE_FORMAT=v1 atau v2. # =============================================================== $OpenCodeMaps = @("provider", "providers") function Test-OpenCodeEntryOurs($entry) { return ($entry -is [System.Collections.IDictionary]) -and (Has-Key $entry "name") -and ` ([string]$entry["name"]).EndsWith("(switch-api)") } function Get-OpenCodeFormat($cfg) { $force = [string]$env:SWITCH_API_OPENCODE_FORMAT if ($force -eq "v1" -or $force -eq "v2") { return $force } if (Get-Command opencode -ErrorAction SilentlyContinue) { try { $out = [string](& opencode --version 2>$null | Select-Object -First 1) if ($out -match '(\d+)\.\d+') { if ([int]$Matches[1] -ge 2) { return "v2" } else { return "v1" } } } catch { } } if (Has-Key $cfg "providers") { return "v2" } return "v1" } function New-OpenCodeEntry([string]$q, $c, [string]$format) { $models = New-Dict foreach ($mname in @($c.Opus, $c.Sonnet, $c.Haiku)) { if ($mname -and -not (Has-Key $models $mname)) { $mm = New-Dict; $mm["name"] = $mname; $models[$mname] = $mm } } $headers = $null if ($ProviderTokenVar[$q] -eq "ANTHROPIC_AUTH_TOKEN") { $headers = New-Dict $headers["Authorization"] = "Bearer $($c.Token)" } $entry = New-Dict if ($format -eq "v2") { $settings = New-Dict $settings["baseURL"] = "$($c.Base)/v1" $settings["apiKey"] = $c.Token $entry["name"] = "$q (switch-api)" $entry["package"] = "@opencode/ai/providers/anthropic-compatible" $entry["settings"] = $settings if ($null -ne $headers) { $entry["headers"] = $headers } } else { $options = New-Dict $options["baseURL"] = "$($c.Base)/v1" $options["apiKey"] = $c.Token if ($null -ne $headers) { $options["headers"] = $headers } $entry["npm"] = "@ai-sdk/anthropic" $entry["name"] = "$q (switch-api)" $entry["options"] = $options } $entry["models"] = $models return $entry } # Buang entry switch-api dari "provider" dan "providers"; pulangkan id yang dibuang function Remove-OpenCodeOurs($cfg) { $removed = New-Object System.Collections.Generic.List[string] foreach ($mapName in $OpenCodeMaps) { $map = Get-Dict $cfg $mapName if ($null -eq $map) { continue } $ids = @($map.psbase.Keys | Where-Object { Test-OpenCodeEntryOurs $map[$_] }) foreach ($k in $ids) { [void]$map.Remove($k) if (-not $removed.Contains([string]$k)) { $removed.Add([string]$k) } } if ($map.psbase.Count -eq 0) { [void]$cfg.Remove($mapName) } } return ,$removed } function Test-OpenCodeUserEntry($cfg, [string]$id) { foreach ($mapName in $OpenCodeMaps) { $map = Get-Dict $cfg $mapName if (($null -ne $map) -and (Has-Key $map $id) -and -not (Test-OpenCodeEntryOurs $map[$id])) { return $true } } return $false } function Write-OpenCodeConfig($cfg, $s, [string]$backup) { $name = Split-Path -Leaf $OpenCodeConfig try { Write-JsonFile $OpenCodeConfig $cfg $s.HadBom } catch { Die "gagal mengemas kini ${name}: $($_.Exception.Message) (backup: $backup)" } } function Show-OpenCodeLossyNote($s) { if ($s.Lossy) { Info " nota : komen dalam $(Split-Path -Leaf $OpenCodeConfig) tidak dikekalkan (salinan asal ada dalam backup)" } } function OpenCode-Use([string]$p) { Assert-Provider $p $null = Get-ProviderConf $p # pastikan key provider pilihan ada $name = Split-Path -Leaf $OpenCodeConfig $backup = Backup-File $OpenCodeConfig $p Ensure-File $OpenCodeConfig "{}`n" $s = Read-JsonFile $OpenCodeConfig $cfg = $s.Cfg $format = Get-OpenCodeFormat $cfg $mapName = if ($format -eq "v2") { "providers" } else { "provider" } if (-not (Has-Key $cfg '$schema')) { $new = New-Dict $new['$schema'] = "https://opencode.ai/config.json" foreach ($k in $cfg.psbase.Keys) { $new[$k] = $cfg[$k] } $cfg = $new } if (Test-OpenCodeUserEntry $cfg $p) { Die "provider '$p' dalam $name bukan dari switch-api - tidak diusik. Buang entry tu dulu atau edit manual." } # entry lama (format lain atau versi lama) dibuang dulu, kemudian ditulis semula $null = Remove-OpenCodeOurs $cfg $prov = Get-Dict $cfg $mapName if ($null -eq $prov) { $prov = New-Dict; $cfg[$mapName] = $prov } $registered = New-Object System.Collections.Generic.List[string] foreach ($q in $Providers) { $c = Get-ProviderConf $q $false if ($null -eq $c) { continue } if (Test-OpenCodeUserEntry $cfg $q) { Info " (langkau $q - entry sedia ada bukan dari switch-api)" continue } $prov[$q] = New-OpenCodeEntry $q $c $format $registered.Add($q) } $model = "$p/$((Get-ProviderConf $p).Opus)" $cfg["model"] = $model Write-OpenCodeConfig $cfg $s $backup Info "OpenCode: model default ditukar ke $model" Info " fail : $OpenCodeConfig" Info " format : $(if ($format -eq 'v2') { 'OpenCode 2 ("providers")' } else { 'OpenCode 1 ("provider")' })" Info " backup : $backup" Info " didaftar : $($registered -join ', ')" Show-OpenCodeLossyNote $s Info "" Show-OpenCodeRestart $format Info "Dalam OpenCode, taip /models untuk tukar antara provider yang" Info "didaftar tanpa jalankan skrip ini lagi." } # OpenCode 2 jalan sebagai background service - tutup TUI sahaja tidak # memuat semula config. "opencode reload" muat semula tanpa restart service. function Show-OpenCodeRestart([string]$format) { if ($format -ne "v2") { Info "Tutup OpenCode dan buka semula."; return } $done = $false if (Get-Command opencode -ErrorAction SilentlyContinue) { try { & opencode reload 2>$null | Out-Null $done = ($LASTEXITCODE -eq 0) } catch { } } if ($done) { Info "OpenCode 2: config dimuat semula (opencode reload)." } else { Info "OpenCode 2: jalankan opencode reload (atau opencode service restart)" Info "supaya background service membaca config baharu." } } function OpenCode-Off { if (-not (Test-Path -LiteralPath $OpenCodeConfig)) { Die "fail tetapan tidak dijumpai: $OpenCodeConfig" } $backup = Backup-File $OpenCodeConfig "off" $s = Read-JsonFile $OpenCodeConfig $cfg = $s.Cfg $removed = Remove-OpenCodeOurs $cfg if ((Has-Key $cfg "model") -and ($removed | Where-Object { ([string]$cfg["model"]).StartsWith("$_/") })) { [void]$cfg.Remove("model") } Write-OpenCodeConfig $cfg $s $backup Info "OpenCode: provider switch-api dibuang ($(if ($removed.Count) { $removed -join ', ' } else { 'tiada' }))." Info "backup: $backup" Show-OpenCodeLossyNote $s Show-OpenCodeRestart (Get-OpenCodeFormat $cfg) } function OpenCode-Current { Info "[OpenCode] $OpenCodeConfig" if (-not (Test-Path -LiteralPath $OpenCodeConfig)) { Info " (fail belum wujud - guna tetapan lalai OpenCode)"; return } $cfg = (Read-JsonFile $OpenCodeConfig).Cfg $model = "(lalai OpenCode)" if (Has-Key $cfg "model") { $model = [string]$cfg["model"] } $ours = New-Object System.Collections.Generic.List[string] foreach ($mapName in $OpenCodeMaps) { $map = Get-Dict $cfg $mapName if ($null -eq $map) { continue } foreach ($k in $map.psbase.Keys) { if (Test-OpenCodeEntryOurs $map[$k]) { $ours.Add("$k ($mapName)") } } } Info " model : $model" Info " didaftar : $(if ($ours.Count) { $ours -join ', ' } else { '(tiada dari switch-api)' })" } # =============================================================== # HERMES AGENT (~/.hermes/config.yaml - biasanya dalam WSL2) # Blok "model:" asal user dijadikan komen "# [switch-api] ..." dan # blok baru diletak di tempat yang sama, dalam penanda >>> / <<<. # Guna api_mode chat_completions (/v1/chat/completions) - ketiga-tiga # provider ada endpoint tu. # =============================================================== $HermesBegin = "# >>> switch-api: bahagian ini diurus oleh switch-api, jangan edit" $HermesEnd = "# <<< switch-api" $HermesDisabled = "# [switch-api] " $script:HermesHomeCache = $null # Cari folder ~/.hermes: HERMES_HOME -> %USERPROFILE%\.hermes (jika ada) # -> home WSL (\\wsl.localhost\\home\\.hermes). function Get-HermesHome([bool]$probeWsl = $true) { if ($script:HermesHomeCache) { return $script:HermesHomeCache } if ($env:HERMES_HOME) { $script:HermesHomeCache = $env:HERMES_HOME; return $env:HERMES_HOME } $native = Join-Path $env:USERPROFILE ".hermes" if (Test-Path -LiteralPath $native) { $script:HermesHomeCache = $native; return $native } if (-not $probeWsl) { return $null } if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { return $null } $old = $ErrorActionPreference $ErrorActionPreference = "Continue" try { $out = & wsl.exe -e sh -c 'wslpath -w ~' 2>$null $code = $LASTEXITCODE } catch { $code = 1 } finally { $ErrorActionPreference = $old } # WSL tiada / tiada distro -> exit code bukan 0 dan output kosong if ($code -ne 0 -or $null -eq $out) { return $null } $winHome = ("" + @($out)[0]).Trim() -replace "`0", "" if (-not $winHome.StartsWith("\\")) { return $null } $script:HermesHomeCache = Join-Path $winHome ".hermes" return $script:HermesHomeCache } function Get-HermesConfig([bool]$required = $true) { $h = Get-HermesHome if (-not $h) { if ($required) { Die "folder Hermes tidak dijumpai. Hermes perlukan WSL2 di Windows - pasang Hermes dalam WSL dahulu, atau set HERMES_HOME." } return $null } return (Join-Path $h "config.yaml") } function Read-HermesLines([string]$path) { if (-not (Test-Path -LiteralPath $path)) { return @() } $t = (Read-TextFile $path).Text if (-not $t) { return @() } return @($t -split "\r?\n") } function Remove-HermesManaged([string[]]$lines) { $out = New-Object System.Collections.Generic.List[string] $inBlock = $false foreach ($line in $lines) { if ($line -eq $HermesBegin) { $inBlock = $true; continue } if ($inBlock) { if ($line -eq $HermesEnd) { $inBlock = $false }; continue } if ($line.StartsWith($HermesDisabled)) { $out.Add($line.Substring($HermesDisabled.Length)); continue } $out.Add($line) } while ($out.Count -gt 0 -and -not $out[$out.Count - 1].Trim()) { $out.RemoveAt($out.Count - 1) } return ,$out } function Write-HermesLines([string]$path, $lines) { New-Item -ItemType Directory -Force -Path (Split-Path -Parent $path) | Out-Null $text = "" if ($lines.Count -gt 0) { $text = ($lines -join "`n") + "`n" } [IO.File]::WriteAllText($path, $text, $Utf8NoBom) } function Hermes-Use([string]$p) { Assert-Provider $p $c = Get-ProviderConf $p $path = Get-HermesConfig $baseUrl = "$($c.Base)/v1" $backup = Backup-File $path $p $lines = Remove-HermesManaged (Read-HermesLines $path) $block = @( $HermesBegin, "model:", " provider: custom", " default: $(Quote-Str $c.Hermes)", " base_url: $(Quote-Str $baseUrl)", " api_key: $(Quote-Str $c.Token)", " api_mode: chat_completions", $HermesEnd ) # Cari blok "model:" top-level milik user: baris "model:" di lajur 0 # hingga baris seterusnya yang tidak berinden. $out = New-Object System.Collections.Generic.List[string] $inserted = $false $inModel = $false foreach ($line in $lines) { if ($inModel) { if ($line -eq "" -or $line -match '^\s') { $out.Add($HermesDisabled + $line); continue } $inModel = $false } if (-not $inserted -and $line -match '^model\s*:') { foreach ($b in $block) { $out.Add($b) } $out.Add($HermesDisabled + $line) $inserted = $true $inModel = $true continue } $out.Add($line) } if (-not $inserted) { if ($out.Count -gt 0) { $out.Add("") } foreach ($b in $block) { $out.Add($b) } } try { Write-HermesLines $path $out } catch { Die "gagal mengemas kini config.yaml: $($_.Exception.Message) (backup: $backup)" } Info "Hermes ditukar ke: $p" Info " file : $path" Info " backup : $backup" Info " base : $baseUrl" Info " model : $($c.Hermes)" Info "" Info "Tutup Hermes dan buka semula. Nama model boleh ditukar dengan baris" Info "MODEL_HERMES=... dalam $ProvidersHome\$p.conf" } function Hermes-Off { $path = Get-HermesConfig if (-not (Test-Path -LiteralPath $path)) { Die "fail tetapan tidak dijumpai: $path" } $backup = Backup-File $path "off" try { Write-HermesLines $path (Remove-HermesManaged (Read-HermesLines $path)) } catch { Die "gagal mengemas kini config.yaml: $($_.Exception.Message) (backup: $backup)" } Info "Hermes: tetapan switch-api dibuang - tetapan asal Hermes dipulihkan." Info "backup: $backup" Info "Tutup Hermes dan buka semula." } # $probeWsl = $false untuk 'current' semua app, supaya WSL tak dihidupkan function Hermes-Current([bool]$probeWsl = $true) { $h = Get-HermesHome $probeWsl if (-not $h) { if ($probeWsl) { Info "[Hermes Agent] (tidak dijumpai - Hermes perlukan WSL2)" } else { Info "[Hermes Agent] (semak dengan: switch-api hermes current)" } return } $path = Join-Path $h "config.yaml" Info "[Hermes Agent] $path" $lines = Read-HermesLines $path if ($lines.Count -eq 0) { Info " (fail belum wujud - guna tetapan lalai Hermes)"; return } $inBlock = $false; $vals = @{} foreach ($line in $lines) { if ($line -eq $HermesBegin) { $inBlock = $true; continue } if ($line -eq $HermesEnd) { break } if ($inBlock -and $line -match '^\s+(default|base_url)\s*:\s*"?([^"]*)"?\s*$') { $vals[$Matches[1]] = $Matches[2] } } if (-not $inBlock) { Info " provider : (tiada switch-api - guna tetapan Hermes sendiri)"; return } Info " base url : $($vals['base_url'])" Info " model : $($vals['default'])" } # =============================================================== # Arahan umum # =============================================================== function Show-Usage { @" switch-api - tukar provider API untuk Claude Code, OpenCode dan Hermes Agent switch-api setup simpan API key (sekali, untuk semua app) switch-api list senarai provider & status key switch-api current tetapan aktif untuk semua app switch-api use tukar provider untuk app itu switch-api off buang tetapan switch-api dari app itu switch-api current tetapan aktif app itu switch-api install pasang jadi arahan 'switch-api' switch-api uninstall buang arahan 'switch-api' App : $($Apps -join ' ') (tanpa nama app = claude) Provider : $($Providers -join ' ') Contoh : switch-api opencode use mirai VS Code : extension Claude Code ikut tetapan app 'claude' Hermes : perlukan WSL2 (dikesan secara automatik) Fail key : $ProvidersHome\.conf (peribadi, jangan dikongsi) Dipasang : $InstallDir Lepas 'use' atau 'off', tutup app tersebut dan buka semula. "@ | Write-Host } function Cmd-List { Info "Provider tersedia:" foreach ($p in $Providers) { $status = if ($null -ne (Get-ProviderConf $p $false)) { "key ada" } else { "key belum diset" } Info (" {0,-8} {1,-30} {2}" -f $p, $ProviderBase[$p], $status) } Info "" Cmd-CurrentAll } function Cmd-CurrentAll { Claude-Current OpenCode-Current Hermes-Current $false } function Cmd-Setup([string]$p) { Assert-Provider $p New-Item -ItemType Directory -Force -Path $ProvidersHome | Out-Null $conf = Join-Path $ProvidersHome "$p.conf" $sec = Read-Host "Key API untuk $p" -AsSecureString $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec) try { $token = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr).Trim() } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } if (-not $token) { Die "key kosong, tiada apa diubah" } $m = $ProviderModel[$p] $lines = @( "# Key API untuk $p - fail PERIBADI. Jangan dikongsi atau commit.", "# Pilihan (nyahkomen untuk override):", "# BASE=$($ProviderBase[$p])", "# MODEL_OPUS=$($m.opus)", "# MODEL_SONNET=$($m.sonnet)", "# MODEL_HAIKU=$($m.haiku)", "# MODEL_HERMES=$($m.opus)", "TOKEN=$token" ) # Tulis tanpa BOM supaya baris TOKEN= dibaca dengan tepat [IO.File]::WriteAllText($conf, ($lines -join "`n") + "`n", $Utf8NoBom) Info "Key disimpan: $conf" Info "Seterusnya, contoh: switch-api claude use $p" } # --------------------------------------------------------------- # Pasang / buang # --------------------------------------------------------------- # PATH user dibaca/ditulis terus dari registry supaya entri %USERPROFILE% # kekal tidak dikembangkan dan jenisnya kekal REG_EXPAND_SZ. function Get-UserPathParts { $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey("Environment") try { $userPath = [string]$key.GetValue("Path", "", [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) } finally { $key.Close() } if (-not $userPath) { return @() } return @($userPath -split ";" | Where-Object { $_ -ne "" }) } function Set-UserPathParts([string[]]$parts) { $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey("Environment", $true) try { $key.SetValue("Path", ($parts -join ";"), [Microsoft.Win32.RegistryValueKind]::ExpandString) } finally { $key.Close() } # Set & buang pembolehubah sementara untuk hantar notis WM_SETTINGCHANGE, # supaya terminal baru nampak PATH terkini. [Environment]::SetEnvironmentVariable("SWITCH_API_REFRESH", "1", "User") [Environment]::SetEnvironmentVariable("SWITCH_API_REFRESH", $null, "User") } function Same-Dir([string]$a, [string]$b) { $a = [Environment]::ExpandEnvironmentVariables($a).TrimEnd("\").ToLower() $b = [Environment]::ExpandEnvironmentVariables($b).TrimEnd("\").ToLower() return $a -eq $b } function Cmd-Install { New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null $core = Join-Path $InstallDir $CoreName if (-not (Same-Dir $PSCommandPath $core)) { Copy-Item -LiteralPath $PSCommandPath -Destination $core -Force } # Pembungkus .cmd: elak sekatan ExecutionPolicy, dan boleh guna dari CMD juga. # Laluan penuh powershell.exe supaya tak bergantung pada PATH. # '& exit /b' dalam baris sama supaya CMD tak cari fail ini lagi lepas uninstall. $shim = "@""%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"" -NoProfile -ExecutionPolicy Bypass -File `"%~dp0$CoreName`" %* & exit /b`r`n" [IO.File]::WriteAllText((Join-Path $InstallDir $ShimName), $shim, (New-Object System.Text.ASCIIEncoding)) Info "Dipasang: $InstallDir" $parts = Get-UserPathParts $already = $false foreach ($d in $parts) { if (Same-Dir $d $InstallDir) { $already = $true } } if (-not $already) { Set-UserPathParts (@($parts) + $InstallDir) Info "PATH user ditambah: $InstallDir" } Info "Tutup dan buka semula terminal (PowerShell / CMD), kemudian taip: switch-api list" } function Cmd-Uninstall { $parts = Get-UserPathParts $kept = @($parts | Where-Object { -not (Same-Dir $_ $InstallDir) }) if ($kept.Count -ne $parts.Count) { Set-UserPathParts $kept Info "PATH user dibuang: $InstallDir" } $has = @() if (Test-Path -LiteralPath $Settings) { $has += "claude" } if (Test-Path -LiteralPath $OpenCodeConfig) { $has += "opencode" } $hermesCfg = Get-HermesConfig $false if ($hermesCfg -and (Test-Path -LiteralPath $hermesCfg)) { $has += "hermes" } if ($has.Count -gt 0 -and (Ask-Yes "Buang juga tetapan switch-api dari $($has -join ', ') (sama macam 'off')?")) { if ($has -contains "claude") { Claude-Off } if ($has -contains "opencode") { OpenCode-Off } if ($has -contains "hermes") { Hermes-Off } } if ((Test-Path -LiteralPath $ProvidersHome) -and (Ask-Yes "Padam juga semua key dalam $ProvidersHome?")) { Remove-Item -LiteralPath $ProvidersHome -Recurse -Force Info "Key dipadam: $ProvidersHome" } else { Info "Key disimpan (tidak dipadam): $ProvidersHome" } if (Test-Path -LiteralPath $InstallDir) { if (Same-Dir (Split-Path -Parent $PSCommandPath) $InstallDir) { # Sedang jalan dari folder pasang (melalui switch-api.cmd): padam folder # selepas skrip ini & CMD tamat, kalau tidak CMD ralat cari fail .cmd. Start-Process -FilePath "cmd.exe" -WindowStyle Hidden ` -ArgumentList "/c ping -n 3 127.0.0.1 >nul & rd /s /q `"$InstallDir`"" } else { Remove-Item -LiteralPath $InstallDir -Recurse -Force } Info "Dibuang: $InstallDir" } else { Info "switch-api tidak dipasang dalam $InstallDir" } Info "Siap. Fail backup *.bak.* tidak diusik." } # --------------------------------------------------------------- # switch-api [app] [provider] (tanpa app = claude) # --------------------------------------------------------------- $app = "claude" $appGiven = $false $cmd = $A1.ToLower() $name = $A2 if ($Apps -contains $cmd) { $app = $cmd; $appGiven = $true $cmd = $A2.ToLower(); $name = $A3 } switch ($cmd) { { $_ -in "use", "switch" } { switch ($app) { "claude" { Claude-Use $name } "opencode" { OpenCode-Use $name } "hermes" { Hermes-Use $name } } break } { $_ -in "off", "clear" } { switch ($app) { "claude" { Claude-Off } "opencode" { OpenCode-Off } "hermes" { Hermes-Off } } break } { $_ -in "current", "show" } { if (-not $appGiven) { Cmd-CurrentAll; break } switch ($app) { "claude" { Claude-Current } "opencode" { OpenCode-Current } "hermes" { Hermes-Current } } break } { $_ -in "list", "ls" } { Cmd-List; break } "setup" { Cmd-Setup $name; break } "install" { Cmd-Install; break } { $_ -in "uninstall", "remove" } { Cmd-Uninstall; break } { $_ -in "", "-h", "--help", "help" } { Show-Usage; break } default { Show-Usage; exit 1 } }