Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- <#
- .SYNOPSIS
- Launch Claude Code CLI through the Agent Router proxy (https://agentrouter.org)
- with interactive model selection.
- .DESCRIPTION
- This launcher:
- * Fetches the available model list from the Agent Router /v1/models endpoint
- (using the same auth token as the main API).
- * Lets you pick a model from an interactive console menu.
- * Persists your choice (and auth token, if you enter one) to
- agentrouter-config.json next to this script.
- * Sets ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL and
- launches `claude`.
- On subsequent runs the saved model is used automatically (fast path). Use
- -Select to choose a different model, or -List to inspect what is available.
- .PARAMETER Model
- Launch with this model ID directly, bypassing the menu. Not persisted.
- .PARAMETER Select
- Force the interactive selection menu, even when a model is already saved.
- .PARAMETER SkipMenu
- Never show the interactive menu. Use the saved model (or -Model); fail if
- there is no saved model. Useful for automation.
- .PARAMETER List
- Fetch and print the available models, then exit without launching.
- .PARAMETER ConfigPath
- Path to the JSON config file. Defaults to agentrouter-config.json next to
- this script.
- .PARAMETER ClaudeArgs
- Any additional arguments are forwarded verbatim to the claude CLI. Pass
- them after `--` (e.g. `.\agentrouter-claude.ps1 -- -y`) so PowerShell does
- not try to bind them to this script's own parameters.
- .EXAMPLE
- .\agentrouter-claude.ps1
- .\agentrouter-claude.ps1 -Select
- .\agentrouter-claude.ps1 -Model claude-sonnet-5
- .\agentrouter-claude.ps1 -List
- .\agentrouter-claude.ps1 -- -y
- #>
- [CmdletBinding(PositionalBinding = $false)]
- param(
- [string]$Model,
- [switch]$Select,
- [switch]$SkipMenu,
- [switch]$List,
- [string]$ConfigPath,
- [Parameter(ValueFromRemainingArguments = $true)]
- [string[]]$ClaudeArgs
- )
- $ErrorActionPreference = 'Stop'
- Set-StrictMode -Version Latest
- # Windows PowerShell 5.1 defaults to TLS 1.0; the gateway requires TLS 1.2+.
- [System.Net.ServicePointManager]::SecurityProtocol = `
- [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
- # --- Script paths -----------------------------------------------------------
- $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
- if ([string]::IsNullOrWhiteSpace($ConfigPath)) {
- $ConfigPath = Join-Path $scriptDir 'agentrouter-config.json'
- }
- $baseUrl = 'https://agentrouter.org'
- $modelsUrl = "$baseUrl/v1/models"
- # --- Config helpers ---------------------------------------------------------
- function Get-ConfigValue {
- param($Config, [string]$Name)
- if ($null -eq $Config) { return '' }
- $prop = $Config.PSObject.Properties[$Name]
- if ($null -ne $prop) { return [string]$prop.Value }
- return ''
- }
- function Get-AgentRouterConfig {
- param([string]$Path)
- if (Test-Path -LiteralPath $Path) {
- try {
- return Get-Content -LiteralPath $Path -Raw -Encoding utf8 | ConvertFrom-Json
- } catch {
- Write-Warning "Config file '$Path' is invalid JSON; starting fresh."
- }
- }
- return [PSCustomObject]@{ authToken = ''; model = '' }
- }
- function Restrict-ConfigFileAcl {
- param([string]$Path)
- if (-not (Test-Path -LiteralPath $Path)) { return }
- # Best-effort hardening: make the config file readable only by the current
- # user. Not all environments grant SeSecurityPrivilege (needed to strip
- # inherited ACEs), so failures are silently ignored.
- try {
- $owner = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
- $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($owner, 'FullControl', 'Allow')
- $acl = Get-Acl -LiteralPath $Path
- $acl.SetAccessRuleProtection($true, $false) # drop inherited permissions
- $acl.AddAccessRule($rule)
- Set-Acl -LiteralPath $Path -AclObject $acl
- } catch {
- # Non-fatal: the file is still usable, just inherits default ACLs.
- }
- }
- function Save-AgentRouterConfig {
- param([string]$Path, [string]$Token, [string]$Model)
- $config = [ordered]@{
- authToken = $Token
- model = $Model
- }
- $config | ConvertTo-Json | Set-Content -LiteralPath $Path -Encoding utf8
- Restrict-ConfigFileAcl -Path $Path
- }
- # --- Agent Router API -------------------------------------------------------
- function Get-ErrorDetailFromBody {
- param([string]$Body)
- # Pull a human-readable message out of a JSON error body like
- # {"error":{"message":"..."}} without tripping Set-StrictMode on a missing
- # property. Returns '' when the body is not a usable error.
- if ([string]::IsNullOrWhiteSpace($Body)) { return '' }
- try {
- $obj = $Body | ConvertFrom-Json
- if ($null -eq $obj) { return '' }
- $errProp = $obj.PSObject.Properties['error']
- if ($null -ne $errProp -and $null -ne $errProp.Value) {
- $msgProp = $errProp.Value.PSObject.Properties['message']
- if ($null -ne $msgProp -and -not [string]::IsNullOrWhiteSpace([string]$msgProp.Value)) {
- return [string]$msgProp.Value
- }
- }
- $mProp = $obj.PSObject.Properties['message']
- if ($null -ne $mProp -and -not [string]::IsNullOrWhiteSpace([string]$mProp.Value)) {
- return [string]$mProp.Value
- }
- } catch { }
- return ''
- }
- function Get-AgentRouterModels {
- param([string]$Token)
- # Agent Router routes /v1/models to the real gateway only for clients it
- # recognizes. Without the Kilo Code User-Agent, a front gate answers with a
- # generic 401 "unauthorized client detected" regardless of token validity,
- # so we reproduce Kilo Code's OpenAI-compatible headers exactly. We use
- # HttpWebRequest rather than Invoke-RestMethod because PowerShell would
- # override the User-Agent and swallow the response body on error statuses.
- Write-Host "Fetching available models from $modelsUrl ..." -ForegroundColor DarkGray
- $status = 0
- $body = ''
- try {
- $req = [System.Net.HttpWebRequest]::Create($modelsUrl)
- $req.Method = 'GET'
- $req.Timeout = 30000
- $req.Accept = 'application/json'
- $req.UserAgent = 'KiloCode/1.0'
- $req.Headers['Authorization'] = "Bearer $Token"
- try {
- $httpResp = $req.GetResponse()
- } catch [System.Net.WebException] {
- $httpResp = $_.Exception.Response
- if ($null -eq $httpResp) { throw }
- }
- $status = [int]$httpResp.StatusCode
- $stream = $httpResp.GetResponseStream()
- if ($null -ne $stream) {
- $reader = New-Object System.IO.StreamReader($stream)
- try { $body = $reader.ReadToEnd() } finally { $reader.Dispose(); $stream.Dispose() }
- }
- $httpResp.Close()
- } catch {
- Write-Host "Failed to fetch models from Agent Router: $($_.Exception.Message)" -ForegroundColor Red
- return $null
- }
- if ($status -lt 200 -or $status -ge 300) {
- $detail = Get-ErrorDetailFromBody -Body $body
- if ($detail) {
- Write-Host "Failed to fetch models from Agent Router (HTTP $status): $detail" -ForegroundColor Red
- } else {
- Write-Host "Failed to fetch models from Agent Router (HTTP $status)." -ForegroundColor Red
- }
- return $null
- }
- $resp = $null
- try { $resp = $body | ConvertFrom-Json } catch { }
- if ($null -eq $resp) {
- Write-Output -NoEnumerate @()
- return
- }
- # Lenient parsing across common response shapes: OpenAI-style `data[].id`,
- # a `models` array (strings or objects), a flat array of strings, or a
- # single model object. Property presence is checked via PSObject.Properties
- # so Set-StrictMode never throws on a missing property.
- $dataProp = $resp.PSObject.Properties['data']
- $modelsProp = $resp.PSObject.Properties['models']
- $idProp = $resp.PSObject.Properties['id']
- $entries = $null
- if ($null -ne $dataProp) {
- $entries = $dataProp.Value
- } elseif ($null -ne $modelsProp) {
- $entries = $modelsProp.Value
- } elseif ($null -ne $idProp) {
- $entries = @($resp) # single model object
- } elseif ($resp -is [System.Array]) {
- $entries = $resp # flat array of model IDs
- } else {
- $entries = @() # unknown shape
- }
- if ($entries -is [string]) { $entries = @($entries) } # avoid char-by-char foreach
- $ids = [System.Collections.Generic.List[string]]::new()
- foreach ($m in $entries) {
- if ($m -is [string]) {
- if (-not [string]::IsNullOrWhiteSpace($m)) { $ids.Add($m) }
- continue
- }
- $id = ''
- $p = $m.PSObject.Properties['id']
- if ($null -ne $p) { $id = [string]$p.Value }
- if ([string]::IsNullOrWhiteSpace($id)) {
- $p = $m.PSObject.Properties['model']
- if ($null -ne $p) { $id = [string]$p.Value }
- }
- if (-not [string]::IsNullOrWhiteSpace($id)) { $ids.Add($id) }
- }
- # -NoEnumerate keeps single-element (and empty) results as an array instead
- # of letting PowerShell unroll them into a scalar or $null.
- $sorted = @($ids | Sort-Object -Unique)
- Write-Output -NoEnumerate $sorted
- }
- # --- Interactive menu -------------------------------------------------------
- function Select-ModelInteractively {
- param([string[]]$Models, [string]$Current = '')
- $current = [string]$Current
- $currentNumber = 0
- if (-not [string]::IsNullOrWhiteSpace($current)) {
- for ($i = 0; $i -lt $Models.Count; $i++) {
- if ($Models[$i] -eq $current) { $currentNumber = $i + 1; break }
- }
- }
- while ($true) {
- Write-Host ''
- Write-Host '=== Select a model ===' -ForegroundColor Cyan
- for ($i = 0; $i -lt $Models.Count; $i++) {
- $marker = ''
- if ($currentNumber -eq ($i + 1)) { $marker = ' (current)' }
- Write-Host ('{0,3}. {1}{2}' -f ($i + 1), $Models[$i], $marker)
- }
- Write-Host ('{0,3}. {1}' -f 0, 'Quit (do not launch)')
- $hint = if ($currentNumber -gt 0) { ' [Enter] keep current' } else { '' }
- $choice = Read-Host "Select by number or type a model ID$hint"
- if ([string]::IsNullOrWhiteSpace($choice)) {
- if ($currentNumber -gt 0) { return $current }
- Write-Host 'No selection entered.' -ForegroundColor Yellow
- continue
- }
- if ($choice -eq 'q' -or $choice -eq 'Q' -or $choice -eq '0') { return $null }
- if ($choice -match '^\d+$') {
- $n = [int]$choice
- if ($n -ge 1 -and $n -le $Models.Count) { return $Models[$n - 1] }
- Write-Host "'$choice' is out of range (1-$($Models.Count))." -ForegroundColor Yellow
- continue
- }
- $exact = @($Models | Where-Object { $_ -eq $choice })
- if ($exact.Count -eq 1) { return $exact[0] }
- $matched = @($Models | Where-Object { $_ -like "*$choice*" })
- if ($matched.Count -eq 1) { return $matched[0] }
- if ($matched.Count -gt 1) {
- Write-Host "Multiple models match '$choice':" -ForegroundColor Yellow
- foreach ($m in $matched) { Write-Host " - $m" }
- continue
- }
- Write-Host "No model matches '$choice'." -ForegroundColor Yellow
- }
- }
- function Read-Token {
- $sec = Read-Host -Prompt 'Enter your Agent Router auth token' -AsSecureString
- $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
- try {
- return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
- } finally {
- [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
- }
- }
- function Write-FatalError {
- param([string]$Message)
- # Clean user-facing failure. Uses Write-Host (not Write-Error) so it does
- # not throw a terminating error under $ErrorActionPreference = 'Stop'.
- Write-Host "ERROR: $Message" -ForegroundColor Red
- exit 1
- }
- # --- Main -------------------------------------------------------------------
- if ($Select -and $SkipMenu) {
- Write-FatalError '-Select and -SkipMenu are mutually exclusive.'
- }
- $config = Get-AgentRouterConfig -Path $ConfigPath
- $savedToken = Get-ConfigValue $config 'authToken'
- $savedModel = Get-ConfigValue $config 'model'
- # Resolve the auth token: an environment value wins over the saved config;
- # otherwise prompt for one. Only a freshly prompted token is written back to disk.
- $token = if ($env:ANTHROPIC_AUTH_TOKEN) { $env:ANTHROPIC_AUTH_TOKEN } else { $savedToken }
- $tokenChanged = $false
- if ([string]::IsNullOrWhiteSpace($token)) {
- Write-Host 'No auth token found in the environment or config.' -ForegroundColor Yellow
- $token = Read-Token
- if ([string]::IsNullOrWhiteSpace($token)) {
- Write-FatalError 'An auth token is required to use Agent Router.'
- }
- $tokenChanged = $true
- }
- if ($List) {
- $models = Get-AgentRouterModels -Token $token
- if ($null -eq $models) { exit 1 }
- Write-Host ''
- Write-Host "Agent Router models ($($models.Count) available):" -ForegroundColor Cyan
- foreach ($m in $models) { Write-Host " $m" }
- exit 0
- }
- # Determine which model to launch with.
- $finalModel = ''
- $chosenViaMenu = $false # true only when the model came from the interactive menu
- if ($Model) {
- $finalModel = $Model
- } elseif ($Select) {
- $models = Get-AgentRouterModels -Token $token
- if ($null -eq $models) {
- if ($savedModel) {
- Write-Warning "Could not fetch the model list; falling back to saved model '$savedModel'."
- $finalModel = $savedModel
- } else {
- exit 1
- }
- } else {
- $finalModel = Select-ModelInteractively -Models $models -Current $savedModel
- $chosenViaMenu = $true
- }
- } elseif (-not [string]::IsNullOrWhiteSpace($savedModel)) {
- $finalModel = $savedModel
- if (-not $SkipMenu) {
- Write-Host "Using saved model '$savedModel'. Pass -Select to choose another, or -List to see available models." -ForegroundColor DarkGray
- }
- } else {
- if ($SkipMenu) {
- Write-FatalError 'No saved model and -SkipMenu was specified. Use -Select, -Model, or drop -SkipMenu.'
- }
- $models = Get-AgentRouterModels -Token $token
- if ($null -eq $models) {
- Write-FatalError "No model selected and the model list could not be fetched. Use -Model <id> to launch with a specific model."
- }
- $finalModel = Select-ModelInteractively -Models $models -Current ''
- $chosenViaMenu = $true
- }
- if ([string]::IsNullOrWhiteSpace($finalModel)) {
- Write-Host 'Aborted - no model selected.' -ForegroundColor Yellow
- exit 0
- }
- # Persist only deliberate choices: a model picked from the interactive menu, or
- # an auth token entered by the user. A `-Model` override is a one-off and does
- # not touch the saved config.
- if ($tokenChanged -or $chosenViaMenu) {
- $persistToken = if ($tokenChanged) { $token } else { $savedToken }
- Save-AgentRouterConfig -Path $ConfigPath -Token $persistToken -Model $finalModel
- Write-Host "Saved model '$finalModel' to $ConfigPath" -ForegroundColor DarkGray
- }
- # --- Set environment and launch claude --------------------------------------
- $env:ANTHROPIC_BASE_URL = $baseUrl
- $env:ANTHROPIC_AUTH_TOKEN = $token
- $env:ANTHROPIC_MODEL = $finalModel
- Write-Host ''
- Write-Host 'Launching Claude Code via Agent Router...' -ForegroundColor Green
- Write-Host (" Model : {0}" -f $finalModel) -ForegroundColor Green
- Write-Host (" Base : {0}" -f $baseUrl) -ForegroundColor Green
- Write-Host ''
- # Resolve claude first so a missing installation is reported cleanly.
- $claudeCmd = Get-Command claude -ErrorAction SilentlyContinue | Select-Object -First 1
- if ($null -eq $claudeCmd) {
- Write-FatalError "'claude' command not found. Is Claude Code installed and on PATH?"
- }
- $claudeIsNative = ($claudeCmd.CommandType -eq 'Application')
- try {
- & claude @ClaudeArgs
- } catch {
- Write-FatalError "Failed to launch claude: $($_.Exception.Message)"
- }
- # Propagate claude's exit code, but only for native executables (the common
- # case, e.g. claude.exe). For a PowerShell script or function, $LASTEXITCODE is
- # not reliably set by claude itself, so report success rather than a stale value.
- $claudeExit = 0
- if ($claudeIsNative) {
- try { $claudeExit = [int]$LASTEXITCODE } catch { }
- }
- exit $claudeExit
Add Comment
Please, Sign In to add comment