[CmdletBinding()] param( [string]$Key = $(if ($env:OPUSAPI_API_KEY) { $env:OPUSAPI_API_KEY } else { "" }), [string]$BaseUrl = $(if ($env:OPUSAPI_BASE_URL) { $env:OPUSAPI_BASE_URL } else { "https://api.opusapi.xyz/v1" }), [switch]$NoVerify, [switch]$Check, [switch]$Uninstall ) Set-StrictMode -Version 2.0 $ErrorActionPreference = "Stop" $CodexDir = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $HOME ".codex" } $ConfigPath = Join-Path $CodexDir "config.toml" $AuthPath = Join-Path $CodexDir "auth.json" $StatePath = Join-Path $CodexDir ".opusapi-codex-state.json" $MarkBegin = "# >>> opusapi-codex >>>" $MarkEnd = "# <<< opusapi-codex <<<" $OwnedRootKeys = @( "model_provider", "openai_base_url", "model", "review_model", "model_reasoning_effort", "forced_login_method", "cli_auth_credentials_store" ) $ChatgptAuthKeys = @( "tokens", "last_refresh", "access_token", "refresh_token", "id_token" ) function Write-Utf8NoBom([string]$Path, [string]$Content) { $encoding = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($Path, $Content, $encoding) } function Backup-File([string]$Path) { if (-not (Test-Path -LiteralPath $Path)) { return } $stamp = Get-Date -Format "yyyyMMddHHmmssfff" $backup = "$Path.bak.pre-clean.$stamp" Copy-Item -LiteralPath $Path -Destination $backup Write-Host "backup $backup" } function Get-Sha256([string]$Value) { $sha = [System.Security.Cryptography.SHA256]::Create() try { $bytes = [System.Text.Encoding]::UTF8.GetBytes($Value) return -join ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") }) } finally { $sha.Dispose() } } function Normalize-BaseUrl([string]$Value) { $value = $Value.TrimEnd("/") switch ($value) { "https://api.opusapi.xyz" { return "$value/v1" } "https://gpt.opusapi.xyz" { return "$value/v1" } "https://opusapi.xyz" { return "$value/v1" } "https://india.opusapi.xyz" { return "$value/v1" } "https://api.opusapi.xyz/v1" { return $value } "https://gpt.opusapi.xyz/v1" { return $value } "https://opusapi.xyz/v1" { return $value } "https://india.opusapi.xyz/v1" { return $value } default { throw "不允许的 BaseUrl: $value" } } } function Strip-Managed([string]$Text) { $pattern = [regex]::Escape($MarkBegin) + ".*?" + [regex]::Escape($MarkEnd) + "\r?\n?" return [regex]::Replace( $Text, $pattern, "", [System.Text.RegularExpressions.RegexOptions]::Singleline ) } function Remove-OwnedRootKeys([string]$Text) { $out = New-Object System.Collections.Generic.List[string] $removed = New-Object System.Collections.Generic.List[string] $inRoot = $true foreach ($line in [regex]::Split($Text, "(?<=\n)")) { $trimmed = $line.Trim() if ($trimmed.StartsWith("[")) { $inRoot = $false } if ($inRoot -and $trimmed.Contains("=") -and -not $trimmed.StartsWith("#")) { $name = ($trimmed -split "=", 2)[0].Trim() if ($OwnedRootKeys -contains $name) { $removed.Add($line) continue } } $out.Add($line) } return [pscustomobject]@{ Text = -join $out Removed = @($removed) } } function Normalize-Text([string]$Text) { if ([string]::IsNullOrWhiteSpace($Text)) { return "" } $value = [regex]::Replace($Text, "(\r?\n){3,}", "`n`n").Trim() return $value + "`n" } function Split-ConfigSections([string]$Text) { $match = [regex]::Match($Text, '(?m)^[ \t]*\[') if (-not $match.Success) { return [pscustomobject]@{ Root = $Text; Tables = "" } } return [pscustomobject]@{ Root = $Text.Substring(0, $match.Index) Tables = $Text.Substring($match.Index) } } function Read-Json([string]$Path) { if (-not (Test-Path -LiteralPath $Path)) { return $null } try { return Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null } } # --Check is intentionally read-only: no directory creation and no backups. if ($Check) { $configExists = Test-Path -LiteralPath $ConfigPath $authExists = Test-Path -LiteralPath $AuthPath $stateExists = Test-Path -LiteralPath $StatePath Write-Host "config: $ConfigPath exists=$configExists" if ($configExists) { $text = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 Write-Host " has managed: $($text.Contains($MarkBegin))" $match = [regex]::Match($text, '(?m)^openai_base_url\s*=\s*"([^"]+)"') Write-Host " base_url: $(if ($match.Success) { $match.Groups[1].Value } else { '?' })" } Write-Host "auth: $AuthPath exists=$authExists" Write-Host "state: $StatePath exists=$stateExists" return } New-Item -ItemType Directory -Path $CodexDir -Force | Out-Null Backup-File $ConfigPath Backup-File $AuthPath Backup-File $StatePath $raw = if (Test-Path -LiteralPath $ConfigPath) { Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 } else { "" } $state = Read-Json $StatePath $withoutManaged = Strip-Managed $raw if ($Uninstall) { $current = Remove-OwnedRootKeys $withoutManaged if ($state -and $state.previous_root_lines -and $current.Removed.Count -eq 0) { $withoutManaged = (-join @($state.previous_root_lines)) + $withoutManaged.TrimStart() } Write-Utf8NoBom $ConfigPath (Normalize-Text $withoutManaged) $auth = Read-Json $AuthPath if ($auth) { $currentKey = if ($auth.PSObject.Properties["OPENAI_API_KEY"]) { [string]$auth.OPENAI_API_KEY } else { "" } $installedHash = if ($state -and $state.installed_key_sha256) { [string]$state.installed_key_sha256 } else { "" } if ($installedHash -and (Get-Sha256 $currentKey) -eq $installedHash) { $auth.PSObject.Properties.Remove("OPENAI_API_KEY") Write-Utf8NoBom $AuthPath (($auth | ConvertTo-Json -Depth 20) + "`n") Write-Host "cleared installed OPENAI_API_KEY from auth.json" } else { Write-Host "kept OPENAI_API_KEY because it was changed after install" } } if (Test-Path -LiteralPath $StatePath) { Remove-Item -LiteralPath $StatePath -Force } Write-Host "uninstall done" return } $Key = $Key.Trim().Trim('"').Trim("'") if (-not $Key) { throw "请提供 -Key sk-xxx" } $BaseUrl = Normalize-BaseUrl $BaseUrl if (-not $NoVerify) { Write-Host "探测 $BaseUrl/models ..." try { Invoke-WebRequest ` -UseBasicParsing ` -Uri "$BaseUrl/models" ` -Headers @{ Authorization = "Bearer $Key"; "User-Agent" = "OpusCodex-Setup/1.0" } ` -TimeoutSec 25 | Out-Null } catch { throw "密钥探测失败: $($_.Exception.Message)" } } $cleaned = Remove-OwnedRootKeys $withoutManaged $previousRootLines = if ($state -and $state.previous_root_lines) { @($state.previous_root_lines) } else { @($cleaned.Removed) } $sections = Split-ConfigSections $cleaned.Text $sections.Root = Normalize-Text $sections.Root if ($sections.Root) { $sections.Root += "`n" } $sections.Tables = Normalize-Text $sections.Tables function Stop-CodexApps { $defaultHome = Join-Path $HOME ".codex" if ($CodexDir -ne $defaultHome) { return } $running = Get-Process -Name ChatGPT,Codex -ErrorAction SilentlyContinue if (-not $running) { return } Write-Host "退出正在运行的 Codex / ChatGPT 桌面端,避免它把 ChatGPT 登录态写回" $running | Stop-Process -Force -ErrorAction SilentlyContinue $n = 0 while ($n -lt 20) { if (-not (Get-Process -Name ChatGPT,Codex -ErrorAction SilentlyContinue)) { return } Start-Sleep -Milliseconds 250 $n++ } } Stop-CodexApps $managed = @" $MarkBegin # managed by OpusAPI setup-codex.ps1 - safe ownership block model_provider = "opuscodex" openai_base_url = "$BaseUrl" model = "gpt-5.5" review_model = "gpt-5.5" model_reasoning_effort = "xhigh" forced_login_method = "api" cli_auth_credentials_store = "file" [model_providers.opuscodex] name = "OpusCodex" base_url = "$BaseUrl" wire_api = "responses" requires_openai_auth = true $MarkEnd "@ Write-Utf8NoBom $ConfigPath ($sections.Root + $managed + $sections.Tables) $oldAuth = Read-Json $AuthPath $auth = [ordered]@{ OPENAI_API_KEY = $Key auth_mode = "apikey" } if ($oldAuth) { foreach ($prop in $oldAuth.PSObject.Properties) { if ($ChatgptAuthKeys -contains $prop.Name) { continue } if ($prop.Name -eq "OPENAI_API_KEY" -or $prop.Name -eq "auth_mode") { continue } $auth[$prop.Name] = $prop.Value } } Write-Utf8NoBom $AuthPath (($auth | ConvertTo-Json -Depth 20) + "`n") $nextState = [ordered]@{ schema_version = 1 installed_key_sha256 = Get-Sha256 $Key previous_root_lines = $previousRootLines } Write-Utf8NoBom $StatePath (($nextState | ConvertTo-Json -Depth 20) + "`n") function Test-CodexApiLogin([string]$Bin) { if (-not $Bin) { return $false } if (-not (Test-Path -LiteralPath $Bin)) { return $false } try { $item = Get-Item -LiteralPath $Bin -ErrorAction Stop if ($item.Length -eq 0) { return $false } } catch { return $false } try { $help = & $Bin login --help 2>&1 | Out-String return ($help -match "--with-api-key") } catch { return $false } } function Add-CodexCandidate([System.Collections.Generic.List[string]]$List, [string]$Path) { if (-not $Path) { return } if ($List.Contains($Path)) { return } $List.Add($Path) } function Find-CodexBin { $candidates = New-Object System.Collections.Generic.List[string] if ($env:OPUSCODEX_CODEX_BIN) { Add-CodexCandidate $candidates $env:OPUSCODEX_CODEX_BIN } $la = [string]$env:LOCALAPPDATA $up = [string]$env:USERPROFILE $ad = [string]$env:APPDATA if ($la) { foreach ($rel in @( "Programs\OpenAI\Codex\bin\codex.exe", "Programs\OpenAI\ChatGPT\bin\codex.exe", "Programs\ChatGPT\resources\codex.exe", "ChatGPT\resources\codex.exe", "Programs\Codex\resources\codex.exe", "Programs\Codex\bin\codex.exe", "Programs\codex\codex.exe" )) { Add-CodexCandidate $candidates (Join-Path $la $rel) } } if ($up) { Add-CodexCandidate $candidates (Join-Path $up ".codex\bin\codex.exe") $releaseRoot = Join-Path $up ".codex\packages\standalone\releases" if (Test-Path -LiteralPath $releaseRoot) { Get-ChildItem -LiteralPath $releaseRoot -Filter "codex.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 8 | ForEach-Object { Add-CodexCandidate $candidates $_.FullName } } } if ($ad) { Add-CodexCandidate $candidates (Join-Path $ad "npm\codex.cmd") Add-CodexCandidate $candidates (Join-Path $ad "npm\codex.exe") } try { foreach ($n in @("*Codex*", "*ChatGPT*", "*OpenAI*")) { foreach ($pkg in @(Get-AppxPackage -Name $n -ErrorAction SilentlyContinue)) { $root = [string]$pkg.InstallLocation if (-not $root) { continue } foreach ($rel in @( "app\resources\codex.exe", "app\bin\codex.exe", "app\codex.exe", "resources\codex.exe" )) { Add-CodexCandidate $candidates (Join-Path $root $rel) } $appDir = Join-Path $root "app" if (Test-Path -LiteralPath $appDir) { Get-ChildItem -LiteralPath $appDir -Filter "codex.exe" -Recurse -Depth 3 -ErrorAction SilentlyContinue | Select-Object -First 8 | ForEach-Object { Add-CodexCandidate $candidates $_.FullName } } } } } catch {} $cmd = Get-Command codex -ErrorAction SilentlyContinue if ($cmd -and $cmd.Source) { $src = [string]$cmd.Source if ($src -notmatch '\\WindowsApps\\[^\\]+$') { Add-CodexCandidate $candidates $src } } foreach ($p in $candidates) { if (Test-CodexApiLogin $p) { return $p } } return $null } function Start-CodexCli([string]$Bin, [string]$Arguments, [string]$Stdin) { $psi = New-Object System.Diagnostics.ProcessStartInfo if ($Bin -match '\.(cmd|bat)$') { $psi.FileName = $env:ComSpec $psi.Arguments = "/d /s /c `" `"$Bin`" $Arguments `"" } else { $psi.FileName = $Bin $psi.Arguments = $Arguments } $psi.UseShellExecute = $false $psi.RedirectStandardInput = $true $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.CreateNoWindow = $true $psi.WorkingDirectory = $CodexDir if ($psi.EnvironmentVariables.ContainsKey("CODEX_HOME")) { $psi.EnvironmentVariables["CODEX_HOME"] = $CodexDir } else { $psi.EnvironmentVariables.Add("CODEX_HOME", $CodexDir) } $proc = [System.Diagnostics.Process]::Start($psi) if ($null -ne $Stdin) { $proc.StandardInput.Write($Stdin) } $proc.StandardInput.Close() $stdout = $proc.StandardOutput.ReadToEnd() $stderr = $proc.StandardError.ReadToEnd() if (-not $proc.WaitForExit(120000)) { try { $proc.Kill() } catch {} return [pscustomobject]@{ ExitCode = -1; Output = "timeout" } } return [pscustomobject]@{ ExitCode = $proc.ExitCode Output = "$stdout`n$stderr" } } $codexBin = Find-CodexBin if (-not $codexBin) { Write-Host "warn: 没找到 Codex CLI(2026 桌面版通常在 %LOCALAPPDATA%\Programs\OpenAI\Codex\bin\codex.exe)。配置已写入,但桌面仍会走 ChatGPT 验证码登录。装好官方 App 后请重跑本脚本。" } else { Write-Host "向 Codex 注册 API 登录态…" $env:CODEX_HOME = $CodexDir $null = Start-CodexCli $codexBin "logout" $null $null = Start-CodexCli $codexBin '-c cli_auth_credentials_store="auto" logout' $null $login = Start-CodexCli $codexBin "login --with-api-key" $Key if ($login.ExitCode -ne 0) { Write-Host "warn: codex login --with-api-key 失败(exit $($login.ExitCode))。桌面可能仍要验证码登录。" } else { $status = Start-CodexCli $codexBin "login status" $null if ($status.Output -match "API key") { Write-Host "已注册 API 登录(不要再点 ChatGPT / 不要填手机号)" } else { Write-Host "warn: Codex 登录态不是 API key,桌面可能仍走验证码。请完全退出 App 后重跑安装。" } } } Write-Host "OpusCodex 配置完成" Write-Host " base_url: $BaseUrl" Write-Host " config: $ConfigPath" Write-Host "下一步: 新开终端运行 codex;Codex Desktop 请新建任务。不要点 ChatGPT 登录、不要填手机号。" Write-Host "若仍出现验证码登录:完全退出 App 后重跑本脚本(必须能找到 codex.exe 并显示「已注册 API 登录」)。"