Guest User

Agentrouter-claude launcher

a guest
Jul 31st, 2026
38
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
PowerShell 16.58 KB | Software | 0 0
  1. <#
  2. .SYNOPSIS
  3.     Launch Claude Code CLI through the Agent Router proxy (https://agentrouter.org)
  4.     with interactive model selection.
  5.  
  6. .DESCRIPTION
  7.     This launcher:
  8.       * Fetches the available model list from the Agent Router /v1/models endpoint
  9.         (using the same auth token as the main API).
  10.       * Lets you pick a model from an interactive console menu.
  11.       * Persists your choice (and auth token, if you enter one) to
  12.         agentrouter-config.json next to this script.
  13.       * Sets ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL and
  14.         launches `claude`.
  15.  
  16.     On subsequent runs the saved model is used automatically (fast path). Use
  17.     -Select to choose a different model, or -List to inspect what is available.
  18.  
  19. .PARAMETER Model
  20.     Launch with this model ID directly, bypassing the menu. Not persisted.
  21.  
  22. .PARAMETER Select
  23.     Force the interactive selection menu, even when a model is already saved.
  24.  
  25. .PARAMETER SkipMenu
  26.     Never show the interactive menu. Use the saved model (or -Model); fail if
  27.     there is no saved model. Useful for automation.
  28.  
  29. .PARAMETER List
  30.     Fetch and print the available models, then exit without launching.
  31.  
  32. .PARAMETER ConfigPath
  33.     Path to the JSON config file. Defaults to agentrouter-config.json next to
  34.     this script.
  35.  
  36. .PARAMETER ClaudeArgs
  37.     Any additional arguments are forwarded verbatim to the claude CLI. Pass
  38.     them after `--` (e.g. `.\agentrouter-claude.ps1 -- -y`) so PowerShell does
  39.     not try to bind them to this script's own parameters.
  40.  
  41. .EXAMPLE
  42.     .\agentrouter-claude.ps1
  43.     .\agentrouter-claude.ps1 -Select
  44.     .\agentrouter-claude.ps1 -Model claude-sonnet-5
  45.     .\agentrouter-claude.ps1 -List
  46.     .\agentrouter-claude.ps1 -- -y
  47. #>
  48. [CmdletBinding(PositionalBinding = $false)]
  49. param(
  50.     [string]$Model,
  51.     [switch]$Select,
  52.     [switch]$SkipMenu,
  53.     [switch]$List,
  54.     [string]$ConfigPath,
  55.     [Parameter(ValueFromRemainingArguments = $true)]
  56.     [string[]]$ClaudeArgs
  57. )
  58.  
  59. $ErrorActionPreference = 'Stop'
  60. Set-StrictMode -Version Latest
  61. # Windows PowerShell 5.1 defaults to TLS 1.0; the gateway requires TLS 1.2+.
  62. [System.Net.ServicePointManager]::SecurityProtocol = `
  63.     [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
  64.  
  65. # --- Script paths -----------------------------------------------------------
  66. $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
  67. if ([string]::IsNullOrWhiteSpace($ConfigPath)) {
  68.     $ConfigPath = Join-Path $scriptDir 'agentrouter-config.json'
  69. }
  70.  
  71. $baseUrl   = 'https://agentrouter.org'
  72. $modelsUrl = "$baseUrl/v1/models"
  73.  
  74. # --- Config helpers ---------------------------------------------------------
  75.  
  76. function Get-ConfigValue {
  77.     param($Config, [string]$Name)
  78.     if ($null -eq $Config) { return '' }
  79.     $prop = $Config.PSObject.Properties[$Name]
  80.     if ($null -ne $prop) { return [string]$prop.Value }
  81.     return ''
  82. }
  83.  
  84. function Get-AgentRouterConfig {
  85.     param([string]$Path)
  86.     if (Test-Path -LiteralPath $Path) {
  87.         try {
  88.             return Get-Content -LiteralPath $Path -Raw -Encoding utf8 | ConvertFrom-Json
  89.         } catch {
  90.             Write-Warning "Config file '$Path' is invalid JSON; starting fresh."
  91.         }
  92.     }
  93.     return [PSCustomObject]@{ authToken = ''; model = '' }
  94. }
  95.  
  96. function Restrict-ConfigFileAcl {
  97.     param([string]$Path)
  98.     if (-not (Test-Path -LiteralPath $Path)) { return }
  99.     # Best-effort hardening: make the config file readable only by the current
  100.     # user. Not all environments grant SeSecurityPrivilege (needed to strip
  101.     # inherited ACEs), so failures are silently ignored.
  102.     try {
  103.         $owner = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
  104.         $rule  = New-Object System.Security.AccessControl.FileSystemAccessRule($owner, 'FullControl', 'Allow')
  105.         $acl   = Get-Acl -LiteralPath $Path
  106.         $acl.SetAccessRuleProtection($true, $false)   # drop inherited permissions
  107.         $acl.AddAccessRule($rule)
  108.         Set-Acl -LiteralPath $Path -AclObject $acl
  109.     } catch {
  110.         # Non-fatal: the file is still usable, just inherits default ACLs.
  111.     }
  112. }
  113.  
  114. function Save-AgentRouterConfig {
  115.     param([string]$Path, [string]$Token, [string]$Model)
  116.     $config = [ordered]@{
  117.         authToken = $Token
  118.         model     = $Model
  119.     }
  120.     $config | ConvertTo-Json | Set-Content -LiteralPath $Path -Encoding utf8
  121.     Restrict-ConfigFileAcl -Path $Path
  122. }
  123.  
  124. # --- Agent Router API -------------------------------------------------------
  125.  
  126. function Get-ErrorDetailFromBody {
  127.     param([string]$Body)
  128.     # Pull a human-readable message out of a JSON error body like
  129.     # {"error":{"message":"..."}} without tripping Set-StrictMode on a missing
  130.     # property. Returns '' when the body is not a usable error.
  131.     if ([string]::IsNullOrWhiteSpace($Body)) { return '' }
  132.     try {
  133.         $obj = $Body | ConvertFrom-Json
  134.         if ($null -eq $obj) { return '' }
  135.         $errProp = $obj.PSObject.Properties['error']
  136.         if ($null -ne $errProp -and $null -ne $errProp.Value) {
  137.             $msgProp = $errProp.Value.PSObject.Properties['message']
  138.             if ($null -ne $msgProp -and -not [string]::IsNullOrWhiteSpace([string]$msgProp.Value)) {
  139.                 return [string]$msgProp.Value
  140.             }
  141.         }
  142.         $mProp = $obj.PSObject.Properties['message']
  143.         if ($null -ne $mProp -and -not [string]::IsNullOrWhiteSpace([string]$mProp.Value)) {
  144.             return [string]$mProp.Value
  145.         }
  146.     } catch { }
  147.     return ''
  148. }
  149.  
  150. function Get-AgentRouterModels {
  151.     param([string]$Token)
  152.     # Agent Router routes /v1/models to the real gateway only for clients it
  153.     # recognizes. Without the Kilo Code User-Agent, a front gate answers with a
  154.     # generic 401 "unauthorized client detected" regardless of token validity,
  155.     # so we reproduce Kilo Code's OpenAI-compatible headers exactly. We use
  156.     # HttpWebRequest rather than Invoke-RestMethod because PowerShell would
  157.     # override the User-Agent and swallow the response body on error statuses.
  158.     Write-Host "Fetching available models from $modelsUrl ..." -ForegroundColor DarkGray
  159.     $status = 0
  160.     $body = ''
  161.     try {
  162.         $req = [System.Net.HttpWebRequest]::Create($modelsUrl)
  163.         $req.Method = 'GET'
  164.         $req.Timeout = 30000
  165.         $req.Accept = 'application/json'
  166.         $req.UserAgent = 'KiloCode/1.0'
  167.         $req.Headers['Authorization'] = "Bearer $Token"
  168.         try {
  169.             $httpResp = $req.GetResponse()
  170.         } catch [System.Net.WebException] {
  171.             $httpResp = $_.Exception.Response
  172.             if ($null -eq $httpResp) { throw }
  173.         }
  174.         $status = [int]$httpResp.StatusCode
  175.         $stream = $httpResp.GetResponseStream()
  176.         if ($null -ne $stream) {
  177.             $reader = New-Object System.IO.StreamReader($stream)
  178.             try { $body = $reader.ReadToEnd() } finally { $reader.Dispose(); $stream.Dispose() }
  179.         }
  180.         $httpResp.Close()
  181.     } catch {
  182.         Write-Host "Failed to fetch models from Agent Router: $($_.Exception.Message)" -ForegroundColor Red
  183.         return $null
  184.     }
  185.  
  186.     if ($status -lt 200 -or $status -ge 300) {
  187.         $detail = Get-ErrorDetailFromBody -Body $body
  188.         if ($detail) {
  189.             Write-Host "Failed to fetch models from Agent Router (HTTP $status): $detail" -ForegroundColor Red
  190.         } else {
  191.             Write-Host "Failed to fetch models from Agent Router (HTTP $status)." -ForegroundColor Red
  192.         }
  193.         return $null
  194.     }
  195.  
  196.     $resp = $null
  197.     try { $resp = $body | ConvertFrom-Json } catch { }
  198.     if ($null -eq $resp) {
  199.         Write-Output -NoEnumerate @()
  200.         return
  201.     }
  202.  
  203.     # Lenient parsing across common response shapes: OpenAI-style `data[].id`,
  204.     # a `models` array (strings or objects), a flat array of strings, or a
  205.     # single model object. Property presence is checked via PSObject.Properties
  206.     # so Set-StrictMode never throws on a missing property.
  207.     $dataProp   = $resp.PSObject.Properties['data']
  208.     $modelsProp = $resp.PSObject.Properties['models']
  209.     $idProp     = $resp.PSObject.Properties['id']
  210.  
  211.     $entries = $null
  212.     if ($null -ne $dataProp) {
  213.         $entries = $dataProp.Value
  214.     } elseif ($null -ne $modelsProp) {
  215.         $entries = $modelsProp.Value
  216.     } elseif ($null -ne $idProp) {
  217.         $entries = @($resp)   # single model object
  218.     } elseif ($resp -is [System.Array]) {
  219.         $entries = $resp      # flat array of model IDs
  220.     } else {
  221.         $entries = @()        # unknown shape
  222.     }
  223.     if ($entries -is [string]) { $entries = @($entries) }   # avoid char-by-char foreach
  224.  
  225.     $ids = [System.Collections.Generic.List[string]]::new()
  226.     foreach ($m in $entries) {
  227.         if ($m -is [string]) {
  228.             if (-not [string]::IsNullOrWhiteSpace($m)) { $ids.Add($m) }
  229.             continue
  230.         }
  231.         $id = ''
  232.         $p = $m.PSObject.Properties['id']
  233.         if ($null -ne $p) { $id = [string]$p.Value }
  234.         if ([string]::IsNullOrWhiteSpace($id)) {
  235.             $p = $m.PSObject.Properties['model']
  236.             if ($null -ne $p) { $id = [string]$p.Value }
  237.         }
  238.         if (-not [string]::IsNullOrWhiteSpace($id)) { $ids.Add($id) }
  239.     }
  240.  
  241.     # -NoEnumerate keeps single-element (and empty) results as an array instead
  242.     # of letting PowerShell unroll them into a scalar or $null.
  243.     $sorted = @($ids | Sort-Object -Unique)
  244.     Write-Output -NoEnumerate $sorted
  245. }
  246.  
  247. # --- Interactive menu -------------------------------------------------------
  248.  
  249. function Select-ModelInteractively {
  250.     param([string[]]$Models, [string]$Current = '')
  251.  
  252.     $current = [string]$Current
  253.     $currentNumber = 0
  254.     if (-not [string]::IsNullOrWhiteSpace($current)) {
  255.         for ($i = 0; $i -lt $Models.Count; $i++) {
  256.             if ($Models[$i] -eq $current) { $currentNumber = $i + 1; break }
  257.         }
  258.     }
  259.  
  260.     while ($true) {
  261.         Write-Host ''
  262.         Write-Host '=== Select a model ===' -ForegroundColor Cyan
  263.         for ($i = 0; $i -lt $Models.Count; $i++) {
  264.             $marker = ''
  265.             if ($currentNumber -eq ($i + 1)) { $marker = '  (current)' }
  266.             Write-Host ('{0,3}. {1}{2}' -f ($i + 1), $Models[$i], $marker)
  267.         }
  268.         Write-Host ('{0,3}. {1}' -f 0, 'Quit (do not launch)')
  269.  
  270.         $hint = if ($currentNumber -gt 0) { ' [Enter] keep current' } else { '' }
  271.         $choice = Read-Host "Select by number or type a model ID$hint"
  272.  
  273.         if ([string]::IsNullOrWhiteSpace($choice)) {
  274.             if ($currentNumber -gt 0) { return $current }
  275.             Write-Host 'No selection entered.' -ForegroundColor Yellow
  276.             continue
  277.         }
  278.         if ($choice -eq 'q' -or $choice -eq 'Q' -or $choice -eq '0') { return $null }
  279.  
  280.         if ($choice -match '^\d+$') {
  281.             $n = [int]$choice
  282.             if ($n -ge 1 -and $n -le $Models.Count) { return $Models[$n - 1] }
  283.             Write-Host "'$choice' is out of range (1-$($Models.Count))." -ForegroundColor Yellow
  284.             continue
  285.         }
  286.  
  287.         $exact = @($Models | Where-Object { $_ -eq $choice })
  288.         if ($exact.Count -eq 1) { return $exact[0] }
  289.  
  290.         $matched = @($Models | Where-Object { $_ -like "*$choice*" })
  291.         if ($matched.Count -eq 1) { return $matched[0] }
  292.         if ($matched.Count -gt 1) {
  293.             Write-Host "Multiple models match '$choice':" -ForegroundColor Yellow
  294.             foreach ($m in $matched) { Write-Host "  - $m" }
  295.             continue
  296.         }
  297.         Write-Host "No model matches '$choice'." -ForegroundColor Yellow
  298.     }
  299. }
  300.  
  301. function Read-Token {
  302.     $sec = Read-Host -Prompt 'Enter your Agent Router auth token' -AsSecureString
  303.     $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
  304.     try {
  305.         return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
  306.     } finally {
  307.         [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
  308.     }
  309. }
  310.  
  311. function Write-FatalError {
  312.     param([string]$Message)
  313.     # Clean user-facing failure. Uses Write-Host (not Write-Error) so it does
  314.     # not throw a terminating error under $ErrorActionPreference = 'Stop'.
  315.     Write-Host "ERROR: $Message" -ForegroundColor Red
  316.     exit 1
  317. }
  318.  
  319. # --- Main -------------------------------------------------------------------
  320.  
  321. if ($Select -and $SkipMenu) {
  322.     Write-FatalError '-Select and -SkipMenu are mutually exclusive.'
  323. }
  324.  
  325. $config     = Get-AgentRouterConfig -Path $ConfigPath
  326. $savedToken = Get-ConfigValue $config 'authToken'
  327. $savedModel = Get-ConfigValue $config 'model'
  328.  
  329. # Resolve the auth token: an environment value wins over the saved config;
  330. # otherwise prompt for one. Only a freshly prompted token is written back to disk.
  331. $token = if ($env:ANTHROPIC_AUTH_TOKEN) { $env:ANTHROPIC_AUTH_TOKEN } else { $savedToken }
  332. $tokenChanged = $false
  333. if ([string]::IsNullOrWhiteSpace($token)) {
  334.     Write-Host 'No auth token found in the environment or config.' -ForegroundColor Yellow
  335.     $token = Read-Token
  336.     if ([string]::IsNullOrWhiteSpace($token)) {
  337.         Write-FatalError 'An auth token is required to use Agent Router.'
  338.     }
  339.     $tokenChanged = $true
  340. }
  341.  
  342. if ($List) {
  343.     $models = Get-AgentRouterModels -Token $token
  344.     if ($null -eq $models) { exit 1 }
  345.     Write-Host ''
  346.     Write-Host "Agent Router models ($($models.Count) available):" -ForegroundColor Cyan
  347.     foreach ($m in $models) { Write-Host "  $m" }
  348.     exit 0
  349. }
  350.  
  351. # Determine which model to launch with.
  352. $finalModel = ''
  353. $chosenViaMenu = $false   # true only when the model came from the interactive menu
  354. if ($Model) {
  355.     $finalModel = $Model
  356. } elseif ($Select) {
  357.     $models = Get-AgentRouterModels -Token $token
  358.     if ($null -eq $models) {
  359.         if ($savedModel) {
  360.             Write-Warning "Could not fetch the model list; falling back to saved model '$savedModel'."
  361.             $finalModel = $savedModel
  362.         } else {
  363.             exit 1
  364.         }
  365.     } else {
  366.         $finalModel = Select-ModelInteractively -Models $models -Current $savedModel
  367.         $chosenViaMenu = $true
  368.     }
  369. } elseif (-not [string]::IsNullOrWhiteSpace($savedModel)) {
  370.     $finalModel = $savedModel
  371.     if (-not $SkipMenu) {
  372.         Write-Host "Using saved model '$savedModel'. Pass -Select to choose another, or -List to see available models." -ForegroundColor DarkGray
  373.     }
  374. } else {
  375.     if ($SkipMenu) {
  376.         Write-FatalError 'No saved model and -SkipMenu was specified. Use -Select, -Model, or drop -SkipMenu.'
  377.     }
  378.     $models = Get-AgentRouterModels -Token $token
  379.     if ($null -eq $models) {
  380.         Write-FatalError "No model selected and the model list could not be fetched. Use -Model <id> to launch with a specific model."
  381.     }
  382.     $finalModel = Select-ModelInteractively -Models $models -Current ''
  383.     $chosenViaMenu = $true
  384. }
  385.  
  386. if ([string]::IsNullOrWhiteSpace($finalModel)) {
  387.     Write-Host 'Aborted - no model selected.' -ForegroundColor Yellow
  388.     exit 0
  389. }
  390.  
  391. # Persist only deliberate choices: a model picked from the interactive menu, or
  392. # an auth token entered by the user. A `-Model` override is a one-off and does
  393. # not touch the saved config.
  394. if ($tokenChanged -or $chosenViaMenu) {
  395.     $persistToken = if ($tokenChanged) { $token } else { $savedToken }
  396.     Save-AgentRouterConfig -Path $ConfigPath -Token $persistToken -Model $finalModel
  397.     Write-Host "Saved model '$finalModel' to $ConfigPath" -ForegroundColor DarkGray
  398. }
  399.  
  400. # --- Set environment and launch claude --------------------------------------
  401. $env:ANTHROPIC_BASE_URL   = $baseUrl
  402. $env:ANTHROPIC_AUTH_TOKEN = $token
  403. $env:ANTHROPIC_MODEL      = $finalModel
  404.  
  405. Write-Host ''
  406. Write-Host 'Launching Claude Code via Agent Router...' -ForegroundColor Green
  407. Write-Host ("  Model : {0}" -f $finalModel) -ForegroundColor Green
  408. Write-Host ("  Base  : {0}" -f $baseUrl) -ForegroundColor Green
  409. Write-Host ''
  410.  
  411. # Resolve claude first so a missing installation is reported cleanly.
  412. $claudeCmd = Get-Command claude -ErrorAction SilentlyContinue | Select-Object -First 1
  413. if ($null -eq $claudeCmd) {
  414.     Write-FatalError "'claude' command not found. Is Claude Code installed and on PATH?"
  415. }
  416. $claudeIsNative = ($claudeCmd.CommandType -eq 'Application')
  417.  
  418. try {
  419.     & claude @ClaudeArgs
  420. } catch {
  421.     Write-FatalError "Failed to launch claude: $($_.Exception.Message)"
  422. }
  423.  
  424. # Propagate claude's exit code, but only for native executables (the common
  425. # case, e.g. claude.exe). For a PowerShell script or function, $LASTEXITCODE is
  426. # not reliably set by claude itself, so report success rather than a stale value.
  427. $claudeExit = 0
  428. if ($claudeIsNative) {
  429.     try { $claudeExit = [int]$LASTEXITCODE } catch { }
  430. }
  431. exit $claudeExit
  432.  
Add Comment
Please, Sign In to add comment