#requires -Version 5.1 <# .SYNOPSIS Idempotent Windows setup for Claude Code + CLIProxyAPI + claudex aliases. .DESCRIPTION - Installs Claude Code only when the `claude` command is missing. - Downloads CLIProxyAPI v7.2.71 only when the expected executable is missing (or when -ForceReinstall is supplied). - Creates/updates config.yaml, preserving a timestamped backup when replacing it. - Adds a managed `claudex` function to Windows PowerShell and PowerShell 7 profiles. - Runs Codex OAuth login only when no Codex auth JSON is detected. - Creates/updates a Task Scheduler task that starts at boot and restarts the proxy. - Starts the task and verifies that TCP port 8317 is listening. Run from PowerShell: powershell -ExecutionPolicy Bypass -File .\setup-claudex-proxy.ps1 Optional: .\setup-claudex-proxy.ps1 -SkipCodexLogin .\setup-claudex-proxy.ps1 -ForceReinstall .\setup-claudex-proxy.ps1 -NoStart #> [CmdletBinding()] param( [switch]$ForceReinstall, [switch]$SkipCodexLogin, [switch]$NoStart ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $ProxyVersion = '7.2.71' $ProxyArchiveName = "CLIProxyAPI_${ProxyVersion}_windows_amd64.zip" $ProxyDownloadUrl = "https://github.com/router-for-me/CLIProxyAPI/releases/download/v$ProxyVersion/$ProxyArchiveName" $InstallDir = Join-Path $env:ProgramFiles "CLIProxyAPI_${ProxyVersion}_windows_amd64" $ProxyExe = Join-Path $InstallDir 'cli-proxy-api.exe' $ConfigPath = Join-Path $InstallDir 'config.yaml' $RunnerPath = Join-Path $InstallDir 'run-proxy.ps1' $TaskName = 'CLIProxyAPI' $ProxyPort = 8317 $ProxyApiKey = 'sk-dummy' $AuthDir = Join-Path $env:USERPROFILE '.cli-proxy-api' function Write-Step { param([Parameter(Mandatory)][string]$Message) Write-Host "`n==> $Message" -ForegroundColor Cyan } function Write-Skip { param([Parameter(Mandatory)][string]$Message) Write-Host " SKIP: $Message" -ForegroundColor DarkGray } function Test-Administrator { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function Restart-Elevated { Write-Step 'Administrator rights are required; reopening this script with UAC' $forward = @( '-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ('"{0}"' -f $PSCommandPath) ) if ($ForceReinstall) { $forward += '-ForceReinstall' } if ($SkipCodexLogin) { $forward += '-SkipCodexLogin' } if ($NoStart) { $forward += '-NoStart' } Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList ($forward -join ' ') exit } function Refresh-ProcessPath { $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') $env:Path = @($machinePath, $userPath) -join ';' } function Write-Utf8NoBom { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Content ) $parent = Split-Path -Parent $Path if ($parent) { [IO.Directory]::CreateDirectory($parent) | Out-Null } $encoding = New-Object Text.UTF8Encoding($false) [IO.File]::WriteAllText($Path, $Content, $encoding) } function Set-ManagedFile { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Content, [switch]$BackupExisting ) $normalized = $Content.TrimEnd() + [Environment]::NewLine if (Test-Path -LiteralPath $Path) { $existing = [IO.File]::ReadAllText($Path) if ($existing -eq $normalized) { Write-Skip "$Path is already correct" return $false } if ($BackupExisting) { $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' $backup = "$Path.bak-$stamp" Copy-Item -LiteralPath $Path -Destination $backup -Force Write-Host " Backed up existing file to: $backup" } } Write-Utf8NoBom -Path $Path -Content $normalized Write-Host " Wrote: $Path" return $true } function Install-ClaudeCodeIfMissing { Write-Step 'Checking Claude Code' if (Get-Command 'claude' -ErrorAction SilentlyContinue) { $version = try { (& claude --version 2>$null | Select-Object -First 1) } catch { $null } Write-Skip ("Claude Code is already installed" + $(if ($version) { ": $version" } else { '' })) return } Write-Host ' Claude Code was not found. Running the official Windows installer...' $installer = Invoke-RestMethod -Uri 'https://claude.ai/install.ps1' Invoke-Expression $installer Refresh-ProcessPath if (-not (Get-Command 'claude' -ErrorAction SilentlyContinue)) { throw 'Claude Code installation finished, but `claude` is still unavailable in PATH. Open a new PowerShell window and rerun this script.' } Write-Host ' Claude Code installed.' } function Install-ProxyIfMissing { Write-Step "Checking CLIProxyAPI v$ProxyVersion" if ((Test-Path -LiteralPath $ProxyExe) -and -not $ForceReinstall) { Write-Skip "$ProxyExe already exists" return } $tempRoot = Join-Path ([IO.Path]::GetTempPath()) ("cliproxy-setup-" + [guid]::NewGuid().ToString('N')) $zipPath = Join-Path $tempRoot $ProxyArchiveName $extractPath = Join-Path $tempRoot 'extract' try { New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null New-Item -ItemType Directory -Path $extractPath -Force | Out-Null Write-Host " Downloading: $ProxyDownloadUrl" Invoke-WebRequest -Uri $ProxyDownloadUrl -OutFile $zipPath -UseBasicParsing Write-Host ' Extracting archive...' Expand-Archive -LiteralPath $zipPath -DestinationPath $extractPath -Force $foundExe = Get-ChildItem -LiteralPath $extractPath -Filter 'cli-proxy-api.exe' -File -Recurse | Select-Object -First 1 if (-not $foundExe) { throw 'The downloaded archive did not contain cli-proxy-api.exe.' } $sourceRoot = $foundExe.Directory.FullName New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null Copy-Item -Path (Join-Path $sourceRoot '*') -Destination $InstallDir -Recurse -Force if (-not (Test-Path -LiteralPath $ProxyExe)) { throw "Extraction completed, but $ProxyExe was not created." } Write-Host " Installed to: $InstallDir" } finally { Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue } } function Configure-Proxy { Write-Step 'Configuring CLIProxyAPI' New-Item -ItemType Directory -Path $AuthDir -Force | Out-Null $authDirYaml = $AuthDir.Replace('\', '/') $config = @" host: "127.0.0.1" port: $ProxyPort auth-dir: "$authDirYaml" api-keys: - "$ProxyApiKey" debug: false logging-to-file: true logs-max-total-size-mb: 256 usage-statistics-enabled: false "@ Set-ManagedFile -Path $ConfigPath -Content $config -BackupExisting | Out-Null } function Configure-Runner { Write-Step 'Configuring the proxy watchdog script' $escapedInstallDir = $InstallDir.Replace("'", "''") $escapedProxyExe = $ProxyExe.Replace("'", "''") $escapedConfigPath = $ConfigPath.Replace("'", "''") $wrapperLog = (Join-Path $InstallDir 'proxy-watchdog.log').Replace("'", "''") $runner = @" `$ErrorActionPreference = 'Continue' `$installDir = '$escapedInstallDir' `$proxyExe = '$escapedProxyExe' `$configPath = '$escapedConfigPath' `$wrapperLog = '$wrapperLog' Set-Location -LiteralPath `$installDir while (`$true) { `$started = Get-Date try { & `$proxyExe -config `$configPath `$exitCode = `$LASTEXITCODE Add-Content -LiteralPath `$wrapperLog -Value ("{0:u} Proxy exited with code {1}; restarting in 5 seconds." -f (Get-Date), `$exitCode) } catch { Add-Content -LiteralPath `$wrapperLog -Value ("{0:u} Proxy crashed: {1}; restarting in 5 seconds." -f (Get-Date), `$_.Exception.Message) } # Avoid a hot restart loop if startup fails instantly. `$runtime = (Get-Date) - `$started if (`$runtime.TotalSeconds -lt 5) { Start-Sleep -Seconds 5 } } "@ Set-ManagedFile -Path $RunnerPath -Content $runner | Out-Null } function Upsert-ManagedProfileBlock { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Block ) $begin = '# >>> CLIProxyAPI claudex BEGIN >>>' $end = '# <<< CLIProxyAPI claudex END <<<' $pattern = '(?ms)^[ \t]*' + [regex]::Escape($begin) + '.*?^[ \t]*' + [regex]::Escape($end) + '[ \t]*(?:\r?\n)?' $existing = if (Test-Path -LiteralPath $Path) { [IO.File]::ReadAllText($Path) } else { '' } $withoutManagedBlock = [regex]::Replace($existing, $pattern, '').TrimEnd() $newContent = if ([string]::IsNullOrWhiteSpace($withoutManagedBlock)) { $Block.TrimEnd() + [Environment]::NewLine } else { $withoutManagedBlock + [Environment]::NewLine + [Environment]::NewLine + $Block.TrimEnd() + [Environment]::NewLine } if ($existing -eq $newContent) { Write-Skip "$Path already contains the current claudex block" return } Write-Utf8NoBom -Path $Path -Content $newContent Write-Host " Updated: $Path" } function Configure-PowerShellProfiles { Write-Step 'Configuring the claudex model aliases' $profileBlock = @' # >>> CLIProxyAPI claudex BEGIN >>> $global:ClaudeModelAliases = @{ sol = 'gpt-5.6-sol' terra = 'gpt-5.6-terra' luna = 'gpt-5.6-luna' } function claudex { param( [Parameter(Position = 0)] [string]$Model = 'sol', [Parameter(ValueFromRemainingArguments = $true)] [string[]]$ClaudeArgs ) $resolvedModel = if ($global:ClaudeModelAliases.ContainsKey($Model)) { $global:ClaudeModelAliases[$Model] } else { $Model } $names = @( 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_SUBAGENT_MODEL' ) $previous = @{} foreach ($name in $names) { $previous[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') } [Environment]::SetEnvironmentVariable('ANTHROPIC_BASE_URL', 'http://127.0.0.1:8317', 'Process') [Environment]::SetEnvironmentVariable('ANTHROPIC_AUTH_TOKEN', 'sk-dummy', 'Process') [Environment]::SetEnvironmentVariable('CLAUDE_CODE_SUBAGENT_MODEL', $resolvedModel, 'Process') try { & claude --model $resolvedModel @ClaudeArgs } finally { foreach ($name in $names) { [Environment]::SetEnvironmentVariable($name, $previous[$name], 'Process') } } } # <<< CLIProxyAPI claudex END <<< '@ $documents = [Environment]::GetFolderPath([Environment+SpecialFolder]::MyDocuments) # All-host and ConsoleHost profiles for Windows PowerShell 5.1 and PowerShell 7. # Writing the managed block last also overrides an older unmarked `claudex` # function that may already exist earlier in a profile. $profilePaths = @( (Join-Path $documents 'WindowsPowerShell\profile.ps1'), (Join-Path $documents 'WindowsPowerShell\Microsoft.PowerShell_profile.ps1'), (Join-Path $documents 'PowerShell\profile.ps1'), (Join-Path $documents 'PowerShell\Microsoft.PowerShell_profile.ps1') ) | Select-Object -Unique foreach ($profilePath in $profilePaths) { Upsert-ManagedProfileBlock -Path $profilePath -Block $profileBlock } } function Test-CodexAuthentication { if (-not (Test-Path -LiteralPath $AuthDir)) { return $false } foreach ($file in Get-ChildItem -LiteralPath $AuthDir -Filter '*.json' -File -ErrorAction SilentlyContinue) { try { $data = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json if (($data.type -eq 'codex') -and ($data.refresh_token -or $data.access_token)) { return $true } } catch { # Ignore unrelated or malformed JSON files. } } return $false } function Ensure-CodexAuthentication { Write-Step 'Checking Codex OAuth authentication' if (Test-CodexAuthentication) { Write-Skip 'A Codex OAuth credential already exists' return } if ($SkipCodexLogin) { Write-Warning 'No Codex credential was found, and -SkipCodexLogin was supplied.' return } Write-Host ' No Codex credential found. A browser login will open now.' & $ProxyExe -config $ConfigPath -codex-login if ($LASTEXITCODE -ne 0) { throw "Codex login exited with code $LASTEXITCODE." } if (-not (Test-CodexAuthentication)) { throw "Codex login completed, but no Codex credential was detected in $AuthDir." } Write-Host ' Codex OAuth credential saved.' } function Get-ExpectedTaskActionArguments { $escapedRunner = $RunnerPath.Replace('"', '\"') return "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$escapedRunner`"" } function Test-ScheduledTaskMatches { param([Parameter(Mandatory)]$Task) $expectedExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $expectedArgs = Get-ExpectedTaskActionArguments $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().Name $action = @($Task.Actions) | Select-Object -First 1 $trigger = @($Task.Triggers) | Select-Object -First 1 if (-not $action -or -not $trigger) { return $false } $executeMatches = ([IO.Path]::GetFullPath($action.Execute) -ieq [IO.Path]::GetFullPath($expectedExe)) $argumentsMatch = ($action.Arguments -eq $expectedArgs) $userMatches = ($Task.Principal.UserId -ieq $currentUser) $logonMatches = ($Task.Principal.LogonType -eq 'S4U') $startupTrigger = ($trigger.CimClass.CimClassName -eq 'MSFT_TaskBootTrigger') return ($executeMatches -and $argumentsMatch -and $userMatches -and $logonMatches -and $startupTrigger) } function Configure-ScheduledTask { Write-Step 'Configuring Task Scheduler' Import-Module ScheduledTasks $existingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if ($existingTask -and (Test-ScheduledTaskMatches -Task $existingTask)) { Write-Skip "Scheduled task '$TaskName' is already correct" return } $powershellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $action = New-ScheduledTaskAction ` -Execute $powershellExe ` -Argument (Get-ExpectedTaskActionArguments) ` -WorkingDirectory $InstallDir $trigger = New-ScheduledTaskTrigger -AtStartup # S4U runs under the current Windows user without storing a password. # The proxy config uses an explicit auth directory, so it does not depend on # the task receiving a normal interactive user profile. $principal = New-ScheduledTaskPrincipal ` -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name) ` -LogonType S4U ` -RunLevel Highest $settings = New-ScheduledTaskSettingsSet ` -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries ` -StartWhenAvailable ` -RestartCount 999 ` -RestartInterval (New-TimeSpan -Minutes 1) ` -ExecutionTimeLimit ([TimeSpan]::Zero) ` -MultipleInstances IgnoreNew $task = New-ScheduledTask ` -Action $action ` -Trigger $trigger ` -Principal $principal ` -Settings $settings ` -Description 'Runs CLIProxyAPI for Claude Code through the local Codex-compatible proxy.' Register-ScheduledTask -TaskName $TaskName -InputObject $task -Force | Out-Null Write-Host " Registered scheduled task: $TaskName" } function Test-TcpPort { param( [Parameter(Mandatory)][string]$HostName, [Parameter(Mandatory)][int]$Port, [int]$TimeoutMs = 1500 ) $client = New-Object Net.Sockets.TcpClient try { $result = $client.BeginConnect($HostName, $Port, $null, $null) if (-not $result.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) { return $false } $client.EndConnect($result) return $true } catch { return $false } finally { $client.Close() } } function Start-ProxyTask { if ($NoStart) { Write-Step 'Skipping task startup because -NoStart was supplied' return } Write-Step 'Starting CLIProxyAPI' $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction Stop if ($task.State -eq 'Running') { Write-Skip "Scheduled task '$TaskName' is already running" } else { # Stop a manually launched copy from this exact installation before # starting the managed task, preventing a port-conflict restart loop. Get-CimInstance Win32_Process -Filter "Name = 'cli-proxy-api.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.ExecutablePath -and ($_.ExecutablePath -ieq $ProxyExe) } | ForEach-Object { Write-Host " Stopping unmanaged process PID $($_.ProcessId)..." Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } Start-ScheduledTask -TaskName $TaskName } $listening = $false foreach ($attempt in 1..12) { Start-Sleep -Seconds 1 if (Test-TcpPort -HostName '127.0.0.1' -Port $ProxyPort) { $listening = $true break } } if ($listening) { Write-Host " Proxy is listening on http://127.0.0.1:$ProxyPort" -ForegroundColor Green } else { $info = Get-ScheduledTaskInfo -TaskName $TaskName Write-Warning "Task started, but port $ProxyPort is not listening yet. LastTaskResult: $($info.LastTaskResult)" Write-Warning "Check: $InstallDir\proxy-watchdog.log and $InstallDir\logs" } } if (-not (Test-Administrator)) { Restart-Elevated } try { Install-ClaudeCodeIfMissing Install-ProxyIfMissing Configure-Proxy Configure-Runner Configure-PowerShellProfiles Ensure-CodexAuthentication Configure-ScheduledTask Start-ProxyTask Write-Host "`nSetup complete." -ForegroundColor Green Write-Host 'Open a new PowerShell window, then use:' Write-Host ' claudex sol' Write-Host ' claudex terra' Write-Host ' claudex luna' Write-Host ' claudex gpt-5.6-sol --dangerously-skip-permissions' } catch { Write-Host "`nSetup failed: $($_.Exception.Message)" -ForegroundColor Red Write-Host $_.ScriptStackTrace -ForegroundColor DarkGray exit 1 }