commit a04de18db44f2cbdbb726fbcf7ae199aef2b7565 Author: Space Date: Sun Jul 12 19:09:52 2026 +0200 Upload files to "/" diff --git a/setup-claudex-proxy.ps1 b/setup-claudex-proxy.ps1 new file mode 100644 index 0000000..58dda6c --- /dev/null +++ b/setup-claudex-proxy.ps1 @@ -0,0 +1,581 @@ +#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 +}