diff --git a/.chezmoidata.toml b/.chezmoidata.toml new file mode 100644 index 0000000..b464af7 --- /dev/null +++ b/.chezmoidata.toml @@ -0,0 +1,7 @@ +[cxcc] +version = "v0.1.0" +commit = "dfc0bd6ef4b6aafdafff5f6d732e28cc52cfcfc0" +installerPowerShellSha256 = "40a116c2f83a25590ed9d1d74120354c00254ed719e1adda25c429282d57f54e" +installerShellSha256 = "ce6e0712c6a2c0439c334bf849b71fe618a6c296477bc25a447ede47f07e4eb7" +windowsArtifactSha256 = "f8fde14b05170a635d5837650fe587ba96dcdd1164d6f3e2706497a13beced5f" +posixArtifactSha256 = "6ac428ce3002d6e7be8f92b26b69c172380363cdfeb8f588278f7577d06958bd" diff --git a/.chezmoiignore b/.chezmoiignore index 8f0f0b8..f634637 100644 --- a/.chezmoiignore +++ b/.chezmoiignore @@ -21,6 +21,7 @@ /dot_local/** /run_onchange_before_00-install-env.sh /run_onchange_before_00-install-env.sh.tmpl +/10-install-cxcc.sh /run_after_99-smoke-test.sh /run_after_99-smoke-test.sh.tmpl {{ else }} @@ -31,4 +32,5 @@ /run_onchange_after_05-install-powershell-profile-deps.ps1.tmpl /run_onchange_after_10-powershell-ai-env-hook.ps1 /run_onchange_after_10-powershell-ai-env-hook.ps1.tmpl +/10-install-cxcc.ps1 {{ end }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0ab315..4454a34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: - main pull_request: +permissions: + contents: read + jobs: linux-quick: name: Linux Quick Checks @@ -13,6 +16,29 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Check public repository hygiene + shell: bash + run: | + forbidden='(^|/)[^/]*(task_plan|findings|progress|handoff|handover|migration[-_](log|inventory|diary)|machine[-_]snapshot|baseline|copy[-_]hash(es)?)[^/]*$|^docs/superpowers/' + if git ls-files | grep -Eqi "$forbidden"; then + echo "Internal planning material is tracked in the public repository." >&2 + exit 1 + fi + windows_root='[A-Z]:\\' + windows_user='Users\\' + windows_workspace='CodeX_desk\\' + unix_root='/(Users|home)' + unix_tail='/[^/]+/' + workspace_name='CodeX_desk' + unix_workspace="/([^/]+/)*${workspace_name}/" + machine_paths="(${windows_root}(${windows_user}|${windows_workspace})|${unix_root}${unix_tail}|${unix_workspace})" + if git grep -nE "$machine_paths" -- ':!*.lock'; then + echo "Machine-specific absolute path found in tracked content." >&2 + exit 1 + fi - name: Check shell syntax run: | @@ -27,13 +53,14 @@ jobs: scripts/install/modern-cli.sh \ scripts/install/fastfetch.sh \ scripts/install/fonts.sh \ + scripts/install/cxcc.sh \ test/smoke.sh \ test/fonts-smoke.sh \ - test/ai-env-smoke.sh \ + test/cxcc-consumer-smoke.sh \ test/termux-platform-smoke.sh - - name: Test AI env shell helpers - run: ./test/ai-env-smoke.sh + - name: Test cxcc consumer + run: ./test/cxcc-consumer-smoke.sh - name: Test font install run: | @@ -50,13 +77,29 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + persist-credentials: false - - name: Apply dotfiles from bootstrap + - name: Apply dotfiles with cxcc skipped env: DOTFILES_USE_SUDO: "1" INSTALL_CLAUDE: "0" + INSTALL_CXCC: "0" INSTALL_WINDOWS_FONTS_FROM_WSL: "0" - run: bash ./bootstrap.sh + run: | + bash ./bootstrap.sh + test ! -e "$HOME/.local/share/cxcc" + + - name: Install pinned cxcc and verify repeat apply + run: | + profiles_before="$(sha256sum "$HOME/.ai-env/profiles.json" | awk '{print $1}')" + chezmoi apply --source "$GITHUB_WORKSPACE" + current_before="$(sha256sum "$HOME/.local/share/cxcc/current.json" | awk '{print $1}')" + chezmoi apply --source "$GITHUB_WORKSPACE" + profiles_after="$(sha256sum "$HOME/.ai-env/profiles.json" | awk '{print $1}')" + current_after="$(sha256sum "$HOME/.local/share/cxcc/current.json" | awk '{print $1}')" + test "$profiles_before" = "$profiles_after" + test "$current_before" = "$current_after" - name: Run full smoke check env: @@ -69,6 +112,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + persist-credentials: false - name: Check shell syntax run: | @@ -83,17 +128,18 @@ jobs: scripts/install/modern-cli.sh \ scripts/install/fastfetch.sh \ scripts/install/fonts.sh \ + scripts/install/cxcc.sh \ test/smoke.sh \ test/fonts-smoke.sh \ - test/ai-env-smoke.sh + test/cxcc-consumer-smoke.sh - name: Test font install run: | ./scripts/install/fonts.sh ./test/fonts-smoke.sh - - name: Test AI env shell helpers - run: ./test/ai-env-smoke.sh + - name: Test cxcc consumer + run: ./test/cxcc-consumer-smoke.sh windows-quick: name: Windows Quick Checks @@ -104,6 +150,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + persist-credentials: false - name: Check PowerShell syntax run: | @@ -111,13 +159,11 @@ jobs: $errors = $null [System.Management.Automation.Language.Parser]::ParseFile("scripts/install/fonts-windows.ps1", [ref]$tokens, [ref]$errors) | Out-Null [System.Management.Automation.Language.Parser]::ParseFile("scripts/install/powershell-profile-deps.ps1", [ref]$tokens, [ref]$errors) | Out-Null + [System.Management.Automation.Language.Parser]::ParseFile("scripts/install/cxcc.ps1", [ref]$tokens, [ref]$errors) | Out-Null [System.Management.Automation.Language.Parser]::ParseFile("bootstrap.ps1", [ref]$tokens, [ref]$errors) | Out-Null - [System.Management.Automation.Language.Parser]::ParseFile("Documents/PowerShell/Scripts/ai-env.ps1", [ref]$tokens, [ref]$errors) | Out-Null [System.Management.Automation.Language.Parser]::ParseFile("Documents/PowerShell/create_Microsoft.PowerShell_profile.ps1", [ref]$tokens, [ref]$errors) | Out-Null [System.Management.Automation.Language.Parser]::ParseFile("test/fonts-smoke.ps1", [ref]$tokens, [ref]$errors) | Out-Null - [System.Management.Automation.Language.Parser]::ParseFile("test/ai-env-smoke.ps1", [ref]$tokens, [ref]$errors) | Out-Null - [System.Management.Automation.Language.Parser]::ParseFile("test/codex-provider-bridge.ps1", [ref]$tokens, [ref]$errors) | Out-Null - [System.Management.Automation.Language.Parser]::ParseFile("test/codex-app-bridge-management.ps1", [ref]$tokens, [ref]$errors) | Out-Null + [System.Management.Automation.Language.Parser]::ParseFile("test/cxcc-consumer-smoke.ps1", [ref]$tokens, [ref]$errors) | Out-Null [System.Management.Automation.Language.Parser]::ParseFile("test/powershell-profile-smoke.ps1", [ref]$tokens, [ref]$errors) | Out-Null [System.Management.Automation.Language.Parser]::ParseFile("test/windows-full-smoke.ps1", [ref]$tokens, [ref]$errors) | Out-Null if ($errors.Count -gt 0) { @@ -125,13 +171,8 @@ jobs: exit 1 } - - name: Test AI env PowerShell helpers - run: ./test/ai-env-smoke.ps1 -SourceDir "$PWD" - - - name: Test Codex App provider bridge - run: | - ./test/codex-provider-bridge.ps1 -SourceDir "$PWD" - ./test/codex-app-bridge-management.ps1 -SourceDir "$PWD" + - name: Test cxcc consumer + run: ./test/cxcc-consumer-smoke.ps1 - name: Test PowerShell profile load run: ./test/powershell-profile-smoke.ps1 -SourceDir "$PWD" @@ -151,9 +192,30 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + persist-credentials: false - - name: Apply dotfiles from bootstrap - run: ./bootstrap.ps1 -SourceDir "$PWD" + - name: Apply dotfiles with cxcc skipped + env: + INSTALL_CXCC: "0" + run: | + ./bootstrap.ps1 -SourceDir "$PWD" + if (Test-Path -LiteralPath (Join-Path $HOME ".local\share\cxcc")) { + throw "INSTALL_CXCC=0 unexpectedly installed cxcc." + } + + - name: Install pinned cxcc and verify repeat apply + run: | + $profilesPath = Join-Path $HOME ".ai-env\profiles.json" + $currentPath = Join-Path $HOME ".local\share\cxcc\current.json" + $profilesBefore = (Get-FileHash -LiteralPath $profilesPath -Algorithm SHA256).Hash + chezmoi apply --source "$PWD" + $currentBefore = (Get-FileHash -LiteralPath $currentPath -Algorithm SHA256).Hash + chezmoi apply --source "$PWD" + $profilesAfter = (Get-FileHash -LiteralPath $profilesPath -Algorithm SHA256).Hash + $currentAfter = (Get-FileHash -LiteralPath $currentPath -Algorithm SHA256).Hash + if ($profilesBefore -cne $profilesAfter) { throw "cxcc install changed profiles.json." } + if ($currentBefore -cne $currentAfter) { throw "Repeated cxcc apply changed current.json." } - name: Run full Windows smoke check run: ./test/windows-full-smoke.ps1 -SourceDir "$PWD" diff --git a/.gitignore b/.gitignore index 1781e99..afdc968 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,17 @@ /secrets/ *.secret *.secret.* + +# Local agent planning and migration artifacts are not part of the public product. +/task_plan.md +/findings.md +/progress.md +/HANDOFF.md +/HANDOVER.md +/migration-log.md +/migration-inventory.md +/migration-diary.md +/machine-snapshot.md +/BASELINE.md +/COPY_HASHES.md +/docs/superpowers/ diff --git a/Documents/PowerShell/Scripts/ai-env.ps1 b/Documents/PowerShell/Scripts/ai-env.ps1 deleted file mode 100644 index ad050c2..0000000 --- a/Documents/PowerShell/Scripts/ai-env.ps1 +++ /dev/null @@ -1,4526 +0,0 @@ -$script:AiEnvScriptRoot = $PSScriptRoot -$script:AiHome = if ($env:AI_ENV_HOME) { [Environment]::ExpandEnvironmentVariables($env:AI_ENV_HOME) } else { $HOME } -$script:AiConfigDir = Join-Path $script:AiHome ".ai-env" -$script:AiRegistryPath = Join-Path $script:AiConfigDir "profiles.json" -$script:AiStatePath = Join-Path $script:AiConfigDir "state.json" -$script:AiSecretsPath = Join-Path $script:AiHome ".ai-secrets\secrets.toml" -$script:LegacyAiStateDir = Join-Path $script:AiHome ".ai-state" -$script:ClaudeRouterBaseUrl = "https://anyrouter.top" - -function Get-AiProperty { - param( - [AllowNull()]$Object, - [Parameter(Mandatory = $true)][string]$Name, - $Default = $null - ) - - if ($null -eq $Object) { - return $Default - } - - $property = $Object.PSObject.Properties[$Name] - if ($property) { - return $property.Value - } - - return $Default -} - -function Expand-AiPath { - param([AllowNull()][string]$Path) - - if ([string]::IsNullOrWhiteSpace($Path)) { - return $null - } - - $expanded = [Environment]::ExpandEnvironmentVariables($Path) - if ($expanded -eq "~") { - return $script:AiHome - } - - if ($expanded.StartsWith("~/") -or $expanded.StartsWith("~\")) { - return (Join-Path $script:AiHome $expanded.Substring(2)) - } - - return $expanded -} - -function New-AiDefaultRegistry { - return [pscustomobject]@{ - schema = 1 - defaults = [pscustomobject]@{ - codex = "sub" - codex_app = "sub" - claude = "sub" - } - codex = @( - [pscustomobject]@{ - name = "sub" - aliases = @("subscription", "chatgpt") - mode = "sub" - home = "~/.codex" - codex_profile = "sub" - description = "ChatGPT/Codex subscription login cached under CODEX_HOME" - }, - [pscustomobject]@{ - name = "api" - aliases = @("router") - mode = "api" - home = "~/.codex" - codex_profile = "api" - secret_id = "codex.api" - windows_secret = "~/.ai-secrets/codex-api.ps1" - linux_secret = "~/.ai-secrets/codex-api.env" - description = "Default Codex API router" - } - ) - claude = @( - [pscustomobject]@{ - name = "sub" - aliases = @("subscription", "claude-sub") - mode = "sub" - description = "Claude Code subscription/OAuth login" - }, - [pscustomobject]@{ - name = "api" - aliases = @("router", "claude-api") - mode = "api" - base_url = "https://anyrouter.top" - secret_id = "claude.api" - windows_secret = "~/.ai-secrets/claude-api.ps1" - linux_secret = "~/.ai-secrets/claude-api.env" - description = "Default Claude Code API router" - } - ) - } -} - -function Get-AiRegistry { - if (Test-Path -LiteralPath $script:AiRegistryPath) { - try { - return (Get-Content -Raw -LiteralPath $script:AiRegistryPath | ConvertFrom-Json) - } catch { - Write-Warning "Could not read $script:AiRegistryPath. $($_.Exception.Message)" - } - } - - return (New-AiDefaultRegistry) -} - -function Get-AiToolProfiles { - param([Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool) - - $registry = Get-AiRegistry - return @(Get-AiProperty -Object $registry -Name $Tool -Default @()) -} - -function Get-AiDefaultProfileName { - param([Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool) - - $registry = Get-AiRegistry - $defaults = Get-AiProperty -Object $registry -Name "defaults" - $default = Get-AiProperty -Object $defaults -Name $Tool -Default "sub" - if ($default) { - return [string]$default - } - - return "sub" -} - -function Test-AiProfileEnabled { - param([Parameter(Mandatory = $true)]$Profile) - - $enabled = Get-AiProperty -Object $Profile -Name "enabled" -Default $true - return ($enabled -ne $false) -} - -function Get-AiProfileNames { - param([Parameter(Mandatory = $true)]$Profile) - - $names = @([string](Get-AiProperty -Object $Profile -Name "name" -Default "")) - $aliases = @(Get-AiProperty -Object $Profile -Name "aliases" -Default @()) - foreach ($alias in $aliases) { - if ($alias) { - $names += [string]$alias - } - } - - return ($names | Where-Object { $_ }) -} - -function Get-AiProfileByName { - param( - [Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool, - [Parameter(Mandatory = $true)][string]$Name - ) - - $query = $Name.ToLowerInvariant() - foreach ($profile in Get-AiToolProfiles -Tool $Tool) { - if (-not (Test-AiProfileEnabled -Profile $profile)) { - continue - } - - foreach ($candidate in Get-AiProfileNames -Profile $profile) { - if ($candidate.ToLowerInvariant() -eq $query) { - return $profile - } - } - } - - return $null -} - -function Get-AiProfileName { - param([Parameter(Mandatory = $true)]$Profile) - - return [string](Get-AiProperty -Object $Profile -Name "name" -Default "") -} - -function Get-AiProfileMode { - param([Parameter(Mandatory = $true)]$Profile) - - return [string](Get-AiProperty -Object $Profile -Name "mode" -Default "sub") -} - -function Get-AiNextProfileName { - param([Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool) - - $profiles = @(Get-AiToolProfiles -Tool $Tool | Where-Object { Test-AiProfileEnabled -Profile $_ }) - if ($profiles.Count -eq 0) { - return "sub" - } - - $saved = Get-AiSavedProfileName -Tool $Tool - for ($i = 0; $i -lt $profiles.Count; $i++) { - if ((Get-AiProfileName -Profile $profiles[$i]).ToLowerInvariant() -eq $saved.ToLowerInvariant()) { - return (Get-AiProfileName -Profile $profiles[($i + 1) % $profiles.Count]) - } - } - - return (Get-AiDefaultProfileName -Tool $Tool) -} - -function Get-AiLegacyStateName { - param([Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool) - - $legacyName = if ($Tool -eq "codex") { "cx.profile" } else { "cc.profile" } - $legacyPath = Join-Path $script:LegacyAiStateDir $legacyName - if (Test-Path -LiteralPath $legacyPath) { - $value = (Get-Content -Raw -LiteralPath $legacyPath).Trim() - if ($value) { - return $value - } - } - - return $null -} - -function Get-AiState { - $state = [pscustomobject]@{ - codex = $null - claude = $null - updated_at = $null - } - - if (Test-Path -LiteralPath $script:AiStatePath) { - try { - $loaded = Get-Content -Raw -LiteralPath $script:AiStatePath | ConvertFrom-Json - foreach ($name in @("codex", "claude", "updated_at")) { - $value = Get-AiProperty -Object $loaded -Name $name - if ($null -ne $value) { - $state.$name = $value - } - } - } catch { - Write-Warning "Could not read $script:AiStatePath. $($_.Exception.Message)" - } - } - - if (-not $state.codex) { - $state.codex = Get-AiLegacyStateName -Tool "codex" - } - if (-not $state.claude) { - $state.claude = Get-AiLegacyStateName -Tool "claude" - } - if (-not $state.codex) { - $state.codex = Get-AiDefaultProfileName -Tool "codex" - } - if (-not $state.claude) { - $state.claude = Get-AiDefaultProfileName -Tool "claude" - } - - return $state -} - -function Save-AiSelectedProfile { - param( - [Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool, - [Parameter(Mandatory = $true)][string]$Name - ) - - $state = Get-AiState - $state.$Tool = $Name - $state.updated_at = (Get-Date).ToUniversalTime().ToString("o") - New-Item -ItemType Directory -Force -Path $script:AiConfigDir | Out-Null - $state | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $script:AiStatePath -Encoding UTF8 -} - -function Save-AiRegistry { - param([Parameter(Mandatory = $true)]$Registry) - - New-Item -ItemType Directory -Force -Path $script:AiConfigDir | Out-Null - $json = (($Registry | ConvertTo-Json -Depth 20) + "`n") - Write-AiUtf8NoBomAtomic -Path $script:AiRegistryPath -Content $json -} - -function Get-AiNameSlug { - param([Parameter(Mandatory = $true)][string]$Name) - - $slug = $Name.Trim().ToLowerInvariant() -replace '[^a-z0-9_-]+', '-' - $slug = $slug.Trim("-_") - if (-not $slug) { - throw "Profile name '$Name' does not contain any usable letters or numbers." - } - - return $slug -} - -function Assert-AiProfileName { - param([Parameter(Mandatory = $true)][string]$Name) - - if ($Name -notmatch '^[A-Za-z0-9][A-Za-z0-9:_-]*$') { - throw "Profile name '$Name' is not supported. Use letters, numbers, ':', '_' or '-'." - } -} - -function Test-AiProfileNameExists { - param( - [Parameter(Mandatory = $true)]$Registry, - [Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool, - [Parameter(Mandatory = $true)][string]$Name - ) - - $query = $Name.ToLowerInvariant() - foreach ($profile in @(Get-AiProperty -Object $Registry -Name $Tool -Default @())) { - foreach ($candidate in Get-AiProfileNames -Profile $profile) { - if ($candidate.ToLowerInvariant() -eq $query) { - return $true - } - } - } - - return $false -} - -function Add-AiProfileRegistration { - param( - [Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool, - [Parameter(Mandatory = $true)]$Profile - ) - - $name = Get-AiProfileName -Profile $Profile - Assert-AiProfileName -Name $name - $registry = Get-AiRegistry - if (Test-AiProfileNameExists -Registry $registry -Tool $Tool -Name $name) { - throw "$Tool profile '$name' already exists. Remove it first, or choose another name." - } - - $profiles = @(Get-AiProperty -Object $registry -Name $Tool -Default @()) - $registry.$Tool = @($profiles + $Profile) - Save-AiRegistry -Registry $registry - return $Profile -} - -function Remove-AiProfileRegistration { - param( - [Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool, - [Parameter(Mandatory = $true)][string]$Name - ) - - $registry = Get-AiRegistry - $query = $Name.ToLowerInvariant() - $removed = $null - $kept = @() - foreach ($profile in @(Get-AiProperty -Object $registry -Name $Tool -Default @())) { - $matches = $false - foreach ($candidate in Get-AiProfileNames -Profile $profile) { - if ($candidate.ToLowerInvariant() -eq $query) { - $matches = $true - break - } - } - - if ($matches) { - $removed = $profile - } else { - $kept += $profile - } - } - - if (-not $removed) { - throw "$Tool profile '$Name' does not exist." - } - - $removedName = Get-AiProfileName -Profile $removed - $registry.$Tool = @($kept) - Save-AiRegistry -Registry $registry - - if ((Get-AiSavedProfileName -Tool $Tool).ToLowerInvariant() -eq $removedName.ToLowerInvariant()) { - Save-AiSelectedProfile -Tool $Tool -Name (Get-AiDefaultProfileName -Tool $Tool) - } - - return $removedName -} - -function ConvertFrom-AiManagementArgs { - param([string[]]$Arguments) - - $options = @{} - $positionals = @() - for ($i = 0; $i -lt $Arguments.Count; $i++) { - $arg = [string]$Arguments[$i] - if ($arg.StartsWith("--")) { - $key = $arg.Substring(2) - if (-not $key) { - continue - } - if (($i + 1) -lt $Arguments.Count -and -not ([string]$Arguments[$i + 1]).StartsWith("--")) { - $options[$key] = [string]$Arguments[$i + 1] - $i++ - } else { - $options[$key] = "true" - } - } else { - $positionals += $arg - } - } - - return [pscustomobject]@{ - Positionals = $positionals - Options = $options - } -} - -function Get-AiOption { - param( - [Parameter(Mandatory = $true)]$Options, - [Parameter(Mandatory = $true)][string]$Name, - [AllowNull()][string]$Default = $null - ) - - if ($Options.ContainsKey($Name) -and $Options[$Name]) { - return [string]$Options[$Name] - } - - return $Default -} - -function Get-AiSavedProfileName { - param([Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool) - - $state = Get-AiState - $saved = [string](Get-AiProperty -Object $state -Name $Tool -Default "") - if ($saved) { - return $saved - } - - return (Get-AiDefaultProfileName -Tool $Tool) -} - -function Get-AiSecretPath { - param([Parameter(Mandatory = $true)]$Profile) - - $path = Get-AiProperty -Object $Profile -Name "windows_secret" - if (-not $path) { - $path = Get-AiProperty -Object $Profile -Name "secret" - } - - return (Expand-AiPath $path) -} - -function Get-AiSecretId { - param( - [Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool, - [Parameter(Mandatory = $true)]$Profile - ) - - $secretId = Get-AiProperty -Object $Profile -Name "secret_id" - if ($secretId) { - return [string]$secretId - } - - return "$Tool.$(Get-AiProfileName -Profile $Profile)" -} - -function Format-AiSecretPreview { - param([AllowNull()][string]$Value) - - if (-not $Value) { - return "" - } - - if ($Value.Length -le 12) { - return ($Value.Substring(0, [Math]::Min(4, $Value.Length)) + "...") - } - - return ($Value.Substring(0, [Math]::Min(8, $Value.Length)) + "..." + $Value.Substring($Value.Length - 4)) -} - -function ConvertFrom-AiTomlValue { - param([AllowNull()][string]$Value) - - if ($null -eq $Value) { - return $null - } - - $trimmed = $Value.Trim() - if ($trimmed -match '^"((?:\\.|[^"])*)"') { - try { - return ($Matches[0] | ConvertFrom-Json) - } catch { - return $Matches[1] - } - } - if ($trimmed -match "^'([^']*)'") { - return $Matches[1] - } - if ($trimmed -match '^(true|false)\b') { - return $Matches[1].ToLowerInvariant() - } - - return (($trimmed -split '\s+#', 2)[0]).Trim() -} - -function Get-AiTomlSecretSection { - param([Parameter(Mandatory = $true)][string]$SecretId) - - $values = @{} - if (-not (Test-Path -LiteralPath $script:AiSecretsPath)) { - return $values - } - - $current = "" - foreach ($line in Get-Content -LiteralPath $script:AiSecretsPath) { - $trimmed = $line.Trim() - if (-not $trimmed -or $trimmed.StartsWith("#")) { - continue - } - if ($trimmed -match '^\[([^\]]+)\]\s*$') { - $current = $Matches[1].Trim() - continue - } - if ($current -ne $SecretId) { - continue - } - if ($trimmed -match '^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$') { - $values[$Matches[1]] = ConvertFrom-AiTomlValue $Matches[2] - } - } - - return $values -} - -function Test-AiTomlSecretValues { - param( - [Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool, - [Parameter(Mandatory = $true)]$Profile, - [Parameter(Mandatory = $true)][string[]]$Names - ) - - $section = Get-AiTomlSecretSection -SecretId (Get-AiSecretId -Tool $Tool -Profile $Profile) - foreach ($name in $Names) { - if ($section.ContainsKey($name) -and $section[$name]) { - return $true - } - } - - return $false -} - -function Set-AiEnvironmentFromTomlSecret { - param( - [Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool, - [Parameter(Mandatory = $true)]$Profile, - [Parameter(Mandatory = $true)][string[]]$Names - ) - - $secretId = Get-AiSecretId -Tool $Tool -Profile $Profile - $section = Get-AiTomlSecretSection -SecretId $secretId - $loaded = $false - foreach ($name in $Names) { - if ($section.ContainsKey($name) -and $section[$name]) { - Set-Item -Path "Env:$name" -Value ([string]$section[$name]) - $loaded = $true - } - } - - if ($loaded) { - return "$script:AiSecretsPath#$secretId" - } - - return $null -} - -function Get-AiSecretDisplay { - param( - [Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool, - [Parameter(Mandatory = $true)]$Profile, - [Parameter(Mandatory = $true)][string[]]$Names - ) - - if (Test-AiTomlSecretValues -Tool $Tool -Profile $Profile -Names $Names) { - return "$script:AiSecretsPath#$(Get-AiSecretId -Tool $Tool -Profile $Profile)" - } - - $legacy = Get-AiSecretPath -Profile $Profile - if ($legacy) { - if (Test-Path -LiteralPath $legacy) { - return $legacy - } - return " $legacy" - } - - return " $script:AiSecretsPath#$(Get-AiSecretId -Tool $Tool -Profile $Profile)" -} - -function Get-TomlStringValue { - param( - [Parameter(Mandatory = $true)][string]$Path, - [Parameter(Mandatory = $true)][string]$Key - ) - - if (-not (Test-Path -LiteralPath $Path)) { - return $null - } - - $pattern = "^\s*" + [regex]::Escape($Key) + "\s*=\s*(.+)$" - foreach ($line in Get-Content -LiteralPath $Path) { - if ($line -match $pattern) { - return [string](ConvertFrom-AiTomlValue $Matches[1]) - } - } - - return $null -} - -function Get-AiTomlTopLevelStringValue { - param( - [Parameter(Mandatory = $true)][string]$Path, - [Parameter(Mandatory = $true)][string]$Key - ) - - if (-not (Test-Path -LiteralPath $Path)) { return $null } - $pattern = "^\s*" + [regex]::Escape($Key) + "\s*=\s*(.+)$" - foreach ($line in Get-Content -LiteralPath $Path) { - if ($line.Trim() -match '^\[') { break } - if ($line -match $pattern) { return [string](ConvertFrom-AiTomlValue $Matches[1]) } - } - return $null -} - -function Get-AiTomlSectionStringValue { - param( - [Parameter(Mandatory = $true)][string]$Path, - [Parameter(Mandatory = $true)][string]$Section, - [Parameter(Mandatory = $true)][string]$Key - ) - - if (-not (Test-Path -LiteralPath $Path)) { return $null } - $currentSection = "" - $pattern = "^\s*" + [regex]::Escape($Key) + "\s*=\s*(.+)$" - foreach ($line in Get-Content -LiteralPath $Path) { - $trimmed = $line.Trim() - if ($trimmed -match '^\[\[') { - $currentSection = "" - continue - } - if ($trimmed -match '^\[([^\[\]]+)\]\s*(?:#.*)?$') { - $currentSection = $Matches[1].Trim() - continue - } - if ($currentSection -eq $Section -and $line -match $pattern) { - return [string](ConvertFrom-AiTomlValue $Matches[1]) - } - } - return $null -} - -function Get-PowerShellEnvAssignment { - param( - [Parameter(Mandatory = $true)][string]$Path, - [Parameter(Mandatory = $true)][string]$Name - ) - - if (-not (Test-Path -LiteralPath $Path)) { - return $null - } - - $pattern = "^\s*\`$env:" + [regex]::Escape($Name) + "\s*=\s*['`"]([^'`"]+)['`"]" - foreach ($line in Get-Content -LiteralPath $Path) { - if ($line -match $pattern) { - return $Matches[1] - } - } - - return $null -} - -function Get-CodexHome { - param([Parameter(Mandatory = $true)]$Profile) - - return (Expand-AiPath (Get-AiProperty -Object $Profile -Name "home" -Default "~/.codex")) -} - -function Get-CodexRuntimeProfileName { - param([Parameter(Mandatory = $true)]$Profile) - - $runtimeProfile = Get-AiProperty -Object $Profile -Name "codex_profile" - if (-not $runtimeProfile) { - $runtimeProfile = Get-AiProperty -Object $Profile -Name "profile" - } - if (-not $runtimeProfile) { - $runtimeProfile = (Get-AiProfileName -Profile $Profile).Replace(":", "-") - } - - return [string]$runtimeProfile -} - -function Get-CodexProfilePath { - param([Parameter(Mandatory = $true)]$Profile) - - return (Join-Path (Get-CodexHome -Profile $Profile) "$(Get-CodexRuntimeProfileName -Profile $Profile).config.toml") -} - -function Get-CodexExternalCommand { - $cmd = Get-Command codex -CommandType Application,ExternalScript -ErrorAction SilentlyContinue | Select-Object -First 1 - if (-not $cmd) { - throw "Could not find the real codex executable/script in PATH." - } - - return $cmd.Source -} - -function Get-CodexCurrentProfile { - $saved = Get-AiSavedProfileName -Tool "codex" - $profile = Get-AiProfileByName -Tool "codex" -Name ($env:AI_CODEX_LABEL ?? $saved) - if (-not $profile) { - $profile = Get-AiProfileByName -Tool "codex" -Name (Get-AiDefaultProfileName -Tool "codex") - } - if (-not $profile) { throw "No Codex profile is available." } - return $profile -} - -function New-CodexProcessStartInfo { - param( - [Parameter(Mandatory = $true)][string[]]$Arguments, - [string]$Command - ) - - if (-not $Command) { $Command = Get-CodexExternalCommand } - $extension = [IO.Path]::GetExtension($Command).ToLowerInvariant() - $startInfo = if ($extension -eq ".ps1") { - $info = [Diagnostics.ProcessStartInfo]::new((Get-Process -Id $PID -ErrorAction Stop).Path) - foreach ($argument in @("-NoLogo", "-NoProfile", "-NonInteractive", "-File", $Command)) { [void]$info.ArgumentList.Add($argument) } - $info - } elseif ($extension -in @(".cmd", ".bat")) { - $info = [Diagnostics.ProcessStartInfo]::new($env:ComSpec) - foreach ($argument in @("/d", "/s", "/c", $Command)) { [void]$info.ArgumentList.Add($argument) } - $info - } else { - [Diagnostics.ProcessStartInfo]::new($Command) - } - foreach ($argument in $Arguments) { [void]$startInfo.ArgumentList.Add($argument) } - $startInfo.UseShellExecute = $false - $startInfo.CreateNoWindow = $true - $startInfo.RedirectStandardInput = $true - $startInfo.RedirectStandardOutput = $true - $startInfo.RedirectStandardError = $true - return $startInfo -} - -function Get-CodexAppServerExternalCommand { - if ($env:AI_CODEX_APP_SERVER_CLI) { - $path = [IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($env:AI_CODEX_APP_SERVER_CLI)) - if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "AI_CODEX_APP_SERVER_CLI does not exist: $path" } - return (Resolve-Path -LiteralPath $path).Path - } - if ($IsWindows -and (Get-Command Get-CodexAppBridgeRoot -CommandType Function -ErrorAction SilentlyContinue)) { - try { - $bridgeRoot = Get-CodexAppBridgeRoot - $settingsPath = Join-Path $bridgeRoot "codex-provider-bridge.json" - $securedCliPath = Join-Path $bridgeRoot "codex.exe" - if ((Test-Path -LiteralPath $settingsPath -PathType Leaf) -and (Test-Path -LiteralPath $securedCliPath -PathType Leaf)) { - $settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json -Depth 10 - $configuredPath = [IO.Path]::GetFullPath([string]$settings.realCodexPath) - if ($configuredPath.Equals([IO.Path]::GetFullPath($securedCliPath), [StringComparison]::OrdinalIgnoreCase) -and - (Get-FileHash -LiteralPath $securedCliPath -Algorithm SHA256).Hash -ceq [string]$settings.realCodexSha256) { - return $securedCliPath - } - } - } catch { } - } - return (Get-CodexExternalCommand) -} - -function Write-CodexAppServerMessage { - param( - [Parameter(Mandatory = $true)][Diagnostics.Process]$Process, - [Parameter(Mandatory = $true)]$Message - ) - - $Process.StandardInput.WriteLine(($Message | ConvertTo-Json -Depth 30 -Compress)) - $Process.StandardInput.Flush() -} - -function Read-CodexAppServerResponse { - param( - [Parameter(Mandatory = $true)][Diagnostics.Process]$Process, - [Parameter(Mandatory = $true)][string]$RequestId, - [int]$TimeoutSeconds = 15 - ) - - $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) - while ([DateTime]::UtcNow -lt $deadline) { - $remaining = $deadline - [DateTime]::UtcNow - try { - $line = $Process.StandardOutput.ReadLineAsync().WaitAsync($remaining).GetAwaiter().GetResult() - } catch [TimeoutException] { - throw "Timed out waiting for Codex app-server response '$RequestId'." - } - if ($null -eq $line) { throw "Codex app-server closed before response '$RequestId'." } - try { $message = $line | ConvertFrom-Json -Depth 100 -DateKind String } catch { continue } - if ([string]$message.id -eq $RequestId) { return $message } - } - throw "Timed out waiting for Codex app-server response '$RequestId'." -} - -function Get-CodexAllProviderSessions { - param( - [Parameter(Mandatory = $true)]$Profile, - [switch]$Archived - ) - - $arguments = @("app-server") - $appServerCommand = Get-CodexAppServerExternalCommand - $startInfo = New-CodexProcessStartInfo -Arguments $arguments -Command $appServerCommand - $startInfo.Environment["CODEX_HOME"] = Get-CodexHome -Profile $Profile - $secretEnvironmentNames = @("OPENAI_API_KEY", "CODEX_API_KEY") - $profilePath = Get-CodexProfilePath -Profile $Profile - if (Test-Path -LiteralPath $profilePath -PathType Leaf) { - $providerId = Get-AiTomlTopLevelStringValue -Path $profilePath -Key "model_provider" - if ($providerId) { - $envKey = Get-AiTomlSectionStringValue -Path $profilePath -Section "model_providers.$providerId" -Key "env_key" - if ($envKey) { $secretEnvironmentNames += $envKey } - } - } - foreach ($name in @($secretEnvironmentNames | Where-Object { $_ } | Sort-Object -Unique)) { [void]$startInfo.Environment.Remove($name) } - $process = [Diagnostics.Process]::Start($startInfo) - if (-not $process) { throw "Could not start Codex app-server." } - $stderrTask = $process.StandardError.ReadToEndAsync() - $sessions = [System.Collections.Generic.List[object]]::new() - try { - Write-CodexAppServerMessage -Process $process -Message ([ordered]@{ id = "cx-init"; method = "initialize"; params = [ordered]@{ clientInfo = [ordered]@{ name = "cx"; title = "cx"; version = "1" }; capabilities = $null } }) - $initialize = Read-CodexAppServerResponse -Process $process -RequestId "cx-init" - if ($initialize.error) { throw "Codex app-server initialization failed." } - Write-CodexAppServerMessage -Process $process -Message ([ordered]@{ method = "initialized"; params = [ordered]@{} }) - - $cursor = $null - $reachedEnd = $false - for ($page = 0; $page -lt 100; $page++) { - $requestId = "cx-list-$page" - $params = [ordered]@{ cursor = $cursor; limit = 100; sortKey = "updated_at"; modelProviders = @(); archived = [bool]$Archived; useStateDbOnly = $false } - Write-CodexAppServerMessage -Process $process -Message ([ordered]@{ id = $requestId; method = "thread/list"; params = $params }) - $response = Read-CodexAppServerResponse -Process $process -RequestId $requestId - if ($response.error) { - $message = [string]$response.error.message - throw "Codex app-server thread/list failed on page $page$(if ($message) { ": $message" })." - } - foreach ($session in @($response.result.data)) { $sessions.Add($session) } - $nextCursor = [string]$response.result.nextCursor - if (-not $nextCursor -or $nextCursor -eq $cursor) { $reachedEnd = $true; break } - $cursor = $nextCursor - } - if (-not $reachedEnd) { throw "Codex app-server thread/list exceeded the 100-page safety limit." } - return @($sessions) - } finally { - try { $process.StandardInput.Close() } catch { } - if (-not $process.WaitForExit(2000)) { try { $process.Kill($true) } catch { } } - try { [void]$stderrTask.GetAwaiter().GetResult() } catch { } - $process.Dispose() - } -} - -function Get-CodexResumeArguments { - param( - [Parameter(Mandatory = $true)]$Profile, - [Parameter(Mandatory = $true)][string]$SessionId - ) - - if ([string]::IsNullOrWhiteSpace($SessionId) -or $SessionId -match '[\r\n]') { - throw "Codex session id is invalid." - } - return @("--profile", (Get-CodexRuntimeProfileName -Profile $Profile), "resume", $SessionId) -} - -function ConvertTo-CodexSessionView { - param([Parameter(Mandatory = $true)]$Session) - - $title = if ($Session.name) { [string]$Session.name } elseif ($Session.preview) { [string]$Session.preview } else { [string]$Session.id } - $title = ($title -replace '\s+', ' ').Trim() - if ($title.Length -gt 100) { $title = $title.Substring(0, 99) + "…" } - $updated = if ($Session.updatedAt -is [ValueType]) { - [DateTimeOffset]::FromUnixTimeSeconds([long]$Session.updatedAt).LocalDateTime - } else { [string]$Session.updatedAt } - return [pscustomobject]@{ Updated = $updated; Provider = [string]$Session.modelProvider; Title = $title; Cwd = [string]$Session.cwd; Id = [string]$Session.id } -} - -function Select-CodexAllProviderSession { - param([Parameter(Mandatory = $true)][object[]]$Sessions) - - $views = @($Sessions | ForEach-Object { ConvertTo-CodexSessionView -Session $_ }) - if ($views.Count -eq 0) { throw "No Codex sessions were found." } - if (-not $env:AI_ENV_NONINTERACTIVE -and (Get-Command Out-GridView -ErrorAction SilentlyContinue)) { - return ($views | Out-GridView -Title "Codex sessions - all providers" -PassThru | Select-Object -First 1) - } - if ($env:AI_ENV_NONINTERACTIVE) { throw "A session id is required in non-interactive mode." } - - $pageSize = 25 - $page = 0 - while ($true) { - $first = $page * $pageSize - $last = [Math]::Min($first + $pageSize, $views.Count) - 1 - for ($index = $first; $index -le $last; $index++) { - Write-Host ("[{0}] {1} [{2}] {3}" -f ($index + 1), $views[$index].Updated, $views[$index].Provider, $views[$index].Title) - } - $answer = (Read-Host "Select 1-$($views.Count), N(ext), P(revious), or Q(uit)").Trim() - $selection = 0 - if ([int]::TryParse($answer, [ref]$selection) -and $selection -ge 1 -and $selection -le $views.Count) { return $views[$selection - 1] } - if ($answer -match '^n') { $page = [Math]::Min($page + 1, [Math]::Floor(($views.Count - 1) / $pageSize)); continue } - if ($answer -match '^p') { $page = [Math]::Max(0, $page - 1); continue } - if ($answer -match '^q') { return $null } - } -} - -function Show-CodexAllProviderSessions { - param([string[]]$Arguments) - - $json = $Arguments -contains "--json" - $archived = $Arguments -contains "--archived" - if (@($Arguments | Where-Object { $_ -notin @("--json", "--archived") }).Count -gt 0) { throw "Usage: cx sessions [--archived] [--json]" } - $sessions = @(Get-CodexAllProviderSessions -Profile (Get-CodexCurrentProfile) -Archived:$archived) - if ($json) { ConvertTo-Json -InputObject ([object[]]$sessions) -Depth 20; return } - $sessions | ForEach-Object { ConvertTo-CodexSessionView -Session $_ } | Format-Table -AutoSize -} - -function Resume-CodexAllProviderSession { - param([string[]]$Arguments) - - if ($Arguments.Count -gt 1 -or ($Arguments.Count -eq 1 -and $Arguments[0].StartsWith("-"))) { throw "Usage: cx resume [SESSION_ID]" } - $profile = Get-CodexCurrentProfile - $sessionId = if ($Arguments.Count -eq 1) { [string]$Arguments[0] } else { $null } - if (-not $sessionId) { - $selection = Select-CodexAllProviderSession -Sessions @(Get-CodexAllProviderSessions -Profile $profile) - if (-not $selection) { return } - $sessionId = [string]$selection.Id - } - Set-CodexProfileEnvironment -Profile $profile | Out-Null - $resumeArguments = @(Get-CodexResumeArguments -Profile $profile -SessionId $sessionId) - & (Get-CodexExternalCommand) @resumeArguments -} - -function Get-CodexLoginStatusText { - try { - $codexCommand = Get-CodexExternalCommand - return ((& $codexCommand login status 2>&1 | Out-String).Trim()) - } catch { - return "unavailable: $($_.Exception.Message)" - } -} - -function Get-CodexProviderConfigArgs { - param([Parameter(Mandatory = $true)]$Profile) - - $configArgs = @() - $profilePath = Get-CodexProfilePath -Profile $Profile - $model = Get-TomlStringValue -Path $profilePath -Key "model" - $provider = Get-TomlStringValue -Path $profilePath -Key "model_provider" - $reasoning = Get-TomlStringValue -Path $profilePath -Key "model_reasoning_effort" - - if ($model) { - $configArgs += @("-c", "model=`"$model`"") - } - if ($provider) { - $configArgs += @("-c", "model_provider=`"$provider`"") - } - if ($reasoning) { - $configArgs += @("-c", "model_reasoning_effort=`"$reasoning`"") - } - - if ($provider) { - $baseUrl = Get-TomlStringValue -Path $profilePath -Key "base_url" - $wireApi = Get-TomlStringValue -Path $profilePath -Key "wire_api" - $envKey = Get-TomlStringValue -Path $profilePath -Key "env_key" - $requiresOpenAiAuth = Get-TomlStringValue -Path $profilePath -Key "requires_openai_auth" - $providerName = Get-TomlStringValue -Path $profilePath -Key "name" - $hasProviderConfig = [bool]($baseUrl -or $wireApi -or $envKey -or $requiresOpenAiAuth -or $providerName) - - if (($provider -notin @("openai", "ollama", "lmstudio", "amazon-bedrock")) -or $hasProviderConfig) { - if (-not $providerName) { - $providerName = $provider - } - if (-not $wireApi) { - $wireApi = "responses" - } - - $configArgs += @("-c", "model_providers.$provider.name=`"$providerName`"") - if ($baseUrl) { - $configArgs += @("-c", "model_providers.$provider.base_url=`"$baseUrl`"") - } - if ($wireApi) { - $configArgs += @("-c", "model_providers.$provider.wire_api=`"$wireApi`"") - } - if ($envKey) { - $configArgs += @("-c", "model_providers.$provider.env_key=`"$envKey`"") - } - if ($requiresOpenAiAuth) { - $configArgs += @("-c", "model_providers.$provider.requires_openai_auth=$requiresOpenAiAuth") - } - } - } - - return $configArgs -} - -function Get-CodexDoctorArgs { - param([Parameter(Mandatory = $true)]$Profile) - - return @("doctor", "--json") + (Get-CodexProviderConfigArgs -Profile $Profile) -} - -function Get-CodexDoctorReport { - param([Parameter(Mandatory = $true)]$Profile) - - try { - $codexCommand = Get-CodexExternalCommand - $doctorArgs = Get-CodexDoctorArgs -Profile $Profile - $json = & $codexCommand @doctorArgs 2>$null - if (-not $json) { - return $null - } - return ($json | ConvertFrom-Json) - } catch { - Write-Verbose "Codex doctor failed: $($_.Exception.Message)" - return $null - } -} - -function Get-CodexCheck { - param( - [Parameter(Mandatory = $true)]$Report, - [Parameter(Mandatory = $true)][string]$Name - ) - - return $Report.checks.PSObject.Properties[$Name].Value -} - -function Write-CodexDoctorSummary { - param([Parameter(Mandatory = $true)]$Profile) - - $report = Get-CodexDoctorReport -Profile $Profile - if (-not $report) { - Write-Host " Doctor: unavailable" - return - } - - $auth = Get-CodexCheck -Report $report -Name "auth.credentials" - $config = Get-CodexCheck -Report $report -Name "config.load" - $reach = Get-CodexCheck -Report $report -Name "network.provider_reachability" - $ws = Get-CodexCheck -Report $report -Name "network.websocket_reachability" - $sandbox = Get-CodexCheck -Report $report -Name "sandbox.helpers" - $threads = Get-CodexCheck -Report $report -Name "state.rollout_db_parity" - $updates = Get-CodexCheck -Report $report -Name "updates.status" - - Write-Host " Doctor: $($report.overallStatus), Codex $($report.codexVersion)" - if ($config) { - Write-Host " Runtime: model=$($config.details.model); provider=$($config.details.'model provider'); mcp=$($config.details.'mcp servers')" - } - if ($auth) { - Write-Host " Auth: $($auth.status) - $($auth.summary)" - if ($auth.details.'stored auth mode') { - Write-Host " Auth cache: $($auth.details.'stored auth mode'); api_key=$($auth.details.'stored API key'); chatgpt_tokens=$($auth.details.'stored ChatGPT tokens')" - } - } - if ($reach) { - Write-Host " Network: $($reach.status) - $($reach.summary)" - } - if ($ws) { - Write-Host " WebSocket: $($ws.status) - $($ws.summary)" - } - if ($sandbox) { - Write-Host " Sandbox: approval=$($sandbox.details.'approval policy'); fs=$($sandbox.details.'filesystem sandbox'); net=$($sandbox.details.'network sandbox')" - } - if ($threads) { - Write-Host " Threads: active=$($threads.details.'rollout DB active rows'); archived=$($threads.details.'rollout DB archived rows'); providers=$($threads.details.'rollout DB model providers')" - } - if ($updates) { - Write-Host " Updates: $($updates.details.'latest version status')" - } -} - -function Get-ClaudeAuthStatusReport { - try { - $json = & claude auth status --json 2>$null - if (-not $json) { - return $null - } - return ($json | ConvertFrom-Json) - } catch { - Write-Verbose "Claude auth status failed: $($_.Exception.Message)" - return $null - } -} - -function Write-ClaudeExternalStatus { - $auth = Get-ClaudeAuthStatusReport - if ($auth) { - Write-Host " Auth status: loggedIn=$($auth.loggedIn); method=$($auth.authMethod); provider=$($auth.apiProvider); source=$($auth.apiKeySource ?? '')" - } else { - Write-Host " Auth status: unavailable" - } - - $statsPath = Join-Path $HOME ".claude\stats-cache.json" - if (Test-Path -LiteralPath $statsPath) { - try { - $stats = Get-Content -Raw -LiteralPath $statsPath | ConvertFrom-Json - Write-Host " Local usage cache: sessions=$($stats.totalSessions); messages=$($stats.totalMessages); lastComputed=$($stats.lastComputedDate)" - } catch { - Write-Verbose "Could not read Claude stats cache: $($_.Exception.Message)" - } - } -} - -function Get-LegacyCodexApiKey { - $legacyAuthFiles = @( - (Join-Path $HOME ".codex.API\auth.json"), - (Join-Path $HOME ".codex-api\auth.json") - ) - - foreach ($authFile in $legacyAuthFiles) { - if (-not (Test-Path -LiteralPath $authFile)) { - continue - } - - try { - $auth = Get-Content -Raw -LiteralPath $authFile | ConvertFrom-Json - if ($auth.OPENAI_API_KEY) { - return [string]$auth.OPENAI_API_KEY - } - } catch { - Write-Warning "Could not read legacy Codex API key from $authFile. $($_.Exception.Message)" - } - } - - return $null -} - -function Get-AiToolEnvKeys { - param([Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool) - - $keys = [System.Collections.Generic.HashSet[string]]::new() - foreach ($profile in Get-AiToolProfiles -Tool $Tool) { - $envObj = Get-AiProperty -Object $profile -Name "env" - if ($envObj) { - foreach ($prop in $envObj.PSObject.Properties) { - [void]$keys.Add($prop.Name) - } - } - } - - return $keys -} - -function Clear-AiToolExtraEnv { - param([Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool) - - foreach ($key in (Get-AiToolEnvKeys -Tool $Tool)) { - Remove-Item -Path "Env:$key" -ErrorAction SilentlyContinue - } -} - -function Set-AiProfileExtraEnv { - param([Parameter(Mandatory = $true)]$Profile) - - $envObj = Get-AiProperty -Object $Profile -Name "env" - if (-not $envObj) { - return - } - - foreach ($prop in $envObj.PSObject.Properties) { - Set-Item -Path "Env:$($prop.Name)" -Value ([string]$prop.Value) - } -} - -function Get-AiProfileEnvSummary { - param([Parameter(Mandatory = $true)]$Profile) - - $envObj = Get-AiProperty -Object $Profile -Name "env" - if (-not $envObj) { - return "" - } - - return [string]@($envObj.PSObject.Properties).Count -} - -function Get-AiProfileEnvValue { - param( - [Parameter(Mandatory = $true)]$Profile, - [Parameter(Mandatory = $true)][string]$Name - ) - - $envObj = Get-AiProperty -Object $Profile -Name "env" - if (-not $envObj) { - return $null - } - - $prop = $envObj.PSObject.Properties[$Name] - if ($prop -and $prop.Value) { - return [string]$prop.Value - } - - return $null -} - -function Split-AiEnvArguments { - param([string[]]$Arguments) - - $envMap = [ordered]@{} - $rest = @() - for ($i = 0; $i -lt $Arguments.Count; $i++) { - $arg = [string]$Arguments[$i] - if (($arg -eq "--env" -or $arg -eq "--set-env") -and ($i + 1) -lt $Arguments.Count) { - $pair = [string]$Arguments[$i + 1] - $i++ - $idx = $pair.IndexOf("=") - if ($idx -lt 1) { - throw "Invalid --env value '$pair'. Expected KEY=VALUE." - } - $envMap[$pair.Substring(0, $idx)] = $pair.Substring($idx + 1) - } else { - $rest += $arg - } - } - - return [pscustomobject]@{ Env = $envMap; Rest = @($rest) } -} - -function Test-AiInteractive { - if ($env:AI_ENV_NONINTERACTIVE) { - return $false - } - try { - if ([Console]::IsInputRedirected) { - return $false - } - } catch { - return $false - } - return $true -} - -function Read-AiInput { - param( - [Parameter(Mandatory = $true)][string]$Prompt, - [string]$Default = "" - ) - - $label = if ($Default) { "$Prompt [$Default]" } else { $Prompt } - $answer = Read-Host -Prompt $label - if ([string]::IsNullOrWhiteSpace($answer)) { - return $Default - } - return $answer.Trim() -} - -function Read-AiSecretInput { - param([Parameter(Mandatory = $true)][string]$Prompt) - - $secure = Read-Host -Prompt $Prompt -AsSecureString - if (-not $secure -or $secure.Length -eq 0) { - return "" - } - $ptr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) - try { - return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr) - } finally { - [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) - } -} - -function Add-AiTomlSecretValue { - param( - [Parameter(Mandatory = $true)][string]$SecretId, - [Parameter(Mandatory = $true)][string]$Key, - [Parameter(Mandatory = $true)][string]$Value - ) - - $sectionExists = $false - if (Test-Path -LiteralPath $script:AiSecretsPath) { - foreach ($line in Get-Content -LiteralPath $script:AiSecretsPath) { - if ($line.Trim() -eq "[$SecretId]") { - $sectionExists = $true - break - } - } - } - if ($sectionExists) { - return $false - } - - New-Item -ItemType Directory -Force -Path (Split-Path -Parent $script:AiSecretsPath) | Out-Null - $escaped = $Value.Replace('\', '\\').Replace('"', '\"') - $block = @() - if (Test-Path -LiteralPath $script:AiSecretsPath) { - $block += "" - } - $block += "[$SecretId]" - $block += "$Key = `"$escaped`"" - Add-Content -LiteralPath $script:AiSecretsPath -Value (($block -join "`n") + "`n") -Encoding UTF8 - return $true -} - -function Resolve-AiSecretScaffold { - param( - [Parameter(Mandatory = $true)][string]$SecretId, - [Parameter(Mandatory = $true)][string]$Key, - [bool]$Interactive = $false - ) - - $existing = Get-AiTomlSecretSection -SecretId $SecretId - if ($existing.ContainsKey($Key) -and $existing[$Key]) { - return "$script:AiSecretsPath [$SecretId] $Key (already set)" - } - - if ($Interactive) { - $value = Read-AiSecretInput -Prompt "Enter $Key for [$SecretId] (blank to skip)" - if ($value) { - if (Add-AiTomlSecretValue -SecretId $SecretId -Key $Key -Value $value) { - return "wrote $script:AiSecretsPath [$SecretId] $Key" - } - return "$script:AiSecretsPath [$SecretId] already present; left unchanged" - } - } - - return "add $Key to $script:AiSecretsPath [$SecretId]" -} - -function Set-CodexProfileEnvironment { - param([Parameter(Mandatory = $true)]$Profile) - - $mode = Get-AiProfileMode -Profile $Profile - $name = Get-AiProfileName -Profile $Profile - $env:CODEX_HOME = Get-CodexHome -Profile $Profile - $env:AI_CODEX_PROFILE = Get-CodexRuntimeProfileName -Profile $Profile - $env:AI_CODEX_LABEL = $name - New-Item -ItemType Directory -Force -Path $env:CODEX_HOME | Out-Null - Remove-Item Env:CODEX_API_KEY -ErrorAction SilentlyContinue - Remove-Item Env:OPENAI_API_KEY -ErrorAction SilentlyContinue - Clear-AiToolExtraEnv -Tool "codex" - - $secretSource = "" - if ($mode -eq "api") { - $secret = Get-AiSecretPath -Profile $Profile - $tomlSource = Set-AiEnvironmentFromTomlSecret -Tool "codex" -Profile $Profile -Names @("OPENAI_API_KEY", "CODEX_API_KEY") - if ($tomlSource) { - $secretSource = $tomlSource - } elseif ($secret -and (Test-Path -LiteralPath $secret)) { - . $secret - $secretSource = $secret - } - - if (-not $env:OPENAI_API_KEY -and $env:CODEX_API_KEY) { - $env:OPENAI_API_KEY = $env:CODEX_API_KEY - } - - if (-not $env:OPENAI_API_KEY -and $name -eq "api") { - $legacyKey = Get-LegacyCodexApiKey - if ($legacyKey) { - $env:OPENAI_API_KEY = $legacyKey - $secretSource = "legacy .codex.API auth.json" - } - } - - if (-not $env:OPENAI_API_KEY) { - throw "cx $name needs OPENAI_API_KEY. Put it in $script:AiSecretsPath section [$(Get-AiSecretId -Tool 'codex' -Profile $Profile)] or $secret." - } - } - - Set-AiProfileExtraEnv -Profile $Profile - return $secretSource -} - -function Set-ClaudeProfileEnvironment { - param([Parameter(Mandatory = $true)]$Profile) - - $mode = Get-AiProfileMode -Profile $Profile - $name = Get-AiProfileName -Profile $Profile - $env:AI_CLAUDE_LABEL = $name - foreach ($envName in @("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL")) { - Remove-Item "Env:$envName" -ErrorAction SilentlyContinue - } - Clear-AiToolExtraEnv -Tool "claude" - - $secretSource = "" - if ($mode -eq "api") { - $secret = Get-AiSecretPath -Profile $Profile - $tomlSource = Set-AiEnvironmentFromTomlSecret -Tool "claude" -Profile $Profile -Names @("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL") - if ($tomlSource) { - $secretSource = $tomlSource - } elseif ($secret -and (Test-Path -LiteralPath $secret)) { - . $secret - $secretSource = $secret - } - - if (-not $env:ANTHROPIC_API_KEY -and -not $env:ANTHROPIC_AUTH_TOKEN -and $name -eq "api") { - $userApiKey = [Environment]::GetEnvironmentVariable("ANTHROPIC_API_KEY", "User") - $userAuthToken = [Environment]::GetEnvironmentVariable("ANTHROPIC_AUTH_TOKEN", "User") - if ($userApiKey) { - $env:ANTHROPIC_API_KEY = $userApiKey - $secretSource = "user environment variable" - } - if ($userAuthToken) { - $env:ANTHROPIC_AUTH_TOKEN = $userAuthToken - $secretSource = "user environment variable" - } - } - - if (-not $env:ANTHROPIC_BASE_URL) { - $env:ANTHROPIC_BASE_URL = [string](Get-AiProperty -Object $Profile -Name "base_url" -Default $script:ClaudeRouterBaseUrl) - } - - if (-not $env:ANTHROPIC_API_KEY -and -not $env:ANTHROPIC_AUTH_TOKEN) { - throw "cc $name needs ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN in $script:AiSecretsPath section [$(Get-AiSecretId -Tool 'claude' -Profile $Profile)] or $secret." - } - } - - Set-AiProfileExtraEnv -Profile $Profile - return $secretSource -} - -# Join an origin and a relative path into a URL (no double slashes). -function Join-Path-Uri { - param([Parameter(Mandatory = $true)][string]$Origin, [Parameter(Mandatory = $true)][string]$Relative) - $o = $Origin.TrimEnd('/') - $r = $Relative.TrimStart('/') - return "$o/$r" -} - -function Get-AiProbeModel { - param( - [Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool, - [Parameter(Mandatory = $true)]$Profile - ) - - if ((Get-AiProfileMode -Profile $Profile) -ne "api") { - return "-" - } - $explicit = Get-AiProperty -Object $Profile -Name "probe_model" - if ($explicit) { - return [string]$explicit - } - - if ($Tool -eq "claude") { - $secretId = Get-AiSecretId -Tool $Tool -Profile $Profile - $section = Get-AiTomlSecretSection -SecretId $secretId - if ($section.ContainsKey("ANTHROPIC_MODEL") -and $section["ANTHROPIC_MODEL"]) { - return [string]$section["ANTHROPIC_MODEL"] - } - - $legacyPath = Get-AiSecretPath -Profile $Profile - if ($legacyPath -and (Test-Path -LiteralPath $legacyPath)) { - $legacyModel = Get-PowerShellEnvAssignment -Path $legacyPath -Name "ANTHROPIC_MODEL" - if ($legacyModel) { - return $legacyModel - } - } - - $profileModel = Get-AiProfileEnvValue -Profile $Profile -Name "ANTHROPIC_MODEL" - if ($profileModel) { - return $profileModel - } - - $processModel = [Environment]::GetEnvironmentVariable("ANTHROPIC_MODEL") - if ($processModel) { - return $processModel - } - - $userModel = [Environment]::GetEnvironmentVariable("ANTHROPIC_MODEL", "User") - if ($userModel) { - return $userModel - } - - if ($section.ContainsKey("ANTHROPIC_DEFAULT_HAIKU_MODEL") -and $section["ANTHROPIC_DEFAULT_HAIKU_MODEL"]) { - return [string]$section["ANTHROPIC_DEFAULT_HAIKU_MODEL"] - } - - if ($legacyPath -and (Test-Path -LiteralPath $legacyPath)) { - $legacyHaikuModel = Get-PowerShellEnvAssignment -Path $legacyPath -Name "ANTHROPIC_DEFAULT_HAIKU_MODEL" - if ($legacyHaikuModel) { - return $legacyHaikuModel - } - } - - $profileHaikuModel = Get-AiProfileEnvValue -Profile $Profile -Name "ANTHROPIC_DEFAULT_HAIKU_MODEL" - if ($profileHaikuModel) { - return $profileHaikuModel - } - - $processHaikuModel = [Environment]::GetEnvironmentVariable("ANTHROPIC_DEFAULT_HAIKU_MODEL") - if ($processHaikuModel) { - return $processHaikuModel - } - - $userHaikuModel = [Environment]::GetEnvironmentVariable("ANTHROPIC_DEFAULT_HAIKU_MODEL", "User") - if ($userHaikuModel) { - return $userHaikuModel - } - - return "claude-3-5-haiku-20241022" - } - - $profilePath = Get-CodexProfilePath -Profile $Profile - $model = Get-TomlStringValue -Path $profilePath -Key "model" - if (-not $model) { - $model = Get-TomlStringValue -Path (Join-Path (Get-CodexHome -Profile $Profile) "config.toml") -Key "model" - } - if ($model) { - return $model - } - - return "gpt-5.4-mini" -} - -# Returns probe target (base origin + auth headers) for a profile WITHOUT -# mutating $env:. Used by Get-AiProfileHealth so a `cc list` check never -# disturbs the current shell session. -function Get-AiProfileProbeTarget { - param( - [Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool, - [Parameter(Mandatory = $true)]$Profile - ) - - $result = [pscustomobject]@{ - BaseOrigin = $null - Headers = @{} - ProbeModel = $null - SecretOk = $false - SecretLabel = "" - } - - $mode = Get-AiProfileMode -Profile $Profile - if ($mode -ne "api") { - return $result - } - - $secretId = Get-AiSecretId -Tool $Tool -Profile $Profile - $section = Get-AiTomlSecretSection -SecretId $secretId - $legacyPath = Get-AiSecretPath -Profile $Profile - - if ($Tool -eq "claude") { - $baseUrl = $null - if ($section.ContainsKey("ANTHROPIC_BASE_URL") -and $section["ANTHROPIC_BASE_URL"]) { - $baseUrl = [string]$section["ANTHROPIC_BASE_URL"] - } elseif ($legacyPath -and (Test-Path -LiteralPath $legacyPath)) { - $baseUrl = Get-PowerShellEnvAssignment -Path $legacyPath -Name "ANTHROPIC_BASE_URL" - } - if (-not $baseUrl) { - $baseUrl = [string](Get-AiProperty -Object $Profile -Name "base_url" -Default $script:ClaudeRouterBaseUrl) - } - - $authToken = $null - $apiKey = $null - if ($section.ContainsKey("ANTHROPIC_AUTH_TOKEN") -and $section["ANTHROPIC_AUTH_TOKEN"]) { - $authToken = [string]$section["ANTHROPIC_AUTH_TOKEN"] - } - if ($section.ContainsKey("ANTHROPIC_API_KEY") -and $section["ANTHROPIC_API_KEY"]) { - $apiKey = [string]$section["ANTHROPIC_API_KEY"] - } - if (-not $authToken -and -not $apiKey -and $legacyPath -and (Test-Path -LiteralPath $legacyPath)) { - $authToken = Get-PowerShellEnvAssignment -Path $legacyPath -Name "ANTHROPIC_AUTH_TOKEN" - $apiKey = Get-PowerShellEnvAssignment -Path $legacyPath -Name "ANTHROPIC_API_KEY" - } - if (-not $authToken) { $authToken = [Environment]::GetEnvironmentVariable("ANTHROPIC_AUTH_TOKEN", "User") } - if (-not $apiKey) { $apiKey = [Environment]::GetEnvironmentVariable("ANTHROPIC_API_KEY", "User") } - - $probeModel = Get-AiProbeModel -Tool $Tool -Profile $Profile - $headers = @{ "anthropic-version" = "2023-06-01" } - if ($authToken) { $headers["Authorization"] = "Bearer $authToken" } - if ($apiKey) { $headers["x-api-key"] = $apiKey } - if ($probeModel -match '\[1m\]') { $headers["anthropic-beta"] = "context-1m-2025-08-07" } - # Some relays gate on User-Agent (e.g. AixHan rejects unknown clients with a - # 400 "Client not allowed ..."). Identify as the real Claude Code CLI so the - # probe sees what an actual session sees. Override per-profile via `probe_ua`. - $headers["User-Agent"] = [string](Get-AiProperty -Object $Profile -Name "probe_ua" -Default "claude-cli/1.0.119 (external, cli)") - - $result.BaseOrigin = $baseUrl - $result.Headers = $headers - $result.SecretOk = [bool]($authToken -or $apiKey) - $result.SecretLabel = if ($result.SecretOk) { "$script:AiSecretsPath#$secretId" } else { "" } - $result.ProbeModel = $probeModel - return $result - } - - # codex - $apiKey = $null - if ($section.ContainsKey("OPENAI_API_KEY") -and $section["OPENAI_API_KEY"]) { - $apiKey = [string]$section["OPENAI_API_KEY"] - } - if (-not $apiKey -and $section.ContainsKey("CODEX_API_KEY") -and $section["CODEX_API_KEY"]) { - $apiKey = [string]$section["CODEX_API_KEY"] - } - if (-not $apiKey -and $legacyPath -and (Test-Path -LiteralPath $legacyPath)) { - $apiKey = Get-PowerShellEnvAssignment -Path $legacyPath -Name "OPENAI_API_KEY" - } - if (-not $apiKey) { $apiKey = Get-LegacyCodexApiKey } - - $result.BaseOrigin = (Get-CodexBaseUrl -Profile $Profile) - $result.Headers = if ($apiKey) { @{ "Authorization" = "Bearer $apiKey" } } else { @{} } - # Identify as the real Codex CLI for relays that gate on User-Agent. - $result.Headers["User-Agent"] = [string](Get-AiProperty -Object $Profile -Name "probe_ua" -Default "codex_cli_rs/0.40.0 (external, cli)") - $result.SecretOk = [bool]$apiKey - $result.SecretLabel = if ($apiKey) { "$script:AiSecretsPath#$secretId" } else { "" } - - $result.ProbeModel = Get-AiProbeModel -Tool $Tool -Profile $Profile - return $result -} - -# Build a probe plan for one profile WITHOUT making any request. Returns either: -# @{ Early = } -> no live probe (sub mode / missing config) -# @{ Headers; Kind; Candidates } -> ready to fire; Candidates is an array of -# @{ Label; Url; Body; Check }. For codex -# also EffLabel/AltLabel (wire_api driven). -# Splitting plan-building (pure, instant) from request-firing lets `cc/cx health` -# fire every profile's requests concurrently instead of one-by-one. -function Get-AiProfileProbePlan { - param( - [Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool, - [Parameter(Mandatory = $true)]$Profile - ) - - $mode = Get-AiProfileMode -Profile $Profile - if ($mode -ne "api") { - return @{ Early = [pscustomobject]@{ Status = "skip"; LatencyMs = 0; Method = $null; Error = "subscription mode (no remote probe)" } } - } - - $target = Get-AiProfileProbeTarget -Tool $Tool -Profile $Profile - if (-not $target.BaseOrigin) { - return @{ Early = [pscustomobject]@{ Status = "down"; LatencyMs = 0; Method = $null; Error = "missing base_url" } } - } - if (-not $target.SecretOk) { - return @{ Early = [pscustomobject]@{ Status = "down"; LatencyMs = 0; Method = $null; Error = "missing credentials" } } - } - - $origin = [string]$target.BaseOrigin - $origin = $origin.TrimEnd('/') - $candidates = @() - $effLabel = $null; $altLabel = $null - - if ($Tool -eq "claude") { - $apiBase = if ($origin -match '/v1$') { ($origin -replace '/v1$', '') } else { $origin } - $candidates += @{ - Label = "messages" - Url = Join-Path-Uri $apiBase "v1/messages" - Body = @{ model = $target.ProbeModel; max_tokens = 1; messages = @(@{ role = "user"; content = "." }) } | - ConvertTo-Json -Depth 5 -Compress - Check = "messages" - } - } else { - $hasVersion = $origin -match '/v\d+$' - $responsesRel = if ($hasVersion) { "responses" } else { "v1/responses" } - $chatRel = if ($hasVersion) { "chat/completions" } else { "v1/chat/completions" } - $model = if ($target.ProbeModel) { $target.ProbeModel } else { "probe" } - $candidates += @{ - Label = "responses" - Url = Join-Path-Uri $origin $responsesRel - Body = @{ model = $model; input = "."; max_output_tokens = 1 } | ConvertTo-Json -Depth 5 -Compress - Check = "responses" - } - $candidates += @{ - Label = "chat" - Url = Join-Path-Uri $origin $chatRel - Body = @{ model = $model; max_tokens = 1; messages = @(@{ role = "user"; content = "." }) } | - ConvertTo-Json -Depth 5 -Compress - Check = "chat" - } - # verdict endpoint = the configured wire_api (default responses) - $cfgPath = Get-CodexProfilePath -Profile $Profile - $wireApi = $null - if ($cfgPath -and (Test-Path -LiteralPath $cfgPath)) { - $wireApi = Get-TomlStringValue -Path $cfgPath -Key "wire_api" - } - $effLabel = if ($wireApi -and $wireApi -match "chat") { "chat" } else { "responses" } - $altLabel = if ($effLabel -eq "chat") { "responses" } else { "chat" } - } - - return @{ Headers = $target.Headers; Kind = $Tool; Candidates = $candidates; EffLabel = $effLabel; AltLabel = $altLabel } -} - -# Fire ONE probe request. Uses Invoke-RestMethod (Invoke-WebRequest stalls to -# timeout on some relays under PS7). Returns Ok/Code/LatencyMs/Detail/Body; the -# caller validates the body (Test-AiProbeBody) and applies the verdict. -function Invoke-AiProbeRequest { - param($Url, $Headers, $Body, [int]$TimeoutSec = 20) - $sw = [System.Diagnostics.Stopwatch]::StartNew() - $r = [pscustomobject]@{ Ok = $false; Code = 0; LatencyMs = 0; Detail = $null; Body = $null } - try { - $j = Invoke-RestMethod -Uri $Url -Method Post -Headers $Headers -Body $Body ` - -ContentType "application/json" -TimeoutSec $TimeoutSec -ErrorAction Stop - $sw.Stop() - $r.Code = 200 - $r.LatencyMs = [int]$sw.ElapsedMilliseconds - $r.Body = $j - } catch { - $sw.Stop() - $r.LatencyMs = [int]$sw.ElapsedMilliseconds - if ($_.Exception.Response) { - $r.Code = [int]$_.Exception.Response.StatusCode - $bodyText = [string]$_.ErrorDetails.Message - if ($bodyText) { - try { - $bodyJson = $bodyText | ConvertFrom-Json -ErrorAction Stop - $message = if ($bodyJson.error -and $bodyJson.error.message) { [string]$bodyJson.error.message } elseif ($bodyJson.message) { [string]$bodyJson.message } else { $null } - if ($message) { $bodyText = $message } - } catch { } - $bodyText = ConvertFrom-AiPrintableUnicodeEscapes $bodyText - $bodyText = ($bodyText -replace "\s+", " ").Trim() - if ($bodyText.Length -gt 240) { $bodyText = $bodyText.Substring(0, 240) } - $r.Detail = "HTTP $($r.Code) $bodyText" - } else { - $r.Detail = "HTTP $($r.Code)" - } - } else { - $r.Detail = $_.Exception.Message - } - } - return $r -} - -# Validate a fired request's parsed body against its Check type (mutates $Req: -# sets Ok + Detail). A non-2xx Code is left as-is (not ok). -function Test-AiProbeBody { - param($Req, [string]$Check) - if ($Req.Code -lt 200 -or $Req.Code -ge 300) { return } - $valid = $false - $j = $Req.Body - switch ($Check) { - "messages" { $valid = (($j.content -is [array]) -and ($j.content.Count -gt 0)) -or ($j.type -eq "message") } - "responses" { $valid = (($j.output -is [array] -and $j.output.Count -gt 0) -or $j.output_text -or $j.status -eq "completed") } - "chat" { $valid = (($j.choices -is [array]) -and ($j.choices.Count -gt 0)) } - } - $Req.Ok = $valid - if (-not $valid -and -not $Req.Detail) { $Req.Detail = "200 but no generated content" } -} - -$script:AiHealthMaxOutputWidth = 120 - -function ConvertFrom-AiPrintableUnicodeEscapes { - param([AllowNull()][string]$Text) - if (-not $Text) { return "" } - $decoded = [regex]::Replace($Text, '\\u([0-9a-fA-F]{4})', { - param($match) - $code = [Convert]::ToInt32($match.Groups[1].Value, 16) - if ($code -lt 0x20 -or ($code -ge 0x7f -and $code -lt 0xa0)) { return "?" } - return [char]$code - }) - return [regex]::Replace($decoded, '[\x00-\x1f\x7f-\x9f]', '?') -} - -function Get-AiFallbackDisplayWidth { - param([AllowNull()][string]$Text) - if (-not $Text) { return 0 } - $width = 0 - for ($i = 0; $i -lt $Text.Length; $i++) { - $code = if ([char]::IsHighSurrogate($Text[$i]) -and ($i + 1) -lt $Text.Length -and [char]::IsLowSurrogate($Text[$i + 1])) { - $value = [char]::ConvertToUtf32($Text[$i], $Text[$i + 1]) - $i += 1 - $value - } else { [int]$Text[$i] } - if ($code -ge 0xd800 -and $code -le 0xdfff) { - $width += 1 - continue - } - $category = [Globalization.CharUnicodeInfo]::GetUnicodeCategory([char]::ConvertFromUtf32($code), 0) - if ($category -in @( - [Globalization.UnicodeCategory]::Control, - [Globalization.UnicodeCategory]::Format, - [Globalization.UnicodeCategory]::NonSpacingMark, - [Globalization.UnicodeCategory]::EnclosingMark - )) { continue } - $isWide = - ($code -ge 0x1100 -and $code -le 0x115f) -or $code -eq 0x2329 -or $code -eq 0x232a -or - ($code -ge 0x2e80 -and $code -le 0xa4cf) -or ($code -ge 0xac00 -and $code -le 0xd7a3) -or - ($code -ge 0xf900 -and $code -le 0xfaff) -or ($code -ge 0xfe10 -and $code -le 0xfe19) -or - ($code -ge 0xfe30 -and $code -le 0xfe6f) -or ($code -ge 0xff00 -and $code -le 0xff60) -or - ($code -ge 0xffe0 -and $code -le 0xffe6) -or ($code -ge 0x1f1e6 -and $code -le 0x1f1ff) -or - ($code -ge 0x1f300 -and $code -le 0x1faff) - $width += if ($isWide) { 2 } else { 1 } - } - return $width -} - -function Get-AiDisplayWidth { - param([AllowNull()][string]$Text) - if (-not $Text) { return 0 } - try { return [int]$Host.UI.RawUI.LengthInBufferCells($Text) } catch { return Get-AiFallbackDisplayWidth $Text } -} - -function Limit-AiDisplayText { - param( - [AllowNull()][string]$Text, - [int]$MaxWidth - ) - if (-not $Text -or $MaxWidth -le 0) { return "" } - if ((Get-AiDisplayWidth $Text) -le $MaxWidth) { return $Text } - $suffix = if ($MaxWidth -gt 3) { "..." } else { "" } - $limit = [Math]::Max(0, $MaxWidth - (Get-AiDisplayWidth $suffix)) - $result = [System.Text.StringBuilder]::new() - $width = 0 - $elements = [System.Globalization.StringInfo]::GetTextElementEnumerator($Text) - while ($elements.MoveNext()) { - $element = $elements.GetTextElement() - $elementWidth = Get-AiDisplayWidth $element - if (($width + $elementWidth) -gt $limit) { break } - [void]$result.Append($element) - $width += $elementWidth - } - return $result.ToString() + $suffix -} - -function Get-AiHealthOutputWidth { - $width = $script:AiHealthMaxOutputWidth - $configured = 0 - if ([int]::TryParse($env:AI_HEALTH_COLUMNS, [ref]$configured) -and $configured -gt 0) { - return [Math]::Min($width, $configured) - } - if (-not [Console]::IsOutputRedirected) { - try { - if ([Console]::WindowWidth -gt 0) { $width = [Math]::Min($width, [Console]::WindowWidth) } - } catch { } - } - return [Math]::Max(1, $width) -} - -# Resolve a final health verdict from per-candidate results (a hashtable keyed -# by candidate Label -> request object from Invoke-AiProbeRequest). Instant. -# Collapse a verbose probe exception into a short, scannable Note. HTTP codes -# and "200 but..." are already concise and pass through; long exception text -# (e.g. the HttpClient timeout / SSL EPROTO messages) is classified so the Note -# column never gets truncated mid-word. -function ConvertTo-AiProbeError { - param([string]$Detail) - if (-not $Detail) { return "" } - if (Test-AiProbeModelUnsupported $Detail) { return "probe model unsupported; set probe_model" } - if ($Detail -match "^(HTTP \d|200 but)") { return $Detail } - $l = $Detail.ToLower() - if ($l -match "timeout|canceled|timed out|httpclient\.timeout") { return "timeout" } - if ($l -match "ssl|handshake|eproto|sslv3|certificate|trust") { return "TLS handshake failed" } - if ($l -match "econnrefused|connection refused") { return "connection refused" } - if ($l -match "enotfound|getaddrinfo|nodata|getaddr|dns") { return "DNS failed" } - if ($l -match "econnreset|socket hang up|reset by peer|reset") { return "connection reset" } - return $Detail -} - -function Test-AiProbeModelUnsupported { - param([string]$Detail) - if (-not $Detail) { return $false } - $l = (ConvertFrom-AiPrintableUnicodeEscapes $Detail).ToLowerInvariant() - return ($l -match "no available providers|model_not_found|model not found|model does not exist|unknown model|unsupported model|model .*not supported|not support.*model|invalid model|model_not_supported|模型不存在|模型.*不存在|请检查模型代码") -} - -function ConvertTo-AiHealthDisplayError { - param([string]$Detail) - if (-not $Detail) { return "" } - $text = ((ConvertFrom-AiPrintableUnicodeEscapes $Detail) -replace "\s+", " ").Trim() - function ShortProbeDetail([string]$Item) { - $itemText = ($Item -replace "\s+", " ").Trim() - if (Test-AiProbeModelUnsupported $itemText) { - return "probe model unsupported; set probe_model" - } - if ($itemText -match "^(HTTP \d{3})(?:\s+(.+))?$") { - $code = $Matches[1] - $body = if ($Matches.Count -gt 2) { $Matches[2] } else { "" } - if (-not $body) { return $code } - try { - $json = $body | ConvertFrom-Json -ErrorAction Stop - $msg = $null - if ($json.error -and $json.error.message) { $msg = [string]$json.error.message } - elseif ($json.message) { $msg = [string]$json.message } - elseif ($json.error) { $msg = [string]$json.error } - elseif ($json.type) { $msg = [string]$json.type } - if ($msg) { return ($code + " " + (($msg -replace "\s+", " ").Trim())) } - } catch { } - if ($body -match '"message"\s*:\s*"([^"]+)"') { - return ($code + " " + (($Matches[1] -replace "\s+", " ").Trim())) - } - return $itemText - } - return $itemText - } - if ($text -match "^(POST\s+/\S+\s+->\s+)(.+?)(;\s+/\S+\s+->\s+)(.+)$") { - $prefix = $Matches[1] - $first = $Matches[2] - $middle = $Matches[3] - $second = $Matches[4] - return $prefix + (ShortProbeDetail $first) + $middle + (ShortProbeDetail $second) - } - if ($text -match "^(POST\s+/\S+\s+->\s+)(.+?)(;\s+but\s+/\S+\s+works\s+->\s+.+)$") { - $prefix = $Matches[1] - $first = $Matches[2] - $suffix = $Matches[3] - return $prefix + (ShortProbeDetail $first) + $suffix - } - if ($text -match "^(POST\s+/\S+\s+)(.+)$") { - $prefix = $Matches[1] - $detail = $Matches[2] - return $prefix + (ShortProbeDetail $detail) - } - return ShortProbeDetail $text -} - -function Resolve-AiProfileHealth { - param($Plan, $Results, [int]$DegradedMs = 8000) - - if ($Plan.Kind -eq "claude") { - $m = $Results["messages"] - if ($m.Ok) { - $st = if ($m.LatencyMs -gt $DegradedMs) { "degraded" } else { "healthy" } - return [pscustomobject]@{ Status = $st; LatencyMs = $m.LatencyMs; Method = "generation"; Error = $null } - } - if (Test-AiProbeModelUnsupported $m.Detail) { - return [pscustomobject]@{ Status = "degraded"; LatencyMs = $m.LatencyMs; Method = "none"; Error = ("POST /v1/messages " + $m.Detail) } - } - $md = ConvertTo-AiProbeError $m.Detail - if ($m.Code -eq 429 -or ($m.Code -ge 500 -and $m.Code -lt 600)) { - return [pscustomobject]@{ Status = "degraded"; LatencyMs = $m.LatencyMs; Method = "none"; Error = ("POST /v1/messages " + $md + " (transient)") } - } - return [pscustomobject]@{ Status = "down"; LatencyMs = $m.LatencyMs; Method = "none"; Error = ("POST /v1/messages " + $md) } - } - - # codex: verdict = the endpoint matching the configured wire_api. - $eff = $Results[$Plan.EffLabel] - $alt = $Results[$Plan.AltLabel] - if ($eff.Ok) { - $st = if ($eff.LatencyMs -gt $DegradedMs) { "degraded" } else { "healthy" } - return [pscustomobject]@{ Status = $st; LatencyMs = $eff.LatencyMs; Method = "generation:$($Plan.EffLabel)"; Error = $null } - } - $effD = ConvertTo-AiProbeError $eff.Detail - $altD = ConvertTo-AiProbeError $alt.Detail - $note = "POST /$($Plan.EffLabel) -> $($eff.Detail)" - if ($alt.Ok) { - $note += "; but /$($Plan.AltLabel) works -> set wire_api = `"$($Plan.AltLabel)`" in config.toml" - return [pscustomobject]@{ Status = "degraded"; LatencyMs = $eff.LatencyMs; Method = "none"; Error = $note } - } - if ((Test-AiProbeModelUnsupported $eff.Detail) -or (Test-AiProbeModelUnsupported $alt.Detail)) { - $note += "; /$($Plan.AltLabel) -> $($alt.Detail)" - return [pscustomobject]@{ Status = "degraded"; LatencyMs = $eff.LatencyMs; Method = "none"; Error = $note } - } - $note = "POST /$($Plan.EffLabel) -> $effD" - if ($eff.Code -eq 429 -or ($eff.Code -ge 500 -and $eff.Code -lt 600)) { - $note += "; /$($Plan.AltLabel) -> $altD (transient)" - return [pscustomobject]@{ Status = "degraded"; LatencyMs = $eff.LatencyMs; Method = "none"; Error = $note } - } - $note += "; /$($Plan.AltLabel) -> $altD" - return [pscustomobject]@{ Status = "down"; LatencyMs = $eff.LatencyMs; Method = "none"; Error = $note } -} - -# Probe a profile by issuing a real (cheap/free) API request and measuring -# latency. Returns: Status = healthy | degraded | down | skip; LatencyMs; Error. -# Claude: GET {base}/v1/models (free), fall back to POST /v1/messages (1 token) -# Codex: GET {base}/v1/models (free) -# Never mutates the current process environment. -function Get-AiProfileHealth { - param( - [Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool, - [Parameter(Mandatory = $true)]$Profile, - [int]$TimeoutSec = 20, - [int]$DegradedMs = 8000 - ) - - # Single-profile path (used by `cx doctor`, switch-time, etc.). `cc/cx health` - # builds plans for ALL profiles and fires requests concurrently via - # Invoke-AiProbeRequest directly; this wrapper stays sequential for one-off use. - $plan = Get-AiProfileProbePlan -Tool $Tool -Profile $Profile - if ($plan.ContainsKey("Early")) { return $plan.Early } - - $results = @{} - foreach ($c in $plan.Candidates) { - $req = Invoke-AiProbeRequest -Url $c.Url -Headers $plan.Headers -Body $c.Body -TimeoutSec $TimeoutSec - Test-AiProbeBody -Req $req -Check $c.Check - $results[$c.Label] = $req - } - return Resolve-AiProfileHealth -Plan $plan -Results $results -DegradedMs $DegradedMs -} - -# --- Health cache (on-demand only; no background scheduler) --- -# Caches probe results in ~/.ai-env/health.json with a TTL so `cc list` / `cx -# list` and switch-time selection don't re-probe on every call. A probe fires -# only when the cache is stale or -Fresh is passed. - -function Get-AiHealthCachePath { - return (Join-Path $script:AiConfigDir "health.json") -} - -function Read-AiHealthCache { - $path = Get-AiHealthCachePath - if (-not (Test-Path -LiteralPath $path)) { return @{} } - try { - $obj = Get-Content -Raw -LiteralPath $path | ConvertFrom-Json - $h = @{} - if ($obj) { foreach ($prop in $obj.PSObject.Properties) { $h[$prop.Name] = $prop.Value } } - return $h - } catch { return @{} } -} - -function Write-AiHealthCacheEntry { - param([Parameter(Mandatory = $true)][string]$Key, [Parameter(Mandatory = $true)]$Entry) - $path = Get-AiHealthCachePath - $h = Read-AiHealthCache - $h[$Key] = $Entry - New-Item -ItemType Directory -Force -Path $script:AiConfigDir | Out-Null - $h | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $path -Encoding UTF8 -} - -function Clear-AiHealthCache { - $path = Get-AiHealthCachePath - if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -ErrorAction SilentlyContinue } -} - -# Probe a profile with a TTL cache. Returns the health result plus ProbedAt -# (unix seconds) and a Cached flag (true if served from cache). -function Get-AiProfileHealthCached { - param( - [Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool, - [Parameter(Mandatory = $true)]$Profile, - [switch]$Fresh, - [switch]$CacheOnly, - [int]$TtlSec = 300 - ) - - $name = Get-AiProfileName -Profile $Profile - $key = "$Tool.$name" - - # Fresh cache hit short-circuits (whether or not CacheOnly). - if (-not $Fresh) { - $cache = Read-AiHealthCache - if ($cache.ContainsKey($key)) { - $entry = $cache[$key] - $probedAt = 0 - try { $probedAt = [int64]$entry.probedAt } catch { } - $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - if ($probedAt -gt 0 -and ($now - $probedAt) -lt $TtlSec) { - return [pscustomobject]@{ - Status = [string]$entry.status - LatencyMs = [int]$entry.latencyMs - Method = [string]$entry.method - Error = $entry.error - ProbedAt = $probedAt - Cached = $true - } - } - } - } - - # CacheOnly: never probe (keeps `list`/`status`/switch instant — a stale or - # unprobed entry shows as skip ⏭, matching the lean list). Use `health` / - # `status --refresh` to force a live probe. - if ($CacheOnly) { - return [pscustomobject]@{ Status = "skip"; LatencyMs = 0; Method = $null; Error = $null; ProbedAt = 0; Cached = $false } - } - - $result = Get-AiProfileHealth -Tool $Tool -Profile $Profile - $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - Write-AiHealthCacheEntry -Key $key -Entry ([pscustomobject]@{ - status = $result.Status - latencyMs = $result.LatencyMs - method = $result.Method - error = $result.Error - probedAt = $now - }) - - return [pscustomobject]@{ - Status = $result.Status - LatencyMs = $result.LatencyMs - Method = $result.Method - Error = $result.Error - ProbedAt = $now - Cached = $false - } -} - -function New-CodexProfileConfig { - param( - [Parameter(Mandatory = $true)]$Profile, - [AllowNull()][string]$BaseUrl, - [AllowNull()][string]$Model, - [AllowNull()][string]$EnvKey, - [AllowNull()][string]$ProviderName - ) - - $mode = Get-AiProfileMode -Profile $Profile - $profilePath = Get-CodexProfilePath -Profile $Profile - if (Test-Path -LiteralPath $profilePath) { - return $profilePath - } - - New-Item -ItemType Directory -Force -Path (Split-Path -Parent $profilePath) | Out-Null - $providerId = "api-router" - $displayName = if ($ProviderName) { $ProviderName } else { (Get-AiProfileName -Profile $Profile).Replace(":", " ") } - $keyName = if ($EnvKey) { $EnvKey } else { "OPENAI_API_KEY" } - - if ($mode -eq "api") { - $url = if ($BaseUrl) { $BaseUrl } else { "https://your-router.example/v1" } - $lines = @("model_provider = `"$providerId`"") - if ($Model) { - $lines += "model = `"$Model`"" - } - $lines += @( - "disable_response_storage = true" - "" - "[model_providers.$providerId]" - "name = `"$displayName`"" - "base_url = `"$url`"" - "env_key = `"$keyName`"" - ) - (($lines -join "`n") + "`n") | Set-Content -LiteralPath $profilePath -Encoding UTF8 - } else { - $lines = @("model_provider = `"openai`"") - if ($Model) { - $lines += "model = `"$Model`"" - } - (($lines -join "`n") + "`n") | Set-Content -LiteralPath $profilePath -Encoding UTF8 - } - - return $profilePath -} - -function Add-CodexApiProfile { - param([string[]]$Arguments) - - $split = Split-AiEnvArguments -Arguments $Arguments - $parsed = ConvertFrom-AiManagementArgs -Arguments $split.Rest - if ($parsed.Positionals.Count -lt 1) { - throw "Usage: cx add-api [--base-url URL] [--env-key NAME] [--provider-name NAME] [--model MODEL] [--home PATH] [--env KEY=VALUE ...]" - } - - $name = [string]$parsed.Positionals[0] - Assert-AiProfileName -Name $name - $slug = Get-AiNameSlug -Name $name - $interactive = Test-AiInteractive - $profileHome = Get-AiOption -Options $parsed.Options -Name "home" -Default "~/.codex" - $runtimeProfile = Get-AiOption -Options $parsed.Options -Name "profile" -Default "api-$($slug.Replace(':', '-'))" - $secretId = Get-AiOption -Options $parsed.Options -Name "secret-id" -Default "codex.$name" - $model = Get-AiOption -Options $parsed.Options -Name "model" - - $baseUrl = Get-AiOption -Options $parsed.Options -Name "base-url" - if (-not $baseUrl) { - $baseUrl = if ($interactive) { Read-AiInput -Prompt "Codex base_url" -Default "https://your-router.example/v1" } else { "https://your-router.example/v1" } - } - $envKey = Get-AiOption -Options $parsed.Options -Name "env-key" - if (-not $envKey) { - $envKey = if ($interactive) { Read-AiInput -Prompt "Codex env_key (secret variable name)" -Default "OPENAI_API_KEY" } else { "OPENAI_API_KEY" } - } - $providerName = Get-AiOption -Options $parsed.Options -Name "provider-name" - if (-not $providerName) { - $providerName = if ($interactive) { Read-AiInput -Prompt "Codex provider display name" -Default $name } else { $name } - } - - $profile = [pscustomobject]@{ - name = $name - aliases = @() - mode = "api" - home = $profileHome - codex_profile = $runtimeProfile - secret_id = $secretId - windows_secret = "~/.ai-secrets/codex-$slug.ps1" - linux_secret = "~/.ai-secrets/codex-$slug.env" - description = "Codex API profile" - } - if ($split.Env.Count -gt 0) { - $profile | Add-Member -NotePropertyName "env" -NotePropertyValue ([pscustomobject]$split.Env) - } - Add-AiProfileRegistration -Tool "codex" -Profile $profile | Out-Null - $profilePath = New-CodexProfileConfig -Profile $profile -BaseUrl $baseUrl -Model $model -EnvKey $envKey -ProviderName $providerName - $secretState = Resolve-AiSecretScaffold -SecretId $secretId -Key $envKey -Interactive $interactive - - Write-Host "Added Codex API profile '$name'." - Write-Host " Registry: $script:AiRegistryPath" - Write-Host " CODEX_HOME: $(Expand-AiPath $profileHome)" - Write-Host " Config: $profilePath" - Write-Host " Secret: $secretState" - if ($split.Env.Count -gt 0) { - Write-Host " Env: $(@($split.Env.Keys) -join ', ')" - } -} - -function Add-CodexSubProfile { - param([string[]]$Arguments) - - $parsed = ConvertFrom-AiManagementArgs -Arguments $Arguments - if ($parsed.Positionals.Count -lt 1) { - throw "Usage: cx add-sub [--home PATH] [--model MODEL]" - } - - $name = [string]$parsed.Positionals[0] - Assert-AiProfileName -Name $name - $slug = Get-AiNameSlug -Name $name - $interactive = Test-AiInteractive - $profileHome = Get-AiOption -Options $parsed.Options -Name "home" - if (-not $profileHome) { - $profileHome = if ($interactive) { Read-AiInput -Prompt "Codex CODEX_HOME for this subscription" -Default "~/.codex-$slug" } else { "~/.codex-$slug" } - } - $runtimeProfile = Get-AiOption -Options $parsed.Options -Name "profile" -Default "sub" - $model = Get-AiOption -Options $parsed.Options -Name "model" - - $profile = [pscustomobject]@{ - name = $name - aliases = @() - mode = "sub" - home = $profileHome - codex_profile = $runtimeProfile - description = "Codex subscription profile" - } - Add-AiProfileRegistration -Tool "codex" -Profile $profile | Out-Null - $profilePath = New-CodexProfileConfig -Profile $profile -Model $model - Write-Host "Added Codex subscription profile '$name'." - Write-Host " Registry: $script:AiRegistryPath" - Write-Host " CODEX_HOME: $(Expand-AiPath $profileHome)" - Write-Host " Config: $profilePath" - Write-Host " Login: CODEX_HOME=`"$(Expand-AiPath $profileHome)`" codex login" -} - -function Remove-CodexProfile { - param([string[]]$Arguments) - - $parsed = ConvertFrom-AiManagementArgs -Arguments $Arguments - if ($parsed.Positionals.Count -lt 1) { - throw "Usage: cx remove [--delete-config]" - } - - $existing = Get-AiProfileByName -Tool "codex" -Name ([string]$parsed.Positionals[0]) - if (-not $existing) { - throw "codex profile '$($parsed.Positionals[0])' does not exist." - } - $existingName = Get-AiProfileName -Profile $existing - if ((Get-CodexAppDefaultProfileName) -eq $existingName) { - throw "Cannot remove Codex profile '$existingName' while it is the Codex App default. Run 'cx app-default sub' first." - } - $profilePath = Get-CodexProfilePath -Profile $existing - $removed = Remove-AiProfileRegistration -Tool "codex" -Name ([string]$parsed.Positionals[0]) - if ((Get-AiOption -Options $parsed.Options -Name "delete-config") -eq "true") { - Remove-Item -LiteralPath $profilePath -ErrorAction SilentlyContinue - } - - Write-Host "Removed Codex profile '$removed'." - Write-Host " Registry: $script:AiRegistryPath" - Write-Host " Config: $(if (Test-Path -LiteralPath $profilePath) { $profilePath } else { '' })" -} - -function Add-ClaudeApiProfile { - param([string[]]$Arguments) - - $split = Split-AiEnvArguments -Arguments $Arguments - $parsed = ConvertFrom-AiManagementArgs -Arguments $split.Rest - if ($parsed.Positionals.Count -lt 1) { - throw "Usage: cc add-api [--base-url URL] [--env-key NAME] [--env KEY=VALUE ...]" - } - - $name = [string]$parsed.Positionals[0] - Assert-AiProfileName -Name $name - $slug = Get-AiNameSlug -Name $name - $interactive = Test-AiInteractive - $secretId = Get-AiOption -Options $parsed.Options -Name "secret-id" -Default "claude.$name" - - $baseUrl = Get-AiOption -Options $parsed.Options -Name "base-url" - if (-not $baseUrl) { - $baseUrl = if ($interactive) { Read-AiInput -Prompt "Claude base_url" -Default $script:ClaudeRouterBaseUrl } else { $script:ClaudeRouterBaseUrl } - } - $envKey = Get-AiOption -Options $parsed.Options -Name "env-key" - if (-not $envKey) { - $envKey = if ($interactive) { Read-AiInput -Prompt "Claude secret variable (ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY)" -Default "ANTHROPIC_AUTH_TOKEN" } else { "ANTHROPIC_AUTH_TOKEN" } - } - - $profile = [pscustomobject]@{ - name = $name - aliases = @() - mode = "api" - base_url = $baseUrl - secret_id = $secretId - windows_secret = "~/.ai-secrets/claude-$slug.ps1" - linux_secret = "~/.ai-secrets/claude-$slug.env" - description = "Claude Code API profile" - } - if ($split.Env.Count -gt 0) { - $profile | Add-Member -NotePropertyName "env" -NotePropertyValue ([pscustomobject]$split.Env) - } - Add-AiProfileRegistration -Tool "claude" -Profile $profile | Out-Null - $secretState = Resolve-AiSecretScaffold -SecretId $secretId -Key $envKey -Interactive $interactive - - Write-Host "Added Claude Code API profile '$name'." - Write-Host " Registry: $script:AiRegistryPath" - Write-Host " Base URL: $baseUrl" - Write-Host " Secret: $secretState" - if ($split.Env.Count -gt 0) { - Write-Host " Env: $(@($split.Env.Keys) -join ', ')" - } -} - -function Add-ClaudeSubProfile { - param([string[]]$Arguments) - - $parsed = ConvertFrom-AiManagementArgs -Arguments $Arguments - if ($parsed.Positionals.Count -lt 1) { - throw "Usage: cc add-sub " - } - - $name = [string]$parsed.Positionals[0] - Assert-AiProfileName -Name $name - $profile = [pscustomobject]@{ - name = $name - aliases = @() - mode = "sub" - description = "Claude Code subscription profile" - } - Add-AiProfileRegistration -Tool "claude" -Profile $profile | Out-Null - Write-Host "Added Claude Code subscription profile '$name'." - Write-Host " Registry: $script:AiRegistryPath" - Write-Host " Login: claude /login" -} - -function Remove-ClaudeProfile { - param([string[]]$Arguments) - - $parsed = ConvertFrom-AiManagementArgs -Arguments $Arguments - if ($parsed.Positionals.Count -lt 1) { - throw "Usage: cc remove " - } - - $removed = Remove-AiProfileRegistration -Tool "claude" -Name ([string]$parsed.Positionals[0]) - Write-Host "Removed Claude Code profile '$removed'." - Write-Host " Registry: $script:AiRegistryPath" -} - -function Get-CodexRolloutTokenStats { - param( - [Parameter(Mandatory = $true)][string]$CodexHome, - [int]$Days = 30 - ) - - $sessionsDir = Join-Path $CodexHome "sessions" - $cutoff = (Get-Date).ToUniversalTime().AddDays(-1 * $Days) - $stats = [ordered]@{ - Sessions = 0 - Samples = 0 - Input = 0L - CachedInput = 0L - Output = 0L - ReasoningOutput = 0L - Total = 0L - Since = $cutoff - } - - if (-not (Test-Path -LiteralPath $sessionsDir)) { - return [pscustomobject]$stats - } - - foreach ($file in Get-ChildItem -LiteralPath $sessionsDir -Recurse -File -Filter "*.jsonl" -ErrorAction SilentlyContinue) { - if ($file.LastWriteTimeUtc -lt $cutoff) { - continue - } - - $latest = $null - $samples = 0 - foreach ($line in Get-Content -LiteralPath $file.FullName -ErrorAction SilentlyContinue) { - if ($line -notlike '*"token_count"*') { - continue - } - try { - $json = $line | ConvertFrom-Json - if ($json.type -ne "event_msg" -or $json.payload.type -ne "token_count") { - continue - } - $usage = $json.payload.info.total_token_usage - if ($usage) { - $latest = $usage - $samples++ - } - } catch { - } - } - - if ($latest) { - $stats.Sessions++ - $stats.Samples += $samples - $stats.Input += [int64](Get-AiProperty -Object $latest -Name "input_tokens" -Default 0) - $stats.CachedInput += [int64](Get-AiProperty -Object $latest -Name "cached_input_tokens" -Default (Get-AiProperty -Object $latest -Name "cache_read_input_tokens" -Default 0)) - $stats.Output += [int64](Get-AiProperty -Object $latest -Name "output_tokens" -Default 0) - $stats.ReasoningOutput += [int64](Get-AiProperty -Object $latest -Name "reasoning_output_tokens" -Default 0) - $total = [int64](Get-AiProperty -Object $latest -Name "total_tokens" -Default 0) - if ($total -le 0) { - $total = [int64](Get-AiProperty -Object $latest -Name "input_tokens" -Default 0) + [int64](Get-AiProperty -Object $latest -Name "output_tokens" -Default 0) - } - $stats.Total += $total - } - } - - return [pscustomobject]$stats -} - -function Format-AiTokenCount { - param([int64]$Value) - - if ($Value -ge 1000000) { - return ("{0:N2}M" -f ($Value / 1000000.0)) - } - if ($Value -ge 1000) { - return ("{0:N1}K" -f ($Value / 1000.0)) - } - return [string]$Value -} - -function Format-AiTokenBar { - param( - [int64]$Value, - [int64]$Total - ) - - if ($Total -le 0 -or $Value -le 0) { - return "" - } - - $width = [Math]::Max(1, [Math]::Round(($Value / [double]$Total) * 24)) - return ("#" * $width) -} - -function Show-CodexStats { - param([string[]]$Arguments) - - $parsed = ConvertFrom-AiManagementArgs -Arguments $Arguments - $daysText = Get-AiOption -Options $parsed.Options -Name "days" -Default "30" - $days = 30 - if (-not [int]::TryParse($daysText, [ref]$days) -or $days -lt 1) { - throw "cx stats --days must be a positive integer." - } - - $saved = Get-AiSavedProfileName -Tool "codex" - $profile = Get-AiProfileByName -Tool "codex" -Name ($env:AI_CODEX_LABEL ?? $saved) - if (-not $profile) { - $profile = Get-AiProfileByName -Tool "codex" -Name (Get-AiDefaultProfileName -Tool "codex") - } - $codexHome = if ($env:CODEX_HOME) { $env:CODEX_HOME } elseif ($profile) { Get-CodexHome -Profile $profile } else { Join-Path $script:AiHome ".codex" } - $stats = Get-CodexRolloutTokenStats -CodexHome $codexHome -Days $days - - Write-Host "Codex local token stats:" - Write-Host " CODEX_HOME: $codexHome" - Write-Host " Window: last $days days" - Write-Host " Sessions with usage: $($stats.Sessions)" - Write-Host " Token samples: $($stats.Samples)" - Write-Host " Total: $(Format-AiTokenCount $stats.Total) ($($stats.Total))" - foreach ($row in @( - @("input", $stats.Input), - @("cached", $stats.CachedInput), - @("output", $stats.Output), - @("reasoning", $stats.ReasoningOutput) - )) { - $label = $row[0] - $value = [int64]$row[1] - Write-Host (" {0,-9} {1,10} {2}" -f $label, (Format-AiTokenCount $value), (Format-AiTokenBar -Value $value -Total $stats.Total)) - } -} - -function Get-CodexBaseUrl { - param([Parameter(Mandatory = $true)]$Profile) - - $profilePath = Get-CodexProfilePath -Profile $Profile - $baseUrl = Get-TomlStringValue -Path $profilePath -Key "base_url" - if (-not $baseUrl) { - $baseUrl = Get-TomlStringValue -Path (Join-Path (Get-CodexHome -Profile $Profile) "config.toml") -Key "openai_base_url" - } - if (-not $baseUrl) { - $baseUrl = "built-in OpenAI/ChatGPT endpoint" - } - - return $baseUrl -} - -function Write-CodexSwitchStatus { - param( - [Parameter(Mandatory = $true)]$Profile, - [string]$SecretSource - ) - - $mode = Get-AiProfileMode -Profile $Profile - $profilePath = Get-CodexProfilePath -Profile $Profile - Write-Host "Codex state switched: $(Get-AiProfileName -Profile $Profile)" - Write-Host " Run next: codex" - Write-Host " Registry: $script:AiRegistryPath" - Write-Host " CODEX_HOME: $env:CODEX_HOME" - if (Test-Path -LiteralPath $profilePath) { - Write-Host " Profile: $(Get-CodexRuntimeProfileName -Profile $Profile) ($profilePath)" - } else { - Write-Host " Profile: ($profilePath missing)" - } - Write-Host " Base URL: $(Get-CodexBaseUrl -Profile $Profile)" - Write-Host " Probe model: $(Get-AiProbeModel -Tool codex -Profile $Profile)" - Write-Host " Cached login: $(Get-CodexLoginStatusText)" - - if ($mode -eq "api") { - Write-Host " OPENAI_API_KEY: $(Format-AiSecretPreview $env:OPENAI_API_KEY)" - Write-Host " Secret source: $SecretSource" - Write-Host " API local check: profile file=$((Test-Path -LiteralPath $profilePath)); key=$([bool]$env:OPENAI_API_KEY)" - } else { - Write-Host " OPENAI_API_KEY: " - Write-Host " Subscription quota: not exposed by Codex CLI" - } - - # Cached health line (instant — no network). `codex doctor` is deliberately - # NOT run here: it does live network/websocket checks that stall the switch. - # Use `cx doctor` for the full diagnostic on demand. - $h = Get-AiProfileHealthCached -Tool "codex" -Profile $Profile -CacheOnly - Write-Host (Format-AiHealthStatusLine $h) -} - -function Write-ClaudeSwitchStatus { - param( - [Parameter(Mandatory = $true)]$Profile, - [string]$SecretSource - ) - - $mode = Get-AiProfileMode -Profile $Profile - Write-Host "Claude Code state switched: $(Get-AiProfileName -Profile $Profile)" - Write-Host " Run next: claude" - Write-Host " Registry: $script:AiRegistryPath" - Write-Host " Probe model: $(Get-AiProbeModel -Tool claude -Profile $Profile)" - if ($mode -eq "api") { - Write-Host " ANTHROPIC_BASE_URL: $env:ANTHROPIC_BASE_URL" - Write-Host " ANTHROPIC_API_KEY: $(Format-AiSecretPreview $env:ANTHROPIC_API_KEY)" - Write-Host " ANTHROPIC_AUTH_TOKEN: $(Format-AiSecretPreview $env:ANTHROPIC_AUTH_TOKEN)" - Write-Host " Secret source: $SecretSource" - Write-Host " API local check: auth=$([bool]($env:ANTHROPIC_API_KEY -or $env:ANTHROPIC_AUTH_TOKEN)); url=$([bool]$env:ANTHROPIC_BASE_URL)" - } else { - Write-Host " Anthropic API env: " - Write-Host " Subscription status: local Claude login is used if present" - } - - $h = Get-AiProfileHealthCached -Tool "claude" -Profile $Profile -CacheOnly - Write-Host (Format-AiHealthStatusLine $h) - Write-ClaudeExternalStatus -} - -function Show-CxHelp { - @" -cx - switch Codex state for this PowerShell session - -Usage: - cx Auto-select a cached healthy Codex profile (default fallback) - cx sub Use a named subscription profile - cx sub:work Use another subscription profile, if registered - cx api Use the default API profile - cx api:docker Use a named API profile - cx list List registry profiles and cached health - cx status Print current saved/process state; --fresh/--refresh re-probes selected profile - cx stats Summarize local rollout token usage - cx add-api NAME Register a Codex API profile that shares ~/.codex by default - Options: --base-url URL --env-key NAME --provider-name NAME - --model MODEL --home PATH --env KEY=VALUE - Prompts for missing base-url/env-key and the secret in a terminal. - cx add-sub NAME Register an isolated Codex subscription CODEX_HOME - cx remove NAME Remove a Codex profile registration - cx probe-model NAME [MODEL] Set/clear health-probe model override - cx default [NAME] Show/set the default (primary) profile - cx app-default [NAME] Show/set the Codex App provider profile - cx sessions [--archived] [--json] List local sessions from every provider - cx resume [SESSION_ID] Resume a local session with the currently selected profile - cx app-bridge install|status|remove Make Codex App list sessions from every provider - cx edit Open the profile registry (profiles.json) in EDITOR - cx health Probe & report profile health table (🟢🟡🔴, parallel); --fresh/--refresh re-probes - cx doctor Run codex doctor full diagnostic (slow, on-demand) - cx health-clear Clear the health probe cache - cx next Cycle to the next enabled profile - cx help Show this help - -Config: - Registry: ~/.ai-env/profiles.json - State: ~/.ai-env/state.json - Secrets: ~/.ai-secrets/secrets.toml - -After switching, run Codex separately: - codex - codex exec "your task" - -Notes: - cx does not launch Codex. The PowerShell codex shim injects --profile for runtime commands. - Codex App has no --profile selector; app-default projects the selected profile into base config.toml. - Session discovery is provider-neutral. cx resume chooses history first, then consumes with the current cx profile. - app-bridge changes only App thread/list requests; restart Codex App after install/remove. - After a Codex App update, close the App and rerun cx app-bridge install to refresh the pinned CLI. - Other unprofiled app-server consumers of the same CODEX_HOME (for example an IDE integration) see the same base provider. - Subscription uses codex login cached under the selected CODEX_HOME. - API mode does not run codex login --with-api-key; it loads OPENAI_API_KEY only for this shell. - Multiple API profiles can share ~/.codex. Multiple subscription accounts need separate home values. - Add commands only write profile metadata and Codex config. Put real tokens in secrets.toml. - Without probe_model, Codex probes use runtime/global config.toml model, then a cheap fallback. - Legacy ~/.ai-secrets/*.ps1 files are still accepted as a fallback. -"@ | Write-Host -} - -function Show-CcHelp { - @" -cc - switch Claude Code state for this PowerShell session - -Usage: - cc Auto-select a cached healthy Claude Code profile (default fallback) - cc sub Clear Anthropic API env and use local Claude subscription login - cc sub:work Use another subscription profile, if registered - cc api Use the default API profile - cc api:docker Use a named API profile - cc list List registry profiles and cached health - cc status Print current saved/process state; --fresh/--refresh re-probes selected profile - cc add-api NAME Register a Claude Code API profile - Options: --base-url URL --env-key NAME --env KEY=VALUE (repeatable) - Prompts for missing base-url and the secret in a terminal. - cc add-sub NAME Register a Claude Code subscription label - cc remove NAME Remove a Claude Code profile registration - cc probe-model NAME [MODEL] Set/clear health-probe model override - cc default [NAME] Show/set the default (primary) profile - cc edit Open the profile registry (profiles.json) in EDITOR - cc health Probe & report profile health table (🟢🟡🔴, parallel); --fresh/--refresh re-probes - cc health-clear Clear the health probe cache - cc next Cycle to the next enabled profile - cc help Show this help - -Config: - Registry: ~/.ai-env/profiles.json - State: ~/.ai-env/state.json - Secrets: ~/.ai-secrets/secrets.toml - -After switching, run Claude Code separately: - claude - -Notes: - cc does not launch Claude Code. Claude reads the environment variables set in this shell. - A Claude API profile can define base_url; otherwise https://anyrouter.top is used. - --env adds non-secret per-profile vars (e.g. ANTHROPIC_DEFAULT_SONNET_MODEL, - CLAUDE_CODE_AUTO_COMPACT_WINDOW) stored in the registry; exported on switch and - cleared when switching to another profile so values do not leak. - Add commands only write profile metadata. Put real tokens in secrets.toml. - Without probe_model, Claude probes use ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_HAIKU_MODEL, then a cheap fallback. - Legacy ~/.ai-secrets/*.ps1 files are still accepted as a fallback. -"@ | Write-Host -} - -# Compact one-line health cell for list/status tables. -function Format-AiHealthCell { - param([AllowNull()]$H) - if (-not $H) { return "?" } - $code = $null - if ($H.Error -and ($H.Error -match 'HTTP (\d{3})')) { $code = $Matches[1] } - switch ($H.Status) { - "healthy" { "🟢" + $H.LatencyMs + "ms" } - "degraded" { "🟡" + ($(if ($code) { $code } else { "slow" })) } - "down" { "🔴" + ($(if ($code) { $code } else { "err" })) } - "skip" { "⏭" } - default { "?" } - } -} - -function Format-AiHealthStatusLine { - param([AllowNull()]$H) - $line = " Health: " + (Format-AiHealthCell $H) - if ($H -and $H.Error) { $line += " " + (ConvertTo-AiHealthDisplayError ([string]$H.Error)) } - return Limit-AiDisplayText $line (Get-AiHealthOutputWidth) -} - -function Test-AiFreshFlag { - param([AllowNull()][string[]]$Tokens) - if (-not $Tokens) { return $false } - foreach ($t in $Tokens) { if ($t -in @("--fresh", "-f", "--refresh", "-r")) { return $true } } - return $false -} - -# Health cell read ONLY from the cache (no live probe) — used by `list` so it -# stays fast/offline. Fresh cache -> status icon; stale/never-probed -> ⏭ to -# signal "run ` health` to refresh". -function Get-AiHealthCellCached { - param( - [Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool, - [Parameter(Mandatory = $true)]$Profile, - [int]$TtlSec = 300 - ) - $key = "$Tool." + (Get-AiProfileName -Profile $Profile) - $cache = Read-AiHealthCache - if (-not $cache.ContainsKey($key)) { return "⏭" } - $e = $cache[$key] - $probedAt = 0 - try { $probedAt = [int64]$e.probedAt } catch { } - $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - if ($probedAt -gt 0 -and ($now - $probedAt) -lt $TtlSec) { - return Format-AiHealthCell $e - } - return "⏭" -} - -# Set or clear a profile's probe_model (the model used by Get-AiProfileHealth). -# Use this for routers that do not serve the runtime/default model or report -# "No available providers" for the automatic probe model. -function Set-AiProfileProbeModel { - param( - [Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool, - [string[]]$Arguments - ) - - $parsed = ConvertFrom-AiManagementArgs -Arguments $Arguments - if ($parsed.Positionals.Count -lt 1) { - throw "Usage: probe-model [model] (omit model to clear -> automatic probe model)" - } - $query = ([string]$parsed.Positionals[0]).ToLowerInvariant() - $model = if ($parsed.Positionals.Count -ge 2) { [string]$parsed.Positionals[1] } else { "" } - - $registry = Get-AiRegistry - $found = $false - foreach ($profile in @(Get-AiProperty -Object $registry -Name $Tool -Default @())) { - $matched = $false - foreach ($cand in Get-AiProfileNames -Profile $profile) { - if ($cand.ToLowerInvariant() -eq $query) { $matched = $true; break } - } - if (-not $matched) { continue } - $found = $true - $pname = Get-AiProfileName -Profile $profile - if ($model) { - if ($profile.PSObject.Properties.Name -contains 'probe_model') { - $profile.probe_model = $model - } else { - $profile | Add-Member -NotePropertyName probe_model -NotePropertyValue $model - } - Write-Host "Set $Tool '$pname' probe_model = $model" - } else { - if ($profile.PSObject.Properties.Name -contains 'probe_model') { - $profile.PSObject.Properties.Remove('probe_model') - Write-Host "Cleared $Tool '$pname' probe_model (using automatic probe model)" - } else { - Write-Host "$Tool '$pname' has no probe_model set (already automatic)" - } - } - break - } - if (-not $found) { throw "$Tool profile '$($parsed.Positionals[0])' not found." } - Save-AiRegistry -Registry $registry -} - -# Auto-failover selection: return the first NON-down profile in priority order -# (default profile first, then the rest). Degraded/skip are usable; only down -# (🔴) is skipped. If everything is down, fall back to the default so a switch -# still succeeds (user can investigate via `list`). On-demand only — probes go -# through the cache, so this is cheap within the TTL window. -function Get-AiHealthyProfileName { - param([Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool) - - $defaultName = Get-AiDefaultProfileName -Tool $Tool - $profiles = @(Get-AiToolProfiles -Tool $Tool | Where-Object { Test-AiProfileEnabled -Profile $_ }) - $defaultProfile = $null - $others = @() - foreach ($p in $profiles) { - if ((Get-AiProfileName -Profile $p) -eq $defaultName) { $defaultProfile = $p } else { $others += $p } - } - $ordered = if ($defaultProfile) { @($defaultProfile) + $others } else { $others } - - foreach ($p in $ordered) { - # Cache-only: no-arg cc/cx must stay instant. If the cache is empty/stale - # every entry reads skip (not "down"), so the default is chosen without - # probing. Run `cc health` first to populate health for auto-failover. - $h = Get-AiProfileHealthCached -Tool $Tool -Profile $p -CacheOnly - # Only auto-select a profile with a cached POSITIVE signal (healthy/ - # degraded). Unprobed api profiles and subscription profiles both read - # "skip" and are NOT auto-selected (we can't confirm they're up). Run - # `cc health` first to populate health for real auto-failover. - if ($h.Status -eq "healthy" -or $h.Status -eq "degraded") { - return (Get-AiProfileName -Profile $p) - } - } - return $defaultName -} - -# Show or set the default (primary) profile for a tool. With no name, prints the -# current default; with a name, validates it exists and writes defaults.. -function Set-AiDefaultProfile { - param( - [Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool, - [string[]]$Arguments - ) - - $parsed = ConvertFrom-AiManagementArgs -Arguments $Arguments - $current = Get-AiDefaultProfileName -Tool $Tool - if ($parsed.Positionals.Count -lt 1) { - Write-Host "$Tool default = $current" - return - } - $name = [string]$parsed.Positionals[0] - if (-not (Get-AiProfileByName -Tool $Tool -Name $name)) { - throw "Unknown $Tool profile '$name'." - } - $registry = Get-AiRegistry - $defaults = Get-AiProperty -Object $registry -Name "defaults" - if (-not $defaults) { - $defaults = [pscustomobject]@{} - $registry | Add-Member -NotePropertyName defaults -NotePropertyValue $defaults - } - if ($defaults.PSObject.Properties.Name -contains $Tool) { - $defaults.$Tool = $name - } else { - $defaults | Add-Member -NotePropertyName $Tool -NotePropertyValue $name - } - Save-AiRegistry -Registry $registry - Write-Host "Set $Tool default = $name" -} - -function ConvertTo-AiTomlString { - param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value) - - return ($Value | ConvertTo-Json -Compress) -} - -function Get-CodexAppDefaultProfileName { - $registry = Get-AiRegistry - $defaults = Get-AiProperty -Object $registry -Name "defaults" - $name = Get-AiProperty -Object $defaults -Name "codex_app" -Default "sub" - if ($name) { return [string]$name } - return "sub" -} - -function Get-CodexAppBaselineConfigPath { - $configPath = Get-AiCodexConfigPath - $backupPath = "$configPath.aienv-app.bak" - if (Test-Path -LiteralPath $backupPath -PathType Leaf) { return $backupPath } - return $configPath -} - -function Test-AiIsolatedTestHome { - if (-not $env:AI_ENV_HOME -or -not $env:AI_CODEX_CONFIG_PATH) { return $false } - try { - $homePath = [IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($env:AI_ENV_HOME)).TrimEnd('\', '/') - $tempPath = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\', '/') - if (-not $homePath.StartsWith($tempPath + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { return $false } - foreach ($path in @($script:AiConfigDir, $script:AiSecretsPath, (Get-AiCodexConfigPath))) { - $fullPath = [IO.Path]::GetFullPath($path) - if (-not $fullPath.StartsWith($homePath + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { return $false } - } - return $true - } catch { - return $false - } -} - -function Assert-CodexAppWritablePathsProtected { - param([Parameter(Mandatory = $true)][string[]]$Paths) - - if (-not $IsWindows -or (Test-AiIsolatedTestHome)) { return } - - $currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value - $allowedSids = @($currentSid, "S-1-5-18", "S-1-5-32-544") - try { - $allowedSids += ([System.Security.Principal.NTAccount]"NT SERVICE\TrustedInstaller").Translate([System.Security.Principal.SecurityIdentifier]).Value - } catch { } - $writeRights = [System.Security.AccessControl.FileSystemRights]::WriteData -bor - [System.Security.AccessControl.FileSystemRights]::AppendData -bor - [System.Security.AccessControl.FileSystemRights]::WriteExtendedAttributes -bor - [System.Security.AccessControl.FileSystemRights]::WriteAttributes -bor - [System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor - [System.Security.AccessControl.FileSystemRights]::Delete -bor - [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor - [System.Security.AccessControl.FileSystemRights]::TakeOwnership - $privateRights = $writeRights -bor - [System.Security.AccessControl.FileSystemRights]::ReadData -bor - [System.Security.AccessControl.FileSystemRights]::ReadExtendedAttributes -bor - [System.Security.AccessControl.FileSystemRights]::ReadAttributes -bor - [System.Security.AccessControl.FileSystemRights]::ReadPermissions -bor - [System.Security.AccessControl.FileSystemRights]::ExecuteFile - $privatePaths = @( - [IO.Path]::GetFullPath((Split-Path -Parent $script:AiSecretsPath)).TrimEnd('\', '/'), - [IO.Path]::GetFullPath($script:AiSecretsPath).TrimEnd('\', '/') - ) - - foreach ($path in $Paths) { - if (-not (Test-Path -LiteralPath $path)) { - throw "Codex App credential path is missing: $path" - } - $item = Get-Item -Force -LiteralPath $path - if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { - throw "Codex App credential path must not be a reparse point: $path" - } - $acl = Get-Acl -LiteralPath $item.FullName - try { - $ownerSid = ([System.Security.Principal.NTAccount]$acl.Owner).Translate([System.Security.Principal.SecurityIdentifier]).Value - } catch { - try { $ownerSid = ([System.Security.Principal.SecurityIdentifier]$acl.Owner).Value } catch { $ownerSid = [string]$acl.Owner } - } - if ($ownerSid -notin $allowedSids) { - throw "Unsafe owner on ${path}: $($acl.Owner)." - } - foreach ($rule in $acl.Access) { - if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { continue } - try { - $sid = $rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value - } catch { - $sid = [string]$rule.IdentityReference - } - $fullPath = [IO.Path]::GetFullPath($item.FullName).TrimEnd('\', '/') - $forbiddenRights = if ($fullPath -in $privatePaths) { $privateRights } else { $writeRights } - if ($sid -notin $allowedSids -and (($rule.FileSystemRights -band $forbiddenRights) -ne 0)) { - throw "Unsafe ACL on ${path}: $($rule.IdentityReference) can access protected App credential inputs. Repair the ACL before selecting an API profile." - } - } - } -} - -function Get-CodexAppTokenCommandPath { - if ((Test-AiIsolatedTestHome) -and $env:AI_CODEX_APP_TOKEN_COMMAND) { - $path = [Environment]::ExpandEnvironmentVariables($env:AI_CODEX_APP_TOKEN_COMMAND) - if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { - throw "Codex App token command is missing: $path" - } - return $path - } - - $appAuthDir = Join-Path (Split-Path -Parent (Get-AiCodexConfigPath)) "app-auth" - $targetPath = Join-Path $appAuthDir "codex-app-token.ps1" - if (-not (Test-Path -LiteralPath $targetPath -PathType Leaf)) { - throw "Codex App token command is missing: $targetPath. Apply the dotfiles first." - } - return $targetPath -} - -function Assert-CodexAppProfilePath { - param([Parameter(Mandatory = $true)]$Profile) - - $appConfigPath = Get-AiCodexConfigPath - $appHome = Split-Path -Parent $appConfigPath - $profileHome = Get-CodexHome -Profile $Profile - if (-not (Test-Path -LiteralPath $appHome -PathType Container)) { - New-Item -ItemType Directory -Force -Path $appHome | Out-Null - } - if (-not (Test-Path -LiteralPath $profileHome -PathType Container)) { - throw "Codex profile home is missing: $profileHome" - } - - foreach ($path in @($appHome, $profileHome)) { - $item = Get-Item -Force -LiteralPath $path - if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { - throw "Codex App profile paths must not be reparse points: $path" - } - } - - $resolvedAppHome = (Resolve-Path -LiteralPath $appHome).Path.TrimEnd('\', '/') - $resolvedProfileHome = (Resolve-Path -LiteralPath $profileHome).Path.TrimEnd('\', '/') - if (-not $resolvedAppHome.Equals($resolvedProfileHome, [StringComparison]::OrdinalIgnoreCase)) { - throw "Codex App can only select profiles that share its CODEX_HOME ($resolvedAppHome). '$((Get-AiProfileName -Profile $Profile))' uses $resolvedProfileHome." - } - - $profilePath = Get-CodexProfilePath -Profile $Profile - if (-not (Test-Path -LiteralPath $profilePath -PathType Leaf)) { - throw "Codex profile config is missing: $profilePath" - } - if (((Get-Item -Force -LiteralPath $profilePath).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { - throw "Codex profile config must not be a reparse point: $profilePath" - } - $resolvedProfilePath = (Resolve-Path -LiteralPath $profilePath).Path - $resolvedParent = (Split-Path -Parent $resolvedProfilePath).TrimEnd('\', '/') - if (-not $resolvedParent.Equals($resolvedProfileHome, [StringComparison]::OrdinalIgnoreCase)) { - throw "Codex profile config escapes its CODEX_HOME: $resolvedProfilePath" - } - return $resolvedProfilePath -} - -function Write-AiUtf8NoBomAtomic { - param( - [Parameter(Mandatory = $true)][string]$Path, - [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content - ) - - $directory = Split-Path -Parent $Path - New-Item -ItemType Directory -Force -Path $directory | Out-Null - $tempPath = Join-Path $directory ((Split-Path -Leaf $Path) + "." + [guid]::NewGuid().ToString("N") + ".tmp") - try { - [IO.File]::WriteAllText($tempPath, $Content, [Text.UTF8Encoding]::new($false)) - Move-Item -LiteralPath $tempPath -Destination $Path -Force - } finally { - Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue - } -} - -function Set-CodexAppConfig { - param( - [Parameter(Mandatory = $true)][System.Collections.IDictionary]$TopLevelValues, - [AllowNull()][string]$ProviderBlock - ) - - $path = Get-AiCodexConfigPath - $original = if (Test-Path -LiteralPath $path -PathType Leaf) { Get-Content -Raw -LiteralPath $path } else { "" } - $lines = if ($original) { @($original -split '\r?\n') } else { @() } - if ($lines.Count -gt 0 -and $lines[-1] -eq "") { $lines = @($lines[0..($lines.Count - 2)]) } - - $output = [System.Collections.Generic.List[string]]::new() - $seen = @{} - $isTopLevel = $true - $skipManagedProvider = $false - foreach ($line in $lines) { - $trimmed = "$line".Trim() - if ($trimmed -match '^\[([^\[\]]+)\]\s*(?:#.*)?$') { - $isTopLevel = $false - $header = $Matches[1].Trim() - $skipManagedProvider = [bool]($ProviderBlock -and $header -in @("model_providers.ai-env-app", "model_providers.ai-env-app.auth")) - if ($skipManagedProvider) { continue } - } elseif ($trimmed -match '^\[\[') { - $isTopLevel = $false - $skipManagedProvider = $false - } elseif ($skipManagedProvider) { - continue - } - - if ($isTopLevel -and $trimmed -match '^([A-Za-z_][A-Za-z0-9_]*)\s*=') { - $key = $Matches[1] - if ($TopLevelValues.Contains($key)) { - if (-not $seen.ContainsKey($key) -and $null -ne $TopLevelValues[$key]) { - $output.Add("$key = $($TopLevelValues[$key])") - } - $seen[$key] = $true - continue - } - } - $output.Add([string]$line) - } - - $prefix = [System.Collections.Generic.List[string]]::new() - foreach ($key in $TopLevelValues.Keys) { - if (-not $seen.ContainsKey([string]$key) -and $null -ne $TopLevelValues[$key]) { - $prefix.Add("$key = $($TopLevelValues[$key])") - } - } - if ($prefix.Count -gt 0 -and $output.Count -gt 0 -and $output[0].Trim()) { $prefix.Add("") } - $finalLines = @($prefix) + @($output) - while ($finalLines.Count -gt 0 -and -not "$($finalLines[-1])".Trim()) { - if ($finalLines.Count -eq 1) { $finalLines = @(); break } - $finalLines = @($finalLines[0..($finalLines.Count - 2)]) - } - if ($ProviderBlock) { - if ($finalLines.Count -gt 0) { $finalLines += "" } - $finalLines += @($ProviderBlock.Trim() -split '\r?\n') - } - $updated = (($finalLines -join "`n").TrimEnd() + "`n") - - if ($updated -cne $original) { - $backupPath = "$path.aienv-app.bak" - if ($original -and -not (Test-Path -LiteralPath $backupPath)) { - Copy-Item -LiteralPath $path -Destination $backupPath -Force - } - Write-AiUtf8NoBomAtomic -Path $path -Content $updated - } - return $original -} - -function Set-CodexAppDefaultProfile { - param([string[]]$Arguments) - - $parsed = ConvertFrom-AiManagementArgs -Arguments $Arguments - if ($parsed.Positionals.Count -lt 1) { - Write-Host "Codex App default = $(Get-CodexAppDefaultProfileName)" - return - } - if ($parsed.Positionals.Count -gt 1 -or $parsed.Options.Count -gt 0) { - throw "Usage: cx app-default [NAME]" - } - - $requestedName = [string]$parsed.Positionals[0] - $profile = Get-AiProfileByName -Tool "codex" -Name $requestedName - if (-not $profile) { throw "Unknown Codex profile '$requestedName'." } - $name = Get-AiProfileName -Profile $profile - $mode = Get-AiProfileMode -Profile $profile - $profilePath = Assert-CodexAppProfilePath -Profile $profile - $topLevel = [ordered]@{ model_provider = ConvertTo-AiTomlString $(if ($mode -eq "api") { "ai-env-app" } else { "openai" }) } - - $model = Get-AiTomlTopLevelStringValue -Path $profilePath -Key "model" - $reasoning = Get-AiTomlTopLevelStringValue -Path $profilePath -Key "model_reasoning_effort" - $baselinePath = Get-CodexAppBaselineConfigPath - if (-not $model) { $model = Get-AiTomlTopLevelStringValue -Path $baselinePath -Key "model" } - if (-not $reasoning) { $reasoning = Get-AiTomlTopLevelStringValue -Path $baselinePath -Key "model_reasoning_effort" } - $topLevel["model"] = if ($model) { ConvertTo-AiTomlString $model } else { $null } - $topLevel["model_reasoning_effort"] = if ($reasoning) { ConvertTo-AiTomlString $reasoning } else { $null } - $topLevel["disable_response_storage"] = $null - - $providerBlock = $null - if ($mode -eq "api") { - $profileProvider = Get-AiTomlTopLevelStringValue -Path $profilePath -Key "model_provider" - if (-not $profileProvider -or $profileProvider -notmatch '^[A-Za-z0-9_-]+$') { - throw "Codex App API profile '$name' has an invalid model_provider." - } - $providerSection = "model_providers.$profileProvider" - $baseUrl = Get-AiTomlSectionStringValue -Path $profilePath -Section $providerSection -Key "base_url" - $providerName = Get-AiTomlSectionStringValue -Path $profilePath -Section $providerSection -Key "name" - $wireApi = Get-AiTomlSectionStringValue -Path $profilePath -Section $providerSection -Key "wire_api" - $envKey = Get-AiTomlSectionStringValue -Path $profilePath -Section $providerSection -Key "env_key" - if (-not $providerName) { $providerName = $name } - if (-not $wireApi) { $wireApi = "responses" } - if (-not $envKey) { $envKey = "OPENAI_API_KEY" } - $uri = $null - if (-not [Uri]::TryCreate($baseUrl, [UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -ne "https" -or $uri.UserInfo) { - throw "Codex App API profile '$name' must use an absolute HTTPS base_url without user info." - } - if ($wireApi -ne "responses") { throw "Codex App only supports wire_api = responses for managed API profiles." } - if (-not (Test-AiTomlSecretValues -Tool "codex" -Profile $profile -Names @($envKey))) { - throw "Codex App API profile '$name' is missing $envKey in $script:AiSecretsPath#$(Get-AiSecretId -Tool 'codex' -Profile $profile)." - } - - $tokenCommand = Get-CodexAppTokenCommandPath - $pwshCommand = (Get-Process -Id $PID -ErrorAction Stop).Path - Assert-CodexAppWritablePathsProtected -Paths @( - $script:AiConfigDir, - $script:AiRegistryPath, - (Split-Path -Parent $script:AiSecretsPath), - $script:AiSecretsPath, - (Split-Path -Parent (Get-AiCodexConfigPath)), - (Get-AiCodexConfigPath), - $profilePath, - (Split-Path -Parent $tokenCommand), - $tokenCommand, - (Split-Path -Parent $pwshCommand), - $pwshCommand - ) - $secretId = Get-AiSecretId -Tool "codex" -Profile $profile - $args = @("-NoLogo", "-NoProfile", "-NonInteractive", "-File", $tokenCommand, "-SecretId", $secretId, "-Key", $envKey, "-SecretsPath", $script:AiSecretsPath) - $tomlArgs = (@($args | ForEach-Object { ConvertTo-AiTomlString ([string]$_) }) -join ", ") - $providerBlock = @( - "[model_providers.ai-env-app]" - "name = $(ConvertTo-AiTomlString $providerName)" - "base_url = $(ConvertTo-AiTomlString $baseUrl)" - "wire_api = `"responses`"" - "" - "[model_providers.ai-env-app.auth]" - "command = $(ConvertTo-AiTomlString $pwshCommand)" - "args = [$tomlArgs]" - "timeout_ms = 5000" - "refresh_interval_ms = 0" - ) -join "`n" - } - - $configPath = Get-AiCodexConfigPath - $configExisted = Test-Path -LiteralPath $configPath -PathType Leaf - $originalConfig = Set-CodexAppConfig -TopLevelValues $topLevel -ProviderBlock $providerBlock - try { - $registry = Get-AiRegistry - $defaults = Get-AiProperty -Object $registry -Name "defaults" - if (-not $defaults) { - $defaults = [pscustomobject]@{} - $registry | Add-Member -NotePropertyName defaults -NotePropertyValue $defaults - } - $defaults | Add-Member -NotePropertyName "codex_app" -NotePropertyValue $name -Force - Save-AiRegistry -Registry $registry - } catch { - if ($configExisted) { - Write-AiUtf8NoBomAtomic -Path $configPath -Content $originalConfig - } else { - Remove-Item -LiteralPath $configPath -Force -ErrorAction SilentlyContinue - } - throw - } - - Write-Host "Codex App default = $name" - Write-Host " Config: $configPath" - Write-Host " Provider: $($topLevel['model_provider'].Trim('`"'))" - Write-Host " Reload: close and reopen Codex App" -} - -function Get-CodexAppBridgeRoot { - $path = if ($env:AI_CODEX_APP_BRIDGE_HOME) { - [Environment]::ExpandEnvironmentVariables($env:AI_CODEX_APP_BRIDGE_HOME) - } else { - Join-Path $script:AiHome ".codex\app-bridge" - } - return [IO.Path]::GetFullPath($path) -} - -function Get-CodexAppBridgeEnvironmentTarget { - param([string]$Name) - - $name = if ($Name) { $Name } elseif ($env:AI_CODEX_APP_BRIDGE_ENV_TARGET) { $env:AI_CODEX_APP_BRIDGE_ENV_TARGET } else { "User" } - if ($name -ieq "Process") { return [EnvironmentVariableTarget]::Process } - if ($name -ieq "User") { return [EnvironmentVariableTarget]::User } - throw "AI_CODEX_APP_BRIDGE_ENV_TARGET must be User or Process." -} - -function Get-CodexAppBridgeProjectPath { - $candidates = [System.Collections.Generic.List[string]]::new() - if ($env:AI_CODEX_APP_BRIDGE_PROJECT) { $candidates.Add([Environment]::ExpandEnvironmentVariables($env:AI_CODEX_APP_BRIDGE_PROJECT)) } - if ($script:AiEnvScriptRoot) { $candidates.Add((Join-Path $script:AiEnvScriptRoot "..\..\..\tools\codex-provider-bridge\CodexProviderBridge.csproj")) } - $chezmoi = Get-Command chezmoi -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($chezmoi) { - try { - $sourcePath = (& $chezmoi.Source source-path 2>$null | Select-Object -First 1) - if ($sourcePath) { $candidates.Add((Join-Path ([string]$sourcePath).Trim() "tools\codex-provider-bridge\CodexProviderBridge.csproj")) } - } catch { } - } - foreach ($candidate in $candidates) { - if ($candidate -and (Test-Path -LiteralPath $candidate -PathType Leaf)) { return (Resolve-Path -LiteralPath $candidate).Path } - } - throw "Codex App bridge project was not found. Set AI_CODEX_APP_BRIDGE_PROJECT to CodexProviderBridge.csproj." -} - -function Resolve-CodexAppRealCliPath { - if ($env:AI_CODEX_APP_REAL_CLI) { - $overridePath = [IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($env:AI_CODEX_APP_REAL_CLI)) - if (-not (Test-Path -LiteralPath $overridePath -PathType Leaf)) { throw "AI_CODEX_APP_REAL_CLI does not exist: $overridePath" } - if (-not (Test-AiIsolatedTestHome)) { Assert-CodexAppWritablePathsProtected -Paths @($overridePath) } - return (Resolve-Path -LiteralPath $overridePath).Path - } - - $bundledCli = $null - if ($IsWindows) { - try { - $mainApp = Get-CimInstance Win32_Process -Filter "Name='ChatGPT.exe'" -ErrorAction Stop | - Where-Object { $_.ExecutablePath -and $_.ExecutablePath -match '\\WindowsApps\\OpenAI\.Codex_' } | - Sort-Object CreationDate | - Select-Object -First 1 - if ($mainApp) { - $candidate = Join-Path (Split-Path -Parent $mainApp.ExecutablePath) "resources\codex.exe" - if (Test-Path -LiteralPath $candidate -PathType Leaf) { $bundledCli = (Resolve-Path -LiteralPath $candidate).Path } - } - } catch { } - } - - if (-not $bundledCli) { - $bundledCli = Get-Command codex.exe -CommandType Application -All -ErrorAction SilentlyContinue | - Where-Object { $_.Source -match '\\WindowsApps\\OpenAI\.Codex_' } | - ForEach-Object { Get-Item -Force -LiteralPath $_.Source } | - Sort-Object LastWriteTimeUtc -Descending | - Select-Object -First 1 -ExpandProperty FullName - } - if (-not $bundledCli) { throw "Could not find the protected Codex App executable under WindowsApps. Open or reinstall Codex App, then retry." } - Assert-CodexAppWritablePathsProtected -Paths @($bundledCli) - return $bundledCli -} - -function Test-CodexAppBridgeProcessRunning { - param([Parameter(Mandatory = $true)][string]$BridgePath) - - if (-not $IsWindows) { return $false } - foreach ($process in Get-Process -Name "codex-provider-bridge" -ErrorAction SilentlyContinue) { - try { - if ($process.Path -and [IO.Path]::GetFullPath($process.Path).Equals([IO.Path]::GetFullPath($BridgePath), [StringComparison]::OrdinalIgnoreCase)) { return $true } - } catch { } - } - return $false -} - -function Assert-CodexAppBridgeInstallPathsProtected { - param( - [Parameter(Mandatory = $true)][string]$BridgeRoot, - [string[]]$Files = @() - ) - - if (Test-AiIsolatedTestHome) { return } - $expectedRoot = [IO.Path]::GetFullPath((Join-Path $script:AiHome ".codex\app-bridge")).TrimEnd('\', '/') - $resolvedRoot = [IO.Path]::GetFullPath($BridgeRoot).TrimEnd('\', '/') - if (-not $resolvedRoot.Equals($expectedRoot, [StringComparison]::OrdinalIgnoreCase)) { - throw "Codex App bridge must be installed at the protected default path: $expectedRoot" - } - $codexHome = Split-Path -Parent $resolvedRoot - Assert-CodexAppWritablePathsProtected -Paths (@($script:AiHome, $codexHome, $resolvedRoot) + @($Files)) -} - -function Copy-CodexAppBridgeFileAtomic { - param( - [Parameter(Mandatory = $true)][string]$Source, - [Parameter(Mandatory = $true)][string]$Destination - ) - - $tempPath = "$Destination.$([guid]::NewGuid().ToString('N')).tmp" - try { - Copy-Item -LiteralPath $Source -Destination $tempPath - Move-Item -LiteralPath $tempPath -Destination $Destination -Force - } finally { - Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue - } -} - -function Get-CodexAppBundleExecutableNames { - return @("codex.exe", "codex-command-runner.exe", "codex-code-mode-host.exe", "codex-windows-sandbox-setup.exe") -} - -function Send-CodexAppBridgeEnvironmentChanged { - if (-not $IsWindows) { return } - try { - if (-not ("AiEnv.NativeEnvironment" -as [type])) { - Add-Type -TypeDefinition @' -using System; -using System.Runtime.InteropServices; -namespace AiEnv { - public static class NativeEnvironment { - [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint msg, IntPtr wParam, string lParam, uint flags, uint timeout, out IntPtr result); - } -} -'@ - } - $result = [IntPtr]::Zero - [void][AiEnv.NativeEnvironment]::SendMessageTimeout([IntPtr]0xffff, 0x001A, [IntPtr]::Zero, "Environment", 2, 2000, [ref]$result) - } catch { - Write-Warning "CODEX_CLI_PATH was saved, but the environment-change broadcast failed: $($_.Exception.Message)" - } -} - -function Install-CodexAppBridge { - $bridgeRoot = Get-CodexAppBridgeRoot - $bridgePath = Join-Path $bridgeRoot "codex-provider-bridge.exe" - $securedRealCliPath = Join-Path $bridgeRoot "codex.exe" - $settingsPath = Join-Path $bridgeRoot "codex-provider-bridge.json" - $activationPath = Join-Path $bridgeRoot "activation.json" - if (Test-CodexAppBridgeProcessRunning -BridgePath $bridgePath) { - throw "Codex App is using this bridge. Close Codex App before reinstalling the bridge." - } - $projectPath = Get-CodexAppBridgeProjectPath - $trustedSourceCliPath = Resolve-CodexAppRealCliPath - $target = Get-CodexAppBridgeEnvironmentTarget - $existingActivation = if (Test-Path -LiteralPath $activationPath -PathType Leaf) { - Get-Content -LiteralPath $activationPath -Raw | ConvertFrom-Json -Depth 10 - } else { $null } - if ($existingActivation -and [string]$existingActivation.environmentTarget -cne $target.ToString()) { - throw "The bridge is activated for $($existingActivation.environmentTarget), not $target. Remove it before changing environment target." - } - $stagingPath = Join-Path $bridgeRoot (".staging-" + [guid]::NewGuid().ToString("N")) - New-Item -ItemType Directory -Force -Path $bridgeRoot | Out-Null - Assert-CodexAppBridgeInstallPathsProtected -BridgeRoot $bridgeRoot - New-Item -ItemType Directory -Force -Path $stagingPath | Out-Null - try { - & dotnet publish $projectPath -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -p:DebugType=None -p:DebugSymbols=false -o $stagingPath --nologo | Out-Host - if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed with exit code $LASTEXITCODE." } - $publishedPath = Join-Path $stagingPath "codex-provider-bridge.exe" - if (-not (Test-Path -LiteralPath $publishedPath -PathType Leaf)) { throw "Bridge publish did not create $publishedPath" } - - $trustedSourceRoot = Split-Path -Parent $trustedSourceCliPath - $trustedSourcePaths = @((Get-CodexAppBundleExecutableNames) | ForEach-Object { Join-Path $trustedSourceRoot $_ }) - foreach ($sourcePath in $trustedSourcePaths) { - if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { throw "Protected Codex App bundle is missing $(Split-Path -Leaf $sourcePath)." } - } - if (-not (Test-AiIsolatedTestHome)) { Assert-CodexAppWritablePathsProtected -Paths (@($trustedSourceRoot) + $trustedSourcePaths) } - $bundleHashes = [ordered]@{} - $securedBundlePaths = [System.Collections.Generic.List[string]]::new() - foreach ($name in (Get-CodexAppBundleExecutableNames)) { - $sourcePath = Join-Path $trustedSourceRoot $name - $stagedPath = Join-Path $stagingPath $name - Copy-Item -LiteralPath $sourcePath -Destination $stagedPath - $sourceHash = (Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash - if ((Get-FileHash -LiteralPath $stagedPath -Algorithm SHA256).Hash -cne $sourceHash) { throw "Secured copy differs from protected source: $name" } - $destinationPath = Join-Path $bridgeRoot $name - Copy-CodexAppBridgeFileAtomic -Source $stagedPath -Destination $destinationPath - $bundleHashes[$name] = $sourceHash - $securedBundlePaths.Add($destinationPath) - } - Copy-CodexAppBridgeFileAtomic -Source $publishedPath -Destination $bridgePath - Assert-CodexAppBridgeInstallPathsProtected -BridgeRoot $bridgeRoot -Files (@($securedBundlePaths) + @($bridgePath)) - - $settings = [ordered]@{ realCodexPath = $securedRealCliPath; realCodexSha256 = $bundleHashes["codex.exe"]; realCodexPrefixArgs = @(); realCodexBundleSha256 = $bundleHashes } - Write-AiUtf8NoBomAtomic -Path $settingsPath -Content (($settings | ConvertTo-Json -Depth 5) + "`n") - Assert-CodexAppBridgeInstallPathsProtected -BridgeRoot $bridgeRoot -Files (@($securedBundlePaths) + @($bridgePath, $settingsPath)) - - $activation = if ($existingActivation) { $existingActivation } else { - $previousValue = [Environment]::GetEnvironmentVariable("CODEX_CLI_PATH", $target) - [pscustomobject]@{ schema = 1; hadPrevious = ($null -ne $previousValue); previousValue = $previousValue; environmentTarget = $target.ToString() } - } - $activationRecord = [ordered]@{ - schema = 1 - hadPrevious = [bool]$activation.hadPrevious - previousValue = $activation.previousValue - environmentTarget = $target.ToString() - bridgePath = $bridgePath - installedAt = [DateTimeOffset]::UtcNow.ToString("O") - } - Write-AiUtf8NoBomAtomic -Path $activationPath -Content (($activationRecord | ConvertTo-Json -Depth 5) + "`n") - Assert-CodexAppBridgeInstallPathsProtected -BridgeRoot $bridgeRoot -Files (@($securedBundlePaths) + @($bridgePath, $settingsPath, $activationPath)) - [Environment]::SetEnvironmentVariable("CODEX_CLI_PATH", $bridgePath, $target) - $env:CODEX_CLI_PATH = $bridgePath - if ($target -eq [EnvironmentVariableTarget]::User) { Send-CodexAppBridgeEnvironmentChanged } - } finally { - Remove-Item -LiteralPath $stagingPath -Recurse -Force -ErrorAction SilentlyContinue - } - - Write-Host "Codex App all-provider bridge installed." - Write-Host " Bridge: $bridgePath" - Write-Host " Real CLI: $securedRealCliPath" - Write-Host " Reload: close and reopen Codex App" -} - -function Get-CodexAppBridgeStatus { - $bridgeRoot = Get-CodexAppBridgeRoot - $bridgePath = Join-Path $bridgeRoot "codex-provider-bridge.exe" - $securedRealCliPath = Join-Path $bridgeRoot "codex.exe" - $settingsPath = Join-Path $bridgeRoot "codex-provider-bridge.json" - $activationPath = Join-Path $bridgeRoot "activation.json" - $activation = if (Test-Path -LiteralPath $activationPath -PathType Leaf) { - try { Get-Content -LiteralPath $activationPath -Raw | ConvertFrom-Json -Depth 10 } catch { $null } - } else { $null } - $target = Get-CodexAppBridgeEnvironmentTarget -Name $(if ($activation) { [string]$activation.environmentTarget } else { $null }) - $configuredPath = [Environment]::GetEnvironmentVariable("CODEX_CLI_PATH", $target) - $isConfigured = $false - if ($configuredPath) { - try { $isConfigured = [IO.Path]::GetFullPath($configuredPath).Equals([IO.Path]::GetFullPath($bridgePath), [StringComparison]::OrdinalIgnoreCase) } catch { } - } - $isRunning = Test-CodexAppBridgeProcessRunning -BridgePath $bridgePath - $isCurrentAppCli = $false - if (Test-Path -LiteralPath $settingsPath -PathType Leaf) { - try { - $settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json -Depth 10 - $currentSourceCli = Resolve-CodexAppRealCliPath - $currentSourceRoot = Split-Path -Parent $currentSourceCli - $configuredRealPath = [IO.Path]::GetFullPath([string]$settings.realCodexPath) - $isCurrentAppCli = $configuredRealPath.Equals([IO.Path]::GetFullPath($securedRealCliPath), [StringComparison]::OrdinalIgnoreCase) - foreach ($name in (Get-CodexAppBundleExecutableNames)) { - $sourcePath = Join-Path $currentSourceRoot $name - $securedPath = Join-Path $bridgeRoot $name - $expectedHash = [string](Get-AiProperty -Object $settings.realCodexBundleSha256 -Name $name) - if (-not $expectedHash -or - -not (Test-Path -LiteralPath $sourcePath -PathType Leaf) -or - -not (Test-Path -LiteralPath $securedPath -PathType Leaf) -or - (Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash -cne $expectedHash -or - (Get-FileHash -LiteralPath $securedPath -Algorithm SHA256).Hash -cne $expectedHash) { - $isCurrentAppCli = $false - break - } - } - } catch { } - } - $missingBundleFiles = @((Get-CodexAppBundleExecutableNames) | Where-Object { -not (Test-Path -LiteralPath (Join-Path $bridgeRoot $_) -PathType Leaf) }) - return [pscustomobject]@{ - IsInstalled = [bool]((Test-Path -LiteralPath $bridgePath -PathType Leaf) -and (Test-Path -LiteralPath $settingsPath -PathType Leaf) -and $missingBundleFiles.Count -eq 0) - IsConfigured = $isConfigured - IsRunning = $isRunning - IsCurrentAppCli = $isCurrentAppCli - EnvironmentTarget = $target.ToString() - ConfiguredPath = $configuredPath - BridgePath = $bridgePath - } -} - -function Show-CodexAppBridgeStatus { - $status = Get-CodexAppBridgeStatus - Write-Host "Codex App all-provider bridge:" - Write-Host " Installed: $($status.IsInstalled)" - Write-Host " Configured: $($status.IsConfigured) ($($status.EnvironmentTarget))" - Write-Host " Running in App: $($status.IsRunning)" - Write-Host " App CLI match: $($status.IsCurrentAppCli)" - Write-Host " Bridge: $($status.BridgePath)" - if ($status.IsConfigured -and -not $status.IsRunning) { Write-Host " Reload required: close and reopen Codex App" } - if ($status.IsConfigured -and -not $status.IsCurrentAppCli) { Write-Host " Refresh required: close Codex App, then run cx app-bridge install" } -} - -function Remove-CodexAppBridge { - $bridgeRoot = Get-CodexAppBridgeRoot - $activationPath = Join-Path $bridgeRoot "activation.json" - if (-not (Test-Path -LiteralPath $activationPath -PathType Leaf)) { - Write-Host "Codex App all-provider bridge is already disabled." - return - } - $activation = Get-Content -LiteralPath $activationPath -Raw | ConvertFrom-Json -Depth 10 - $target = Get-CodexAppBridgeEnvironmentTarget -Name ([string]$activation.environmentTarget) - $hadPrevious = [bool]$activation.hadPrevious - $previousValue = if ($hadPrevious) { [string]$activation.previousValue } else { $null } - $configuredValue = [Environment]::GetEnvironmentVariable("CODEX_CLI_PATH", $target) - $ownsConfiguredValue = $false - try { - $ownsConfiguredValue = [bool]($configuredValue -and [IO.Path]::GetFullPath($configuredValue).Equals([IO.Path]::GetFullPath([string]$activation.bridgePath), [StringComparison]::OrdinalIgnoreCase)) - } catch { } - if ($ownsConfiguredValue) { - [Environment]::SetEnvironmentVariable("CODEX_CLI_PATH", $previousValue, $target) - if ($hadPrevious) { $env:CODEX_CLI_PATH = $previousValue } else { Remove-Item Env:CODEX_CLI_PATH -ErrorAction SilentlyContinue } - if ($target -eq [EnvironmentVariableTarget]::User) { Send-CodexAppBridgeEnvironmentChanged } - } else { - Write-Warning "CODEX_CLI_PATH no longer points to this bridge; leaving the current value unchanged." - } - Remove-Item -LiteralPath $activationPath -Force -ErrorAction SilentlyContinue - Write-Host "Codex App all-provider bridge disabled." - Write-Host " Reload: close and reopen Codex App" -} - -function Invoke-CodexAppBridgeCommand { - param([string[]]$Arguments) - - if ($Arguments.Count -eq 0 -or $Arguments[0] -ieq "status") { - if ($Arguments.Count -gt 1) { throw "Usage: cx app-bridge install|status|remove" } - Show-CodexAppBridgeStatus - return - } - if ($Arguments.Count -ne 1) { throw "Usage: cx app-bridge install|status|remove" } - switch ($Arguments[0].ToLowerInvariant()) { - "install" { Install-CodexAppBridge; return } - "remove" { Remove-CodexAppBridge; return } - default { throw "Usage: cx app-bridge install|status|remove" } - } -} - -# Drop cache entries for profiles that no longer exist (keeps health.json tidy -# after `cc/cx remove`). Called from the list commands. -function Sync-AiHealthCache { - param([Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool) - - $cache = Read-AiHealthCache - if ($cache.Count -eq 0) { return } - $valid = [System.Collections.Generic.HashSet[string]]::new() - foreach ($p in Get-AiToolProfiles -Tool $Tool) { - [void]$valid.Add("$Tool." + (Get-AiProfileName -Profile $p)) - } - $changed = $false - foreach ($k in @($cache.Keys)) { - if ($k.StartsWith("$Tool.") -and -not $valid.Contains($k)) { $cache.Remove($k); $changed = $true } - } - if ($changed) { - $path = Get-AiHealthCachePath - New-Item -ItemType Directory -Force -Path $script:AiConfigDir | Out-Null - $cache | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $path -Encoding UTF8 - } -} - -function Get-CodexProfileRows { - $saved = Get-AiSavedProfileName -Tool "codex" - foreach ($profile in Get-AiToolProfiles -Tool "codex") { - $mode = Get-AiProfileMode -Profile $profile - $profileHome = Get-CodexHome -Profile $profile - $profilePath = Get-CodexProfilePath -Profile $profile - $secretOk = if ($mode -eq "api") { - (Test-AiTomlSecretValues -Tool "codex" -Profile $profile -Names @("OPENAI_API_KEY", "CODEX_API_KEY")) -or - ((Get-AiSecretPath -Profile $profile) -and (Test-Path -LiteralPath (Get-AiSecretPath -Profile $profile))) - } else { - $true - } - $configOk = Test-Path -LiteralPath $profilePath - $ready = if ($mode -eq "api" -and -not $configOk) { - "missing config" - } elseif (-not $secretOk) { - "missing secret" - } elseif ($mode -eq "sub" -and -not $configOk) { - "ok default" - } else { - "ok" - } - $name = Get-AiProfileName -Profile $profile - [pscustomobject]@{ - Sel = if ($name -eq $saved) { "*" } else { " " } - Name = $name - Mode = $mode - Health = Get-AiHealthCellCached -Tool "codex" -Profile $profile - Profile = Get-CodexRuntimeProfileName -Profile $profile - Ready = $ready - Home = $profileHome - Config = if (Test-Path -LiteralPath $profilePath) { $profilePath } else { " $profilePath" } - Secret = if ($mode -eq "api") { Get-AiSecretDisplay -Tool "codex" -Profile $profile -Names @("OPENAI_API_KEY", "CODEX_API_KEY") } else { "" } - BaseUrl = Get-CodexBaseUrl -Profile $profile - Env = Get-AiProfileEnvSummary -Profile $profile - } - } -} - -function Get-ClaudeProfileRows { - $saved = Get-AiSavedProfileName -Tool "claude" - foreach ($profile in Get-AiToolProfiles -Tool "claude") { - $mode = Get-AiProfileMode -Profile $profile - $secret = if ($mode -eq "api") { Get-AiSecretPath -Profile $profile } else { "" } - $secretOk = if ($mode -eq "api") { - (Test-AiTomlSecretValues -Tool "claude" -Profile $profile -Names @("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN")) -or - ($secret -and (Test-Path -LiteralPath $secret)) - } else { - $true - } - $name = Get-AiProfileName -Profile $profile - $baseUrl = if ($mode -eq "api") { - $secretId = Get-AiSecretId -Tool "claude" -Profile $profile - $tomlSection = Get-AiTomlSecretSection -SecretId $secretId - if ($tomlSection.ContainsKey("ANTHROPIC_BASE_URL") -and $tomlSection["ANTHROPIC_BASE_URL"]) { - $tomlSection["ANTHROPIC_BASE_URL"] - } elseif ($secret -and (Test-Path -LiteralPath $secret)) { - $configured = Get-PowerShellEnvAssignment -Path $secret -Name "ANTHROPIC_BASE_URL" - if ($configured) { $configured } else { Get-AiProperty -Object $profile -Name "base_url" -Default $script:ClaudeRouterBaseUrl } - } else { - Get-AiProperty -Object $profile -Name "base_url" -Default $script:ClaudeRouterBaseUrl - } - } else { - "local Claude subscription login" - } - - [pscustomobject]@{ - Sel = if ($name -eq $saved) { "*" } else { " " } - Name = $name - Mode = $mode - Health = Get-AiHealthCellCached -Tool "claude" -Profile $profile - Ready = if ($secretOk) { "ok" } else { "missing secret" } - Secret = if ($mode -eq "api") { Get-AiSecretDisplay -Tool "claude" -Profile $profile -Names @("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN") } else { "" } - BaseUrl = $baseUrl - Env = Get-AiProfileEnvSummary -Profile $profile - } - } -} - -function Show-CodexList { - Write-Host "Codex profiles ($script:AiRegistryPath):" - Get-CodexProfileRows | Format-Table Sel, Name, Mode, Health, Profile, BaseUrl -AutoSize - Write-Host " (Health = cached snapshot, ⏭=stale/unprobed; run 'cx health' to refresh)" -ForegroundColor DarkGray -} - -function Show-ClaudeList { - Write-Host "Claude Code profiles ($script:AiRegistryPath):" - Get-ClaudeProfileRows | Format-Table Sel, Name, Mode, Health, BaseUrl -AutoSize - Write-Host " (Health = cached snapshot, ⏭=stale/unprobed; run 'cc health' to refresh)" -ForegroundColor DarkGray -} - -# Dedicated health report — keeps `list`/`status` focused on config; health is -# its own concern. Probes every profile via the cache; --fresh forces re-probe. -function Show-AiHealth { - param( - [Parameter(Mandatory = $true)][ValidateSet("codex", "claude")][string]$Tool, - [switch]$Fresh, - [int]$DegradedMs = 8000, - [int]$TimeoutSec = 10 - ) - Sync-AiHealthCache -Tool $Tool - $label = if ($Tool -eq "codex") { "Codex" } else { "Claude Code" } - $saved = Get-AiSavedProfileName -Tool $Tool - $profiles = @(Get-AiToolProfiles -Tool $Tool) - - # Phase 1 (instant): build a plan per profile. Cached/Early results resolve - # immediately; stale/missing profiles queue real requests. $display holds the - # current cell/method/note for each profile so the table can be redrawn as - # probes land (pending rows show ⏳…). - $tasks = [System.Collections.Generic.List[object]]::new() - $display = @{} # name -> @{ Health; Method; Note; Pending } - $plans = @{} # name -> plan (for verdict) - $expected = @{} # name -> #candidates (1 claude / 2 codex) - $cellOf = { - param($h) - if ($h) { Format-AiHealthCell $h } else { "?" } - } - foreach ($p in $profiles) { - $n = Get-AiProfileName -Profile $p - $plan = Get-AiProfileProbePlan -Tool $Tool -Profile $p - if ($plan.ContainsKey("Early")) { - $e = $plan.Early - $display[$n] = @{ Health = (& $cellOf $e); Method = ($(if ($e.Method) { $e.Method } else { "-" })); Note = ($(if ($e.Error) { ConvertTo-AiHealthDisplayError $e.Error } else { "" })); Pending = $false } - continue - } - $plans[$n] = $plan - $useCache = $false - if (-not $Fresh) { - $cached = Get-AiProfileHealthCached -Tool $Tool -Profile $p -CacheOnly - if ($cached.Cached) { - $display[$n] = @{ Health = (& $cellOf $cached); Method = ($(if ($cached.Method) { $cached.Method } else { "-" })); Note = ($(if ($cached.Error) { ConvertTo-AiHealthDisplayError $cached.Error } else { "" })); Pending = $false } - $useCache = $true - } - } - if (-not $useCache) { - $display[$n] = @{ Health = "⏳…"; Method = "-"; Note = "probing…"; Pending = $true } - foreach ($c in $plan.Candidates) { - $tasks.Add([pscustomobject]@{ - Name = $n; Label = $c.Label; Url = $c.Url; Headers = $plan.Headers; Body = $c.Body; Check = $c.Check; Timeout = $TimeoutSec - }) - } - $expected[$n] = @($plan.Candidates).Count - } - } - - $outputWidth = Get-AiHealthOutputWidth - $buildLines = { - param([int]$Tick = 0) - $dots = ($Tick % 7) + 1 - $lines = @() - $lines += Limit-AiDisplayText ("{0,-3} {1,-14} {2,-9} {3,-11} {4}" -f "Sel", "Name", "Health", "Method", "Note") $outputWidth - $lines += Limit-AiDisplayText ("{0,-3} {1,-14} {2,-9} {3,-11} {4}" -f "---", "----", "------", "------", "----") $outputWidth - foreach ($p in $profiles) { - $n = Get-AiProfileName -Profile $p - $d = $display[$n] - if (-not $d) { $d = @{ Health = "?"; Method = "-"; Note = ""; Pending = $false } } - if ($d.Pending) { $cell = "⏳"; $method = "-"; $note = "waiting ⏳" + ("." * $dots) } - else { $cell = $d.Health; $method = $d.Method; $note = $d.Note } - $sel = if ($n -eq $saved) { "*" } else { " " } - $row = "{0,-3} {1,-14} {2,-9} {3,-11} {4}" -f $sel, $n, $cell, $method, $note - $lines += Limit-AiDisplayText $row $outputWidth - } - $lines - } - - # Probe block: builtins only (no engine functions in the child runsape). Body - # is validated INSIDE the block — objects crossing the runsape boundary get - # XML-serialized, so `-is [array]` would fail in the parent; returning only - # primitives sidesteps that. - $probeBlock = { - $t = $_; $timeout = $t.Timeout - $sw = [System.Diagnostics.Stopwatch]::StartNew() - $r = [pscustomobject]@{ Name = $t.Name; Label = $t.Label; Ok = $false; Code = 0; LatencyMs = 0; Detail = $null } - try { - $j = Invoke-RestMethod -Uri $t.Url -Method Post -Headers $t.Headers -Body $t.Body ` - -ContentType "application/json" -TimeoutSec $timeout -ErrorAction Stop - $sw.Stop() - $r.Code = 200; $r.LatencyMs = [int]$sw.ElapsedMilliseconds - $valid = $false - switch ($t.Check) { - "messages" { $valid = (($j.content -is [array]) -and ($j.content.Count -gt 0)) -or ($j.type -eq "message") } - "responses" { $valid = (($j.output -is [array] -and $j.output.Count -gt 0) -or $j.output_text -or $j.status -eq "completed") } - "chat" { $valid = (($j.choices -is [array]) -and ($j.choices.Count -gt 0)) } - } - $r.Ok = $valid - if (-not $valid) { $r.Detail = "200 but no generated content" } - } catch { - $sw.Stop() - $r.LatencyMs = [int]$sw.ElapsedMilliseconds - if ($_.Exception.Response) { - $r.Code = [int]$_.Exception.Response.StatusCode - $bodyText = [string]$_.ErrorDetails.Message - if ($bodyText) { - try { - $bodyJson = $bodyText | ConvertFrom-Json -ErrorAction Stop - $message = if ($bodyJson.error -and $bodyJson.error.message) { [string]$bodyJson.error.message } elseif ($bodyJson.message) { [string]$bodyJson.message } else { $null } - if ($message) { $bodyText = $message } - } catch { } - $bodyText = [regex]::Replace($bodyText, '\\u([0-9a-fA-F]{4})', { - param($match) - $code = [Convert]::ToInt32($match.Groups[1].Value, 16) - if ($code -lt 0x20 -or ($code -ge 0x7f -and $code -lt 0xa0)) { return "?" } - return [char]$code - }) - $bodyText = [regex]::Replace($bodyText, '[\x00-\x1f\x7f-\x9f]', '?') - $bodyText = ($bodyText -replace "\s+", " ").Trim() - if ($bodyText.Length -gt 240) { $bodyText = $bodyText.Substring(0, 240) } - $r.Detail = "HTTP " + $r.Code + " " + $bodyText - } else { $r.Detail = "HTTP " + $r.Code } - } - else { $r.Detail = $_.Exception.Message } - } - $r - } - - # Cache + display update for a profile once ALL its candidates are in. - $finalize = { - param($n) - $h = Resolve-AiProfileHealth -Plan $plans[$n] -Results $reqs[$n] -DegradedMs $DegradedMs - $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - Write-AiHealthCacheEntry -Key "$Tool.$n" -Entry ([pscustomobject]@{ - status = $h.Status; latencyMs = $h.LatencyMs; method = $h.Method; error = $h.Error; probedAt = $now - }) - $display[$n] = @{ Health = (Format-AiHealthCell $h); Method = ($(if ($h.Method) { $h.Method } else { "-" })); Note = ($(if ($h.Error) { ConvertTo-AiHealthDisplayError $h.Error } else { "" })); Pending = $false } - } - - $footer = " (health " + ($(if ($Fresh) { "re-probed (fresh, parallel)" } else { "cached <=5min" })) + "; '" + $Tool + " health --fresh' re-probe, '" + $Tool + " health-clear' clears)" - $titleStr = "$label profile health ($script:AiRegistryPath):" - $pendingNames = @($expected.Keys) - $isTty = (-not [Console]::IsOutputRedirected) -or ($env:AI_HEALTH_LIVE -eq '1') - - if ($pendingNames.Count -gt 0 -and $isTty) { - # === Animated in-place table (TTY only) === - # Probes run in background runsapes (concurrency); the FOREGROUND ticks every - # 300ms and redraws the table with [Console]::Write + relative cursor-up - # (\e[A). Only the main thread writes to the console, so there's no host - # contention (the earlier in-place attempt used \e7/\e8 save-restore + Write- - # Host in a ForEach-Object -Parallel consumer, which deadlocked). Pending rows - # show "waiting ⏳" + dots cycling 1..7; each row fills when its probe lands. - $esc = [char]27 - $probeScriptStr = @' -param($Candidates, $Headers, $TimeoutSec) -foreach ($c in $Candidates) { - $sw = [System.Diagnostics.Stopwatch]::StartNew() - $r = [pscustomobject]@{ Label = $c.Label; Ok = $false; Code = 0; LatencyMs = 0; Detail = $null } - try { - $j = Invoke-RestMethod -Uri $c.Url -Method Post -Headers $Headers -Body $c.Body -ContentType "application/json" -TimeoutSec $TimeoutSec -ErrorAction Stop - $sw.Stop(); $r.Code = 200; $r.LatencyMs = [int]$sw.ElapsedMilliseconds - $valid = $false - switch ($c.Check) { - "messages" { $valid = (($j.content -is [array]) -and ($j.content.Count -gt 0)) -or ($j.type -eq "message") } - "responses" { $valid = (($j.output -is [array] -and $j.output.Count -gt 0) -or $j.output_text -or $j.status -eq "completed") } - "chat" { $valid = (($j.choices -is [array]) -and ($j.choices.Count -gt 0)) } - } - $r.Ok = $valid; if (-not $valid) { $r.Detail = "200 but no generated content" } - } catch { - $sw.Stop(); $r.LatencyMs = [int]$sw.ElapsedMilliseconds - if ($_.Exception.Response) { - $r.Code = [int]$_.Exception.Response.StatusCode - $bodyText = [string]$_.ErrorDetails.Message - if ($bodyText) { - try { - $bodyJson = $bodyText | ConvertFrom-Json -ErrorAction Stop - $message = if ($bodyJson.error -and $bodyJson.error.message) { [string]$bodyJson.error.message } elseif ($bodyJson.message) { [string]$bodyJson.message } else { $null } - if ($message) { $bodyText = $message } - } catch { } - $bodyText = [regex]::Replace($bodyText, '\\u([0-9a-fA-F]{4})', { - param($match) - $code = [Convert]::ToInt32($match.Groups[1].Value, 16) - if ($code -lt 0x20 -or ($code -ge 0x7f -and $code -lt 0xa0)) { return "?" } - return [char]$code - }) - $bodyText = [regex]::Replace($bodyText, '[\x00-\x1f\x7f-\x9f]', '?') - $bodyText = ($bodyText -replace "\s+", " ").Trim() - if ($bodyText.Length -gt 240) { $bodyText = $bodyText.Substring(0, 240) } - $r.Detail = "HTTP " + $r.Code + " " + $bodyText - } else { - $r.Detail = "HTTP " + $r.Code - } - } else { $r.Detail = $_.Exception.Message } - } - $r -} -'@ - $runspaces = @{} - try { - foreach ($n in $pendingNames) { - $plan = $plans[$n] - $ps = [PowerShell]::Create() - [void]$ps.AddScript($probeScriptStr).AddArgument(@($plan.Candidates)).AddArgument($plan.Headers).AddArgument($TimeoutSec) - $runspaces[$n] = @{ PS = $ps; Handle = $ps.BeginInvoke(); Plan = $plan } - } - - [Console]::WriteLine((Limit-AiDisplayText $titleStr $outputWidth)) - $sw = [System.Diagnostics.Stopwatch]::StartNew() - $nlines = (& $buildLines 0).Count - foreach ($l in & $buildLines 0) { [Console]::Write($l + "`n") } - while ($pendingNames.Count -gt 0) { - Start-Sleep -Milliseconds 300 - $done = @() - foreach ($n in $pendingNames) { - if ($runspaces[$n].Handle.IsCompleted) { - $raw = $runspaces[$n].PS.EndInvoke($runspaces[$n].Handle) - $results = @{}; foreach ($r in $raw) { $results[$r.Label] = $r } - $h = Resolve-AiProfileHealth -Plan $runspaces[$n].Plan -Results $results -DegradedMs $DegradedMs - $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - Write-AiHealthCacheEntry -Key "$Tool.$n" -Entry ([pscustomobject]@{ status = $h.Status; latencyMs = $h.LatencyMs; method = $h.Method; error = $h.Error; probedAt = $now }) - $display[$n] = @{ Health = (Format-AiHealthCell $h); Method = ($(if ($h.Method) { $h.Method } else { "-" })); Note = ($(if ($h.Error) { ConvertTo-AiHealthDisplayError $h.Error } else { "" })); Pending = $false } - $done += $n - } - } - if ($done.Count -gt 0) { $pendingNames = @($pendingNames | Where-Object { $_ -notin $done }) } - [Console]::Write($esc + "[" + $nlines + "A") - $tick = [int][math]::Floor($sw.ElapsedMilliseconds / 400) - foreach ($l in & $buildLines $tick) { [Console]::Write("`r" + $esc + "[K" + $l + "`n") } - } - } finally { - foreach ($n in $expected.Keys) { if ($runspaces[$n] -and $runspaces[$n].PS) { $runspaces[$n].PS.Dispose() } } - } - [Console]::WriteLine((Limit-AiDisplayText $footer $outputWidth)) - return - } - - # === Streaming fallback (pipes / CI / non-TTY): one line per completion === - Write-Host (Limit-AiDisplayText $titleStr $outputWidth) - if ($tasks.Count -gt 0) { - $probeProfileCount = 0 - foreach ($n in $expected.Keys) { $probeProfileCount += 1 } - Write-Host (Limit-AiDisplayText (" probing {0} profile(s) in parallel (results stream as they resolve)…" -f $probeProfileCount) $outputWidth) -ForegroundColor DarkGray - $reqs = @{}; $doneCount = @{} - $tasks | Microsoft.PowerShell.Core\ForEach-Object -Parallel $probeBlock -ThrottleLimit ([Math]::Max(8, $tasks.Count)) | ForEach-Object { - if (-not $reqs.ContainsKey($_.Name)) { $reqs[$_.Name] = @{}; $doneCount[$_.Name] = 0 } - $reqs[$_.Name][$_.Label] = $_ - $doneCount[$_.Name] += 1 - if ($doneCount[$_.Name] -ge $expected[$_.Name]) { - & $finalize $_.Name - $d = $display[$_.Name] - Write-Host (Limit-AiDisplayText (" {0} {1,-14} {2}" -f $d.Health, $_.Name, $d.Note) $outputWidth) -ForegroundColor DarkGray - } - } - } - - # Final registry-ordered summary (cached profiles land here too). - Write-Host "" - foreach ($line in & $buildLines 0) { Write-Host $line } - Write-Host (Limit-AiDisplayText $footer $outputWidth) -ForegroundColor DarkGray -} - -function Show-CodexStatus { - param([switch]$Fresh) - $saved = Get-AiSavedProfileName -Tool "codex" - $profile = Get-AiProfileByName -Tool "codex" -Name ($env:AI_CODEX_LABEL ?? $saved) - if (-not $profile) { - $profile = Get-AiProfileByName -Tool "codex" -Name (Get-AiDefaultProfileName -Tool "codex") - } - - $codexHome = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Get-CodexHome -Profile $profile } - Write-Host "Codex state:" - Write-Host " Registry: $script:AiRegistryPath" - Write-Host " State: $script:AiStatePath" - Write-Host " Saved: $saved" - Write-Host " Process label: $($env:AI_CODEX_LABEL ?? '')" - Write-Host " Process profile: $($env:AI_CODEX_PROFILE ?? '')" - Write-Host " CODEX_HOME: $codexHome" - Write-Host " OPENAI_API_KEY: $(Format-AiSecretPreview $env:OPENAI_API_KEY)" - Write-Host " Cached login: $(Get-CodexLoginStatusText)" - if ($profile) { - Write-Host " Probe model: $(Get-AiProbeModel -Tool codex -Profile $profile)" - $h = Get-AiProfileHealthCached -Tool "codex" -Profile $profile -Fresh:$Fresh -CacheOnly:(-not $Fresh) - Write-Host (Format-AiHealthStatusLine $h) - } -} - -# On-demand `codex doctor` (slow: real network/websocket checks). Kept out of -# `cx status` so status stays instant; run this only for the full Codex -# self-diagnostic. Injects the active profile's provider via -c overrides. -function Show-CodexDoctor { - $saved = Get-AiSavedProfileName -Tool "codex" - $profile = Get-AiProfileByName -Tool "codex" -Name ($env:AI_CODEX_LABEL ?? $saved) - if (-not $profile) { $profile = Get-AiProfileByName -Tool "codex" -Name (Get-AiDefaultProfileName -Tool "codex") } - if (-not $profile) { Write-Host "No codex profile to diagnose."; return } - Write-CodexDoctorSummary -Profile $profile -} - -function Show-ClaudeStatus { - param([switch]$Fresh) - $saved = Get-AiSavedProfileName -Tool "claude" - Write-Host "Claude Code state:" - Write-Host " Registry: $script:AiRegistryPath" - Write-Host " State: $script:AiStatePath" - Write-Host " Saved: $saved" - Write-Host " Process label: $($env:AI_CLAUDE_LABEL ?? '')" - Write-Host " ANTHROPIC_BASE_URL: $($env:ANTHROPIC_BASE_URL ?? '')" - Write-Host " ANTHROPIC_API_KEY: $(Format-AiSecretPreview $env:ANTHROPIC_API_KEY)" - Write-Host " ANTHROPIC_AUTH_TOKEN: $(Format-AiSecretPreview $env:ANTHROPIC_AUTH_TOKEN)" - $cprofile = Get-AiProfileByName -Tool "claude" -Name ($env:AI_CLAUDE_LABEL ?? $saved) - if (-not $cprofile) { $cprofile = Get-AiProfileByName -Tool "claude" -Name (Get-AiDefaultProfileName -Tool "claude") } - if ($cprofile) { - Write-Host " Probe model: $(Get-AiProbeModel -Tool claude -Profile $cprofile)" - $h = Get-AiProfileHealthCached -Tool "claude" -Profile $cprofile -Fresh:$Fresh -CacheOnly:(-not $Fresh) - Write-Host (Format-AiHealthStatusLine $h) - } - Write-ClaudeExternalStatus -} - -# =========================================================================== -# MCP module. ~/.ai-env/mcp.toml is the single source of truth for MCP servers -# across Claude Code and Codex. `mcp sync` pushes each enabled server to its -# targets (global): -# Claude -> ~/.claude.json mcpServers (direct JSON merge, atomic) -# Codex -> ~/.codex/config.toml [mcp_servers.NAME] (native enabled flag) -# `enabled` is uniform: Claude has no per-server flag (disabled = omitted); -# Codex uses native enabled = false. Edit ONLY mcp.toml (mcp edit). -# Target paths honor env overrides (AI_CLAUDE_JSON_PATH / AI_CODEX_CONFIG_PATH) -# so tests can isolate without touching the real config files. -# =========================================================================== - -function Get-AiMcpRegistryPath { - return (Join-Path $script:AiConfigDir "mcp.toml") -} -function Get-AiClaudeJsonPath { - if ($env:AI_CLAUDE_JSON_PATH) { return $env:AI_CLAUDE_JSON_PATH } - return (Join-Path $HOME '.claude.json') -} -function Get-AiCodexConfigPath { - if ($env:AI_CODEX_CONFIG_PATH) { return $env:AI_CODEX_CONFIG_PATH } - return (Join-Path $HOME '.codex\config.toml') -} - -# TOML array of strings: ["a", "b"] -> @('a','b') -function ConvertFrom-AiTomlStringArray { - param([AllowNull()][string]$Raw) - $list = @() - if (-not $Raw) { return $list } - foreach ($m in [regex]::Matches($Raw, '"((?:\\.|[^"])*)"')) { $list += $m.Groups[1].Value } - return $list -} -# TOML inline table of strings: { K = "V" } -> @{K='V'} -function ConvertFrom-AiTomlInlineTable { - param([AllowNull()][string]$Raw) - $h = @{} - if (-not $Raw) { return $h } - foreach ($m in [regex]::Matches($Raw, '([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"((?:\\.|[^"])*)"')) { $h[$m.Groups[1].Value] = $m.Groups[2].Value } - return $h -} - -# Read mcp.toml -> ordered hashtable name -> entry(pscustomobject). -function Read-AiMcpRegistry { - $path = Get-AiMcpRegistryPath - $result = [ordered]@{} - if (-not (Test-Path -LiteralPath $path)) { return $result } - $current = $null - foreach ($line in Get-Content -LiteralPath $path) { - $t = "$line".Trim() - if (-not $t -or $t.StartsWith('#')) { continue } - if ($t -match '^\[mcp\.([^\]]+)\]\s*$') { - $current = $Matches[1].Trim() - $result[$current] = [pscustomobject]@{ - Name = $current; Kind = 'stdio'; Command = @(); Url = $null; Env = @{}; Sync = @('claude', 'codex'); Enabled = $true - } - continue - } - if ($t -match '^\[') { $current = $null; continue } - if (-not $current) { continue } - if ($t -match '^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$') { - $key = $Matches[1]; $raw = $Matches[2].Trim() - $e = $result[$current] - switch ($key) { - 'command' { $e.Kind = 'stdio'; $e.Command = @(ConvertFrom-AiTomlStringArray $raw) } - 'url' { $e.Kind = 'http'; $e.Url = [string](ConvertFrom-AiTomlValue $raw) } - 'env' { $e.Env = ConvertFrom-AiTomlInlineTable $raw } - 'sync' { $e.Sync = @(ConvertFrom-AiTomlStringArray $raw) } - 'enabled' { $e.Enabled = ($raw -match 'true') } - } - } - } - return $result -} - -# --- Claude target (~/.claude.json mcpServers) --- -function Read-ClaudeMcpServerNames { - $path = Get-AiClaudeJsonPath - if (-not (Test-Path -LiteralPath $path)) { return @() } - try { - $d = Get-Content -Raw -LiteralPath $path | ConvertFrom-Json - if ($d.mcpServers) { return @($d.mcpServers.PSObject.Properties.Name) } - } catch { } - return @() -} -# Build the object Claude stores for a server. -function ConvertTo-ClaudeMcpEntry { - param([Parameter(Mandatory = $true)]$Entry) - if ($Entry.Kind -eq 'http') { return [pscustomobject]@{ type = 'http'; url = [string]$Entry.Url } } - $cmd = @($Entry.Command) - $obj = [ordered]@{} - if ($cmd.Count -gt 0) { $obj['command'] = [string]$cmd[0] } - $obj['args'] = if ($cmd.Count -gt 1) { @($cmd[1..($cmd.Count - 1)]) } else { @() } - if ($Entry.Env.Count -gt 0) { $obj['env'] = ($Entry.Env) } - return [pscustomobject]$obj -} -# Upsert ($Entry) or remove ($Entry=$null) a server in .claude.json mcpServers. -# Atomic (tmp+move); preserves all other keys. Backs up once per path. -function Set-ClaudeMcpServer { - param([Parameter(Mandatory = $true)][string]$Name, $Entry) - $path = Get-AiClaudeJsonPath - $bak = "$path.aienv.bak" - if ((Test-Path -LiteralPath $path) -and -not (Test-Path -LiteralPath $bak)) { - Copy-Item -LiteralPath $path -Destination $bak -Force - } - $d = $null - if (Test-Path -LiteralPath $path) { - try { $d = Get-Content -Raw -LiteralPath $path | ConvertFrom-Json } catch { $d = $null } - } - if (-not $d) { $d = [pscustomobject]@{ } } - # copy existing mcpServers into a mutable hashtable (preserves siblings), - # then upsert/remove the one entry, and write it back. - $ms = @{ } - if ($d.PSObject.Properties.Name -contains 'mcpServers' -and $d.mcpServers) { - foreach ($p in $d.mcpServers.PSObject.Properties) { $ms[$p.Name] = $p.Value } - } - if ($Entry) { - $ms[$Name] = (ConvertTo-ClaudeMcpEntry $Entry) - } elseif ($ms.ContainsKey($Name)) { - $ms.Remove($Name) - } - $d | Add-Member -NotePropertyName mcpServers -NotePropertyValue $ms -Force - $tmp = "$path.tmp" - ($d | ConvertTo-Json -Depth 100) | Set-Content -LiteralPath $tmp -Encoding UTF8 - Move-Item -LiteralPath $tmp -Destination $path -Force -} - -# --- Codex target (~/.codex/config.toml [mcp_servers.NAME]) --- -function Test-CodexMcpServer { - param([Parameter(Mandatory = $true)][string]$Name) - $path = Get-AiCodexConfigPath - if (-not (Test-Path -LiteralPath $path)) { return $false } - return [bool](Select-String -LiteralPath $path -Pattern ('^\[mcp_servers\.' + [regex]::Escape($Name) + '\]') -Quiet) -} -function ConvertTo-CodexMcpBlock { - param([Parameter(Mandatory = $true)]$Entry) - $lines = @("[mcp_servers.$($Entry.Name)]") - if ($Entry.Kind -eq 'http') { - $lines += 'url = "' + [string]$Entry.Url + '"' - } else { - $arr = (@($Entry.Command) | ForEach-Object { '"' + [string]$_ + '"' }) -join ', ' - $lines += 'command = [' + $arr + ']' - if ($Entry.Env.Count -gt 0) { - $pairs = ($Entry.Env.GetEnumerator() | ForEach-Object { [string]$_.Key + ' = "' + [string]$_.Value + '"' }) -join ', ' - $lines += 'env = { ' + $pairs + ' }' - } - } - $lines += 'enabled = ' + $(if ($Entry.Enabled) { 'true' } else { 'false' }) - return ($lines -join "`n") -} -# Remove [mcp_servers.NAME] section; if $Block, append it. Preserves all other -# config.toml content (model, providers, user-managed mcp_servers, etc.). -function Set-CodexMcpServer { - param([Parameter(Mandatory = $true)][string]$Name, [AllowEmptyString()][string]$Block) - $path = Get-AiCodexConfigPath - if (-not (Test-Path -LiteralPath $path)) { - if (-not $Block) { return } - New-Item -ItemType Directory -Force -Path (Split-Path -Parent $path) | Out-Null - "" | Set-Content -LiteralPath $path -Encoding UTF8 - } - $lines = @(Get-Content -LiteralPath $path) - $out = New-Object System.Collections.Generic.List[string] - $header = "[mcp_servers.$Name]" - $skip = $false - foreach ($l in $lines) { - if ("$l".Trim() -match '^\[([^\]]+)\]\s*$') { $skip = ("[" + $Matches[1].Trim() + "]") -ceq $header } - if ($skip) { continue } - $out.Add($l) - } - if ($Block) { - if ($out.Count -gt 0 -and "$($out[$out.Count - 1])".Trim() -ne '') { $out.Add('') } - $out.Add($Block.TrimEnd()) - } - ($out -join "`n") | Set-Content -LiteralPath $path -Encoding UTF8 -} - -# Push every mcp.toml entry to its targets (idempotent). -function Sync-AiMcp { - $reg = Read-AiMcpRegistry - $path = Get-AiMcpRegistryPath - if ($reg.Count -eq 0) { - Write-Host "No MCP servers in $path. Run 'mcp edit' to define some." - return - } - $upserts = 0; $removes = 0 - foreach ($entry in $reg.Values) { - foreach ($tool in @('claude', 'codex')) { - $want = ($entry.Enabled -and ($entry.Sync -contains $tool)) - if ($tool -eq 'claude') { - if ($want) { Set-ClaudeMcpServer -Name $entry.Name -Entry $entry; $upserts++ } - else { Set-ClaudeMcpServer -Name $entry.Name -Entry $null; $removes++ } - } else { - $block = if ($want) { ConvertTo-CodexMcpBlock -Entry $entry } else { '' } - Set-CodexMcpServer -Name $entry.Name -Block $block - if ($want) { $upserts++ } else { $removes++ } - } - } - } - Write-Host "MCP sync done: $upserts upsert(s), $removes remove(s). Targets: Claude ($(Get-AiClaudeJsonPath)), Codex ($(Get-AiCodexConfigPath))." -} - -# --- reverse direction: pull existing servers from targets into mcp.toml --- -function Read-ClaudeMcpServers { - $path = Get-AiClaudeJsonPath - $result = [ordered]@{ } - if (-not (Test-Path -LiteralPath $path)) { return $result } - try { - $d = Get-Content -Raw -LiteralPath $path | ConvertFrom-Json - if ($d.mcpServers) { foreach ($p in $d.mcpServers.PSObject.Properties) { $result[$p.Name] = $p.Value } } - } catch { } - return $result -} -function Read-CodexMcpServers { - $path = Get-AiCodexConfigPath - $result = [ordered]@{ } - if (-not (Test-Path -LiteralPath $path)) { return $result } - $current = $null - foreach ($line in Get-Content -LiteralPath $path) { - $t = "$line".Trim() - if ($t -match '^\[mcp_servers\.([^\]]+)\]\s*$') { - $current = $Matches[1].Trim() - $result[$current] = [pscustomobject]@{ Name = $current; Command = @(); Url = $null; Env = @{}; Enabled = $true } - continue - } - if ($t -match '^\[') { $current = $null; continue } - if (-not $current) { continue } - if ($t -match '^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$') { - $key = $Matches[1]; $raw = $Matches[2].Trim(); $e = $result[$current] - switch ($key) { - 'command' { $e.Command = @(ConvertFrom-AiTomlStringArray $raw) } - 'url' { $e.Url = [string](ConvertFrom-AiTomlValue $raw) } - 'env' { $e.Env = ConvertFrom-AiTomlInlineTable $raw } - 'enabled' { $e.Enabled = ($raw -match 'true') } - } - } - } - return $result -} -# Convert a Claude target entry -> mcp.toml entry shape. -function ConvertFrom-ClaudeMcpTarget { - param([Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)]$Entry) - $e = [pscustomobject]@{ Name = $Name; Kind = 'stdio'; Command = @(); Url = $null; Env = @{}; Sync = @('claude'); Enabled = $true } - if ("$Entry.type" -in @('http', 'sse') -or $Entry.url) { - $e.Kind = 'http'; $e.Url = [string]$Entry.url - } else { - if ($Entry.command -is [array]) { $e.Command = @($Entry.command) } - else { - $cmd = @(); if ($Entry.command) { $cmd += [string]$Entry.command } - if ($Entry.args) { $cmd += @($Entry.args) } - $e.Command = $cmd - } - if ($Entry.env) { foreach ($p in $Entry.env.PSObject.Properties) { $e.Env[$p.Name] = [string]$p.Value } } - } - return $e -} -# Convert a Codex target entry -> mcp.toml entry shape. -function ConvertFrom-CodexMcpTarget { - param([Parameter(Mandatory = $true)]$Entry) - $e = [pscustomobject]@{ Name = $Entry.Name; Kind = 'stdio'; Command = @(); Url = $null; Env = @{}; Sync = @('codex'); Enabled = $Entry.Enabled } - if ($Entry.Url) { $e.Kind = 'http'; $e.Url = $Entry.Url } else { $e.Command = @($Entry.Command); $e.Env = $Entry.Env } - return $e -} -# Serialize an mcp.toml entry -> TOML block text. -function ConvertTo-McpTomlBlock { - param([Parameter(Mandatory = $true)]$Entry) - $lines = @("[mcp.$($Entry.Name)]") - if ($Entry.Kind -eq 'http') { - $lines += 'url = "' + [string]$Entry.Url + '"' - } else { - $arr = (@($Entry.Command) | ForEach-Object { '"' + [string]$_ + '"' }) -join ', ' - $lines += 'command = [' + $arr + ']' - if ($Entry.Env.Count -gt 0) { - $pairs = ($Entry.Env.GetEnumerator() | ForEach-Object { [string]$_.Key + ' = "' + [string]$_.Value + '"' }) -join ', ' - $lines += 'env = { ' + $pairs + ' }' - } - } - $lines += 'sync = [' + ((@($Entry.Sync) | ForEach-Object { '"' + $_ + '"' }) -join ', ') + ']' - $lines += 'enabled = ' + $(if ($Entry.Enabled) { 'true' } else { 'false' }) - return ($lines -join "`n") -} -# Pull existing MCP servers from Claude + Codex targets into mcp.toml. Adds only -# names not already present (preserves your mcp.toml edits). -Name pulls one. -function Import-AiMcpFromTargets { - param([string]$Name) - $claude = Read-ClaudeMcpServers - $codex = Read-CodexMcpServers - $existing = Read-AiMcpRegistry - if ($Name) { - $names = @($Name) - if (-not ($claude.Contains($Name) -or $codex.Contains($Name))) { Write-Host "'$Name' not found in Claude or Codex targets."; return } - } else { - $names = @(@($claude.Keys) + @($codex.Keys) | Select-Object -Unique) - } - if ($names.Count -eq 0) { Write-Host "No MCP servers found in targets to pull."; return } - $added = 0; $skipped = 0; $newBlocks = @() - foreach ($n in $names) { - if ($existing.Contains($n)) { $skipped++; continue } - $inC = $claude.Contains($n); $inX = $codex.Contains($n) - if ($inC) { $e = ConvertFrom-ClaudeMcpTarget -Name $n -Entry $claude[$n] } else { $e = ConvertFrom-CodexMcpTarget -Entry $codex[$n] } - $sync = @(); if ($inC) { $sync += 'claude' }; if ($inX) { $sync += 'codex' } - $e.Sync = $sync - $newBlocks += (ConvertTo-McpTomlBlock -Entry $e) - $added++ - } - if ($added -gt 0) { - $path = Get-AiMcpRegistryPath - if (-not (Test-Path -LiteralPath $path)) { - New-Item -ItemType Directory -Force -Path $script:AiConfigDir | Out-Null - "# ~/.ai-env/mcp.toml - pulled from Claude Code & Codex. Edit freely; run mcp sync to push back." | Set-Content -LiteralPath $path - } - $tail = (Get-Content -Raw -LiteralPath $path).TrimEnd() - $sep = if ($tail -ne '') { "`n`n" } else { "" } - Add-Content -LiteralPath $path -Value ($sep + ($newBlocks -join "`n`n") + "`n") - } - Write-Host "MCP pull: +$added added, $skipped skipped (already in mcp.toml). -> $(Get-AiMcpRegistryPath)" -} - -function Show-AiMcpList { - $reg = Read-AiMcpRegistry - $path = Get-AiMcpRegistryPath - if ($reg.Count -eq 0) { Write-Host "No MCP servers in $path. Run 'mcp edit'."; return } - $claude = Read-ClaudeMcpServerNames - $rows = foreach ($e in $reg.Values) { - [pscustomobject]@{ - Name = $e.Name - Type = if ($e.Kind -eq 'http') { 'http' } else { 'stdio' } - Claude = if ($claude -contains $e.Name) { 'yes' } else { '-' } - Codex = if (Test-CodexMcpServer -Name $e.Name) { 'yes' } else { '-' } - Enabled = if ($e.Enabled) { 'on' } else { 'off' } - Sync = ($e.Sync -join ',') - } - } - $rows | Format-Table -AutoSize - Write-Host " (yes = present in target's live config; run 'mcp sync' to align)" -ForegroundColor DarkGray -} - -function Show-AiMcpGet { - param([string]$Name) - $reg = Read-AiMcpRegistry - if (-not $Name) { Write-Host "Usage: mcp get NAME"; return } - if (-not $reg.Contains($Name)) { Write-Host "No MCP server '$Name' in mcp.toml."; return } - $e = $reg[$Name] - Write-Host "mcp.$Name :" - Write-Host " kind : $($e.Kind)" - if ($e.Kind -eq 'http') { Write-Host " url : $($e.Url)" } else { Write-Host " command : $($e.Command -join ' ')" } - if ($e.Env.Count -gt 0) { Write-Host " env : " + (($e.Env.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join ', ') } - Write-Host " sync : $($e.Sync -join ', ')" - Write-Host " enabled : $($e.Enabled)" - $claude = Read-ClaudeMcpServerNames - Write-Host " claude : $(if ($claude -contains $Name) { 'present' } else { '-' })" - Write-Host " codex : $(if (Test-CodexMcpServer -Name $Name) { 'present' } else { '-' })" -} - -function Edit-AiMcpRegistry { - $path = Get-AiMcpRegistryPath - if (-not (Test-Path -LiteralPath $path)) { - New-Item -ItemType Directory -Force -Path $script:AiConfigDir | Out-Null - @' -# ~/.ai-env/mcp.toml — single source of truth for MCP servers (Claude Code + Codex). -# `mcp sync` pushes each enabled server to global targets: -# Claude -> ~/.claude.json mcpServers -# Codex -> ~/.codex/config.toml [mcp_servers.NAME] -# A server is EITHER stdio (command = [...]) OR http (url = "..."). -# sync = which tools get it (omit = both). enabled = false keeps it defined but skips it. - -# [mcp.context7] -# command = ["npx", "-y", "@upstash/context7-mcp"] -# env = {} -# sync = ["claude", "codex"] -# enabled = true - -# [mcp.figma] -# url = "https://mcp.figma.com/mcp" -# sync = ["codex"] -# enabled = false -'@ | Set-Content -LiteralPath $path -Encoding UTF8 - Write-Host "Created starter mcp.toml at $path" - } - $editor = if ($env:EDITOR) { $env:EDITOR } elseif ($env:VISUAL) { $env:VISUAL } elseif ($IsWindows) { 'notepad' } else { 'vi' } - # mcp edit should open-and-return (non-blocking): drop any --wait/-w flag so - # GUI editors (cursor/code) launch and hand the shell back immediately. The - # global $EDITOR keeps --wait for tools that need blocking (e.g. git commit). - $parts = @($editor -split '\s+' | Where-Object { $_ -and $_ -notin @('--wait', '-w') }) - if ($parts.Count -eq 0) { $parts = @('notepad') } - $rest = @(); if ($parts.Count -gt 1) { $rest = @($parts[1..($parts.Count - 1)]) }; $rest += $path - Write-Host "Opening $path with $($parts[0]) ..." - & $parts[0] @rest -} - -# `cc edit` / `cx edit` — jump straight to the profile registry (profiles.json), -# where base_url, model, probe_model, probe_ua, mode etc. live for every profile. -# Non-blocking: strips --wait so GUI editors (cursor/code) open and return. -function Edit-AiRegistry { - $path = $script:AiRegistryPath - if (-not (Test-Path -LiteralPath $path)) { - Write-Host "Registry not found: $path" - return - } - $editor = if ($env:EDITOR) { $env:EDITOR } elseif ($env:VISUAL) { $env:VISUAL } elseif ($IsWindows) { 'notepad' } else { 'vi' } - $parts = @($editor -split '\s+' | Where-Object { $_ -and $_ -notin @('--wait', '-w') }) - if ($parts.Count -eq 0) { $parts = @('notepad') } - $rest = @(); if ($parts.Count -gt 1) { $rest = @($parts[1..($parts.Count - 1)]) }; $rest += $path - Write-Host "Opening $path with $($parts[0]) ..." - & $parts[0] @rest -} - -function Show-AiMcpHelp { - @' -mcp - manage MCP servers across Claude Code & Codex from ~/.ai-env/mcp.toml - -Usage: - mcp Show this help - mcp list List servers + whether each target has them - mcp edit Open mcp.toml in EDITOR (creates a starter if absent) - mcp sync Push mcp.toml -> Claude (~/.claude.json) & Codex (~/.codex/config.toml) - mcp pull [NAME] Import existing MCP servers FROM Claude & Codex into mcp.toml - mcp get NAME Show one server's config + target status - -mcp.toml is the single source of truth; edit it, then `mcp sync` (idempotent). -enabled = false keeps a server defined but skips it on sync. -sync = ["claude"] or ["codex"] limits a server to one tool (omit = both). -'@ | Write-Host -} - -function mcp { - $remaining = @($args) - if ($remaining.Count -eq 0 -or ($remaining[0] -in @('help', '-h', '--help'))) { Show-AiMcpHelp; return } - switch (($remaining[0]).ToString().ToLowerInvariant()) { - 'list' { Show-AiMcpList; return } - 'edit' { Edit-AiMcpRegistry; return } - 'sync' { Sync-AiMcp; return } - { $_ -in @('pull', 'import') } { Import-AiMcpFromTargets -Name ($remaining[1]); return } - { $_ -in @('get', 'show') } { Show-AiMcpGet -Name ($remaining[1]); return } - default { Write-Host "Unknown mcp command '$($remaining[0])'."; Show-AiMcpHelp; return } - } -} - -function cx { - $remaining = @($args) - - if ($remaining.Count -gt 0) { - switch (($remaining[0] ?? "").ToString().ToLowerInvariant()) { - { $_ -in @("help", "-h", "--help", "/?") } { Show-CxHelp; return } - "list" { Show-CodexList; return } - "status" { Show-CodexStatus -Fresh:(Test-AiFreshFlag ($remaining | Select-Object -Skip 1)); return } - "doctor" { Show-CodexDoctor; return } - "edit" { Edit-AiRegistry; return } - "health" { Show-AiHealth -Tool "codex" -Fresh:(Test-AiFreshFlag ($remaining | Select-Object -Skip 1)); return } - "stats" { Show-CodexStats -Arguments @($remaining | Select-Object -Skip 1); return } - "add-api" { Add-CodexApiProfile -Arguments @($remaining | Select-Object -Skip 1); return } - "add-sub" { Add-CodexSubProfile -Arguments @($remaining | Select-Object -Skip 1); return } - "remove" { Remove-CodexProfile -Arguments @($remaining | Select-Object -Skip 1); return } - "probe-model" { Set-AiProfileProbeModel -Tool "codex" -Arguments @($remaining | Select-Object -Skip 1); return } - "default" { Set-AiDefaultProfile -Tool "codex" -Arguments @($remaining | Select-Object -Skip 1); return } - "app-default" { Set-CodexAppDefaultProfile -Arguments @($remaining | Select-Object -Skip 1); return } - "app-bridge" { Invoke-CodexAppBridgeCommand -Arguments @($remaining | Select-Object -Skip 1); return } - "sessions" { Show-CodexAllProviderSessions -Arguments @($remaining | Select-Object -Skip 1); return } - "resume" { Resume-CodexAllProviderSession -Arguments @($remaining | Select-Object -Skip 1); return } - "health-clear" { Clear-AiHealthCache; Write-Host "health cache cleared"; return } - "next" { $remaining = @((Get-AiNextProfileName -Tool "codex")) } - } - } - - if ($remaining.Count -gt 0) { - $profile = Get-AiProfileByName -Tool "codex" -Name ([string]$remaining[0]) - if (-not $profile) { - throw "Unknown cx profile '$($remaining[0])'. Add it to $script:AiRegistryPath or run 'cx help'." - } - $remaining = @($remaining | Select-Object -Skip 1) - } else { - $autoName = Get-AiHealthyProfileName -Tool "codex" - $profile = Get-AiProfileByName -Tool "codex" -Name $autoName - if ($profile) { - $ah = Get-AiProfileHealthCached -Tool "codex" -Profile $profile - Write-Host ("auto-select: $autoName " + (Format-AiHealthCell $ah)) -ForegroundColor DarkGray - } - } - - if ($remaining.Count -gt 0) { - throw "cx only switches state and does not forward arguments. Run 'codex $($remaining -join ' ')' separately after switching." - } - - Save-AiSelectedProfile -Tool "codex" -Name (Get-AiProfileName -Profile $profile) - $secretSource = Set-CodexProfileEnvironment -Profile $profile - Write-CodexSwitchStatus -Profile $profile -SecretSource $secretSource -} - -function cc { - $remaining = @($args) - - if ($remaining.Count -gt 0) { - switch (($remaining[0] ?? "").ToString().ToLowerInvariant()) { - { $_ -in @("help", "-h", "--help", "/?") } { Show-CcHelp; return } - "list" { Show-ClaudeList; return } - "status" { Show-ClaudeStatus -Fresh:(Test-AiFreshFlag ($remaining | Select-Object -Skip 1)); return } - "edit" { Edit-AiRegistry; return } - "health" { Show-AiHealth -Tool "claude" -Fresh:(Test-AiFreshFlag ($remaining | Select-Object -Skip 1)); return } - "add-api" { Add-ClaudeApiProfile -Arguments @($remaining | Select-Object -Skip 1); return } - "add-sub" { Add-ClaudeSubProfile -Arguments @($remaining | Select-Object -Skip 1); return } - "remove" { Remove-ClaudeProfile -Arguments @($remaining | Select-Object -Skip 1); return } - "probe-model" { Set-AiProfileProbeModel -Tool "claude" -Arguments @($remaining | Select-Object -Skip 1); return } - "default" { Set-AiDefaultProfile -Tool "claude" -Arguments @($remaining | Select-Object -Skip 1); return } - "health-clear" { Clear-AiHealthCache; Write-Host "health cache cleared"; return } - "next" { $remaining = @((Get-AiNextProfileName -Tool "claude")) } - } - } - - if ($remaining.Count -gt 0) { - $profile = Get-AiProfileByName -Tool "claude" -Name ([string]$remaining[0]) - if (-not $profile) { - throw "Unknown cc profile '$($remaining[0])'. Add it to $script:AiRegistryPath or run 'cc help'." - } - $remaining = @($remaining | Select-Object -Skip 1) - } else { - $autoName = Get-AiHealthyProfileName -Tool "claude" - $profile = Get-AiProfileByName -Tool "claude" -Name $autoName - if ($profile) { - $ah = Get-AiProfileHealthCached -Tool "claude" -Profile $profile - Write-Host ("auto-select: $autoName " + (Format-AiHealthCell $ah)) -ForegroundColor DarkGray - } - } - - if ($remaining.Count -gt 0) { - throw "cc only switches state and does not forward arguments. Run 'claude $($remaining -join ' ')' separately after switching." - } - - Save-AiSelectedProfile -Tool "claude" -Name (Get-AiProfileName -Profile $profile) - $secretSource = Set-ClaudeProfileEnvironment -Profile $profile - Write-ClaudeSwitchStatus -Profile $profile -SecretSource $secretSource -} - -function Test-CodexArgsHaveExplicitProfile { - param([string[]]$Arguments) - - foreach ($arg in $Arguments) { - if ($arg -eq "--profile" -or $arg -eq "-p" -or $arg -like "--profile=*") { - return $true - } - } - - return $false -} - -function Get-CodexFirstToken { - param([string[]]$Arguments) - - $optionsWithValue = @( - "-c", "--config", "-i", "--image", "-m", "--model", "-p", "--profile", - "-s", "--sandbox", "-C", "--cd", "--add-dir", "-a", "--ask-for-approval", - "--remote", "--remote-auth-token-env", "--local-provider" - ) - - for ($i = 0; $i -lt $Arguments.Count; $i++) { - $arg = $Arguments[$i] - if ($arg -eq "--") { - return $null - } - if ($optionsWithValue -contains $arg) { - $i++ - continue - } - if ($arg.StartsWith("-")) { - continue - } - return $arg - } - - return $null -} - -function Test-CodexShouldInjectProfile { - param([string[]]$Arguments) - - if (Test-CodexArgsHaveExplicitProfile -Arguments $Arguments) { - return $false - } - - $first = Get-CodexFirstToken -Arguments $Arguments - if (-not $first) { - return $true - } - - $knownNoProfile = @( - "login", "logout", "doctor", "app", "completion", "update", "features", "help", - "cloud", "app-server", "remote-control", "mcp-server", "exec-server", "mcp", - "plugin", "sandbox", "debug", "apply", "archive", "unarchive" - ) - if ($knownNoProfile -contains $first) { - return $false - } - - return $true -} - -function codex { - $arguments = @($args) - $codexCommand = Get-CodexExternalCommand - $saved = Get-AiSavedProfileName -Tool "codex" - $profile = Get-AiProfileByName -Tool "codex" -Name ($env:AI_CODEX_LABEL ?? $saved) - if (-not $profile) { - $profile = Get-AiProfileByName -Tool "codex" -Name (Get-AiDefaultProfileName -Tool "codex") - } - - Set-CodexProfileEnvironment -Profile $profile | Out-Null - - $profilePath = Get-CodexProfilePath -Profile $profile - if ((Test-Path -LiteralPath $profilePath) -and (Test-CodexShouldInjectProfile -Arguments $arguments)) { - $arguments = @("--profile", (Get-CodexRuntimeProfileName -Profile $profile)) + $arguments - } - - & $codexCommand @arguments -} - -function Initialize-AiEnvProfiles { - $codexSaved = Get-AiSavedProfileName -Tool "codex" - $codexProfile = Get-AiProfileByName -Tool "codex" -Name $codexSaved - if ($codexProfile) { - try { - Set-CodexProfileEnvironment -Profile $codexProfile | Out-Null - } catch { - Write-Warning "Could not initialize saved Codex profile '$codexSaved'. $($_.Exception.Message)" - } - } - - $claudeSaved = Get-AiSavedProfileName -Tool "claude" - $claudeProfile = Get-AiProfileByName -Tool "claude" -Name $claudeSaved - if ($claudeProfile) { - try { - Set-ClaudeProfileEnvironment -Profile $claudeProfile | Out-Null - } catch { - Write-Warning "Could not initialize saved Claude Code profile '$claudeSaved'. $($_.Exception.Message)" - } - } -} - -Initialize-AiEnvProfiles diff --git a/Documents/PowerShell/create_Microsoft.PowerShell_profile.ps1 b/Documents/PowerShell/create_Microsoft.PowerShell_profile.ps1 index 0f9d5e9..a9ebb0f 100644 --- a/Documents/PowerShell/create_Microsoft.PowerShell_profile.ps1 +++ b/Documents/PowerShell/create_Microsoft.PowerShell_profile.ps1 @@ -76,9 +76,9 @@ if (($isPaseoTerminal -or -not $env:CODEX_THREAD_ID) -and (Get-Command oh-my-pos } # chezmoi-ai-env begin -$aiEnvScriptHome = if ($env:AI_ENV_SCRIPT_HOME) { [Environment]::ExpandEnvironmentVariables($env:AI_ENV_SCRIPT_HOME) } else { $HOME } -$aiEnv = Join-Path $aiEnvScriptHome 'Documents\PowerShell\Scripts\ai-env.ps1' -if (Test-Path -LiteralPath $aiEnv) { - . $aiEnv +$cxccRoot = if ($env:CXCC_HOME) { [Environment]::ExpandEnvironmentVariables($env:CXCC_HOME) } else { Join-Path $HOME '.local\share\cxcc' } +$cxccLoader = Join-Path $cxccRoot 'load.ps1' +if (Test-Path -LiteralPath $cxccLoader) { + . $cxccLoader } # chezmoi-ai-env end diff --git a/README.ja.md b/README.ja.md index 4a3f84d..d99ad09 100644 --- a/README.ja.md +++ b/README.ja.md @@ -10,8 +10,9 @@ Linux、WSL、Termux、Windows PowerShell 向けの chezmoi 管理 dotfiles で - **基本環境**: shell、tmux、font、runtime installer、安全な cross-platform bootstrap。 - **モダン CLI ツール**: `rg`、`fd`、`jq`、`yq`、`delta`、`dust`、`duf`、 `xh`、`btop` などをユーザー領域へインストール。 -- **AI ワークスペースツール**: `cx`、`cc`、`mcp` による Codex / Claude Code - profile、API router、health check、MCP sync、secret-safe switching。 +- **AI ワークスペースツール**: 固定バージョンの + [cxcc](https://github.com/Tim-1e/cxcc) が `cx`、`cc`、`mcp` を提供し、 + Codex / Claude Code profile、API router、health check、MCP sync を管理します。 既存のローカル設定はできるだけ上書きせず、足りないデフォルトだけを作成します。 実際の secret はこのリポジトリに保存しません。 @@ -23,7 +24,8 @@ Linux、WSL、Termux、Windows PowerShell 向けの chezmoi 管理 dotfiles で | 基本 shell | zsh, Oh My Zsh plugins, tmux, fzf, zoxide, uv, rustup, locale guard | `dot_zshrc`, `dot_tmux.conf`, `scripts/install.sh` | | Modern CLI | prebuilt release binary を `~/.local/bin` へインストール、root 不要 | `scripts/install/modern-cli.sh` | | Fonts | Linux, macOS, Windows, WSL host 用 0xProto Nerd Font | `0xProto/`, font run-on-change scripts | -| Windows | PowerShell profile hook と `cx`/`cc` helper | `Documents/PowerShell/Scripts/ai-env.ps1` | +| cxcc | 固定バージョンの cross-platform installer と安定した shell loader | `.chezmoidata.toml`, `scripts/install/cxcc.*`, `run_before_10-install-cxcc.*.tmpl` | +| Windows | cxcc を読み込む PowerShell profile と互換 hook | `Documents/PowerShell/create_Microsoft.PowerShell_profile.ps1`, `run_onchange_after_10-powershell-ai-env-hook.ps1.tmpl` | | AI profiles | Codex/Claude registry, health cache, state, default seed config | `dot_ai-env/`, `dot_codex/`, `dot_claude/` | | MCP | Claude Code と Codex に同期するローカル MCP registry | `~/.ai-env/mcp.toml`, `mcp` helper | | Secrets | 安全なテンプレートのみ、実際の key は repo 外 | `secret_examples/`, `~/.ai-secrets/secrets.toml` | @@ -68,6 +70,7 @@ INSTALL_CLAUDE=1 bash ./bootstrap.sh INSTALL_NODE=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles INSTALL_FASTFETCH=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles INSTALL_MODERN_CLI=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles +INSTALL_CXCC=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles INSTALL_FONTS=0 bash ./bootstrap.sh INSTALL_WINDOWS_FONTS_FROM_WSL=0 bash ./bootstrap.sh DOTFILES_USE_SUDO=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles @@ -89,7 +92,7 @@ sudo にパスワードが必要な場合は確認し、デフォルトでは使 - 互換性のある fastfetch を `~/.local/bin` へ - システムパッケージが有効な場合は Node.js と npm - 現在ユーザーの font directory へ 0xProto Nerd Font -- Windows PowerShell 用 `cx` / `cc` profile hook +- 固定バージョンの cxcc と、`cx`、`cc`、`mcp` を読み込む PowerShell/Zsh hook zsh がなく build tools がある場合は、zsh を `~/.local` にビルドします。 古い Linux では fastfetch の polyfilled binary を優先し、適合するものがなければ @@ -118,8 +121,10 @@ flag が互換ではない `rg`、`fd`、`sd`、`jq`、`yq`、`xh`、`delta` は ## CX/CC AI Profile ツール -Codex と Claude Code のローカル状態を切り替える軽量 shell function を提供します。 -CLI 自体は起動しません。 +dotfiles は固定バージョンの cxcc をインストールし、その軽量 shell function で +Codex と Claude Code のローカル状態を切り替えます。CLI 自体は起動しません。 +コマンド実装と cross-platform test は cxcc が、version pin、install hook、loader +接続、default user config はこの repo が管理します。 ```sh cx list @@ -149,10 +154,16 @@ cc edit インストール先: ```text -Windows: ~/Documents/PowerShell/Scripts/ai-env.ps1 -Linux: ~/.local/share/ai-env/ai-env.sh +PowerShell: ~/.local/share/cxcc/load.ps1 +Bash/Zsh: ~/.local/share/cxcc/load.sh +Payload: ~/.local/share/cxcc/versions/v0.1.0/ ``` +release tag、immutable commit、installer digest、platform artifact digest は +`.chezmoidata.toml` にまとめてあります。対応する pin をすべて更新して +`chezmoi apply` を実行すると upgrade できます。`INSTALL_CXCC=0` で install を +skip でき、変数を外した次回 apply で通常動作に戻ります。 + 状態ファイル: ```text @@ -264,12 +275,13 @@ local MCP secret は commit しないでください。 | `dot_zshrc` | `~/.zshrc` | | `dot_tmux.conf` | `~/.tmux.conf` | | `dot_config/fastfetch/*` | `~/.config/fastfetch/*` | -| `dot_local/share/ai-env/ai-env.sh` | `~/.local/share/ai-env/ai-env.sh` | +| `.chezmoidata.toml`, `scripts/install/cxcc.*` | 固定 cxcc release を `~/.local/share/cxcc` に install | | `dot_ai-env/create_profiles.json` | `~/.ai-env/profiles.json` if missing | | `dot_codex/create_*.toml` | `~/.codex/*.toml` if missing | | `dot_claude/create_settings.json` | `~/.claude/settings.json` if missing | -| `Documents/PowerShell/Scripts/ai-env.ps1` | Windows PowerShell helper | +| `Documents/PowerShell/create_Microsoft.PowerShell_profile.ps1` | `~/.local/share/cxcc/load.ps1` を読む Windows profile | | `run_onchange_before_00-install-env.sh.tmpl` | installer hook | +| `run_before_10-install-cxcc.*.tmpl` | 固定 cxcc install hook | | `run_after_99-smoke-test.sh.tmpl` | post-apply smoke hook | `create_` files は不足している設定だけを seed し、既存の machine settings は @@ -278,20 +290,13 @@ local MCP secret は commit しないでください。 ## 検証 ```powershell -pwsh -NoProfile -File test/ai-env-smoke.ps1 -SourceDir . -pwsh -NoProfile -File test/ai-env-health.ps1 -SourceDir . +pwsh -NoProfile -File test/cxcc-consumer-smoke.ps1 +pwsh -NoProfile -File test/powershell-profile-smoke.ps1 -SourceDir . ``` ```sh -bash -n dot_local/share/ai-env/ai-env.sh -node --check dot_local/share/ai-env/ai-health.mjs -bash test/ai-env-smoke.sh -``` - -ローカル Claude Docker container での Linux 検証: - -```sh -docker compose exec -T claude bash -lc 'cd /workspace/CodeX_desk/dotfiles && chezmoi apply --force -- "$HOME/.local" && source "$HOME/.local/share/ai-env/ai-env.sh" && bash test/ai-env-smoke.sh' +bash -n scripts/install/cxcc.sh test/cxcc-consumer-smoke.sh +bash test/cxcc-consumer-smoke.sh ``` ## AI-assisted Maintenance diff --git a/README.ko.md b/README.ko.md index f8005a6..e6be70d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -10,8 +10,9 @@ Linux, WSL, Termux, Windows PowerShell 환경을 chezmoi로 관리하는 dotfile - **기본 환경**: shell, tmux, font, runtime 설치 스크립트, 안전한 크로스 플랫폼 bootstrap. - **현대적인 CLI 도구**: `rg`, `fd`, `jq`, `yq`, `delta`, `dust`, `duf`, `xh`, `btop` 등을 사용자 디렉터리에 설치. -- **AI 작업 도구**: `cx`, `cc`, `mcp`로 Codex / Claude Code 프로필, API - router, health check, MCP sync, secret-safe switching을 관리. +- **AI 작업 도구**: 고정 버전의 [cxcc](https://github.com/Tim-1e/cxcc)가 + `cx`, `cc`, `mcp`를 제공해 Codex / Claude Code 프로필, API router, + health check, MCP sync, secret-safe switching을 관리합니다. 기존 로컬 설정은 되도록 덮어쓰지 않고, 누락된 기본값만 생성합니다. 실제 secret은 이 저장소에 저장하지 않습니다. @@ -23,7 +24,8 @@ Linux, WSL, Termux, Windows PowerShell 환경을 chezmoi로 관리하는 dotfile | 기본 shell | zsh, Oh My Zsh plugins, tmux, fzf, zoxide, uv, rustup, locale guard | `dot_zshrc`, `dot_tmux.conf`, `scripts/install.sh` | | Modern CLI | prebuilt release binary를 `~/.local/bin`에 설치, root 불필요 | `scripts/install/modern-cli.sh` | | Fonts | Linux, macOS, Windows, WSL host용 0xProto Nerd Font | `0xProto/`, font run-on-change scripts | -| Windows | PowerShell profile hook 및 `cx`/`cc` helper | `Documents/PowerShell/Scripts/ai-env.ps1` | +| cxcc | 고정 버전 cross-platform installer와 안정적인 shell loader | `.chezmoidata.toml`, `scripts/install/cxcc.*`, `run_before_10-install-cxcc.*.tmpl` | +| Windows | cxcc를 로드하는 PowerShell profile과 호환 hook | `Documents/PowerShell/create_Microsoft.PowerShell_profile.ps1`, `run_onchange_after_10-powershell-ai-env-hook.ps1.tmpl` | | AI profiles | Codex/Claude registry, health cache, state, default seed config | `dot_ai-env/`, `dot_codex/`, `dot_claude/` | | MCP | Claude Code와 Codex에 동기화되는 로컬 MCP registry | `~/.ai-env/mcp.toml`, `mcp` helper | | Secrets | 안전한 예시만 제공, 실제 key는 repo 밖에 저장 | `secret_examples/`, `~/.ai-secrets/secrets.toml` | @@ -68,6 +70,7 @@ INSTALL_CLAUDE=1 bash ./bootstrap.sh INSTALL_NODE=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles INSTALL_FASTFETCH=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles INSTALL_MODERN_CLI=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles +INSTALL_CXCC=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles INSTALL_FONTS=0 bash ./bootstrap.sh INSTALL_WINDOWS_FONTS_FROM_WSL=0 bash ./bootstrap.sh DOTFILES_USE_SUDO=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles @@ -89,7 +92,7 @@ DOTFILES_USE_SUDO=1 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/ - 호환 가능한 fastfetch를 `~/.local/bin`에 설치 - 시스템 패키지 설치가 켜져 있으면 Node.js와 npm 설치 - 현재 사용자 font 디렉터리에 0xProto Nerd Font 설치 -- Windows PowerShell용 `cx` / `cc` profile hook +- 고정 버전 cxcc와 `cx`, `cc`, `mcp`를 로드하는 PowerShell/Zsh hook zsh가 없고 build tool이 있으면 zsh를 `~/.local`에 빌드합니다. 오래된 Linux에서는 fastfetch polyfilled binary를 우선 사용하고, 맞는 binary가 없으면 전체 apply를 @@ -118,8 +121,10 @@ interactive alias는 보수적으로만 켭니다. `du` -> `dust`, `df` -> `duf` ## CX/CC AI Profile 도구 -Codex와 Claude Code의 로컬 상태를 전환하는 가벼운 shell function을 제공합니다. -CLI를 직접 실행하지는 않습니다. +dotfiles는 고정 버전 cxcc를 설치하고 가벼운 shell function으로 Codex와 Claude Code의 +로컬 상태를 전환합니다. CLI를 직접 실행하지는 않습니다. 명령 구현과 cross-platform +test는 cxcc가, version pin, install hook, loader 연결, default user config는 이 repo가 +관리합니다. ```sh cx list @@ -149,10 +154,16 @@ cc edit 설치 위치: ```text -Windows: ~/Documents/PowerShell/Scripts/ai-env.ps1 -Linux: ~/.local/share/ai-env/ai-env.sh +PowerShell: ~/.local/share/cxcc/load.ps1 +Bash/Zsh: ~/.local/share/cxcc/load.sh +Payload: ~/.local/share/cxcc/versions/v0.1.0/ ``` +release tag, immutable commit, installer digest, platform artifact digest는 +`.chezmoidata.toml`에 함께 있습니다. 대응하는 pin을 모두 갱신하고 `chezmoi apply`를 +실행하면 upgrade할 수 있습니다. `INSTALL_CXCC=0`은 설치를 건너뛰며, 변수를 제거한 +다음 apply부터 정상 설치가 다시 시작됩니다. + 상태 파일: ```text @@ -265,12 +276,13 @@ state, 로컬 MCP secret은 commit하지 않습니다. | `dot_zshrc` | `~/.zshrc` | | `dot_tmux.conf` | `~/.tmux.conf` | | `dot_config/fastfetch/*` | `~/.config/fastfetch/*` | -| `dot_local/share/ai-env/ai-env.sh` | `~/.local/share/ai-env/ai-env.sh` | +| `.chezmoidata.toml`, `scripts/install/cxcc.*` | 고정 cxcc release를 `~/.local/share/cxcc`에 설치 | | `dot_ai-env/create_profiles.json` | `~/.ai-env/profiles.json` if missing | | `dot_codex/create_*.toml` | `~/.codex/*.toml` if missing | | `dot_claude/create_settings.json` | `~/.claude/settings.json` if missing | -| `Documents/PowerShell/Scripts/ai-env.ps1` | Windows PowerShell helper | +| `Documents/PowerShell/create_Microsoft.PowerShell_profile.ps1` | `~/.local/share/cxcc/load.ps1`을 읽는 Windows profile | | `run_onchange_before_00-install-env.sh.tmpl` | installer hook | +| `run_before_10-install-cxcc.*.tmpl` | 고정 cxcc install hook | | `run_after_99-smoke-test.sh.tmpl` | post-apply smoke hook | `create_` 파일은 누락된 설정만 seed하며 기존 머신 설정을 덮어쓰지 않습니다. @@ -278,20 +290,13 @@ state, 로컬 MCP secret은 commit하지 않습니다. ## 검증 ```powershell -pwsh -NoProfile -File test/ai-env-smoke.ps1 -SourceDir . -pwsh -NoProfile -File test/ai-env-health.ps1 -SourceDir . +pwsh -NoProfile -File test/cxcc-consumer-smoke.ps1 +pwsh -NoProfile -File test/powershell-profile-smoke.ps1 -SourceDir . ``` ```sh -bash -n dot_local/share/ai-env/ai-env.sh -node --check dot_local/share/ai-env/ai-health.mjs -bash test/ai-env-smoke.sh -``` - -로컬 Claude Docker 컨테이너에서 Linux 검증: - -```sh -docker compose exec -T claude bash -lc 'cd /workspace/CodeX_desk/dotfiles && chezmoi apply --force -- "$HOME/.local" && source "$HOME/.local/share/ai-env/ai-env.sh" && bash test/ai-env-smoke.sh' +bash -n scripts/install/cxcc.sh test/cxcc-consumer-smoke.sh +bash test/cxcc-consumer-smoke.sh ``` ## AI-assisted Maintenance diff --git a/README.md b/README.md index 284769c..1fb4855 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ PowerShell. The repo focuses on three layers: cross-platform bootstrap behavior. - **Modern CLI tools**: user-level installs of fast daily tools such as `rg`, `fd`, `jq`, `yq`, `delta`, `dust`, `duf`, `xh`, and `btop`. -- **AI workspace tools**: `cx`, `cc`, and `mcp` helpers for local Codex, - Claude Code, API-router profiles, health checks, MCP sync, and secret-safe - switching. +- **AI workspace tools**: a pinned [cxcc](https://github.com/Tim-1e/cxcc) + release provides `cx`, `cc`, and `mcp` for local Codex, Claude Code, + API-router profiles, health checks, MCP sync, and secret-safe switching. The dotfiles create missing defaults, but they avoid overwriting existing machine-local settings. Secrets are never stored in this repository. @@ -25,7 +25,8 @@ machine-local settings. Secrets are never stored in this repository. | Base shell | zsh, Oh My Zsh plugins, tmux, fzf, zoxide, uv, rustup, locale guards | `dot_zshrc`, `dot_tmux.conf`, `scripts/install.sh` | | Modern CLI | release-binary installs into `~/.local/bin`, best-effort and no root required | `scripts/install/modern-cli.sh` | | Fonts | 0xProto Nerd Font for Linux, macOS, Windows, and WSL host installs | `0xProto/`, font run-on-change scripts | -| Windows | PowerShell profile hook and `cx`/`cc` helper script | `Documents/PowerShell/Scripts/ai-env.ps1` | +| cxcc | Pinned cross-platform installer and stable shell loaders | `.chezmoidata.toml`, `scripts/install/cxcc.*`, `run_before_10-install-cxcc.*.tmpl` | +| Windows | PowerShell profile and compatibility hook that load cxcc | `Documents/PowerShell/create_Microsoft.PowerShell_profile.ps1`, `run_onchange_after_10-powershell-ai-env-hook.ps1.tmpl` | | AI profiles | Codex/Claude profile registry, health cache, local state, default seed files | `dot_ai-env/`, `dot_codex/`, `dot_claude/` | | MCP | Single local MCP registry, sync/pull helpers for Claude Code and Codex | `~/.ai-env/mcp.toml`, `mcp` helper | | Secrets | Example templates only; real keys stay outside git | `secret_examples/`, `~/.ai-secrets/secrets.toml` | @@ -70,6 +71,7 @@ INSTALL_CLAUDE=1 bash ./bootstrap.sh INSTALL_NODE=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles INSTALL_FASTFETCH=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles INSTALL_MODERN_CLI=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles +INSTALL_CXCC=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles INSTALL_FONTS=0 bash ./bootstrap.sh INSTALL_WINDOWS_FONTS_FROM_WSL=0 bash ./bootstrap.sh DOTFILES_USE_SUDO=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles @@ -92,7 +94,8 @@ The base layer installs or configures: - latest compatible fastfetch in `~/.local/bin` - Node.js and npm by default when system package installation is enabled - 0xProto Nerd Fonts in the current user's font directory -- Windows PowerShell profile hooks for `cx` and `cc` +- a pinned cxcc release plus PowerShell/Zsh loader hooks for `cx`, `cc`, and + `mcp` If `zsh` is unavailable but build tools are present, zsh is built into `~/.local`. On older Linux systems, fastfetch falls back to its polyfilled @@ -121,8 +124,11 @@ over standard commands. ## CX/CC AI Profile Tools -The repo ships lightweight shell functions for switching local Codex and Claude -Code state without launching either CLI: +The dotfiles install a pinned cxcc release, then load its lightweight shell +functions for switching local Codex and Claude Code state without launching +either CLI. cxcc owns the command implementation and its cross-platform tests; +this repo owns the version pin, installation hook, loader wiring, and default +user configuration. ```sh cx list @@ -152,10 +158,16 @@ cc edit Installed helper locations: ```text -Windows: ~/Documents/PowerShell/Scripts/ai-env.ps1 -Linux: ~/.local/share/ai-env/ai-env.sh +PowerShell: ~/.local/share/cxcc/load.ps1 +Bash/Zsh: ~/.local/share/cxcc/load.sh +Payload: ~/.local/share/cxcc/versions/v0.1.0/ ``` +The release tag, immutable commit, installer digest, and platform artifact +digests are stored together in `.chezmoidata.toml`. Update all matching pins +and run `chezmoi apply` to upgrade. Set `INSTALL_CXCC=0` to skip the install +hook; a later apply without the variable resumes normal installation. + Runtime state: ```text @@ -273,12 +285,13 @@ OAuth files, generated auth state, or local MCP secrets. | `dot_zshrc` | `~/.zshrc` | | `dot_tmux.conf` | `~/.tmux.conf` | | `dot_config/fastfetch/*` | `~/.config/fastfetch/*` | -| `dot_local/share/ai-env/ai-env.sh` | `~/.local/share/ai-env/ai-env.sh` | +| `.chezmoidata.toml`, `scripts/install/cxcc.*` | pinned cxcc release under `~/.local/share/cxcc` | | `dot_ai-env/create_profiles.json` | `~/.ai-env/profiles.json` if missing | | `dot_codex/create_*.toml` | `~/.codex/*.toml` if missing | | `dot_claude/create_settings.json` | `~/.claude/settings.json` if missing | -| `Documents/PowerShell/Scripts/ai-env.ps1` | Windows PowerShell helper | +| `Documents/PowerShell/create_Microsoft.PowerShell_profile.ps1` | Windows profile that loads `~/.local/share/cxcc/load.ps1` | | `run_onchange_before_00-install-env.sh.tmpl` | installer hook | +| `run_before_10-install-cxcc.*.tmpl` | pinned cxcc install hooks | | `run_after_99-smoke-test.sh.tmpl` | post-apply smoke hook | Files prefixed with `create_` seed missing local config only. Existing machine @@ -287,20 +300,13 @@ settings are left in place. ## Validation ```powershell -pwsh -NoProfile -File test/ai-env-smoke.ps1 -SourceDir . -pwsh -NoProfile -File test/ai-env-health.ps1 -SourceDir . +pwsh -NoProfile -File test/cxcc-consumer-smoke.ps1 +pwsh -NoProfile -File test/powershell-profile-smoke.ps1 -SourceDir . ``` ```sh -bash -n dot_local/share/ai-env/ai-env.sh -node --check dot_local/share/ai-env/ai-health.mjs -bash test/ai-env-smoke.sh -``` - -Docker/Linux validation in the local Claude container: - -```sh -docker compose exec -T claude bash -lc 'cd /workspace/CodeX_desk/dotfiles && chezmoi apply --force -- "$HOME/.local" && source "$HOME/.local/share/ai-env/ai-env.sh" && bash test/ai-env-smoke.sh' +bash -n scripts/install/cxcc.sh test/cxcc-consumer-smoke.sh +bash test/cxcc-consumer-smoke.sh ``` ## AI-Assisted Maintenance diff --git a/README.zh-CN.md b/README.zh-CN.md index aedac42..3a96bc3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,8 +10,9 @@ Windows PowerShell。它主要同步三层内容: - **基础环境**:shell、tmux、字体、运行时安装脚本,以及跨平台 bootstrap。 - **现代命令行工具**:把 `rg`、`fd`、`jq`、`yq`、`delta`、`dust`、`duf`、 `xh`、`btop` 等日常工具安装到用户目录。 -- **AI 工作区工具**:`cx`、`cc`、`mcp`,用于切换 Codex / Claude Code - 配置、API router、健康检查、MCP 同步和密钥隔离。 +- **AI 工作区工具**:固定版本的 [cxcc](https://github.com/Tim-1e/cxcc) + 提供 `cx`、`cc`、`mcp`,用于切换 Codex / Claude Code 配置、API router、 + 健康检查、MCP 同步和密钥隔离。 仓库只创建缺失的默认配置,尽量不覆盖机器上的已有设置。真实密钥不进 git。 @@ -22,7 +23,8 @@ Windows PowerShell。它主要同步三层内容: | 基础 shell | zsh、Oh My Zsh 插件、tmux、fzf、zoxide、uv、rustup、locale 保护 | `dot_zshrc`, `dot_tmux.conf`, `scripts/install.sh` | | 现代 CLI | 预编译 release binary 安装到 `~/.local/bin`,无需 root | `scripts/install/modern-cli.sh` | | 字体 | 0xProto Nerd Font,覆盖 Linux、macOS、Windows、WSL host | `0xProto/`, font run-on-change 脚本 | -| Windows | PowerShell profile hook 和 `cx`/`cc` helper | `Documents/PowerShell/Scripts/ai-env.ps1` | +| cxcc | 固定版本的跨平台安装器和稳定 shell loader | `.chezmoidata.toml`, `scripts/install/cxcc.*`, `run_before_10-install-cxcc.*.tmpl` | +| Windows | 加载 cxcc 的 PowerShell profile 与兼容 hook | `Documents/PowerShell/create_Microsoft.PowerShell_profile.ps1`, `run_onchange_after_10-powershell-ai-env-hook.ps1.tmpl` | | AI profiles | Codex/Claude profile registry、health cache、本地状态、默认种子配置 | `dot_ai-env/`, `dot_codex/`, `dot_claude/` | | MCP | 本机 MCP registry,并同步到 Claude Code / Codex | `~/.ai-env/mcp.toml`, `mcp` helper | | Secrets | 只提供安全模板,真实 key 放在仓库外 | `secret_examples/`, `~/.ai-secrets/secrets.toml` | @@ -66,6 +68,7 @@ INSTALL_CLAUDE=1 bash ./bootstrap.sh INSTALL_NODE=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles INSTALL_FASTFETCH=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles INSTALL_MODERN_CLI=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles +INSTALL_CXCC=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles INSTALL_FONTS=0 bash ./bootstrap.sh INSTALL_WINDOWS_FONTS_FROM_WSL=0 bash ./bootstrap.sh DOTFILES_USE_SUDO=0 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/dotfiles @@ -86,7 +89,7 @@ DOTFILES_USE_SUDO=1 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply Tim-1e/ - 兼容的 fastfetch 到 `~/.local/bin` - Node.js 和 npm,默认随系统包安装 - 0xProto Nerd Font 到当前用户字体目录 -- Windows PowerShell 的 `cx` / `cc` profile hook +- 固定版本的 cxcc,以及加载 `cx`、`cc`、`mcp` 的 PowerShell/Zsh hook 如果系统没有 zsh,但有编译工具,脚本会把 zsh 编译到 `~/.local`。老 Linux 系统上,fastfetch 会优先尝试 polyfilled binary;没有合适版本时跳过,不让整个 @@ -113,8 +116,9 @@ apply 失败。 ## CX/CC AI Profile 工具 -仓库提供轻量 shell function,用于切换 Codex 和 Claude Code 的本地状态, -但不会直接启动 CLI: +dotfiles 安装固定版本的 cxcc,再加载其轻量 shell function,用于切换 Codex 和 +Claude Code 的本地状态,但不会直接启动 CLI。命令实现和跨平台测试归 cxcc 仓库 +维护;本仓库只负责版本钉住、安装 hook、loader 接线和默认用户配置: ```sh cx list @@ -144,10 +148,15 @@ cc edit 安装位置: ```text -Windows: ~/Documents/PowerShell/Scripts/ai-env.ps1 -Linux: ~/.local/share/ai-env/ai-env.sh +PowerShell: ~/.local/share/cxcc/load.ps1 +Bash/Zsh: ~/.local/share/cxcc/load.sh +Payload: ~/.local/share/cxcc/versions/v0.1.0/ ``` +release tag、不可变 commit、installer digest 和平台 artifact digest 统一位于 +`.chezmoidata.toml`;同步更新这些 pin 后运行 `chezmoi apply` 即可升级。设置 +`INSTALL_CXCC=0` 会跳过安装,之后取消变量再 apply 即可恢复。 + 运行状态: ```text @@ -254,12 +263,13 @@ ANTHROPIC_AUTH_TOKEN = "sk-..." | `dot_zshrc` | `~/.zshrc` | | `dot_tmux.conf` | `~/.tmux.conf` | | `dot_config/fastfetch/*` | `~/.config/fastfetch/*` | -| `dot_local/share/ai-env/ai-env.sh` | `~/.local/share/ai-env/ai-env.sh` | +| `.chezmoidata.toml`, `scripts/install/cxcc.*` | 安装固定版本 cxcc 到 `~/.local/share/cxcc` | | `dot_ai-env/create_profiles.json` | `~/.ai-env/profiles.json`,仅缺失时创建 | | `dot_codex/create_*.toml` | `~/.codex/*.toml`,仅缺失时创建 | | `dot_claude/create_settings.json` | `~/.claude/settings.json`,仅缺失时创建 | -| `Documents/PowerShell/Scripts/ai-env.ps1` | Windows PowerShell helper | +| `Documents/PowerShell/create_Microsoft.PowerShell_profile.ps1` | 加载 `~/.local/share/cxcc/load.ps1` 的 Windows profile | | `run_onchange_before_00-install-env.sh.tmpl` | 安装 hook | +| `run_before_10-install-cxcc.*.tmpl` | 固定版本 cxcc 安装 hook | | `run_after_99-smoke-test.sh.tmpl` | apply 后 smoke hook | `create_` 前缀文件只 seed 缺失配置,不覆盖已有机器设置。 @@ -267,20 +277,13 @@ ANTHROPIC_AUTH_TOKEN = "sk-..." ## 验证 ```powershell -pwsh -NoProfile -File test/ai-env-smoke.ps1 -SourceDir . -pwsh -NoProfile -File test/ai-env-health.ps1 -SourceDir . +pwsh -NoProfile -File test/cxcc-consumer-smoke.ps1 +pwsh -NoProfile -File test/powershell-profile-smoke.ps1 -SourceDir . ``` ```sh -bash -n dot_local/share/ai-env/ai-env.sh -node --check dot_local/share/ai-env/ai-health.mjs -bash test/ai-env-smoke.sh -``` - -本地 Claude Docker 容器里的 Linux 验证: - -```sh -docker compose exec -T claude bash -lc 'cd /workspace/CodeX_desk/dotfiles && chezmoi apply --force -- "$HOME/.local" && source "$HOME/.local/share/ai-env/ai-env.sh" && bash test/ai-env-smoke.sh' +bash -n scripts/install/cxcc.sh test/cxcc-consumer-smoke.sh +bash test/cxcc-consumer-smoke.sh ``` ## AI 协作维护 diff --git a/docs/superpowers/plans/2026-07-14-codex-app-provider-switch.md b/docs/superpowers/plans/2026-07-14-codex-app-provider-switch.md deleted file mode 100644 index 3105d11..0000000 --- a/docs/superpowers/plans/2026-07-14-codex-app-provider-switch.md +++ /dev/null @@ -1,18 +0,0 @@ -# Codex App provider switch implementation plan - -## Files - -- Modify `test/ai-env-smoke.ps1` with isolated App-switch tests. -- Modify `Documents/PowerShell/Scripts/ai-env.ps1` with the `cx app-default` management path and targeted TOML update helpers. -- Add `dot_codex/private_app-auth/private_codex-app-token.ps1` so chezmoi deploys the command-auth helper directly into protected `~/.codex/app-auth`. -- Update help and the default registry template. - -## Steps - -1. Add a failing smoke test for API activation, subscription restoration, idempotency, unrelated-config preservation, registry state, `auth.json` preservation, and secret non-disclosure. -2. Add the fixed-output token helper and cover success/failure behavior. -3. Add the App default getter/setter and profile-to-provider projection. -4. Run PowerShell smoke tests and syntax/config checks. -5. Review the diff for security and correctness. -6. Target-apply only the managed `ai-env.ps1` and `~/.codex/app-auth/codex-app-token.ps1` files, repair and verify the control/secret ACLs with a rollback record, then switch the live App config to `surplus`. -7. Verify the effective config and a direct Responses-compatible API probe without exposing the key. diff --git a/docs/superpowers/plans/2026-07-16-codex-all-provider-sessions.md b/docs/superpowers/plans/2026-07-16-codex-all-provider-sessions.md deleted file mode 100644 index 2e55402..0000000 --- a/docs/superpowers/plans/2026-07-16-codex-all-provider-sessions.md +++ /dev/null @@ -1,21 +0,0 @@ -# Codex all-provider sessions implementation plan - -## Files - -- Add `tools/codex-provider-bridge/CodexProviderBridge.csproj`. -- Add `tools/codex-provider-bridge/Program.cs`. -- Add `test/codex-provider-bridge.ps1`. -- Modify `Documents/PowerShell/Scripts/ai-env.ps1`. -- Modify `test/ai-env-smoke.ps1`. -- Update `README.md` only if the existing command reference documents cx subcommands. - -## Steps - -1. Add a failing bridge contract test for `thread/list` transformation, byte-preserving pass-through, argument/stderr forwarding, missing settings, and recursion rejection. -2. Implement the minimal .NET stdio bridge and make the contract test pass. -3. Add failing isolated smoke tests for `cx sessions`, selector-independent `cx resume SESSION_ID`, and App bridge install/status/remove state. -4. Implement the all-provider app-server query, session formatting/selection, and current-profile resume command. -5. Implement App bridge discovery and reversible per-user environment activation without stopping Codex Desktop. -6. Run bridge, ai-env, Windows, syntax, and secret-disclosure tests. -7. Review the diff for code quality and security, then deploy only the tested bridge and PowerShell script to the live user paths. -8. Restart Codex Desktop manually and verify that unpinned sessions from at least two providers are visible while session metadata, auth, and Remote Control enrollment remain unchanged. diff --git a/docs/superpowers/specs/2026-07-14-codex-app-provider-switch-design.md b/docs/superpowers/specs/2026-07-14-codex-app-provider-switch-design.md deleted file mode 100644 index eaa56f7..0000000 --- a/docs/superpowers/specs/2026-07-14-codex-app-provider-switch-design.md +++ /dev/null @@ -1,36 +0,0 @@ -# Codex App provider switch design - -## Goal - -Let the existing `cx` registry select a Codex App model provider without changing `CODEX_HOME`, copying API keys, or replacing ChatGPT login state. - -## Interface - -- `cx app-default` shows the App profile selection. -- `cx app-default NAME` validates an existing Codex profile, writes `defaults.codex_app`, and projects the profile into the shared App `config.toml`. -- Subscription profiles select the built-in `openai` provider. -- API profiles select a distinct managed provider ID, `ai-env-app`. - -## Configuration mapping - -Codex App has no native equivalent of the CLI `--profile` selector. The switch therefore updates only App-consumed settings in the base `config.toml`: - -- top-level `model_provider`; -- optional model and reasoning settings already present in the selected profile file; -- `[model_providers.ai-env-app]` and `[model_providers.ai-env-app.auth]`. - -The managed provider copies the selected profile's display name, base URL, and Responses wire API. It deliberately omits `env_key` and `requires_openai_auth`. Command-backed auth uses a fixed helper deployed directly to protected `~/.codex/app-auth` and reads the selected `secret_id` and key from the existing `~/.ai-secrets/secrets.toml` at request time. - -## State and safety - -- `defaults.codex` remains the CLI default; `defaults.codex_app` is independent. -- `auth.json`, sessions, projects, plugins, SQLite state, and Remote identifiers are not modified. -- Unrelated TOML sections are preserved, and repeated switching is idempotent. -- The API key is never written to `config.toml`, the registry, command arguments, or output. -- Live activation requires protected registry, secret-store, profile-config, App-config, and helper paths. The current broad sandbox ACLs on `~/.ai-env` and `~/.ai-secrets` must be repaired and verified before enabling command-backed auth. - -## Reload behavior - -The change is durable immediately. A running App may retain its current effective configuration, so closing and reopening Codex App is the supported reload boundary. - -Because the switch changes the shared base `config.toml`, any other unprofiled app-server consumer using the same `CODEX_HOME` (for example an IDE integration) will also see the selected provider after it reloads. diff --git a/docs/superpowers/specs/2026-07-16-codex-all-provider-sessions-design.md b/docs/superpowers/specs/2026-07-16-codex-all-provider-sessions-design.md deleted file mode 100644 index 1ea050e..0000000 --- a/docs/superpowers/specs/2026-07-16-codex-all-provider-sessions-design.md +++ /dev/null @@ -1,56 +0,0 @@ -# Codex all-provider session routing design - -## Goal - -Let both the `cx` CLI workflow and Codex Desktop enumerate sessions created by any model provider, then resume the selected session with the provider selected by the current CLI or App profile. - -The design keeps two decisions independent: - -1. session discovery uses no provider filter; -2. session resume uses the current profile's explicit provider override. - -## Verified behavior - -- App-server `thread/list` treats `modelProviders: []` as all providers. -- Local Codex CLI 0.144.x still sends the current provider from its resume picker even with `--all`; that flag only disables cwd filtering. -- A CLI `--profile` layer containing `model_provider` makes resume use the current config rather than persisted model settings. -- Codex Desktop's normal UI resume path already sends the provider returned by its current new-thread configuration. Only its recent-thread list sends `modelProviders: null`. - -## CLI interface - -- `cx sessions` prints sessions from every provider without launching Codex. -- `cx resume` opens an all-provider selector and resumes the selected ID with the currently selected cx profile. -- `cx resume SESSION_ID` skips the selector and resumes that ID with the current profile. - -The selector queries a short-lived local app-server with `thread/list.modelProviders = []`. It does not query or rewrite SQLite directly. On Windows it prefers `Out-GridView`; non-GUI and non-Windows sessions use a numbered console selector. - -## Desktop bridge - -Codex Desktop supports a process-level `CODEX_CLI_PATH` override. A small user-owned executable is installed as that path and launches a secured copy of the App's exact bundled Codex executable set. - -The bridge is a line-oriented JSON-RPC proxy: - -- requests whose method is `thread/list` receive `params.modelProviders = []`; -- every other request, including `thread/resume`, is forwarded unchanged; -- stdout and stderr are streamed unchanged from the real Codex process; -- malformed input is forwarded unchanged; -- recursive or missing downstream executable paths fail closed; -- `codex.exe`, command runner, code-mode host, and sandbox setup are copied together from the protected WindowsApps bundle and individually hash-pinned. - -This avoids modifying the signed MSIX, `app.asar`, Appx block hashes, ACLs, databases, rollouts, authentication, and Remote Control enrollment. - -## Activation and updates - -- `cx app-bridge install` builds or locates the bridge, records the current same-hash user copy of the App's Codex executable, and sets the per-user `CODEX_CLI_PATH`. -- `cx app-bridge status` reports configured, active-process, bridge, and protected bundle hash-match state. -- `cx app-bridge remove` restores the previous per-user `CODEX_CLI_PATH` value. -- A running App must be closed and reopened. The installer never terminates the App. -- On App updates, `cx app-bridge install` is rerun to refresh the downstream path. Unknown or mismatched layouts are rejected. - -## Security and state boundaries - -- The bridge settings contain paths and hashes only, never API keys or login tokens. -- Source and destination directories/files must have an approved owner, no untrusted write ACE, and no reparse points before user-level activation is changed. -- Provider credentials continue to come from the existing subscription login or command-backed App auth. -- Existing session metadata remains historical. A resumed turn records the effective current thread settings without rewriting the session's original creation metadata. -- Remote Control login and enrollment are not changed. Mobile Remote history requests handled inside the downstream app-server do not pass back through the Desktop stdio request transformer; the upstream custom-provider refresh bug therefore remains a separate boundary that requires an upstream fix or a version-matched custom app-server. diff --git a/dot_local/share/ai-env/ai-env.sh b/dot_local/share/ai-env/ai-env.sh deleted file mode 100644 index 4c9af43..0000000 --- a/dot_local/share/ai-env/ai-env.sh +++ /dev/null @@ -1,2056 +0,0 @@ -# AI environment profile functions. Source this file from an interactive shell. - -AI_HOME="${AI_ENV_HOME:-$HOME}" -AI_CONFIG_DIR="${AI_HOME}/.ai-env" -AI_REGISTRY_PATH="${AI_CONFIG_DIR}/profiles.json" -AI_STATE_PATH="${AI_CONFIG_DIR}/state.json" -AI_SECRETS_PATH="${AI_HOME}/.ai-secrets/secrets.toml" -LEGACY_AI_STATE_DIR="${AI_HOME}/.ai-state" -CLAUDE_ROUTER_BASE_URL="https://anyrouter.top" - -_ai_expand_path() { - local input_path="${1:-}" - case "$input_path" in - "") return 0 ;; - "~") printf '%s\n' "$AI_HOME" ;; - "~/"*) printf '%s/%s\n' "$AI_HOME" "${input_path#\~/}" ;; - *) printf '%s\n' "$input_path" ;; - esac -} - -_ai_require_node() { - command -v node >/dev/null 2>&1 || { - echo "ai-env needs node to read $AI_REGISTRY_PATH" >&2 - return 1 - } -} - -_ai_profile_json() { - local tool="$1" name="$2" - _ai_require_node || return 1 - node -e ' -const fs = require("fs"); -const path = process.argv[1]; -const tool = process.argv[2]; -const query = String(process.argv[3] || "").toLowerCase(); -const fallback = { - defaults: { codex: "sub", claude: "sub" }, - codex: [ - { name: "sub", aliases: ["subscription", "chatgpt"], mode: "sub", home: "~/.codex", codex_profile: "sub" }, - { name: "api", aliases: ["router"], mode: "api", home: "~/.codex", codex_profile: "api", secret_id: "codex.api", linux_secret: "~/.ai-secrets/codex-api.env" } - ], - claude: [ - { name: "sub", aliases: ["subscription", "claude-sub"], mode: "sub" }, - { name: "api", aliases: ["router", "claude-api"], mode: "api", base_url: "https://anyrouter.top", secret_id: "claude.api", linux_secret: "~/.ai-secrets/claude-api.env" } - ] -}; -let registry = fallback; -if (fs.existsSync(path)) registry = JSON.parse(fs.readFileSync(path, "utf8")); -for (const p of registry[tool] || []) { - if (p.enabled === false) continue; - const names = [p.name, ...(p.aliases || [])].filter(Boolean).map((x) => String(x).toLowerCase()); - if (names.includes(query)) { - process.stdout.write(JSON.stringify(p)); - process.exit(0); - } -} -process.exit(2); -' "$AI_REGISTRY_PATH" "$tool" "$name" -} - -_ai_profile_value() { - local profile_json="$1" key="$2" default_value="${3:-}" - _ai_require_node || return 1 - node -e ' -const p = JSON.parse(process.argv[1]); -const key = process.argv[2]; -const fallback = process.argv[3] || ""; -const value = Object.prototype.hasOwnProperty.call(p, key) ? p[key] : fallback; -if (Array.isArray(value)) process.stdout.write(value.join(",")); -else if (value === undefined || value === null) process.stdout.write(fallback); -else process.stdout.write(String(value)); -' "$profile_json" "$key" "$default_value" -} - -_ai_default_profile() { - local tool="$1" - _ai_require_node || return 1 - node -e ' -const fs = require("fs"); -const path = process.argv[1]; -const tool = process.argv[2]; -let registry = { defaults: { codex: "sub", claude: "sub" } }; -if (fs.existsSync(path)) registry = JSON.parse(fs.readFileSync(path, "utf8")); -process.stdout.write(String((registry.defaults && registry.defaults[tool]) || "sub")); -' "$AI_REGISTRY_PATH" "$tool" -} - -_ai_legacy_saved_profile() { - local tool="$1" file - if [ "$tool" = "codex" ]; then - file="${LEGACY_AI_STATE_DIR}/cx.profile" - else - file="${LEGACY_AI_STATE_DIR}/cc.profile" - fi - [ -f "$file" ] && head -n 1 "$file" -} - -_ai_saved_profile() { - local tool="$1" default_profile value - default_profile="$(_ai_default_profile "$tool" 2>/dev/null || printf 'sub')" - if _ai_require_node >/dev/null 2>&1 && [ -f "$AI_STATE_PATH" ]; then - value="$(node -e ' -const fs = require("fs"); -const path = process.argv[1]; -const tool = process.argv[2]; -try { - const state = JSON.parse(fs.readFileSync(path, "utf8")); - process.stdout.write(String(state[tool] || "")); -} catch {} -' "$AI_STATE_PATH" "$tool")" - if [ -n "$value" ]; then - printf '%s\n' "$value" - return - fi - fi - value="$(_ai_legacy_saved_profile "$tool" || true)" - if [ -n "$value" ]; then - printf '%s\n' "$value" - else - printf '%s\n' "$default_profile" - fi -} - -_ai_save_profile() { - local tool="$1" name="$2" - mkdir -p "$AI_CONFIG_DIR" - _ai_require_node || return 1 - node -e ' -const fs = require("fs"); -const path = process.argv[1]; -const tool = process.argv[2]; -const name = process.argv[3]; -let state = {}; -try { - if (fs.existsSync(path)) state = JSON.parse(fs.readFileSync(path, "utf8")); -} catch {} -state[tool] = name; -state.updated_at = new Date().toISOString(); -fs.writeFileSync(path, JSON.stringify(state, null, 2) + "\n"); -' "$AI_STATE_PATH" "$tool" "$name" -} - -_ai_name_slug() { - local slug - slug="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]+/-/g; s/^[-_]+//; s/[-_]+$//')" - if [ -z "$slug" ]; then - echo "profile name '$1' does not contain any usable letters or numbers" >&2 - return 1 - fi - printf '%s\n' "$slug" -} - -_ai_validate_name() { - case "$1" in - ""|[^A-Za-z0-9]*|*[!A-Za-z0-9:_-]*) - echo "profile name '$1' is not supported. Use letters, numbers, ':', '_' or '-'." >&2 - return 1 - ;; - esac -} - -_ai_registry_add_profile() { - local tool="$1" profile_json="$2" - mkdir -p "$AI_CONFIG_DIR" - _ai_require_node || return 1 - node -e ' -const fs = require("fs"); -const path = process.argv[1]; -const tool = process.argv[2]; -const profile = JSON.parse(process.argv[3]); -const fallback = { schema: 1, defaults: { codex: "sub", claude: "sub" }, codex: [], claude: [] }; -let registry = fallback; -try { - if (fs.existsSync(path)) registry = JSON.parse(fs.readFileSync(path, "utf8")); -} catch {} -registry.schema = registry.schema || 1; -registry.defaults = registry.defaults || { codex: "sub", claude: "sub" }; -registry.codex = Array.isArray(registry.codex) ? registry.codex : []; -registry.claude = Array.isArray(registry.claude) ? registry.claude : []; -const query = String(profile.name || "").toLowerCase(); -for (const p of registry[tool] || []) { - const names = [p.name, ...(p.aliases || [])].filter(Boolean).map((x) => String(x).toLowerCase()); - if (names.includes(query)) { - console.error(`${tool} profile ${JSON.stringify(profile.name)} already exists. Remove it first, or choose another name.`); - process.exit(4); - } -} -registry[tool].push(profile); -fs.writeFileSync(path, JSON.stringify(registry, null, 2) + "\n"); -' "$AI_REGISTRY_PATH" "$tool" "$profile_json" -} - -_ai_registry_remove_profile() { - local tool="$1" name="$2" - _ai_require_node || return 1 - node -e ' -const fs = require("fs"); -const path = process.argv[1]; -const tool = process.argv[2]; -const query = String(process.argv[3] || "").toLowerCase(); -if (!fs.existsSync(path)) { - console.error(`${tool} profile ${JSON.stringify(process.argv[3])} does not exist.`); - process.exit(4); -} -const registry = JSON.parse(fs.readFileSync(path, "utf8")); -const profiles = Array.isArray(registry[tool]) ? registry[tool] : []; -let removed = ""; -registry[tool] = profiles.filter((p) => { - const names = [p.name, ...(p.aliases || [])].filter(Boolean).map((x) => String(x).toLowerCase()); - if (names.includes(query)) { - removed = String(p.name || process.argv[3]); - return false; - } - return true; -}); -if (!removed) { - console.error(`${tool} profile ${JSON.stringify(process.argv[3])} does not exist.`); - process.exit(4); -} -fs.writeFileSync(path, JSON.stringify(registry, null, 2) + "\n"); -process.stdout.write(removed); -' "$AI_REGISTRY_PATH" "$tool" "$name" -} - -_ai_parse_management_args() { - _ai_require_node || return 1 - node -e ' -const out = { positionals: [], options: {} }; -const args = process.argv.slice(1); -for (let i = 0; i < args.length; i++) { - const arg = String(args[i]); - if (arg === "--env" && i + 1 < args.length) { - (out.env = out.env || []).push(String(args[++i])); - continue; - } - if (arg.startsWith("--")) { - const key = arg.slice(2); - if (!key) continue; - if (i + 1 < args.length && !String(args[i + 1]).startsWith("--")) { - out.options[key] = String(args[++i]); - } else { - out.options[key] = "true"; - } - } else { - out.positionals.push(arg); - } -} -process.stdout.write(JSON.stringify(out)); -' -- "$@" -} - -_ai_mgmt_value() { - local parsed="$1" key="$2" default_value="${3:-}" - node -e ' -const parsed = JSON.parse(process.argv[1]); -const key = process.argv[2]; -const fallback = process.argv[3] || ""; -process.stdout.write(parsed.options && parsed.options[key] ? String(parsed.options[key]) : fallback); -' "$parsed" "$key" "$default_value" -} - -_ai_mgmt_positional() { - local parsed="$1" index="$2" - node -e ' -const parsed = JSON.parse(process.argv[1]); -const index = Number(process.argv[2]); -process.stdout.write(parsed.positionals && parsed.positionals[index] ? String(parsed.positionals[index]) : ""); -' "$parsed" "$index" -} - -_ai_json_profile() { - _ai_require_node || return 1 - node -e ' -const profile = {}; -for (let i = 1; i < process.argv.length; i += 2) { - const key = process.argv[i]; - const raw = process.argv[i + 1] || ""; - if (key === "aliases") profile[key] = raw ? raw.split(",").filter(Boolean) : []; - else if (key === "env") { if (raw) profile[key] = JSON.parse(raw); } - else profile[key] = raw; -} -process.stdout.write(JSON.stringify(profile)); -' "$@" -} - -_ai_next_profile() { - local tool="$1" saved - saved="$(_ai_saved_profile "$tool")" - _ai_require_node || return 1 - node -e ' -const fs = require("fs"); -const path = process.argv[1]; -const tool = process.argv[2]; -const saved = String(process.argv[3] || "").toLowerCase(); -let registry = { defaults: { codex: "sub", claude: "sub" }, codex: [], claude: [] }; -if (fs.existsSync(path)) registry = JSON.parse(fs.readFileSync(path, "utf8")); -const profiles = (registry[tool] || []).filter((p) => p.enabled !== false); -if (!profiles.length) { - process.stdout.write("sub"); - process.exit(0); -} -const index = profiles.findIndex((p) => String(p.name || "").toLowerCase() === saved); -if (index >= 0) process.stdout.write(String(profiles[(index + 1) % profiles.length].name)); -else process.stdout.write(String((registry.defaults && registry.defaults[tool]) || profiles[0].name || "sub")); -' "$AI_REGISTRY_PATH" "$tool" "$saved" -} - -_ai_secret_path() { - local profile_json="$1" secret_path - secret_path="$(_ai_profile_value "$profile_json" linux_secret "")" - [ -n "$secret_path" ] || secret_path="$(_ai_profile_value "$profile_json" secret "")" - _ai_expand_path "$secret_path" -} - -_ai_secret_id() { - local tool="$1" profile_json="$2" secret_id name - secret_id="$(_ai_profile_value "$profile_json" secret_id "")" - if [ -n "$secret_id" ]; then - printf '%s\n' "$secret_id" - else - name="$(_ai_profile_value "$profile_json" name "")" - printf '%s.%s\n' "$tool" "$name" - fi -} - -_ai_toml_secret_exports() { - local secret_id="$1" - shift - _ai_require_node || return 1 - [ -f "$AI_SECRETS_PATH" ] || return 1 - node -e ' -const fs = require("fs"); -const path = process.argv[1]; -const target = process.argv[2]; -const allowed = new Set(process.argv.slice(3)); -const quote = (value) => { - const text = String(value); - return "'"'"'" + text.replace(/'"'"'/g, "'"'"'\\'"'"''"'"'") + "'"'"'"; -}; -const parseValue = (raw) => { - const value = String(raw || "").trim(); - if (value.startsWith("\"")) { - const match = value.match(/^"((?:\\.|[^"])*)"/); - if (match) { - try { return JSON.parse(match[0]); } catch { return match[1]; } - } - } - if (value.startsWith("'"'"'")) { - const match = value.match(/^'"'"'([^'"'"']*)'"'"'/); - if (match) return match[1]; - } - const bare = value.replace(/\s+#.*$/, "").trim(); - if (bare === "true" || bare === "false") return bare; - return bare; -}; -let current = ""; -const values = {}; -for (const line of fs.readFileSync(path, "utf8").split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const section = trimmed.match(/^\[([^\]]+)\]\s*$/); - if (section) { - current = section[1].trim(); - continue; - } - if (current !== target) continue; - const item = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/); - if (!item || !allowed.has(item[1])) continue; - const parsed = parseValue(item[2]); - if (parsed !== "") values[item[1]] = parsed; -} -const entries = Object.entries(values); -if (!entries.length) process.exit(3); -for (const [key, value] of entries) console.log(`export ${key}=${quote(value)}`); -' "$AI_SECRETS_PATH" "$secret_id" "$@" -} - -_ai_apply_toml_secret() { - local secret_id="$1" exports - shift - exports="$(_ai_toml_secret_exports "$secret_id" "$@" 2>/dev/null)" || return 1 - [ -n "$exports" ] || return 1 - eval "$exports" - AI_SECRET_SOURCE="${AI_SECRETS_PATH}#${secret_id}" -} - -# Union of extra-env keys declared by any profile of the tool (for cleanup on switch). -_ai_all_env_keys() { - local tool="$1" - [ -f "$AI_REGISTRY_PATH" ] || return 0 - _ai_require_node || return 1 - node -e ' -const fs = require("fs"); -let registry; -try { registry = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); } catch { process.exit(0); } -const keys = new Set(); -for (const p of registry[process.argv[2]] || []) { - if (p && p.env && typeof p.env === "object") for (const k of Object.keys(p.env)) keys.add(k); -} -for (const k of keys) console.log(k); -' "$AI_REGISTRY_PATH" "$tool" -} - -# Emit `export KEY=value` lines for a profile's env map (shell-quoted). -_ai_profile_env_exports() { - local profile_json="$1" - _ai_require_node || return 1 - node -e ' -const p = JSON.parse(process.argv[1]); -const quote = (value) => { - const text = String(value); - return "'"'"'" + text.replace(/'"'"'/g, "'"'"'\\'"'"''"'"'") + "'"'"'"; -}; -const env = (p && p.env && typeof p.env === "object") ? p.env : {}; -for (const [k, v] of Object.entries(env)) console.log(`export ${k}=${quote(v)}`); -' "$profile_json" -} - -# Build a JSON object from parsed --env KEY=VALUE pairs (empty string if none). -_ai_mgmt_env_json() { - local parsed="$1" - _ai_require_node || return 1 - node -e ' -const parsed = JSON.parse(process.argv[1]); -const env = {}; -for (const pair of parsed.env || []) { - const s = String(pair); - const idx = s.indexOf("="); - if (idx < 1) continue; - env[s.slice(0, idx)] = s.slice(idx + 1); -} -process.stdout.write(Object.keys(env).length ? JSON.stringify(env) : ""); -' "$parsed" -} - -_ai_env_keys_csv() { - local env_json="$1" - [ -n "$env_json" ] || return 0 - _ai_require_node || return 1 - node -e 'const e=JSON.parse(process.argv[1]||"{}");process.stdout.write(Object.keys(e).join(", "));' "$env_json" -} - -_ai_interactive() { - [ -z "${AI_ENV_NONINTERACTIVE:-}" ] || return 1 - [ -t 0 ] || return 1 - return 0 -} - -_ai_prompt() { - local prompt="$1" default_value="${2:-}" answer label - if [ -n "$default_value" ]; then label="$prompt [$default_value]: "; else label="$prompt: "; fi - printf '%s' "$label" >&2 - IFS= read -r answer || answer="" - [ -n "$answer" ] || answer="$default_value" - printf '%s' "$answer" -} - -_ai_prompt_secret() { - local prompt="$1" answer - printf '%s: ' "$prompt" >&2 - if [ -n "${BASH_VERSION:-}" ] || [ -n "${ZSH_VERSION:-}" ]; then - read -rs answer || answer="" - else - read -r answer || answer="" - fi - printf '\n' >&2 - printf '%s' "$answer" -} - -_ai_secret_section_exists() { - local secret_id="$1" - [ -f "$AI_SECRETS_PATH" ] || return 1 - awk -v want="[$secret_id]" '{ line=$0; gsub(/^[ \t]+|[ \t]+$/,"",line); if (line==want) { found=1; exit } } END { exit found?0:1 }' "$AI_SECRETS_PATH" -} - -_ai_append_secret() { - local secret_id="$1" key="$2" value="$3" esc - mkdir -p "$(dirname "$AI_SECRETS_PATH")" - esc="$(printf '%s' "$value" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')" - { - [ -f "$AI_SECRETS_PATH" ] && printf '\n' - printf '[%s]\n' "$secret_id" - printf '%s = "%s"\n' "$key" "$esc" - } >>"$AI_SECRETS_PATH" -} - -# Returns a human-readable status; in interactive shells prompts for and writes a missing secret. -_ai_scaffold_secret() { - local secret_id="$1" key="$2" interactive="$3" value - if _ai_toml_secret_exports "$secret_id" "$key" >/dev/null 2>&1; then - printf '%s [%s] %s (already set)' "$AI_SECRETS_PATH" "$secret_id" "$key" - return 0 - fi - if [ "$interactive" = "1" ] && ! _ai_secret_section_exists "$secret_id"; then - value="$(_ai_prompt_secret "Enter $key for [$secret_id] (blank to skip)")" - if [ -n "$value" ]; then - _ai_append_secret "$secret_id" "$key" "$value" - printf 'wrote %s [%s] %s' "$AI_SECRETS_PATH" "$secret_id" "$key" - return 0 - fi - fi - printf 'add %s to %s [%s]' "$key" "$AI_SECRETS_PATH" "$secret_id" -} - -_ai_secret_preview() { - local value="${1:-}" len - if [ -z "$value" ]; then - printf '\n' - return - fi - len=${#value} - if [ "$len" -le 12 ]; then - printf '%s...\n' "${value:0:4}" - else - printf '%s...%s\n' "${value:0:8}" "${value: -4}" - fi -} - -_ai_toml_value() { - local file="$1" key="$2" - [ -f "$file" ] || return 0 - sed -n \ - -e "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*\"\([^\"]*\)\".*/\1/p" \ - -e "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*\([^#[:space:]]*\).*/\1/p" \ - "$file" | head -n 1 -} - -_codex_profile_name() { - local profile_json="$1" value name - value="$(_ai_profile_value "$profile_json" codex_profile "")" - [ -n "$value" ] || value="$(_ai_profile_value "$profile_json" profile "")" - if [ -z "$value" ]; then - name="$(_ai_profile_value "$profile_json" name "")" - value="${name/:/-}" - fi - printf '%s\n' "$value" -} - -_codex_home() { - local profile_json="$1" - _ai_expand_path "$(_ai_profile_value "$profile_json" home "~/.codex")" -} - -_codex_profile_path() { - local profile_json="$1" home profile - home="$(_codex_home "$profile_json")" - profile="$(_codex_profile_name "$profile_json")" - printf '%s/%s.config.toml\n' "$home" "$profile" -} - -_ai_has_fresh_flag() { - local a - for a in "$@"; do - case "$a" in - --fresh|-f|--refresh|-r) return 0 ;; - esac - done - return 1 -} - -_ai_probe_model_for() { - local tool="$1" profile_json="$2" - _ai_require_node || return 1 - node - "$tool" "$profile_json" "$AI_SECRETS_PATH" "$AI_HOME" <<'NODE' -const fs=require("fs"); -const [tool,profileJson,secretsPath,homeArg]=process.argv.slice(2); -const P=JSON.parse(profileJson); -const home=homeArg||process.env.HOME||""; -if((P.mode||"sub")!=="api"){process.stdout.write("-");process.exit(0);} -const expand=(x)=>!x?"":String(x).replace(/^~(?=\/|$)/,home); -const tomlStr=(file,key)=>{if(!file||!fs.existsSync(file))return"";const re=new RegExp("^\\s*"+key.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"\\s*=\\s*\"([^\"]*)\"");for(const line of fs.readFileSync(file,"utf8").split(/\r?\n/)){const m=line.match(re);if(m)return m[1];}return"";}; -const unquote=(raw)=>{let v=String(raw||"").trim();if(v.startsWith("\"")){const mm=v.match(/^"((?:\\.|[^"])*)"/);if(mm){try{return JSON.parse(mm[0]);}catch{return mm[1];}}}if(v.startsWith("'")){const end=v.indexOf("'",1);if(end>0)return v.slice(1,end);}return v.replace(/\s+#.*$/,"").trim();}; -const parseSecrets=(file)=>{const s={};if(!file||!fs.existsSync(file))return s;let c="";for(const line of fs.readFileSync(file,"utf8").split(/\r?\n/)){const t=line.trim();if(!t||t.startsWith("#"))continue;const sec=t.match(/^\[([^\]]+)\]\s*$/);if(sec){c=sec[1].trim();s[c]=s[c]||{};continue;}if(!c)continue;const m=t.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/);if(!m)continue;s[c][m[1]]=unquote(m[2]);}return s;}; -const parseEnvFile=(file)=>{const e={};if(!file||!fs.existsSync(file))return e;for(const line of fs.readFileSync(file,"utf8").split(/\r?\n/)){const m=line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/);if(!m)continue;e[m[1]]=unquote(m[2]);}return e;}; -const profileEnv=(p,key)=>p&&p.env&&typeof p.env==="object"&&p.env[key]?String(p.env[key]):""; -const sec=(parseSecrets(secretsPath)[P.secret_id||(tool+"."+P.name)])||{}; -const legacyEnv=parseEnvFile(expand(P.linux_secret||P.secret||"")); -let model=""; -if(P.probe_model)model=String(P.probe_model); -else if(tool==="claude")model=sec.ANTHROPIC_MODEL||legacyEnv.ANTHROPIC_MODEL||profileEnv(P,"ANTHROPIC_MODEL")||process.env.ANTHROPIC_MODEL||sec.ANTHROPIC_DEFAULT_HAIKU_MODEL||legacyEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL||profileEnv(P,"ANTHROPIC_DEFAULT_HAIKU_MODEL")||process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL||"claude-3-5-haiku-20241022"; -else{const profPath=expand((P.home||"~/.codex")+"/"+(P.codex_profile||P.profile||String(P.name||"").replace(":","-"))+".config.toml");model=tomlStr(profPath,"model")||tomlStr(expand(P.home||"~/.codex")+"/config.toml","model")||"gpt-5.4-mini";} -process.stdout.write(model); -NODE -} - -_read_json_openai_key() { - local file="$1" - if command -v jq >/dev/null 2>&1; then - jq -r '.OPENAI_API_KEY // empty' "$file" - else - node -e 'const fs=require("fs"); const p=process.argv[1]; const j=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(j.OPENAI_API_KEY || "");' "$file" - fi -} - -_codex_login_status() { - command codex login status 2>&1 | tr '\n' ' ' -} - -_cx_doctor_summary() { - local profile_json="$1" profile_file provider model base_url wire_api env_key requires_openai_auth provider_name json - local -a args - command -v codex >/dev/null 2>&1 || return - command -v node >/dev/null 2>&1 || { - echo " Doctor: unavailable (node not found for JSON parsing)" - return - } - - profile_file="$(_codex_profile_path "$profile_json")" - provider="$(_ai_toml_value "$profile_file" model_provider)" - model="$(_ai_toml_value "$profile_file" model)" - base_url="$(_ai_toml_value "$profile_file" base_url)" - wire_api="$(_ai_toml_value "$profile_file" wire_api)" - env_key="$(_ai_toml_value "$profile_file" env_key)" - requires_openai_auth="$(_ai_toml_value "$profile_file" requires_openai_auth)" - provider_name="$(_ai_toml_value "$profile_file" name)" - - args=(doctor --json) - [ -n "$model" ] && args+=(-c "model=\"$model\"") - [ -n "$provider" ] && args+=(-c "model_provider=\"$provider\"") - - if [ -n "$provider" ]; then - case "$provider" in - openai|ollama|lmstudio|amazon-bedrock) - [ -z "${base_url}${wire_api}${env_key}${requires_openai_auth}${provider_name}" ] && provider="" - ;; - esac - - if [ -n "$provider" ]; then - [ -n "$provider_name" ] || provider_name="$provider" - [ -n "$wire_api" ] || wire_api="responses" - args+=(-c "model_providers.$provider.name=\"$provider_name\"") - [ -n "$base_url" ] && args+=(-c "model_providers.$provider.base_url=\"$base_url\"") - [ -n "$wire_api" ] && args+=(-c "model_providers.$provider.wire_api=\"$wire_api\"") - [ -n "$env_key" ] && args+=(-c "model_providers.$provider.env_key=\"$env_key\"") - [ -n "$requires_openai_auth" ] && args+=(-c "model_providers.$provider.requires_openai_auth=$requires_openai_auth") - fi - fi - - json="$(command codex "${args[@]}" 2>/dev/null)" || { - echo " Doctor: unavailable" - return - } - - printf '%s' "$json" | node -e ' -const fs = require("fs"); -const j = JSON.parse(fs.readFileSync(0, "utf8")); -const check = (name) => j.checks && j.checks[name]; -const detail = (c, key) => c && c.details ? c.details[key] : undefined; -const auth = check("auth.credentials"); -const config = check("config.load"); -const reach = check("network.provider_reachability"); -const ws = check("network.websocket_reachability"); -const sandbox = check("sandbox.helpers"); -const threads = check("state.rollout_db_parity"); -const updates = check("updates.status"); -console.log(` Doctor: ${j.overallStatus}, Codex ${j.codexVersion}`); -if (config) console.log(` Runtime: model=${detail(config, "model")}; provider=${detail(config, "model provider")}; mcp=${detail(config, "mcp servers")}`); -if (auth) { - console.log(` Auth: ${auth.status} - ${auth.summary}`); - if (detail(auth, "stored auth mode")) { - console.log(` Auth cache: ${detail(auth, "stored auth mode")}; api_key=${detail(auth, "stored API key")}; chatgpt_tokens=${detail(auth, "stored ChatGPT tokens")}`); - } -} -if (reach) console.log(` Network: ${reach.status} - ${reach.summary}`); -if (ws) console.log(` WebSocket: ${ws.status} - ${ws.summary}`); -if (sandbox) console.log(` Sandbox: approval=${detail(sandbox, "approval policy")}; fs=${detail(sandbox, "filesystem sandbox")}; net=${detail(sandbox, "network sandbox")}`); -if (threads) console.log(` Threads: active=${detail(threads, "rollout DB active rows")}; archived=${detail(threads, "rollout DB archived rows")}; providers=${detail(threads, "rollout DB model providers")}`); -if (updates) console.log(` Updates: ${detail(updates, "latest version status")}`); -' -} - -_cc_external_status() { - local json stats_path - if command -v claude >/dev/null 2>&1 && command -v node >/dev/null 2>&1; then - json="$(command claude auth status --json 2>/dev/null || true)" - if [ -n "$json" ]; then - printf '%s' "$json" | node -e ' -const fs = require("fs"); -const a = JSON.parse(fs.readFileSync(0, "utf8")); -console.log(` Auth status: loggedIn=${a.loggedIn}; method=${a.authMethod}; provider=${a.apiProvider}; source=${a.apiKeySource || ""}`); -' - else - echo " Auth status: unavailable" - fi - fi - - stats_path="$HOME/.claude/stats-cache.json" - if [ -f "$stats_path" ] && command -v node >/dev/null 2>&1; then - node -e ' -const fs = require("fs"); -const p = process.argv[1]; -try { - const s = JSON.parse(fs.readFileSync(p, "utf8")); - console.log(` Local usage cache: sessions=${s.totalSessions}; messages=${s.totalMessages}; lastComputed=${s.lastComputedDate}`); -} catch {} -' "$stats_path" - fi -} - -_set_codex_env() { - local profile_json="$1" mode name secret secret_id legacy legacy_key - mode="$(_ai_profile_value "$profile_json" mode sub)" - name="$(_ai_profile_value "$profile_json" name "")" - export CODEX_HOME="$(_codex_home "$profile_json")" - export AI_CODEX_LABEL="$name" - export AI_CODEX_PROFILE="$(_codex_profile_name "$profile_json")" - mkdir -p "$CODEX_HOME" - unset CODEX_API_KEY - unset OPENAI_API_KEY - local _ai_k - for _ai_k in $(_ai_all_env_keys codex 2>/dev/null); do unset "$_ai_k" 2>/dev/null || true; done - - AI_SECRET_SOURCE="" - if [ "$mode" = "api" ]; then - secret="$(_ai_secret_path "$profile_json")" - secret_id="$(_ai_secret_id codex "$profile_json")" - if _ai_apply_toml_secret "$secret_id" OPENAI_API_KEY CODEX_API_KEY; then - : - elif [ -f "$secret" ]; then - # shellcheck source=/dev/null - . "$secret" - AI_SECRET_SOURCE="$secret" - fi - - if [ -z "${OPENAI_API_KEY:-}" ] && [ -n "${CODEX_API_KEY:-}" ]; then - export OPENAI_API_KEY="$CODEX_API_KEY" - fi - - if [ -z "${OPENAI_API_KEY:-}" ] && [ "$name" = "api" ]; then - for legacy in "$HOME/.codex.API/auth.json" "$HOME/.codex-api/auth.json"; do - if [ -f "$legacy" ]; then - legacy_key="$(_read_json_openai_key "$legacy")" - if [ -n "$legacy_key" ]; then - export OPENAI_API_KEY="$legacy_key" - AI_SECRET_SOURCE="legacy .codex.API auth.json" - break - fi - fi - done - fi - - if [ -z "${OPENAI_API_KEY:-}" ]; then - echo "cx $name needs OPENAI_API_KEY. Put it in ${AI_SECRETS_PATH} [${secret_id}] or $secret." >&2 - return 1 - fi - fi - - eval "$(_ai_profile_env_exports "$profile_json" 2>/dev/null)" -} - -_set_claude_env() { - local profile_json="$1" mode name secret secret_id base_url - mode="$(_ai_profile_value "$profile_json" mode sub)" - name="$(_ai_profile_value "$profile_json" name "")" - export AI_CLAUDE_LABEL="$name" - unset ANTHROPIC_API_KEY - unset ANTHROPIC_AUTH_TOKEN - unset ANTHROPIC_BASE_URL - unset ANTHROPIC_MODEL - local _ai_k - for _ai_k in $(_ai_all_env_keys claude 2>/dev/null); do unset "$_ai_k" 2>/dev/null || true; done - - AI_SECRET_SOURCE="" - if [ "$mode" = "api" ]; then - secret="$(_ai_secret_path "$profile_json")" - secret_id="$(_ai_secret_id claude "$profile_json")" - if _ai_apply_toml_secret "$secret_id" ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN ANTHROPIC_BASE_URL ANTHROPIC_MODEL; then - : - elif [ -f "$secret" ]; then - # shellcheck source=/dev/null - . "$secret" - AI_SECRET_SOURCE="$secret" - fi - - base_url="$(_ai_profile_value "$profile_json" base_url "$CLAUDE_ROUTER_BASE_URL")" - [ -n "${ANTHROPIC_BASE_URL:-}" ] || export ANTHROPIC_BASE_URL="$base_url" - - if [ -z "${ANTHROPIC_API_KEY:-}" ] && [ -z "${ANTHROPIC_AUTH_TOKEN:-}" ]; then - echo "cc $name needs ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN in ${AI_SECRETS_PATH} [${secret_id}] or $secret." >&2 - return 1 - fi - fi - - eval "$(_ai_profile_env_exports "$profile_json" 2>/dev/null)" -} - -_cx_write_config_if_missing() { - local profile_json="$1" base_url="${2:-}" model="${3:-}" env_key="${4:-OPENAI_API_KEY}" provider_name="${5:-}" mode name profile_file provider_id - mode="$(_ai_profile_value "$profile_json" mode sub)" - name="$(_ai_profile_value "$profile_json" name "")" - profile_file="$(_codex_profile_path "$profile_json")" - [ -f "$profile_file" ] && { printf '%s\n' "$profile_file"; return 0; } - mkdir -p "$(dirname "$profile_file")" - provider_id="api-router" - [ -n "$provider_name" ] || provider_name="${name//:/ }" - [ -n "$env_key" ] || env_key="OPENAI_API_KEY" - - if [ "$mode" = "api" ]; then - [ -n "$base_url" ] || base_url="https://your-router.example/v1" - { - printf 'model_provider = "%s"\n' "$provider_id" - [ -n "$model" ] && printf 'model = "%s"\n' "$model" - printf 'disable_response_storage = true\n\n' - printf '[model_providers.%s]\n' "$provider_id" - printf 'name = "%s"\n' "$provider_name" - printf 'base_url = "%s"\n' "$base_url" - printf 'env_key = "%s"\n' "$env_key" - } >"$profile_file" - else - { - printf 'model_provider = "openai"\n' - [ -n "$model" ] && printf 'model = "%s"\n' "$model" - } >"$profile_file" - fi - - printf '%s\n' "$profile_file" -} - -_cx_add_api() { - local parsed name slug home runtime_profile secret_id base_url model env_key provider_name env_json profile_json profile_file secret_state interactive - parsed="$(_ai_parse_management_args "$@")" || return - name="$(_ai_mgmt_positional "$parsed" 0)" - [ -n "$name" ] || { echo "Usage: cx add-api [--base-url URL] [--env-key NAME] [--provider-name NAME] [--model MODEL] [--home PATH] [--env KEY=VALUE ...]" >&2; return 1; } - _ai_validate_name "$name" || return - slug="$(_ai_name_slug "$name")" || return - interactive=0; _ai_interactive && interactive=1 - home="$(_ai_mgmt_value "$parsed" home "~/.codex")" - runtime_profile="$(_ai_mgmt_value "$parsed" profile "api-${slug//:/-}")" - secret_id="$(_ai_mgmt_value "$parsed" secret-id "codex.$name")" - model="$(_ai_mgmt_value "$parsed" model "")" - env_json="$(_ai_mgmt_env_json "$parsed")" - - base_url="$(_ai_mgmt_value "$parsed" base-url "")" - if [ -z "$base_url" ]; then - if [ "$interactive" = "1" ]; then base_url="$(_ai_prompt "Codex base_url" "https://your-router.example/v1")"; else base_url="https://your-router.example/v1"; fi - fi - env_key="$(_ai_mgmt_value "$parsed" env-key "")" - if [ -z "$env_key" ]; then - if [ "$interactive" = "1" ]; then env_key="$(_ai_prompt "Codex env_key (secret variable name)" "OPENAI_API_KEY")"; else env_key="OPENAI_API_KEY"; fi - fi - provider_name="$(_ai_mgmt_value "$parsed" provider-name "")" - if [ -z "$provider_name" ]; then - if [ "$interactive" = "1" ]; then provider_name="$(_ai_prompt "Codex provider display name" "$name")"; else provider_name="$name"; fi - fi - - profile_json="$(_ai_json_profile \ - name "$name" aliases "" mode api home "$home" codex_profile "$runtime_profile" \ - secret_id "$secret_id" linux_secret "~/.ai-secrets/codex-$slug.env" windows_secret "~/.ai-secrets/codex-$slug.ps1" \ - description "Codex API profile" env "$env_json")" - _ai_registry_add_profile codex "$profile_json" || return - profile_file="$(_cx_write_config_if_missing "$profile_json" "$base_url" "$model" "$env_key" "$provider_name")" - secret_state="$(_ai_scaffold_secret "$secret_id" "$env_key" "$interactive")" - echo "Added Codex API profile '$name'." - echo " Registry: $AI_REGISTRY_PATH" - echo " CODEX_HOME: $(_ai_expand_path "$home")" - echo " Config: $profile_file" - echo " Secret: $secret_state" - [ -n "$env_json" ] && echo " Env: $(_ai_env_keys_csv "$env_json")" - return 0 -} - -_cx_add_sub() { - local parsed name slug home runtime_profile model profile_json profile_file interactive - parsed="$(_ai_parse_management_args "$@")" || return - name="$(_ai_mgmt_positional "$parsed" 0)" - [ -n "$name" ] || { echo "Usage: cx add-sub [--home PATH] [--model MODEL]" >&2; return 1; } - _ai_validate_name "$name" || return - slug="$(_ai_name_slug "$name")" || return - interactive=0; _ai_interactive && interactive=1 - home="$(_ai_mgmt_value "$parsed" home "")" - if [ -z "$home" ]; then - if [ "$interactive" = "1" ]; then home="$(_ai_prompt "Codex CODEX_HOME for this subscription" "~/.codex-$slug")"; else home="~/.codex-$slug"; fi - fi - runtime_profile="$(_ai_mgmt_value "$parsed" profile "sub")" - model="$(_ai_mgmt_value "$parsed" model "")" - profile_json="$(_ai_json_profile \ - name "$name" aliases "" mode sub home "$home" codex_profile "$runtime_profile" \ - description "Codex subscription profile")" - _ai_registry_add_profile codex "$profile_json" || return - profile_file="$(_cx_write_config_if_missing "$profile_json" "" "$model")" - echo "Added Codex subscription profile '$name'." - echo " Registry: $AI_REGISTRY_PATH" - echo " CODEX_HOME: $(_ai_expand_path "$home")" - echo " Config: $profile_file" - echo " Login: CODEX_HOME=\"$(_ai_expand_path "$home")\" codex login" -} - -_cx_remove_profile() { - local parsed name removed existing_json profile_file delete_config - parsed="$(_ai_parse_management_args "$@")" || return - name="$(_ai_mgmt_positional "$parsed" 0)" - [ -n "$name" ] || { echo "Usage: cx remove [--delete-config]" >&2; return 1; } - existing_json="$(_ai_profile_json codex "$name")" || { echo "codex profile '$name' does not exist." >&2; return 1; } - profile_file="$(_codex_profile_path "$existing_json")" - removed="$(_ai_registry_remove_profile codex "$name")" || return - if [ "$(_ai_saved_profile codex)" = "$removed" ]; then - _ai_save_profile codex "$(_ai_default_profile codex)" || return - fi - delete_config="$(_ai_mgmt_value "$parsed" delete-config "")" - [ "$delete_config" = "true" ] && rm -f "$profile_file" - echo "Removed Codex profile '$removed'." - echo " Registry: $AI_REGISTRY_PATH" - if [ -f "$profile_file" ]; then - echo " Config: $profile_file" - else - echo " Config: " - fi -} - -_cc_add_api() { - local parsed name slug secret_id base_url env_key env_json profile_json secret_state interactive - parsed="$(_ai_parse_management_args "$@")" || return - name="$(_ai_mgmt_positional "$parsed" 0)" - [ -n "$name" ] || { echo "Usage: cc add-api [--base-url URL] [--env-key NAME] [--env KEY=VALUE ...]" >&2; return 1; } - _ai_validate_name "$name" || return - slug="$(_ai_name_slug "$name")" || return - interactive=0; _ai_interactive && interactive=1 - secret_id="$(_ai_mgmt_value "$parsed" secret-id "claude.$name")" - env_json="$(_ai_mgmt_env_json "$parsed")" - - base_url="$(_ai_mgmt_value "$parsed" base-url "")" - if [ -z "$base_url" ]; then - if [ "$interactive" = "1" ]; then base_url="$(_ai_prompt "Claude base_url" "$CLAUDE_ROUTER_BASE_URL")"; else base_url="$CLAUDE_ROUTER_BASE_URL"; fi - fi - env_key="$(_ai_mgmt_value "$parsed" env-key "")" - if [ -z "$env_key" ]; then - if [ "$interactive" = "1" ]; then env_key="$(_ai_prompt "Claude secret variable (ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY)" "ANTHROPIC_AUTH_TOKEN")"; else env_key="ANTHROPIC_AUTH_TOKEN"; fi - fi - - profile_json="$(_ai_json_profile \ - name "$name" aliases "" mode api base_url "$base_url" secret_id "$secret_id" \ - linux_secret "~/.ai-secrets/claude-$slug.env" windows_secret "~/.ai-secrets/claude-$slug.ps1" \ - description "Claude Code API profile" env "$env_json")" - _ai_registry_add_profile claude "$profile_json" || return - secret_state="$(_ai_scaffold_secret "$secret_id" "$env_key" "$interactive")" - echo "Added Claude Code API profile '$name'." - echo " Registry: $AI_REGISTRY_PATH" - echo " Base URL: $base_url" - echo " Secret: $secret_state" - [ -n "$env_json" ] && echo " Env: $(_ai_env_keys_csv "$env_json")" - return 0 -} - -_cc_add_sub() { - local parsed name profile_json - parsed="$(_ai_parse_management_args "$@")" || return - name="$(_ai_mgmt_positional "$parsed" 0)" - [ -n "$name" ] || { echo "Usage: cc add-sub " >&2; return 1; } - _ai_validate_name "$name" || return - profile_json="$(_ai_json_profile name "$name" aliases "" mode sub description "Claude Code subscription profile")" - _ai_registry_add_profile claude "$profile_json" || return - echo "Added Claude Code subscription profile '$name'." - echo " Registry: $AI_REGISTRY_PATH" - echo " Login: claude /login" -} - -_cc_remove_profile() { - local parsed name removed - parsed="$(_ai_parse_management_args "$@")" || return - name="$(_ai_mgmt_positional "$parsed" 0)" - [ -n "$name" ] || { echo "Usage: cc remove " >&2; return 1; } - removed="$(_ai_registry_remove_profile claude "$name")" || return - if [ "$(_ai_saved_profile claude)" = "$removed" ]; then - _ai_save_profile claude "$(_ai_default_profile claude)" || return - fi - echo "Removed Claude Code profile '$removed'." - echo " Registry: $AI_REGISTRY_PATH" -} - -_ai_token_format() { - node -e ' -const value = Number(process.argv[1] || 0); -if (value >= 1000000) process.stdout.write(`${(value / 1000000).toFixed(2)}M`); -else if (value >= 1000) process.stdout.write(`${(value / 1000).toFixed(1)}K`); -else process.stdout.write(String(value)); -' "$1" -} - -_ai_token_bar() { - node -e ' -const value = Number(process.argv[1] || 0); -const total = Number(process.argv[2] || 0); -if (value <= 0 || total <= 0) process.exit(0); -const width = Math.max(1, Math.round((value / total) * 24)); -process.stdout.write("#".repeat(width)); -' "$1" "$2" -} - -_cx_rollout_stats_json() { - local codex_home="$1" days="$2" - _ai_require_node || return 1 - node -e ' -const fs = require("fs"); -const path = require("path"); -const root = process.argv[1]; -const days = Number(process.argv[2] || 30); -const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; -const stats = { sessions: 0, samples: 0, input: 0, cachedInput: 0, output: 0, reasoningOutput: 0, total: 0 }; -const sessions = path.join(root, "sessions"); -const walk = (dir) => { - if (!fs.existsSync(dir)) return []; - const out = []; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) out.push(...walk(full)); - else if (entry.isFile() && entry.name.endsWith(".jsonl")) out.push(full); - } - return out; -}; -for (const file of walk(sessions)) { - let st; - try { st = fs.statSync(file); } catch { continue; } - if (st.mtimeMs < cutoff) continue; - let latest = null; - let samples = 0; - let text = ""; - try { text = fs.readFileSync(file, "utf8"); } catch { continue; } - for (const line of text.split(/\r?\n/)) { - if (!line.includes("\"token_count\"")) continue; - try { - const j = JSON.parse(line); - if (j.type !== "event_msg" || !j.payload || j.payload.type !== "token_count") continue; - const usage = j.payload.info && j.payload.info.total_token_usage; - if (!usage) continue; - latest = usage; - samples += 1; - } catch {} - } - if (!latest) continue; - const input = Number(latest.input_tokens || 0); - const cached = Number(latest.cached_input_tokens || latest.cache_read_input_tokens || 0); - const output = Number(latest.output_tokens || 0); - const reasoning = Number(latest.reasoning_output_tokens || 0); - const total = Number(latest.total_tokens || input + output); - stats.sessions += 1; - stats.samples += samples; - stats.input += input; - stats.cachedInput += cached; - stats.output += output; - stats.reasoningOutput += reasoning; - stats.total += total; -} -process.stdout.write(JSON.stringify(stats)); -' "$codex_home" "$days" -} - -_cx_stats() { - local parsed days saved profile_json codex_home json sessions samples total input cached output reasoning - parsed="$(_ai_parse_management_args "$@")" || return - days="$(_ai_mgmt_value "$parsed" days 30)" - case "$days" in - ''|*[!0-9]*) - echo "cx stats --days must be a positive integer." >&2 - return 1 - ;; - esac - [ "$days" -ge 1 ] || { echo "cx stats --days must be a positive integer." >&2; return 1; } - saved="$(_ai_saved_profile codex)" - profile_json="$(_ai_profile_json codex "${AI_CODEX_LABEL:-$saved}")" || profile_json="$(_ai_profile_json codex "$(_ai_default_profile codex)")" || return 1 - codex_home="${CODEX_HOME:-$(_codex_home "$profile_json")}" - json="$(_cx_rollout_stats_json "$codex_home" "$days")" || return - sessions="$(node -e 'const j=JSON.parse(process.argv[1]); process.stdout.write(String(j.sessions));' "$json")" - samples="$(node -e 'const j=JSON.parse(process.argv[1]); process.stdout.write(String(j.samples));' "$json")" - total="$(node -e 'const j=JSON.parse(process.argv[1]); process.stdout.write(String(j.total));' "$json")" - input="$(node -e 'const j=JSON.parse(process.argv[1]); process.stdout.write(String(j.input));' "$json")" - cached="$(node -e 'const j=JSON.parse(process.argv[1]); process.stdout.write(String(j.cachedInput));' "$json")" - output="$(node -e 'const j=JSON.parse(process.argv[1]); process.stdout.write(String(j.output));' "$json")" - reasoning="$(node -e 'const j=JSON.parse(process.argv[1]); process.stdout.write(String(j.reasoningOutput));' "$json")" - echo "Codex local token stats:" - echo " CODEX_HOME: $codex_home" - echo " Window: last $days days" - echo " Sessions with usage: $sessions" - echo " Token samples: $samples" - echo " Total: $(_ai_token_format "$total") ($total)" - printf ' %-9s %10s %s\n' input "$(_ai_token_format "$input")" "$(_ai_token_bar "$input" "$total")" - printf ' %-9s %10s %s\n' cached "$(_ai_token_format "$cached")" "$(_ai_token_bar "$cached" "$total")" - printf ' %-9s %10s %s\n' output "$(_ai_token_format "$output")" "$(_ai_token_bar "$output" "$total")" - printf ' %-9s %10s %s\n' reasoning "$(_ai_token_format "$reasoning")" "$(_ai_token_bar "$reasoning" "$total")" -} - -_cx_print_status() { - local profile_json="$1" mode name profile_file base_url - mode="$(_ai_profile_value "$profile_json" mode sub)" - name="$(_ai_profile_value "$profile_json" name "")" - profile_file="$(_codex_profile_path "$profile_json")" - base_url="$(_ai_toml_value "$profile_file" base_url)" - [ -n "$base_url" ] || base_url="built-in OpenAI/ChatGPT endpoint" - echo "Codex state switched: $name" - echo " Run next: codex" - echo " Registry: $AI_REGISTRY_PATH" - echo " CODEX_HOME: $CODEX_HOME" - echo " Profile: $AI_CODEX_PROFILE ($profile_file)" - echo " Base URL: $base_url" - echo " Probe model: $(_ai_probe_model_for codex "$profile_json")" - echo " Cached login: $(_codex_login_status)" - if [ "$mode" = "api" ]; then - echo " OPENAI_API_KEY: $(_ai_secret_preview "${OPENAI_API_KEY:-}")" - echo " Secret source: $AI_SECRET_SOURCE" - echo " API local check: profile file=$([ -f "$profile_file" ] && echo true || echo false); key=$([ -n "${OPENAI_API_KEY:-}" ] && echo true || echo false)" - else - echo " OPENAI_API_KEY: " - echo " Subscription quota: not exposed by Codex CLI" - fi - # Cached health line (instant — no network). codex doctor is deliberately - # NOT run here: it does live network/websocket checks that stall the switch. - # Use `cx doctor` for the full diagnostic on demand. - _ai_health_status_line codex "$profile_json" 0 -} - -_cc_print_status() { - local profile_json="$1" mode name - mode="$(_ai_profile_value "$profile_json" mode sub)" - name="$(_ai_profile_value "$profile_json" name "")" - echo "Claude Code state switched: $name" - echo " Run next: claude" - echo " Registry: $AI_REGISTRY_PATH" - echo " Probe model: $(_ai_probe_model_for claude "$profile_json")" - if [ "$mode" = "api" ]; then - echo " ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-}" - echo " ANTHROPIC_API_KEY: $(_ai_secret_preview "${ANTHROPIC_API_KEY:-}")" - echo " ANTHROPIC_AUTH_TOKEN: $(_ai_secret_preview "${ANTHROPIC_AUTH_TOKEN:-}")" - echo " Secret source: $AI_SECRET_SOURCE" - echo " API local check: auth=$([ -n "${ANTHROPIC_API_KEY:-}${ANTHROPIC_AUTH_TOKEN:-}" ] && echo true || echo false); url=$([ -n "${ANTHROPIC_BASE_URL:-}" ] && echo true || echo false)" - else - echo " Anthropic API env: " - echo " Subscription status: local Claude login is used if present" - fi - _ai_health_status_line claude "$profile_json" 0 - _cc_external_status -} - -_ai_list_profiles() { - local tool="$1" output - _ai_health_sync_cache "$tool" - _ai_require_node || return 1 - output="$(node -e ' -const fs = require("fs"); -const path = process.argv[1]; -const tool = process.argv[2]; -const saved = process.argv[3]; -const secretsPath = process.argv[4]; -const healthPath = process.argv[5]; -const ttl = Number(process.argv[6] || 300); -const home = process.env.HOME; -const now = Math.floor(Date.now() / 1000); -const exists = (p) => p && fs.existsSync(p); -const expand = (p) => !p ? "" : p.replace(/^~(?=\/|$)/, home); -const readToml = (file, key) => { - if (!file || !fs.existsSync(file)) return ""; - const re = new RegExp("^\\s*" + key.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&") + "\\s*=\\s*\"([^\"]*)\""); - for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) { - const match = line.match(re); - if (match) return match[1]; - } - return ""; -}; -const parseValue = (raw) => { - const value = String(raw || "").trim(); - if (value.startsWith("\"")) { - const match = value.match(/^"((?:\\.|[^"])*)"/); - if (match) { - try { return JSON.parse(match[0]); } catch { return match[1]; } - } - } - if (value.startsWith("'"'"'")) { - const match = value.match(/^'"'"'([^'"'"']*)'"'"'/); - if (match) return match[1]; - } - return value.replace(/\s+#.*$/, "").trim(); -}; -const parseSecrets = (file) => { - const sections = {}; - if (!fs.existsSync(file)) return sections; - let current = ""; - for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const section = trimmed.match(/^\[([^\]]+)\]\s*$/); - if (section) { - current = section[1].trim(); - sections[current] = sections[current] || {}; - continue; - } - if (!current) continue; - const item = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/); - if (item) sections[current][item[1]] = parseValue(item[2]); - } - return sections; -}; -let registry = JSON.parse(fs.readFileSync(path, "utf8")); -const secrets = parseSecrets(secretsPath); -let health = {}; -try { if (fs.existsSync(healthPath)) health = JSON.parse(fs.readFileSync(healthPath, "utf8")); } catch {} -const secretVars = tool === "codex" ? ["OPENAI_API_KEY", "CODEX_API_KEY"] : ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]; -const healthCell = (p) => { - const e = health[`${tool}.${p.name || ""}`]; - if (!e || !e.probedAt || (now - Number(e.probedAt)) >= ttl) return "⏭"; - if (e.status === "healthy") return `🟢${e.latencyMs || 0}ms`; - const code = String(e.error || "").match(/HTTP\s+(\d{3})/)?.[1]; - if (e.status === "degraded") return `🟡${code || "slow"}`; - if (e.status === "down") return `🔴${code || "err"}`; - if (e.status === "skip") return "⏭"; - return "?"; -}; -console.log(tool === "codex" - ? ["Sel", "Name", "Mode", "Health", "Profile", "BaseUrl"].join("\t") - : ["Sel", "Name", "Mode", "Health", "BaseUrl"].join("\t")); -for (const p of registry[tool] || []) { - const mode = p.mode || "sub"; - const secretId = p.secret_id || `${tool}.${p.name || ""}`; - const selected = String(p.name || "") === saved ? "*" : " "; - const h = healthCell(p); - if (tool === "codex") { - const profile = p.codex_profile || p.profile || String(p.name || "").replace(":", "-"); - const runtime = `${expand(p.home || "~/.codex")}/${profile}.config.toml`; - let baseUrl = readToml(runtime, "base_url") || readToml(`${expand(p.home || "~/.codex")}/config.toml`, "openai_base_url") || "built-in OpenAI/ChatGPT endpoint"; - console.log([selected, p.name || "", mode, h, profile, baseUrl].join("\t")); - } else { - let baseUrl = "local Claude subscription login"; - if (mode === "api") { - baseUrl = (secrets[secretId] && secrets[secretId].ANTHROPIC_BASE_URL) || p.base_url || "https://anyrouter.top"; - } - console.log([selected, p.name || "", mode, h, baseUrl].join("\t")); - } -} -' "$AI_REGISTRY_PATH" "$tool" "$(_ai_saved_profile "$tool")" "$AI_SECRETS_PATH" "$AI_HEALTH_PATH" "$AI_HEALTH_TTL")" || return 1 - - if command -v column >/dev/null 2>&1; then - printf '%s\n' "$output" | column -t -s "$(printf '\t')" - else - printf '%s\n' "$output" - fi - if [ "$tool" = "codex" ]; then - echo " (Health = cached snapshot, ⏭=stale/unprobed; run 'cx health' to refresh)" - else - echo " (Health = cached snapshot, ⏭=stale/unprobed; run 'cc health' to refresh)" - fi -} - -cx() { - local arg="${1:-}" profile_json name - case "$arg" in - help|-h|--help) - cat <<'EOF' -cx - switch Codex state for this shell - -Usage: - cx Auto-select a cached healthy Codex profile (default fallback) - cx sub Use a named subscription profile - cx sub:work Use another subscription profile, if registered - cx api Use the default API profile - cx api:docker Use a named API profile - cx list List registry profiles and cached health - cx status Print current state; --fresh/--refresh re-probes selected profile - cx stats Summarize local rollout token usage - cx add-api NAME Register a Codex API profile that shares ~/.codex by default - Options: --base-url URL --env-key NAME --provider-name NAME - --model MODEL --home PATH --env KEY=VALUE - Prompts for missing base-url/env-key and the secret in a terminal. - cx add-sub NAME Register an isolated Codex subscription CODEX_HOME - cx remove NAME Remove a Codex profile registration - cx probe-model NAME [MODEL] Set/clear health-probe model override - cx default [NAME] Show/set the default (primary) profile - cx edit Open the profile registry (profiles.json) in EDITOR - cx health Probe & report profile health table; --fresh/--refresh re-probes - cx doctor Run codex doctor full diagnostic (slow, on-demand) - cx health-clear Clear the health probe cache - cx next Cycle to the next enabled profile - cx help Show this help - -Config: - Registry: ~/.ai-env/profiles.json - State: ~/.ai-env/state.json - Secrets: ~/.ai-secrets/secrets.toml - -After switching, run Codex separately: codex -Add commands only write profile metadata and Codex config. Put real tokens in secrets.toml. -Without probe_model, Codex probes use runtime/global config.toml model, then a cheap fallback. -Legacy ~/.ai-secrets/*.env files are still accepted as a fallback. -EOF - return - ;; - list) - echo "Codex profiles ($AI_REGISTRY_PATH):" - _ai_list_profiles codex - return - ;; - status) - _hr=0; _ai_has_fresh_flag "$@" && _hr=1 - echo "Codex state:" - echo " Registry: $AI_REGISTRY_PATH" - echo " State: $AI_STATE_PATH" - echo " Saved: $(_ai_saved_profile codex)" - echo " Process label: ${AI_CODEX_LABEL:-}" - echo " Process profile: ${AI_CODEX_PROFILE:-}" - echo " CODEX_HOME: ${CODEX_HOME:-$HOME/.codex}" - echo " OPENAI_API_KEY: $(_ai_secret_preview "${OPENAI_API_KEY:-}")" - echo " Cached login: $(_codex_login_status)" - profile_json="$(_ai_profile_json codex "${AI_CODEX_LABEL:-$(_ai_saved_profile codex)}")" && { - echo " Probe model: $(_ai_probe_model_for codex "$profile_json")" - _ai_health_status_line codex "$profile_json" "$_hr" - } - return - ;; - edit) - _ai_registry_edit - return - ;; - stats) - shift - _cx_stats "$@" - return - ;; - add-api) - shift - _cx_add_api "$@" - return - ;; - add-sub) - shift - _cx_add_sub "$@" - return - ;; - remove) - shift - _cx_remove_profile "$@" - return - ;; - health) - shift - _hf=0; _ai_has_fresh_flag "$@" && _hf=1 - _ai_health_show codex "$_hf" - return - ;; - doctor) - profile_json="$(_ai_profile_json codex "${AI_CODEX_LABEL:-$(_ai_saved_profile codex)}")" && _cx_doctor_summary "$profile_json" - return - ;; - default) - shift; _ai_set_default codex "$@"; return - ;; - probe-model) - shift; _ai_set_probe_model codex "$@"; return - ;; - health-clear) - _ai_health_clear; echo "health cache cleared"; return - ;; - next) - arg="$(_ai_next_profile codex)" - ;; - esac - - if [ -n "$arg" ]; then - profile_json="$(_ai_profile_json codex "$arg")" || { echo "Unknown cx profile '$arg'. Add it to $AI_REGISTRY_PATH or run 'cx help'." >&2; return 1; } - else - profile_json="$(_ai_profile_json codex "$(_ai_healthy_profile codex)")" || return 1 - fi - name="$(_ai_profile_value "$profile_json" name "")" - _ai_save_profile codex "$name" || return - _set_codex_env "$profile_json" || return - _cx_print_status "$profile_json" -} - -cc() { - local arg="${1:-}" profile_json name - case "$arg" in - help|-h|--help) - cat <<'EOF' -cc - switch Claude Code state for this shell - -Usage: - cc Auto-select a cached healthy Claude Code profile (default fallback) - cc sub Clear Anthropic API env and use local Claude subscription login - cc sub:work Use another subscription profile, if registered - cc api Use the default API profile - cc api:docker Use a named API profile - cc list List registry profiles and cached health - cc status Print current state; --fresh/--refresh re-probes selected profile - cc add-api NAME Register a Claude Code API profile - Options: --base-url URL --env-key NAME --env KEY=VALUE (repeatable) - Prompts for missing base-url and the secret in a terminal. - --env adds non-secret per-profile vars (model mapping, compact window). - cc add-sub NAME Register a Claude Code subscription label - cc remove NAME Remove a Claude Code profile registration - cc probe-model NAME [MODEL] Set/clear health-probe model override - cc default [NAME] Show/set the default (primary) profile - cc edit Open the profile registry (profiles.json) in EDITOR - cc health Probe & report profile health table; --fresh/--refresh re-probes - cc health-clear Clear the health probe cache - cc next Cycle to the next enabled profile - cc help Show this help - -Config: - Registry: ~/.ai-env/profiles.json - State: ~/.ai-env/state.json - Secrets: ~/.ai-secrets/secrets.toml - -After switching, run Claude Code separately: claude -Add commands only write profile metadata. Put real tokens in secrets.toml. -Without probe_model, Claude probes use ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_HAIKU_MODEL, then a cheap fallback. -Legacy ~/.ai-secrets/*.env files are still accepted as a fallback. -EOF - return - ;; - list) - echo "Claude Code profiles ($AI_REGISTRY_PATH):" - _ai_list_profiles claude - return - ;; - status) - _hr=0; _ai_has_fresh_flag "$@" && _hr=1 - echo "Claude Code state:" - echo " Registry: $AI_REGISTRY_PATH" - echo " State: $AI_STATE_PATH" - echo " Saved: $(_ai_saved_profile claude)" - echo " Process label: ${AI_CLAUDE_LABEL:-}" - echo " ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-}" - echo " ANTHROPIC_API_KEY: $(_ai_secret_preview "${ANTHROPIC_API_KEY:-}")" - echo " ANTHROPIC_AUTH_TOKEN: $(_ai_secret_preview "${ANTHROPIC_AUTH_TOKEN:-}")" - profile_json="$(_ai_profile_json claude "${AI_CLAUDE_LABEL:-$(_ai_saved_profile claude)}")" && { - echo " Probe model: $(_ai_probe_model_for claude "$profile_json")" - _ai_health_status_line claude "$profile_json" "$_hr" - } - _cc_external_status - return - ;; - edit) - _ai_registry_edit - return - ;; - add-api) - shift - _cc_add_api "$@" - return - ;; - add-sub) - shift - _cc_add_sub "$@" - return - ;; - remove) - shift - _cc_remove_profile "$@" - return - ;; - health) - shift - _hf=0; _ai_has_fresh_flag "$@" && _hf=1 - _ai_health_show claude "$_hf" - return - ;; - default) - shift; _ai_set_default claude "$@"; return - ;; - probe-model) - shift; _ai_set_probe_model claude "$@"; return - ;; - health-clear) - _ai_health_clear; echo "health cache cleared"; return - ;; - next) - arg="$(_ai_next_profile claude)" - ;; - esac - - if [ -n "$arg" ]; then - profile_json="$(_ai_profile_json claude "$arg")" || { echo "Unknown cc profile '$arg'. Add it to $AI_REGISTRY_PATH or run 'cc help'." >&2; return 1; } - else - profile_json="$(_ai_profile_json claude "$(_ai_healthy_profile claude)")" || return 1 - fi - name="$(_ai_profile_value "$profile_json" name "")" - _ai_save_profile claude "$name" || return - _set_claude_env "$profile_json" || return - _cc_print_status "$profile_json" -} - -_codex_has_explicit_profile() { - local arg - for arg in "$@"; do - case "$arg" in - --profile|-p|--profile=*) return 0 ;; - esac - done - return 1 -} - -_codex_first_token() { - local skip_next=false arg - for arg in "$@"; do - if [ "$skip_next" = true ]; then - skip_next=false - continue - fi - case "$arg" in - --) return 1 ;; - -c|--config|-i|--image|-m|--model|-p|--profile|-s|--sandbox|-C|--cd|--add-dir|-a|--ask-for-approval|--remote|--remote-auth-token-env|--local-provider) - skip_next=true - continue - ;; - -*) continue ;; - *) printf '%s\n' "$arg"; return 0 ;; - esac - done - return 1 -} - -_codex_should_inject_profile() { - local first - _codex_has_explicit_profile "$@" && return 1 - first="$(_codex_first_token "$@")" || return 0 - case "$first" in - login|logout|doctor|app|completion|update|features|help|cloud|app-server|remote-control|mcp-server|exec-server|mcp|plugin|sandbox|debug|apply|archive|unarchive) - return 1 - ;; - esac - return 0 -} - -codex() { - local saved profile_json profile_name - saved="$(_ai_saved_profile codex)" - profile_json="$(_ai_profile_json codex "${AI_CODEX_LABEL:-$saved}")" || profile_json="$(_ai_profile_json codex "$(_ai_default_profile codex)")" || return 1 - _set_codex_env "$profile_json" || return - profile_name="$(_codex_profile_name "$profile_json")" - if _codex_should_inject_profile "$@"; then - if [ -f "$(_codex_profile_path "$profile_json")" ]; then - command codex --profile "$profile_name" "$@" - else - command codex "$@" - fi - else - command codex "$@" - fi -} - -# ===================== health subsystem (mirrors ai-env.ps1) ===================== -# Real-wire probe (node https), TTL cache (~/.ai-env/health.json), on-demand. -AI_HEALTH_PATH="${AI_CONFIG_DIR}/health.json" -AI_HEALTH_TTL="${AI_HEALTH_TTL:-300}" -AI_HEALTH_DEGRADED_MS="${AI_HEALTH_DEGRADED_MS:-8000}" -AI_MCP_PATH="${AI_CONFIG_DIR}/mcp.toml" - -_ai_claude_json_path() { printf '%s\n' "${AI_CLAUDE_JSON_PATH:-$HOME/.claude.json}"; } -_ai_codex_config_path() { printf '%s\n' "${AI_CODEX_CONFIG_PATH:-$HOME/.codex/config.toml}"; } - -# Probe a profile via a real wire request. Echoes JSON: -# {"status","latencyMs","method","error"}. Never exports env. -_ai_probe_health() { - local tool="$1" profile_json="$2" - _ai_require_node || return 1 - node -e ' -const fs=require("fs"),https=require("https"),http=require("http"),{URL}=require("url"); -const tool=process.argv[1],P=JSON.parse(process.argv[2]),secretsPath=process.argv[3],router=process.argv[4]; -const degradedMs=Number(process.env.AI_HEALTH_DEGRADED_MS||8000); -const out=(o)=>process.stdout.write(JSON.stringify(o)); -const probeErr=(m)=>{if(!m)return"";const l=(""+m).toLowerCase();if(/timeout|canceled|timed out|httpclient\.timeout/.test(l))return"timeout";if(/ssl|handshake|eproto|sslv3|certificate|trust/.test(l))return"TLS handshake failed";if(/econnrefused|connection refused/.test(l))return"connection refused";if(/enotfound|getaddrinfo|nodata|getaddr|dns/.test(l))return"DNS failed";if(/econnreset|socket hang up|reset by peer|reset/.test(l))return"connection reset";return m;}; -const mode=P.mode||"sub"; -if(mode!=="api"){out({status:"skip",latencyMs:0,method:null,error:"subscription mode (no remote probe)"});process.exit(0);} -const parseSecrets=(file)=>{const s={};if(!fs.existsSync(file))return s;let c="";for(const line of fs.readFileSync(file,"utf8").split(/\r?\n/)){const t=line.trim();if(!t||t.startsWith("#"))continue;const sec=t.match(/^\[([^\]]+)\]\s*$/);if(sec){c=sec[1].trim();s[c]=s[c]||{};continue;}if(!c)continue;const m=t.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/);if(!m)continue;let v=m[2].trim();if(v.startsWith("\"")){const mm=v.match(/^"((?:\\.|[^"])*)"/);if(mm){try{v=JSON.parse(mm[0]);}catch{v=mm[1];}}}else if(v.startsWith("'\''")){const mm=v.match(/^'\''([^'\'']*)'\''/);if(mm)v=mm[1];}else v=v.replace(/\s+#.*$/,"").trim();s[c][m[1]]=v;}return s;}; -const expand=(x)=>!x?"":x.replace(/^~(?=\/|$)/,process.env.HOME||""); -const tomlStr=(file,key)=>{if(!fs.existsSync(file))return"";const re=new RegExp("^\\s*"+key.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"\\s*=\\s*\"([^\"]*)\"");for(const line of fs.readFileSync(file,"utf8").split(/\r?\n/)){const m=line.match(re);if(m)return m[1];}return"";};const profileEnv=(p,key)=>p&&p.env&&typeof p.env==="object"&&p.env[key]?String(p.env[key]):"";const probeModelFor=(toolName,p,sec,legacyEnv,profPath)=>{if(p.probe_model)return String(p.probe_model);if(toolName==="claude")return sec.ANTHROPIC_MODEL||legacyEnv.ANTHROPIC_MODEL||profileEnv(p,"ANTHROPIC_MODEL")||process.env.ANTHROPIC_MODEL||sec.ANTHROPIC_DEFAULT_HAIKU_MODEL||legacyEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL||profileEnv(p,"ANTHROPIC_DEFAULT_HAIKU_MODEL")||process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL||"claude-3-5-haiku-20241022";return tomlStr(profPath,"model")||tomlStr(expand(p.home||"~/.codex")+"/config.toml","model")||"gpt-5.4-mini";}; -const legacyEnv={};const legacy=expand(P.linux_secret||P.secret||"");if(legacy&&fs.existsSync(legacy)){for(const line of fs.readFileSync(legacy,"utf8").split(/\r?\n/)){const m=line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/);if(m)legacyEnv[m[1]]=m[2].trim();}} -const secrets=parseSecrets(secretsPath);const sid=P.secret_id||(tool+"."+P.name);const sec=secrets[sid]||{}; -let baseOrigin="",headers={},probeModel="",secretOk=false; -if(tool==="claude"){probeModel=probeModelFor(tool,P,sec,legacyEnv,"");let b=sec.ANTHROPIC_BASE_URL||legacyEnv.ANTHROPIC_BASE_URL||P.base_url||router;baseOrigin=b.replace(/\/+$/,"");const at=sec.ANTHROPIC_AUTH_TOKEN||legacyEnv.ANTHROPIC_AUTH_TOKEN||process.env.ANTHROPIC_AUTH_TOKEN||"";const ak=sec.ANTHROPIC_API_KEY||legacyEnv.ANTHROPIC_API_KEY||process.env.ANTHROPIC_API_KEY||"";headers={"anthropic-version":"2023-06-01"};if(at)headers["Authorization"]="Bearer "+at;if(ak)headers["x-api-key"]=ak;headers["User-Agent"]=P.probe_ua||"claude-cli/1.0.119 (external, cli)";secretOk=!!(at||ak); -}else{const profPath=expand((P.home||"~/.codex")+"/"+(P.codex_profile||P.profile||String(P.name||"").replace(":","-"))+".config.toml");probeModel=probeModelFor(tool,P,sec,legacyEnv,profPath);let b=tomlStr(profPath,"base_url");if(!b)b=tomlStr(expand(P.home||"~/.codex")+"/config.toml","openai_base_url");if(!b)b="built-in OpenAI/ChatGPT endpoint";baseOrigin=b.replace(/\/+$/,"");const k=sec.OPENAI_API_KEY||sec.CODEX_API_KEY||legacyEnv.OPENAI_API_KEY||"";headers=k?{"Authorization":"Bearer "+k}:{};headers["User-Agent"]=P.probe_ua||"codex_cli_rs/0.40.0 (external, cli)";secretOk=!!k;} -if(!baseOrigin||/^built-in/.test(baseOrigin)){out({status:"down",latencyMs:0,method:"none",error:"missing base_url"});process.exit(0);} -if(!secretOk){out({status:"down",latencyMs:0,method:"none",error:"missing credentials"});process.exit(0);} -let urls=[]; -if(tool==="claude"){const apiBase=/\/v1$/.test(baseOrigin)?baseOrigin.replace(/\/v1$/,""):baseOrigin;urls.push(apiBase+"/v1/messages");} -else{const hasVer=/\/v\d+$/.test(baseOrigin);const apiBase=hasVer?baseOrigin:baseOrigin+"/v1";urls.push(apiBase+"/responses");urls.push(apiBase+"/chat/completions");} -const bodyFor=(u)=>u.endsWith("/responses")?JSON.stringify({model:probeModel,input:".",max_output_tokens:1}):JSON.stringify({model:probeModel,max_tokens:1,messages:[{role:"user",content:"."}]}); -const decode=(x)=>String(x||"").replace(/\\u([0-9a-f]{4})/gi,(raw,hex)=>{const code=parseInt(hex,16);return code<0x20||(code>=0x7f&&code<0xa0)?"?":String.fromCharCode(code)}).replace(/[\u0000-\u001f\u007f-\u009f]/g,"?"); -const compactBody=(raw)=>{try{const j=JSON.parse(raw);const msg=j?.error?.message||j?.message||j?.error||j?.type||"";if(msg)return decode(msg).replace(/\s+/g," ").trim();}catch{}return decode(raw).replace(/\s+/g," ").trim();}; -const req=(u)=>new Promise((resolve)=>{const t0=Date.now();let done=false;const fin=(r)=>{if(!done){done=true;r.latencyMs=Date.now()-t0;resolve(r);}};const obj=new URL(u);const lib=obj.protocol==="http:"?http:https;const body=bodyFor(u);const r=lib.request(obj,{method:"POST",headers:{...headers,"Content-Type":"application/json","Content-Length":Buffer.byteLength(body)},timeout:20000},(res)=>{let d="";res.on("data",(c)=>d+=c);res.on("end",()=>{const detail=compactBody(d).slice(0,240);fin({ok:res.statusCode>=200&&res.statusCode<300,code:res.statusCode,body:d,err:detail?"HTTP "+res.statusCode+" "+detail:"HTTP "+res.statusCode});});});r.on("timeout",()=>{r.destroy();fin({ok:false,code:0,body:"",err:"timeout"});});r.on("error",(e)=>fin({ok:false,code:0,body:"",err:probeErr(e.message)}));r.write(body);r.end();});const modelUnsupported=(x)=>/no available providers|model_not_found|model not found|model does not exist|unknown model|unsupported model|model .*not supported|not support.*model|invalid model|model_not_supported|模型不存在|模型.*不存在|请检查模型代码/.test(decode(x).toLowerCase()); -(async()=>{let lastErr=null,anyTransient=false;for(const u of urls){const r=await req(u);if(r.ok){let valid=true;try{const j=JSON.parse(r.body);if(u.endsWith("/messages"))valid=(Array.isArray(j.content)&&j.content.length>0)||j.type==="message";else if(u.endsWith("/responses"))valid=(Array.isArray(j.output)&&j.output.length>0)||j.output_text||j.status==="completed";else valid=Array.isArray(j.choices)&&j.choices.length>0;}catch{valid=false;}if(valid){const st=r.latencyMs>degradedMs?"degraded":"healthy";return out({status:st,latencyMs:r.latencyMs,method:"generation",error:null});}lastErr="200 but no generated content";}else{lastErr=r.err||(r.code?("HTTP "+r.code):"request failed");if(modelUnsupported(lastErr))return out({status:"degraded",latencyMs:r.latencyMs||0,method:"none",error:String(lastErr)});if(r.code===429||(r.code>=500&&r.code<600))anyTransient=true;}} -if(anyTransient)return out({status:"degraded",latencyMs:0,method:"none",error:String(lastErr)+(anyTransient?" (transient)":"")}); -return out({status:"down",latencyMs:0,method:"none",error:String(lastErr)}); -})(); -' "$tool" "$profile_json" "$AI_SECRETS_PATH" "$CLAUDE_ROUTER_BASE_URL" -} - -# Extract one field from a health-result JSON. -_ai_health_field() { node -e 'const j=JSON.parse(process.argv[1]||"{}");process.stdout.write(String(j[process.argv[2]]??""));' "$1" "$2"; } - -# Read cached entry JSON for tool.name (empty if absent). -_ai_health_read_entry() { - local tool="$1" name="$2" - [ -f "$AI_HEALTH_PATH" ] || return 0 - node -e 'const fs=require("fs");const j=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));const k=process.argv[2]+"."+process.argv[3];const e=j[k];if(e)process.stdout.write(JSON.stringify(e));' "$AI_HEALTH_PATH" "$tool" "$name" -} -_ai_health_store() { - local tool="$1" name="$2" result_json="$3" - mkdir -p "$AI_CONFIG_DIR" - node -e 'const fs=require("fs");const p=process.argv[1],tool=process.argv[2],name=process.argv[3],r=JSON.parse(process.argv[4]);let j={};try{j=JSON.parse(fs.readFileSync(p,"utf8"));}catch{}j[tool+"."+name]=r;fs.writeFileSync(p,JSON.stringify(j,null,2)+"\n");' "$AI_HEALTH_PATH" "$tool" "$name" "$result_json" -} - -# TTL-cached probe. Echoes result JSON. $3=1 forces a fresh probe. -# $4=1 -> cache-only: never probe (keeps list/status/switch instant; a stale or -# unprobed entry reads as "skip" ⏭). Use `health` / `status --refresh` to probe. -_ai_health_cached() { - local tool="$1" profile_json="$2" fresh="${3:-0}" cache_only="${4:-0}" name result probed now stamped - name="$(_ai_profile_value "$profile_json" name "")" - if [ "$fresh" != "1" ]; then - result="$(_ai_health_read_entry "$tool" "$name")" - if [ -n "$result" ]; then - probed="$(_ai_health_field "$result" probedAt)" - now="$(node -e 'process.stdout.write(String(Math.floor(Date.now()/1000)))')" - if [ -n "$probed" ] && [ "$probed" -gt 0 ] && [ $((now - probed)) -lt "$AI_HEALTH_TTL" ]; then - printf '%s' "$result"; return 0 - fi - fi - fi - if [ "$cache_only" = "1" ]; then - printf '{"status":"skip","latencyMs":0,"method":null,"error":null,"probedAt":0}' - return 0 - fi - result="$(_ai_probe_health "$tool" "$profile_json")" - stamped="$(node -e 'const r=JSON.parse(process.argv[1]);r.probedAt=Math.floor(Date.now()/1000);process.stdout.write(JSON.stringify(r));' "$result")" - _ai_health_store "$tool" "$name" "$stamped" 2>/dev/null || true - printf '%s' "$stamped" -} - -_ai_health_cell() { - local result="$1" hstatus code - [ -n "$result" ] || { printf '?'; return; } - hstatus="$(_ai_health_field "$result" status)" - case "$hstatus" in - healthy) printf '🟢%sms' "$(_ai_health_field "$result" latencyMs)";; - degraded) code="$(printf '%s' "$(_ai_health_field "$result" error)" | grep -oE 'HTTP [0-9]{3}' | head -1 | grep -oE '[0-9]{3}')"; printf '🟡%s' "${code:-slow}";; - down) code="$(printf '%s' "$(_ai_health_field "$result" error)" | grep -oE 'HTTP [0-9]{3}' | head -1 | grep -oE '[0-9]{3}')"; printf '🔴%s' "${code:-err}";; - skip) printf '⏭';; - *) printf '?';; - esac -} -_ai_health_cell_cached() { - local tool="$1" profile_json="$2" name result probed now - name="$(_ai_profile_value "$profile_json" name "")" - result="$(_ai_health_read_entry "$tool" "$name")" - [ -n "$result" ] || { printf '⏭'; return; } - probed="$(_ai_health_field "$result" probedAt)" - now="$(node -e 'process.stdout.write(String(Math.floor(Date.now()/1000)))')" - if [ -n "$probed" ] && [ "$probed" -gt 0 ] && [ $((now - probed)) -lt "$AI_HEALTH_TTL" ]; then - _ai_health_cell "$result" - else - printf '⏭' - fi -} - -_ai_health_display_error() { - local error="${1:-}" max="${2:-${AI_HEALTH_COLUMNS:-${COLUMNS:-120}}}" prefix="${3:-}" - node -e ' -const decode=(x)=>String(x||"").replace(/\\u([0-9a-f]{4})/gi,(raw,hex)=>{const code=parseInt(hex,16);return code<0x20||(code>=0x7f&&code<0xa0)?"?":String.fromCharCode(code)}).replace(/[\u0000-\u001f\u007f-\u009f]/g,"?"); -const text=decode(process.argv[1]).replace(/\s+/g," ").trim(); -const modelUnsupported=(x)=>/no available providers|model_not_found|model not found|model does not exist|unknown model|unsupported model|model .*not supported|not support.*model|invalid model|model_not_supported|模型不存在|模型.*不存在|请检查模型代码/.test(decode(x).toLowerCase()); -const compactHttp=(s)=>{ - s=decode(s).replace(/\s+/g," ").trim(); - const m=s.match(/^(HTTP \d{3})(?:\s+(.+))?$/); - if(!m)return s; - const code=m[1], body=m[2]||""; - if(!body)return code; - try{const j=JSON.parse(body);const msg=j?.error?.message||j?.message||j?.error||j?.type||"";if(msg)return code+" "+String(msg).replace(/\s+/g," ").trim();}catch{} - const mm=body.match(/"message"\s*:\s*"([^"]+)"/)||body.match(/message=([^,;}]+)/); - if(mm)return code+" "+mm[1].replace(/\s+/g," ").trim(); - return s; -}; -const short=(s)=>modelUnsupported(s)?"probe model unsupported; set probe_model":compactHttp(s); -const one=text.match(/^(POST\s+\/\S+\s+)(.+)$/); -const dual=text.match(/^(POST\s+\/\S+\s+->\s+)(.+?)(;\s+\/\S+\s+->\s+)(.+)$/); -let out=text; -if(dual)out=dual[1]+short(dual[2])+dual[3]+short(dual[4]); -else if(one)out=one[1]+short(one[2]); -else out=short(text); -const charWidth=(cp)=>cp===0||cp<32||(cp>=0x7f&&cp<0xa0)?0:(cp>=0x1100&&(cp<=0x115f||cp===0x2329||cp===0x232a||(cp>=0x2e80&&cp<=0xa4cf)||(cp>=0xac00&&cp<=0xd7a3)||(cp>=0xf900&&cp<=0xfaff)||(cp>=0xfe10&&cp<=0xfe19)||(cp>=0xfe30&&cp<=0xfe6f)||(cp>=0xff00&&cp<=0xff60)||(cp>=0xffe0&&cp<=0xffe6)||(cp>=0x1f300&&cp<=0x1faff)))?2:1; -const width=(s)=>{let n=0;for(const ch of String(s||""))n+=charWidth(ch.codePointAt(0));return n}; -const trunc=(s,n)=>{s=String(s||"");if(width(s)<=n)return s;const suffix=n>3?"...":"",limit=Math.max(0,n-width(suffix));let result="",w=0;for(const ch of s){const cw=charWidth(ch.codePointAt(0));if(w+cw>limit)break;result+=ch;w+=cw}return result+suffix}; -const requested=Number(process.argv[2]||120),max=Math.max(1,Math.min(120,requested>0?requested:120)),prefix=String(process.argv[3]||""); -process.stdout.write(trunc(prefix+(prefix&&out?" ":"")+out,max)); -' "$error" "$max" "$prefix" -} - -_ai_healthy_profile() { - local tool="$1" default ordered pj h st nm - default="$(_ai_default_profile "$tool")" - _ai_require_node || return 1 - ordered="$(node -e ' -const fs=require("fs");const p=process.argv[1],tool=process.argv[2],def=process.argv[3]; -let r={};try{r=JSON.parse(fs.readFileSync(p,"utf8"));}catch{} -const all=(r[tool]||[]).filter(x=>x.enabled!==false); -const d=all.find(x=>String(x.name)===def);const rest=all.filter(x=>String(x.name)!==def); -const out=d?[d,...rest]:rest; -for(const x of out)console.log(JSON.stringify(x)); -' "$AI_REGISTRY_PATH" "$tool" "$default")" || return 1 - while IFS= read -r pj; do - [ -n "$pj" ] || continue - h="$(_ai_health_cached "$tool" "$pj" 0 1)" - st="$(_ai_health_field "$h" status)" - # Only auto-select a profile with a cached POSITIVE signal (healthy/ - # degraded) — an unprobed api profile (skip) or a subscription profile - # (unprobeable, also skip) is NOT chosen, since we can't confirm it's up. - # Run `cc health` first to populate health for real auto-failover. - if [ "$st" = "healthy" ] || [ "$st" = "degraded" ]; then - _ai_profile_value "$pj" name ""; return - fi - done <<<"$ordered" - printf '%s\n' "$default" -} - -_ai_set_probe_model() { - local tool="$1"; shift - local parsed name model - parsed="$(_ai_parse_management_args "$@")" || return - name="$(_ai_mgmt_positional "$parsed" 0)" - [ -n "$name" ] || { echo "Usage: probe-model [model] (omit model to clear -> automatic probe model)" >&2; return 1; } - model="$(_ai_mgmt_positional "$parsed" 1)" - _ai_profile_json "$tool" "$name" >/dev/null || { echo "$tool profile '$name' not found." >&2; return 1; } - local res - res="$(node -e ' -const fs=require("fs");const p=process.argv[1],tool=process.argv[2],query=String(process.argv[3]).toLowerCase(),model=process.argv[4]; -let r={};try{r=JSON.parse(fs.readFileSync(p,"utf8"));}catch{} -for(const x of r[tool]||[]){const names=[x.name,...(x.aliases||[])].filter(Boolean).map(s=>String(s).toLowerCase());if(names.includes(query)){if(model)x.probe_model=model;else if(x.probe_model)delete x.probe_model;fs.writeFileSync(p,JSON.stringify(r,null,2)+"\n");process.stdout.write(x.name+"\t"+(model?"set":"clear"));process.exit(0);}} -process.exit(1); -' "$AI_REGISTRY_PATH" "$tool" "$name" "$model")" || { echo "$tool profile '$name' not found." >&2; return 1; } - local pname act; pname="${res%% *}"; act="${res#* }" - if [ "$act" = "set" ]; then echo "Set $tool '$pname' probe_model = $model"; else echo "Cleared $tool '$pname' probe_model (using automatic probe model)"; fi -} - -_ai_set_default() { - local tool="$1"; shift - local parsed name cur - parsed="$(_ai_parse_management_args "$@")" || return - cur="$(_ai_default_profile "$tool")" - name="$(_ai_mgmt_positional "$parsed" 0)" - if [ -z "$name" ]; then echo "$tool default = $cur"; return; fi - _ai_profile_json "$tool" "$name" >/dev/null || { echo "Unknown $tool profile '$name'." >&2; return 1; } - node -e ' -const fs=require("fs");const p=process.argv[1],tool=process.argv[2],name=process.argv[3]; -let r={};try{r=JSON.parse(fs.readFileSync(p,"utf8"));}catch{} -r.defaults=r.defaults||{};r.defaults[tool]=name;fs.writeFileSync(p,JSON.stringify(r,null,2)+"\n"); -' "$AI_REGISTRY_PATH" "$tool" "$name" - echo "Set $tool default = $name" -} - -_ai_health_clear() { rm -f "$AI_HEALTH_PATH"; } -_ai_health_sync_cache() { - local tool="$1" - [ -f "$AI_HEALTH_PATH" ] || return 0 - node -e ' -const fs=require("fs");const hp=process.argv[1],tool=process.argv[2],rp=process.argv[3]; -let r={};try{r=JSON.parse(fs.readFileSync(rp,"utf8"));}catch{} -const valid=new Set((r[tool]||[]).filter(x=>x.enabled!==false).map(x=>tool+"."+x.name)); -let j={};try{j=JSON.parse(fs.readFileSync(hp,"utf8"));}catch{} -let changed=false; -for(const k of Object.keys(j)){if(k.startsWith(tool+".")&&!valid.has(k)){delete j[k];changed=true;}} -if(changed)fs.writeFileSync(hp,JSON.stringify(j,null,2)+"\n"); -' "$AI_HEALTH_PATH" "$tool" "$AI_REGISTRY_PATH" -} - -_ai_health_show() { - # Delegate to the Node TUI helper (ai-health.mjs). The old shell version probed - # via background `&` jobs, whose zsh/bash job-control notifications ([N] PID / - # [N]+done) interleaved with ANSI redraw and broke the table. One foreground - # Node process does the concurrency + timed redraw itself — no shell job - # control, no [N] noise, save-anchor+clear-below rendering, ASCII spinner. - local tool="$1" fresh="${2:-0}" flag="" - [ "$fresh" = "1" ] && flag="--fresh" - _ai_require_node >/dev/null 2>&1 || return 1 - local mjs="$HOME/.local/share/ai-env/ai-health.mjs" - [ -f "$mjs" ] || { echo "health helper missing: $mjs" >&2; return 1; } - node "$mjs" "$tool" $flag -} - - -# $3=1 forces a live probe (status --refresh); otherwise cache-only (instant). -_ai_health_status_line() { - local tool="$1" profile_json="$2" fresh="${3:-0}" h cell err max - if [ "$fresh" = "1" ]; then - h="$(_ai_health_cached "$tool" "$profile_json" 1 0)" - else - h="$(_ai_health_cached "$tool" "$profile_json" 0 1)" - fi - cell="$(_ai_health_cell "$h")" - err="$(_ai_health_field "$h" error)" - max="${AI_HEALTH_COLUMNS:-${COLUMNS:-120}}" - _ai_health_display_error "$err" "$max" " Health: $cell" - printf '\n' -} - -# ===================== MCP module (mirrors ai-env.ps1) ===================== -# ~/.ai-env/mcp.toml is the SSOT. `mcp sync` pushes to global targets: -# Claude -> ~/.claude.json mcpServers (node JSON merge, atomic) -# Codex -> ~/.codex/config.toml [mcp_servers.NAME] (block edit) - -# entry json -> [mcp.NAME] TOML block -_ai_mcp_toml_block() { - node -e ' -const e=JSON.parse(process.argv[1]); -const L=["[mcp."+e.name+"]"]; -if(e.kind==="http"){L.push("url = \""+(e.url||"")+"\"");} -else{const cmd=(e.command||[]).map(x=>"\""+String(x)+"\"").join(", ");L.push("command = ["+cmd+"]");const env=e.env||{};const ks=Object.keys(env);if(ks.length)L.push("env = { "+ks.map(k=>k+" = \""+env[k]+"\"").join(", ")+" }");} -L.push("sync = ["+(e.sync||[]).map(x=>"\""+x+"\"").join(", ")+"]"); -L.push("enabled = "+(e.enabled?"true":"false")); -process.stdout.write(L.join("\n")); -' "$1" -} -# read mcp.toml -> one entry JSON per line -_ai_mcp_read() { - [ -f "$AI_MCP_PATH" ] || return 0 - node -e ' -const fs=require("fs"); -const pval=(raw)=>{const v=String(raw||"").trim();if(v.startsWith("\"")){const m=v.match(/^"((?:\\.|[^"])*)"/);if(m){try{return JSON.parse(m[0]);}catch{return m[1];}}}return v.replace(/\s+#.*$/,"").trim();}; -const parr=(raw)=>{const o=[];for(const m of String(raw||"").matchAll(/"((?:\\.|[^"])*)"/g))o.push(m[1]);return o;}; -const ptbl=(raw)=>{const h={};for(const m of String(raw||"").matchAll(/([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"((?:\\.|[^"])*)"/g))h[m[1]]=m[2];return h;}; -let cur=null;const r={}; -for(const line of fs.readFileSync(process.argv[1],"utf8").split(/\r?\n/)){const t=line.trim();if(!t||t.startsWith("#"))continue;const sec=t.match(/^\[mcp\.([^\]]+)\]\s*$/);if(sec){cur=sec[1].trim();r[cur]={name:cur,kind:"stdio",command:[],url:null,env:{},sync:["claude","codex"],enabled:true};continue;}if(/^\[/.test(t)){cur=null;continue;}if(!cur)continue;const m=t.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/);if(!m)continue;const k=m[1],raw=m[2].trim(),e=r[cur];if(k==="command"){e.kind="stdio";e.command=parr(raw);}else if(k==="url"){e.kind="http";e.url=pval(raw);}else if(k==="env"){e.env=ptbl(raw);}else if(k==="sync"){e.sync=parr(raw);}else if(k==="enabled"){e.enabled=/true/.test(raw);}} -for(const n of Object.keys(r))console.log(JSON.stringify(r[n])); -' "$AI_MCP_PATH" -} - -# Claude target upsert/remove. $2=entryJson (empty=remove) -_ai_mcp_claude_set() { - local name="$1" entry="$2" p bak tmp - p="$(_ai_claude_json_path)"; bak="$p.aienv.bak"; tmp="$p.tmp" - [ -f "$p" ] && [ ! -f "$bak" ] && cp "$p" "$bak" 2>/dev/null || true - node -e ' -const fs=require("fs");const p=process.argv[1],tmp=process.argv[2],name=process.argv[3],hasE=process.argv[4]==="1",entry=process.argv[5]; -let d={};try{if(fs.existsSync(p))d=JSON.parse(fs.readFileSync(p,"utf8"));}catch{} -const ms={};if(d.mcpServers&&typeof d.mcpServers==="object")for(const k of Object.keys(d.mcpServers))ms[k]=d.mcpServers[k]; -if(hasE){const e=JSON.parse(entry);const o={};if(e.kind==="http"){o.type="http";o.url=e.url;}else{if(Array.isArray(e.command)&&e.command.length)o.command=String(e.command[0]);o.args=Array.isArray(e.command)&&e.command.length>1?e.command.slice(1):[];if(e.env&&Object.keys(e.env).length)o.env=e.env;}ms[name]=o;}else if(ms[name]){delete ms[name];} -d.mcpServers=ms;fs.writeFileSync(tmp,JSON.stringify(d,null,2)); -' "$p" "$tmp" "$name" "$([ -n "$entry" ] && echo 1 || echo 0)" "$entry" - mv "$tmp" "$p" -} -# Codex target upsert/remove. $2=TOML block (empty=remove) -_ai_mcp_codex_set() { - local name="$1" block="$2" p header out - p="$(_ai_codex_config_path)"; header="[mcp_servers.$name]" - if [ ! -f "$p" ]; then - [ -z "$block" ] && return 0 - mkdir -p "$(dirname "$p")"; : >"$p" - fi - out="$(awk -v h="$header" ' - { line=$0; sub(/^[ \t]+/,"",line); sub(/[ \t]+$/,"",line); - if (line ~ /^\[/) { skip = (line == h) ? 1 : 0 } - if (!skip) print - } - ' "$p")" - if [ -n "$block" ]; then - [ -n "$out" ] && out="$out"$'\n\n' - out="$out$block" - fi - printf '%s\n' "$out" >"$p" -} - -_ai_claude_mcp_names() { - local p; p="$(_ai_claude_json_path)" - [ -f "$p" ] || return 0 - node -e 'const fs=require("fs");try{const d=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));if(d.mcpServers)for(const k of Object.keys(d.mcpServers))console.log(k);}catch{}' "$p" -} -_ai_codex_mcp_test() { - local p; p="$(_ai_codex_config_path)" - [ -f "$p" ] || return 1 - grep -qE "^\[mcp_servers\.$(printf '%s' "$1" | sed 's/[][\.*/^$[]/\\&/g')\]" "$p" -} - -_ai_mcp_sync() { - local count; count="$(_ai_mcp_read | wc -l | tr -d ' ')" - if [ "$count" -eq 0 ]; then echo "No MCP servers in $AI_MCP_PATH. Run 'mcp edit'."; return; fi - local entry name kind want cblock ups=0 rem=0 - while IFS= read -r entry; do - [ -n "$entry" ] || continue - name="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).name)' "$entry")" - want="$(node -e 'const e=JSON.parse(process.argv[1]);process.stdout.write((e.enabled&&e.sync&&e.sync.indexOf("claude")>=0)?"1":"0")' "$entry")" - if [ "$want" = "1" ]; then _ai_mcp_claude_set "$name" "$entry"; ups=$((ups+1)); else _ai_mcp_claude_set "$name" ""; rem=$((rem+1)); fi - want="$(node -e 'const e=JSON.parse(process.argv[1]);process.stdout.write((e.enabled&&e.sync&&e.sync.indexOf("codex")>=0)?"1":"0")' "$entry")" - if [ "$want" = "1" ]; then - cblock="$(_ai_mcp_codex_block_for_entry "$entry")"; _ai_mcp_codex_set "$name" "$cblock"; ups=$((ups+1)) - else - _ai_mcp_codex_set "$name" ""; rem=$((rem+1)) - fi - done < <(_ai_mcp_read) - echo "MCP sync done: $ups upsert(s), $rem remove(s). Targets: Claude ($(_ai_claude_json_path)), Codex ($(_ai_codex_config_path))." -} -# entry json -> codex [mcp_servers.NAME] block -_ai_mcp_codex_block_for_entry() { - node -e ' -const e=JSON.parse(process.argv[1]);const L=["[mcp_servers."+e.name+"]"]; -if(e.kind==="http"){L.push("url = \""+(e.url||"")+"\"");} -else{const cmd=(e.command||[]).map(x=>"\""+String(x)+"\"").join(", ");L.push("command = ["+cmd+"]");const env=e.env||{};const ks=Object.keys(env);if(ks.length)L.push("env = { "+ks.map(k=>k+" = \""+env[k]+"\"").join(", ")+" }");} -L.push("enabled = "+(e.enabled?"true":"false")); -process.stdout.write(L.join("\n")); -' "$1" -} - -_ai_mcp_list() { - local count; count="$(_ai_mcp_read | wc -l | tr -d ' ')" - if [ "$count" -eq 0 ]; then echo "No MCP servers in $AI_MCP_PATH. Run 'mcp edit'."; return; fi - local entry name kind enabled sync cc cx - printf '%-16s %-7s %-8s %-8s %-8s %s\n' Name Type Claude Codex Enabled Sync - while IFS= read -r entry; do - [ -n "$entry" ] || continue - name="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).name)' "$entry")" - kind="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).kind)' "$entry")" - enabled="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).enabled?"on":"off")' "$entry")" - sync="$(node -e 'process.stdout.write((JSON.parse(process.argv[1]).sync||[]).join(","))' "$entry")" - cc="-"; _ai_claude_mcp_names | grep -qxF "$name" && cc="yes" - cx="-"; _ai_codex_mcp_test "$name" && cx="yes" - printf '%-16s %-7s %-8s %-8s %-8s %s\n' "$name" "$kind" "$cc" "$cx" "$enabled" "$sync" - done < <(_ai_mcp_read) - echo " (yes = present in target; run 'mcp sync' to align)" -} -_ai_mcp_get() { - local name="$1" entry - [ -n "$name" ] || { echo "Usage: mcp get NAME"; return; } - entry="$(_ai_mcp_read | node -e 'const n=process.argv[1];let r="";for(let line of (require("fs").readFileSync(0,"utf8").split(/\r?\n/))){try{const e=JSON.parse(line);if(e.name===n){r=JSON.stringify(e);break;}}catch{}}process.stdout.write(r);' "$name")" - [ -n "$entry" ] || { echo "No MCP server '$name' in mcp.toml."; return; } - node -e ' -const e=JSON.parse(process.argv[1]);console.log("mcp."+e.name+":");console.log(" kind : "+e.kind); -if(e.kind==="http")console.log(" url : "+e.url);else console.log(" command : "+(e.command||[]).join(" ")); -const env=e.env||{};const ks=Object.keys(env);if(ks.length)console.log(" env : "+ks.map(k=>k+"="+env[k]).join(", ")); -console.log(" sync : "+(e.sync||[]).join(", "));console.log(" enabled : "+e.enabled); -' "$entry" - local cc="-" cx="-" - _ai_claude_mcp_names | grep -qxF "$name" && cc="present" - _ai_codex_mcp_test "$name" && cx="present" - echo " claude : $cc"; echo " codex : $cx" -} -_ai_mcp_edit() { - if [ ! -f "$AI_MCP_PATH" ]; then - mkdir -p "$AI_CONFIG_DIR" - cat >"$AI_MCP_PATH" <<'TOML' -# ~/.ai-env/mcp.toml - single source of truth for MCP servers (Claude Code + Codex). -# `mcp sync` pushes each enabled server to global targets: -# Claude -> ~/.claude.json mcpServers -# Codex -> ~/.codex/config.toml [mcp_servers.NAME] -# A server is EITHER stdio (command = [...]) OR http (url = "..."). -# sync = which tools (omit = both). enabled = false keeps it defined but skips it. - -# [mcp.context7] -# command = ["npx", "-y", "@upstash/context7-mcp"] -# env = {} -# sync = ["claude", "codex"] -# enabled = true -TOML - echo "Created starter mcp.toml at $AI_MCP_PATH" - fi - local ed="${EDITOR:-${VISUAL:-}}" - [ -n "$ed" ] || ed="$(command -v cursor || command -v code || echo vi)" - # strip --wait/-w so mcp edit opens and returns (non-blocking) - local cmd rest - cmd="$(printf '%s' "$ed" | awk '{for(i=1;i<=NF;i++)if($i!="--wait"&&$i!="-w")printf "%s%s",$i,(i/dev/null 2>&1 & ) 2>/dev/null ;; - esac -} - -# `cc edit` / `cx edit` — open the profile registry (profiles.json), where every -# profile's base_url, model, probe_model, mode etc. live. Non-blocking (no --wait). -_ai_registry_edit() { - if [ ! -f "$AI_REGISTRY_PATH" ]; then - echo "Registry not found: $AI_REGISTRY_PATH" - return 1 - fi - local ed="${EDITOR:-${VISUAL:-}}" - [ -n "$ed" ] || ed="$(command -v cursor || command -v code || echo vi)" - local cmd - cmd="$(printf '%s' "$ed" | awk '{for(i=1;i<=NF;i++)if($i!="--wait"&&$i!="-w")printf "%s%s",$i,(i/dev/null 2>&1 & ) 2>/dev/null ;; - esac -} -_ai_mcp_pull() { - local name="${1:-}" cp cj cx existing added=0 skipped=0 newblocks="" - # existing mcp.toml names - existing="$(_ai_mcp_read | node -e 'for(let l of require("fs").readFileSync(0,"utf8").split(/\r?\n/)){try{console.log(JSON.parse(l).name);}catch{}}')" - # claude mcpServers - cp="$(_ai_claude_json_path)" - cj="$(node -e 'const fs=require("fs");try{const d=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));const m=d.mcpServers||{};for(const k of Object.keys(m)){const e=m[k];const o={name:k,kind:"stdio",command:[],url:null,env:{},sync:["claude"],enabled:true};if(e.type==="http"||e.type==="sse"||e.url){o.kind="http";o.url=e.url;}else{const c=[];if(e.command){if(Array.isArray(e.command))c.push(...e.command);else c.push(String(e.command));}if(Array.isArray(e.args))c.push(...e.args);o.command=c;if(e.env&&typeof e.env==="object")o.env=e.env;}console.log(JSON.stringify(o));}}catch{}' "$cp")" - # codex mcp_servers - xp="$(_ai_codex_config_path)" - xj="$(node -e 'const fs=require("fs");const p=process.argv[1];if(!fs.existsSync(p)){process.exit(0);}let cur=null;const r={};for(const line of fs.readFileSync(p,"utf8").split(/\r?\n/)){const t=line.trim();if(!t||t.startsWith("#"))continue;const s=t.match(/^\[mcp_servers\.([^\]]+)\]\s*$/);if(s){cur=s[1].trim();r[cur]={name:cur,command:[],url:null,env:{},enabled:true};continue;}if(/^\[/.test(t)){cur=null;continue;}if(!cur)continue;const m=t.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/);if(!m)continue;const k=m[1],raw=m[2].trim();const e=r[cur];if(k==="command"){const arr=[];for(const mm of raw.matchAll(/"((?:\\.|[^"])*)"/g))arr.push(mm[1]);e.command=arr;}else if(k==="url"){e.url=raw.replace(/^"|"$/g,"");e.kind="http";}else if(k==="enabled"){e.enabled=/true/.test(raw);}}for(const n of Object.keys(r)){const e=r[n];const o={name:n,kind:e.url?"http":"stdio",command:e.command||[],url:e.url,env:{},sync:["codex"],enabled:e.enabled};console.log(JSON.stringify(o));}' "$xp")" - # merge: for each unique name in cj+xj - local all - all="$(printf '%s\n%s\n' "$cj" "$xj" | node -e ' -const lines=require("fs").readFileSync(0,"utf8").split(/\r?\n/); -const byName={}; -for(const l of lines){if(!l.trim())continue;try{const e=JSON.parse(l);if(!byName[e.name])byName[e.name]={e:e,sync:new Set()};byName[e.name].sync.add(e.sync[0]);}catch{}} -for(const n of Object.keys(byName)){const x=byName[n];x.e.sync=Array.from(x.sync);console.log(JSON.stringify(x.e));} -')" - if [ -n "$name" ]; then - all="$(printf '%s\n' "$all" | node -e 'const n=process.argv[1];for(const l of require("fs").readFileSync(0,"utf8").split(/\r?\n/)){try{const e=JSON.parse(l);if(e.name===n){console.log(JSON.stringify(e));break;}}catch{}}' "$name")" - [ -n "$all" ] || { echo "'$name' not found in Claude or Codex targets."; return; } - fi - while IFS= read -r e; do - [ -n "$e" ] || continue - en="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).name)' "$e")" - if printf '%s\n' "$existing" | grep -qxF "$en"; then skipped=$((skipped+1)); continue; fi - newblocks="$newblocks"$'\n\n'"$(_ai_mcp_toml_block "$e")" - added=$((added+1)) - done <<<"$all" - if [ "$added" -gt 0 ]; then - if [ ! -f "$AI_MCP_PATH" ]; then mkdir -p "$AI_CONFIG_DIR"; printf '# ~/.ai-env/mcp.toml - pulled from Claude Code & Codex. Edit freely; run mcp sync to push back.\n' >"$AI_MCP_PATH"; fi - { [ -s "$AI_MCP_PATH" ] && printf '\n'; printf '%s\n' "${newblocks#$'\n\n'}"; } >>"$AI_MCP_PATH" - fi - echo "MCP pull: +$added added, $skipped skipped (already in mcp.toml). -> $AI_MCP_PATH" -} - -mcp() { - local arg="${1:-}" - case "$arg" in - ""|help|-h|--help) - cat <<'EOF' -mcp - manage MCP servers across Claude Code & Codex from ~/.ai-env/mcp.toml - -Usage: - mcp Show this help - mcp list List servers + whether each target has them - mcp edit Open mcp.toml in EDITOR (creates a starter if absent) - mcp sync Push mcp.toml -> Claude (~/.claude.json) & Codex (~/.codex/config.toml) - mcp pull [NAME] Import existing MCP servers FROM Claude & Codex into mcp.toml - mcp get NAME Show one server's config + target status - -mcp.toml is the single source of truth; edit it, then `mcp sync` (idempotent). -enabled = false keeps a server defined but skips it on sync. -sync = ["claude"] or ["codex"] limits a server to one tool (omit = both). -EOF - ;; - list) _ai_mcp_list ;; - edit) _ai_mcp_edit ;; - sync) _ai_mcp_sync ;; - pull|import) shift; _ai_mcp_pull "$@" ;; - get|show) _ai_mcp_get "${2:-}" ;; - *) echo "Unknown mcp command '$arg'." >&2; return 1 ;; - esac -} - -_ai_init_saved_profiles() { - local profile_json - profile_json="$(_ai_profile_json codex "$(_ai_saved_profile codex)" 2>/dev/null)" && \ - _set_codex_env "$profile_json" >/dev/null 2>&1 || \ - echo "warning: could not initialize saved Codex profile" >&2 - - profile_json="$(_ai_profile_json claude "$(_ai_saved_profile claude)" 2>/dev/null)" && \ - _set_claude_env "$profile_json" >/dev/null 2>&1 || \ - echo "warning: could not initialize saved Claude Code profile" >&2 -} - -_ai_init_saved_profiles diff --git a/dot_local/share/ai-env/ai-health.mjs b/dot_local/share/ai-env/ai-health.mjs deleted file mode 100644 index 421fe21..0000000 --- a/dot_local/share/ai-env/ai-health.mjs +++ /dev/null @@ -1,419 +0,0 @@ -#!/usr/bin/env node -// ai-health.mjs — concurrent health probe + dynamic TUI for `cc/cx health`. -// -// Why this exists as a Node script (not shell): the old sh version probed via -// background `&` jobs, which printed zsh/bash job-control notifications ([N] PID -// / [N]+done) that interleaved with ANSI redraw and broke the table. Doing the -// concurrency + timed redraw inside one foreground Node process avoids shell job -// control entirely. Non-TTY (pipes/CI) prints a final table only. -// -// Usage: node ai-health.mjs [--fresh] -import fs from 'fs'; -import https from 'https'; -import http from 'http'; -import { URL } from 'url'; - -const tool = process.argv[2] || ''; -const fresh = process.argv.includes('--fresh'); -const HOME = process.env.HOME || process.env.HOMEPATH || '/root'; -const expand = (x) => !x ? '' : String(x).replace(/^~(?=\/|$)/, HOME); -const registryPath = process.env.AI_REGISTRY_PATH || `${HOME}/.ai-env/profiles.json`; -const secretsPath = process.env.AI_SECRETS_PATH || `${HOME}/.ai-secrets/secrets.toml`; -const healthPath = process.env.AI_HEALTH_PATH || `${HOME}/.ai-env/health.json`; -const TTL = Number(process.env.AI_HEALTH_TTL || 300); -const TIMEOUT_MS = Number(process.env.AI_HEALTH_TIMEOUT_MS || 8000); -const DEGRADED_MS = Number(process.env.AI_HEALTH_DEGRADED_MS || 8000); -const isTty = process.stdout.isTTY && process.env.AI_HEALTH_LIVE !== '0'; -const MAX_OUTPUT_WIDTH = 120; - -// ---------- secrets.toml + config parsing (ported from _ai_probe_health) ---------- -function parseSecrets(file) { - const s = {}; - if (!fs.existsSync(file)) return s; - let cur = null; - for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) { - const t = line.trim(); - if (!t || t.startsWith('#')) continue; - const sec = t.match(/^\[([^\]]+)\]\s*$/); - if (sec) { cur = sec[1].trim(); s[cur] = s[cur] || {}; continue; } - if (!cur) continue; - const m = t.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/); - if (!m) continue; - let v = m[2].trim(); - const dq = v.match(/^"((?:\\.|[^"])*)"/); - if (dq) { try { v = JSON.parse(dq[0]); } catch { v = dq[1]; } } - s[cur][m[1]] = v; - } - return s; -} -function tomlStr(file, key) { - if (!fs.existsSync(file)) return ''; - const re = new RegExp('^\\s*' + key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*=\\s*"([^"]*)"'); - for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) { - const m = line.match(re); - if (m) return m[1]; - } - return ''; -} -function profileEnv(p, key) { - return p && p.env && typeof p.env === 'object' && p.env[key] ? String(p.env[key]) : ''; -} -function probeModelFor(toolName, p, sec, legacyEnv, profPath) { - if (p.probe_model) return String(p.probe_model); - if (toolName === 'claude') { - return sec.ANTHROPIC_MODEL || - legacyEnv.ANTHROPIC_MODEL || - profileEnv(p, 'ANTHROPIC_MODEL') || - process.env.ANTHROPIC_MODEL || - sec.ANTHROPIC_DEFAULT_HAIKU_MODEL || - legacyEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || - profileEnv(p, 'ANTHROPIC_DEFAULT_HAIKU_MODEL') || - process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL || - 'claude-3-5-haiku-20241022'; - } - return tomlStr(profPath, 'model') || - tomlStr(expand(p.home || '~/.codex') + '/config.toml', 'model') || - 'gpt-5.4-mini'; -} - -// ---------- probe one profile ---------- -function buildPlan(p) { - const mode = p.mode || 'api'; - if (mode !== 'api') return { early: { status: 'skip', latencyMs: 0, method: null, error: 'subscription mode (no remote probe)' } }; - const secrets = parseSecrets(secretsPath); - const sid = p.secret_id || (tool + '.' + p.name); - const sec = secrets[sid] || {}; - // legacy per-profile file (env) - const legacyEnv = {}; - const legacy = expand(p.linux_secret || p.secret || ''); - if (legacy && fs.existsSync(legacy)) { - for (const line of fs.readFileSync(legacy, 'utf8').split(/\r?\n/)) { - const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/); - if (m) legacyEnv[m[1]] = m[2].trim().replace(/^"|"$/g, ''); - } - } - let baseOrigin = '', headers = {}, probeModel = ''; - if (tool === 'claude') { - probeModel = probeModelFor(tool, p, sec, legacyEnv, ''); - const b = sec.ANTHROPIC_BASE_URL || legacyEnv.ANTHROPIC_BASE_URL || p.base_url || ''; - baseOrigin = b.replace(/\/+$/, ''); - const at = sec.ANTHROPIC_AUTH_TOKEN || legacyEnv.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_AUTH_TOKEN || ''; - const ak = sec.ANTHROPIC_API_KEY || legacyEnv.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || ''; - headers = { 'anthropic-version': '2023-06-01' }; - if (at) headers.Authorization = 'Bearer ' + at; - if (ak) headers['x-api-key'] = ak; - if (/\[1m\]/i.test(probeModel)) headers['anthropic-beta'] = 'context-1m-2025-08-07'; - headers['User-Agent'] = p.probe_ua || 'claude-cli/1.0.119 (external, cli)'; - if (!at && !ak) return { early: { status: 'down', latencyMs: 0, method: null, error: 'missing credentials' } }; - } else { - const profPath = expand((p.home || '~/.codex') + '/' + (p.codex_profile || p.profile || String(p.name || '').replace(':', '-')) + '.config.toml'); - probeModel = probeModelFor(tool, p, sec, legacyEnv, profPath); - let b = tomlStr(profPath, 'base_url'); - if (!b) b = tomlStr(expand(p.home || '~/.codex') + '/config.toml', 'openai_base_url'); - if (!b) b = 'built-in OpenAI/ChatGPT endpoint'; - baseOrigin = b.replace(/\/+$/, ''); - const k = sec.OPENAI_API_KEY || sec.CODEX_API_KEY || legacyEnv.OPENAI_API_KEY || ''; - headers = k ? { Authorization: 'Bearer ' + k } : {}; - headers['User-Agent'] = p.probe_ua || 'codex_cli_rs/0.40.0 (external, cli)'; - if (!k) return { early: { status: 'down', latencyMs: 0, method: null, error: 'missing credentials' } }; - } - if (!baseOrigin || /^built-in/.test(baseOrigin)) return { early: { status: 'down', latencyMs: 0, method: null, error: 'missing base_url' } }; - const candidates = []; - let effLabel = null, altLabel = null; - if (tool === 'claude') { - const apiBase = /\/v1$/.test(baseOrigin) ? baseOrigin.replace(/\/v1$/, '') : baseOrigin; - candidates.push({ label: 'messages', url: apiBase + '/v1/messages', body: JSON.stringify({ model: probeModel, max_tokens: 1, messages: [{ role: 'user', content: '.' }] }), check: 'messages' }); - } else { - const hasVer = /\/v\d+$/.test(baseOrigin); - const apiBase = hasVer ? baseOrigin : baseOrigin + '/v1'; - candidates.push({ label: 'responses', url: apiBase + '/responses', body: JSON.stringify({ model: probeModel, input: '.', max_output_tokens: 1 }), check: 'responses' }); - candidates.push({ label: 'chat', url: apiBase + '/chat/completions', body: JSON.stringify({ model: probeModel, max_tokens: 1, messages: [{ role: 'user', content: '.' }] }), check: 'chat' }); - let wireApi = null; - const profPath = expand((p.home || '~/.codex') + '/' + (p.codex_profile || p.profile || String(p.name || '').replace(':', '-')) + '.config.toml'); - if (fs.existsSync(profPath)) wireApi = tomlStr(profPath, 'wire_api'); - effLabel = (wireApi && /chat/.test(wireApi)) ? 'chat' : 'responses'; - altLabel = effLabel === 'chat' ? 'responses' : 'chat'; - } - return { candidates, headers, effLabel, altLabel }; -} -function fetchOne(c, headers) { - return new Promise((resolve) => { - const t0 = Date.now(); - const r = { ok: false, code: 0, latencyMs: 0, detail: null }; - let done = false; - const fin = (x) => { if (!done) { done = true; Object.assign(r, x); r.latencyMs = Date.now() - t0; resolve(r); } }; - let obj; - try { obj = new URL(c.url); } catch { fin({ detail: 'bad url' }); return; } - const lib = obj.protocol === 'http:' ? http : https; - const req = lib.request(obj, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(c.body) } }, (res) => { - let d = ''; - res.on('data', (c2) => d += c2); - res.on('end', () => { - let valid = false; - if (res.statusCode >= 200 && res.statusCode < 300) { - try { - const j = JSON.parse(d); - if (c.check === 'messages') valid = (Array.isArray(j.content) && j.content.length > 0) || j.type === 'message'; - else if (c.check === 'responses') valid = (Array.isArray(j.output) && j.output.length > 0) || j.output_text || j.status === 'completed'; - else valid = Array.isArray(j.choices) && j.choices.length > 0; - } catch {} - } - const compactBody = compactResponseBody(d).slice(0, 240); - fin({ ok: valid, code: res.statusCode, detail: valid ? null : (res.statusCode >= 200 && res.statusCode < 300 ? '200 but no generated content' : ('HTTP ' + res.statusCode + (compactBody ? ' ' + compactBody : ''))) }); - }); - }); - req.on('timeout', () => { req.destroy(); fin({ detail: 'timeout' }); }); - req.on('error', (e) => fin({ detail: classifyErr(e.message) })); - req.setTimeout(TIMEOUT_MS); - req.write(c.body); - req.end(); - }); -} -function classifyErr(m) { - const l = ('' + m).toLowerCase(); - if (/timeout|canceled|timed out/.test(l)) return 'timeout'; - if (/ssl|handshake|eproto|sslv3|certificate|trust/.test(l)) return 'TLS handshake failed'; - if (/econnrefused|connection refused/.test(l)) return 'connection refused'; - if (/enotfound|getaddrinfo|nodata|dns/.test(l)) return 'DNS failed'; - if (/econnreset|socket hang up|reset/.test(l)) return 'connection reset'; - return m; -} -function decodeUnicodeEscapes(value) { - const decoded = String(value || '').replace(/\\u([0-9a-f]{4})/gi, (raw, hex) => { - const code = Number.parseInt(hex, 16); - return code < 0x20 || (code >= 0x7f && code < 0xa0) ? '?' : String.fromCharCode(code); - }); - return decoded.replace(/[\u0000-\u001f\u007f-\u009f]/g, '?'); -} -function compactResponseBody(body) { - const raw = String(body || ''); - try { - const j = JSON.parse(raw); - const msg = j?.error?.message || j?.message || j?.error || j?.type || ''; - if (msg) return decodeUnicodeEscapes(msg).replace(/\s+/g, ' ').trim(); - } catch {} - return decodeUnicodeEscapes(raw).replace(/\s+/g, ' ').trim(); -} -function isModelUnsupported(detail) { - const l = decodeUnicodeEscapes(detail).toLowerCase(); - return /no available providers|model_not_found|model not found|model does not exist|unknown model|unsupported model|model .*not supported|not support.*model|invalid model|model_not_supported|模型不存在|模型.*不存在|请检查模型代码/.test(l); -} -function compactHttpDetail(detail) { - const text = decodeUnicodeEscapes(detail).replace(/\s+/g, ' ').trim(); - const http = text.match(/^(HTTP \d{3})(?:\s+(.+))?$/); - if (!http) return text; - const code = http[1]; - const body = http[2] || ''; - if (!body) return code; - try { - const j = JSON.parse(body); - const msg = j?.error?.message || j?.message || j?.error || j?.type || ''; - if (msg) return code + ' ' + String(msg).replace(/\s+/g, ' ').trim(); - } catch {} - const msg = body.match(/"message"\s*:\s*"([^"]+)"/) || body.match(/message=([^,;}]+)/); - if (msg) return code + ' ' + msg[1].replace(/\s+/g, ' ').trim(); - return text; -} -function displayProbeErr(detail) { - if (isModelUnsupported(detail)) return 'probe model unsupported; set probe_model'; - return compactHttpDetail(detail); -} -function displayNote(error) { - const text = String(error || '').replace(/\s+/g, ' ').trim(); - if (!text) return ''; - const single = text.match(/^(POST\s+\/\S+\s+)(.+)$/); - const dual = text.match(/^(POST\s+\/\S+\s+->\s+)(.+?)(;\s+\/\S+\s+->\s+)(.+)$/); - if (dual) return dual[1] + displayProbeErr(dual[2]) + dual[3] + displayProbeErr(dual[4]); - const wire = text.match(/^(POST\s+\/\S+\s+->\s+)(.+?)(;\s+but\s+\/\S+\s+works\s+->\s+.+)$/); - if (wire) return wire[1] + displayProbeErr(wire[2]) + wire[3]; - if (single && !single[2].includes('; /')) return single[1] + displayProbeErr(single[2]); - if (isModelUnsupported(text)) return displayProbeErr(text); - return compactHttpDetail(text); -} -async function probeProfile(p) { - const plan = buildPlan(p); - if (plan.early) return plan.early; - const results = {}; - await Promise.all(plan.candidates.map(async (c) => { results[c.label] = await fetchOne(c, plan.headers); })); - if (tool === 'claude') { - const m = results.messages; - if (m.ok) return { status: m.latencyMs > DEGRADED_MS ? 'degraded' : 'healthy', latencyMs: m.latencyMs, method: 'generation', error: null }; - if (isModelUnsupported(m.detail)) return { status: 'degraded', latencyMs: m.latencyMs, method: 'none', error: 'POST /v1/messages ' + (m.detail || '') }; - if (m.code === 429 || (m.code >= 500 && m.code < 600)) return { status: 'degraded', latencyMs: m.latencyMs, method: 'none', error: 'POST /v1/messages ' + (m.detail || '') + ' (transient)' }; - return { status: 'down', latencyMs: m.latencyMs, method: 'none', error: 'POST /v1/messages ' + (m.detail || '') }; - } - const eff = results[plan.effLabel], alt = results[plan.altLabel]; - if (eff.ok) return { status: eff.latencyMs > DEGRADED_MS ? 'degraded' : 'healthy', latencyMs: eff.latencyMs, method: 'generation:' + plan.effLabel, error: null }; - let note = 'POST /' + plan.effLabel + ' -> ' + (eff.detail || ''); - if (alt.ok) { note += '; but /' + plan.altLabel + ' works -> set wire_api = "' + plan.altLabel + '"'; return { status: 'degraded', latencyMs: eff.latencyMs, method: 'none', error: note }; } - if (isModelUnsupported(eff.detail) || isModelUnsupported(alt.detail)) { note += '; /' + plan.altLabel + ' -> ' + (alt.detail || ''); return { status: 'degraded', latencyMs: eff.latencyMs, method: 'none', error: note }; } - if (eff.code === 429 || (eff.code >= 500 && eff.code < 600)) { note += '; /' + plan.altLabel + ' -> ' + (alt.detail || '') + ' (transient)'; return { status: 'degraded', latencyMs: eff.latencyMs, method: 'none', error: note }; } - note += '; /' + plan.altLabel + ' -> ' + (alt.detail || ''); - return { status: 'down', latencyMs: eff.latencyMs, method: 'none', error: note }; -} - -// ---------- rendering ---------- -function cell(r) { - if (!r) return '?'; - if (r.status === 'healthy') return '🟢' + r.latencyMs + 'ms'; - if (r.status === 'degraded') return '🟡' + (r.error && /HTTP \d{3}/.test(r.error) ? (r.error.match(/HTTP \d{3}/)[0].slice(-3)) : 'slow'); - if (r.status === 'down') return '🔴' + (r.error && /HTTP \d{3}/.test(r.error) ? (r.error.match(/HTTP \d{3}/)[0].slice(-3)) : 'err'); - if (r.status === 'skip') return '⏭'; - return '?'; -} -const SPIN = ['-', '\\', '|', '/']; -function charWidth(cp) { - if (cp === 0) return 0; - if (cp < 32 || (cp >= 0x7f && cp < 0xa0)) return 0; - if (cp >= 0x1100 && ( - cp <= 0x115f || cp === 0x2329 || cp === 0x232a || - (cp >= 0x2e80 && cp <= 0xa4cf) || - (cp >= 0xac00 && cp <= 0xd7a3) || - (cp >= 0xf900 && cp <= 0xfaff) || - (cp >= 0xfe10 && cp <= 0xfe19) || - (cp >= 0xfe30 && cp <= 0xfe6f) || - (cp >= 0xff00 && cp <= 0xff60) || - (cp >= 0xffe0 && cp <= 0xffe6) || - (cp >= 0x1f300 && cp <= 0x1faff) - )) return 2; - return 1; -} -function displayWidth(s) { - let w = 0; - for (const ch of String(s || '')) w += charWidth(ch.codePointAt(0)); - return w; -} -function padDisplay(s, n) { - const text = String(s || ''); - return text + ' '.repeat(Math.max(0, n - displayWidth(text))); -} -function trunc(s, n) { - const text = String(s || ''); - if (displayWidth(text) <= n) return text; - const suffix = n > 3 ? '...' : ''; - const limit = Math.max(0, n - displayWidth(suffix)); - let out = '', w = 0; - for (const ch of text) { - const cw = charWidth(ch.codePointAt(0)); - if (w + cw > limit) break; - out += ch; - w += cw; - } - return out + suffix; -} -function outputWidth() { - const configured = Number(process.env.AI_HEALTH_COLUMNS || 0); - const detected = configured > 0 ? configured : (process.stdout.columns || MAX_OUTPUT_WIDTH); - return Math.max(1, Math.min(MAX_OUTPUT_WIDTH, detected)); -} -function boundedLine(text) { - return trunc(text, outputWidth()); -} -function buildTable(rows, saved, tick) { - const cols = outputWidth(); - const w = { sel: 2, name: 14, health: 10, method: 12 }; - const noteW = Math.max(0, cols - (w.sel + w.name + w.health + w.method + 5)); - const lines = []; - const fmt = (a, b, c, d, e) => - trunc(`${padDisplay(a, w.sel)} ${padDisplay(b, w.name)} ${padDisplay(c, w.health)} ${padDisplay(d, w.method)} ${trunc(e, noteW)}`, cols); - lines.push(fmt('Sel', 'Name', 'Health', 'Method', 'Note')); - lines.push(fmt('---', '----', '------', '------', '----')); - for (const r of rows) { - const sel = r.name === saved ? '*' : ' '; - if (r.status === 'pending') { - const sp = SPIN[tick % SPIN.length]; - lines.push(fmt(sel, r.name, '⏳', '-', 'checking ' + sp)); - } else { - lines.push(fmt(sel, r.name, cell(r), r.method || '-', displayNote(r.error))); - } - } - return lines.join('\n'); -} -function readCache() { try { return JSON.parse(fs.readFileSync(healthPath, 'utf8')); } catch { return {}; } } -function writeCache(updates) { - const all = readCache(); - const now = Math.floor(Date.now() / 1000); - for (const [k, v] of Object.entries(updates)) all[k] = { status: v.status, latencyMs: v.latencyMs, method: v.method, error: v.error, probedAt: now }; - try { fs.mkdirSync(HOME + '/.ai-env', { recursive: true }); fs.writeFileSync(healthPath, JSON.stringify(all, null, 2) + '\n'); } catch {} -} - -// ---------- main ---------- -async function main() { - const label = tool === 'codex' ? 'Codex' : 'Claude Code'; - const reg = JSON.parse(fs.readFileSync(registryPath, 'utf8')); - const all = (reg[tool] || []).filter((x) => x.enabled !== false); - const saved = (readCache()._state?.[tool]) || (reg.defaults && reg.defaults[tool]) || (all[0] && all[0].name); - // saved name: read from state.json - let savedName = ''; - try { const st = JSON.parse(fs.readFileSync(HOME + '/.ai-env/state.json', 'utf8')); savedName = st[tool] || ''; } catch {} - - const cache = readCache(); - const rows = []; - const todo = []; // {p, i} - for (const p of all) { - const n = p.name; - if (!fresh) { - const e = cache[tool + '.' + n]; - if (e && e.probedAt && (Math.floor(Date.now() / 1000) - e.probedAt) < TTL) { - rows.push({ name: n, status: e.status, latencyMs: e.latencyMs, method: e.method, error: e.error }); - continue; - } - } - rows.push({ name: n, status: 'pending' }); - todo.push({ p, i: rows.length - 1 }); - } - - process.stdout.write(boundedLine(label + ' profile health (' + registryPath + '):') + '\n'); - const updates = {}; - - if (!isTty || todo.length === 0) { - // Non-TTY: probe all (concurrent), then print final table. - if (todo.length) { - process.stdout.write(boundedLine(' probing ' + todo.length + ' profile(s) in parallel…') + '\n'); - await Promise.all(todo.map(async (t) => { - const r = await probeProfile(t.p); - rows[t.i] = { name: rows[t.i].name, ...r }; - if (r.status !== 'skip') updates[tool + '.' + rows[t.i].name] = r; - })); - } - writeCache(updates); - process.stdout.write(buildTable(rows, savedName, 0) + '\n'); - process.stdout.write(boundedLine(' (health ' + (fresh ? 're-probed (fresh, parallel)' : 'cached <=5min') + '; ' + tool + ' health --fresh re-probe, ' + tool + ' health-clear clears)') + '\n'); - return; - } - - // TTY: dynamic in-place redraw via cursor-up-by-N. The earlier \x1b[s / \x1b[u - // (save/restore cursor) isn't supported by all terminals and made the table - // append/scroll downward. cursor-up \x1b[A is universally supported; N is - // exact because buildTable truncates columns to terminal width (no wrap). - const HIDE = '\x1b[?25l', SHOW = '\x1b[?25h'; - const cleanup = () => { process.stdout.write(SHOW); }; - process.on('SIGINT', () => { cleanup(); process.exit(130); }); - process.on('SIGTERM', () => { cleanup(); process.exit(143); }); - process.on('exit', cleanup); - let tick = 0, lastRender = 0, prevLines = 0; - const render = (force) => { - const now = Date.now(); - if (!force && now - lastRender < 100) return; - lastRender = now; - const table = buildTable(rows, savedName, tick); - // move up the previously-printed table lines, clear to end of screen, reprint - const up = prevLines > 0 ? '\x1b[' + prevLines + 'A\x1b[J' : ''; - process.stdout.write(up + table + '\n'); - prevLines = table.split('\n').length; - }; - process.stdout.write(HIDE); - render(true); - const timer = setInterval(() => { tick++; render(); }, 100); - await Promise.allSettled(todo.map(async (t) => { - const r = await probeProfile(t.p); - rows[t.i] = { name: rows[t.i].name, ...r }; - if (r.status !== 'skip') updates[tool + '.' + rows[t.i].name] = r; - render(true); - })); - clearInterval(timer); - writeCache(updates); - render(true); - process.stdout.write(SHOW + '\n' + boundedLine(' (health ' + (fresh ? 're-probed (fresh, parallel)' : 'cached <=5min') + '; ' + tool + ' health --fresh re-probe, ' + tool + ' health-clear clears)') + '\n'); -} -main().catch((e) => { process.stderr.write(boundedLine('health error: ' + (e && e.message || e)) + '\n'); process.exit(1); }); diff --git a/dot_zshrc b/dot_zshrc index 841a6fa..9b725e8 100644 --- a/dot_zshrc +++ b/dot_zshrc @@ -93,10 +93,12 @@ if [ -f "$HOME/.claude/auth.env" ]; then set +a fi -# AI environment profile wrappers managed by dotfiles. -if [ -f "$HOME/.local/share/ai-env/ai-env.sh" ]; then - source "$HOME/.local/share/ai-env/ai-env.sh" +# cxcc profile wrappers managed by the pinned release installer. +_cxcc_home="${CXCC_HOME:-$HOME/.local/share/cxcc}" +if [ -f "$_cxcc_home/load.sh" ]; then + source "$_cxcc_home/load.sh" fi +unset _cxcc_home zd() { local dir @@ -123,4 +125,4 @@ alias claude='claude --dangerously-skip-permissions' alias codex-np='env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY -u NO_PROXY -u http_proxy -u https_proxy -u all_proxy -u no_proxy codex' alias claude-np='env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY -u NO_PROXY -u http_proxy -u https_proxy -u all_proxy -u no_proxy claude' -echo "zsh已经重新加载" | lolcrab \ No newline at end of file +echo "zsh已经重新加载" | lolcrab diff --git a/run_before_10-install-cxcc.ps1.tmpl b/run_before_10-install-cxcc.ps1.tmpl new file mode 100644 index 0000000..78cf3b2 --- /dev/null +++ b/run_before_10-install-cxcc.ps1.tmpl @@ -0,0 +1,9 @@ +{{- if eq .chezmoi.os "windows" -}} +$ErrorActionPreference = "Stop" + +& "{{ .chezmoi.sourceDir }}\scripts\install\cxcc.ps1" ` + -Version "{{ .cxcc.version }}" ` + -Commit "{{ .cxcc.commit }}" ` + -InstallerSha256 "{{ .cxcc.installerPowerShellSha256 }}" ` + -ArtifactSha256 "{{ .cxcc.windowsArtifactSha256 }}" +{{- end -}} diff --git a/run_before_10-install-cxcc.sh.tmpl b/run_before_10-install-cxcc.sh.tmpl new file mode 100644 index 0000000..9c9ec3a --- /dev/null +++ b/run_before_10-install-cxcc.sh.tmpl @@ -0,0 +1,14 @@ +{{- if ne .chezmoi.os "windows" -}} +{{ if eq .chezmoi.os "android" -}} +#!{{ env "PREFIX" }}/bin/bash +{{- else -}} +#!/usr/bin/env bash +{{- end }} +set -Eeuo pipefail + +bash "{{ .chezmoi.sourceDir }}/scripts/install/cxcc.sh" \ + "{{ .cxcc.version }}" \ + "{{ .cxcc.commit }}" \ + "{{ .cxcc.installerShellSha256 }}" \ + "{{ .cxcc.posixArtifactSha256 }}" +{{- end -}} diff --git a/run_onchange_after_10-powershell-ai-env-hook.ps1.tmpl b/run_onchange_after_10-powershell-ai-env-hook.ps1.tmpl index 6a07687..5f2afa0 100644 --- a/run_onchange_after_10-powershell-ai-env-hook.ps1.tmpl +++ b/run_onchange_after_10-powershell-ai-env-hook.ps1.tmpl @@ -1,11 +1,11 @@ {{- if eq .chezmoi.os "windows" -}} -# Documents/PowerShell/Scripts/ai-env.ps1 checksum: {{ include "Documents/PowerShell/Scripts/ai-env.ps1" | sha256sum }} +# cxcc version: {{ .cxcc.version }} $ErrorActionPreference = "Stop" $isWindowsHost = if (Get-Variable IsWindows -ErrorAction SilentlyContinue) { $IsWindows } else { $env:OS -eq "Windows_NT" } if (-not $isWindowsHost) { - Write-Host "Skipping PowerShell ai-env profile hook on non-Windows host." + Write-Host "Skipping PowerShell cxcc profile hook on non-Windows host." exit 0 } @@ -17,9 +17,10 @@ $begin = "# chezmoi-ai-env begin" $end = "# chezmoi-ai-env end" $block = @' # chezmoi-ai-env begin -$aiEnv = Join-Path $HOME 'Documents\PowerShell\Scripts\ai-env.ps1' -if (Test-Path -LiteralPath $aiEnv) { - . $aiEnv +$cxccRoot = if ($env:CXCC_HOME) { [Environment]::ExpandEnvironmentVariables($env:CXCC_HOME) } else { Join-Path $HOME '.local\share\cxcc' } +$cxccLoader = Join-Path $cxccRoot 'load.ps1' +if (Test-Path -LiteralPath $cxccLoader) { + . $cxccLoader } # chezmoi-ai-env end '@.Trim() diff --git a/scripts/install/cxcc.ps1 b/scripts/install/cxcc.ps1 new file mode 100644 index 0000000..77dfac0 --- /dev/null +++ b/scripts/install/cxcc.ps1 @@ -0,0 +1,114 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$Version, + [Parameter(Mandatory = $true)] + [string]$Commit, + [Parameter(Mandatory = $true)] + [string]$InstallerSha256, + [Parameter(Mandatory = $true)] + [string]$ArtifactSha256 +) + +$ErrorActionPreference = "Stop" +$repository = "Tim-1e/cxcc" +$versionPattern = '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$' + +if ($Version -notmatch $versionPattern) { + throw "cxcc version must be an exact release tag such as v0.1.0." +} +if ($Commit -notmatch '^[0-9a-f]{40}$') { throw "cxcc commit must be a full lowercase Git SHA." } +foreach ($digest in @($InstallerSha256, $ArtifactSha256)) { + if ($digest -notmatch '^[0-9a-f]{64}$') { throw "cxcc SHA-256 pins must contain 64 lowercase hexadecimal characters." } +} + +if ($env:INSTALL_CXCC -ceq "0") { + Write-Host "Skipping cxcc installation because INSTALL_CXCC=0." + return +} + +if ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne [Runtime.InteropServices.Architecture]::X64) { + throw "cxcc $Version provides a Windows x64 artifact only. Set INSTALL_CXCC=0 to skip installation on this host." +} + +$installRoot = if ($env:CXCC_HOME) { + [Environment]::ExpandEnvironmentVariables($env:CXCC_HOME) +} else { + Join-Path $HOME ".local\share\cxcc" +} + +function Test-CxccCurrent { + $markerPath = Join-Path $installRoot ".cxcc-root" + $currentPath = Join-Path $installRoot "current.json" + $versionRoot = Join-Path $installRoot "versions\$Version" + $versionPath = Join-Path $versionRoot "VERSION" + $requiredPaths = @( + $markerPath, + $currentPath, + (Join-Path $installRoot "load.ps1"), + (Join-Path $installRoot "load.sh"), + $versionPath, + (Join-Path $versionRoot ".artifact-sha256"), + (Join-Path $versionRoot "load.ps1"), + (Join-Path $versionRoot "load.sh"), + (Join-Path $versionRoot "src\powershell\CxCc\CxCc.ps1"), + (Join-Path $versionRoot "src\shell\cxcc.sh"), + (Join-Path $versionRoot "src\shell\ai-health.mjs"), + (Join-Path $versionRoot "src\bridge\CodexProviderBridge\CodexProviderBridge.csproj"), + (Join-Path $versionRoot "templates\profiles.json") + ) + foreach ($path in $requiredPaths) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $false } + } + if ((Get-Content -LiteralPath $markerPath -Raw).Trim() -cne "cxcc-install-root-v1") { return $false } + if ((Get-Content -LiteralPath $versionPath -Raw).Trim() -cne $Version) { return $false } + if ((Get-Content -LiteralPath (Join-Path $versionRoot ".artifact-sha256") -Raw).Trim() -cne $ArtifactSha256) { return $false } + try { + $current = Get-Content -LiteralPath $currentPath -Raw | ConvertFrom-Json + } catch { + return $false + } + return $current.schema -eq 1 -and [string]$current.version -ceq $Version +} + +function Invoke-CxccDownload { + param([string]$Uri, [string]$OutFile) + + for ($attempt = 1; $attempt -le 2; $attempt++) { + try { + Invoke-WebRequest -Uri $Uri -OutFile $OutFile -TimeoutSec 120 + return + } catch { + if ($attempt -eq 2) { throw } + Write-Warning "cxcc download failed; retrying once: $($_.Exception.Message)" + Start-Sleep -Seconds 1 + } + } +} + +if (Test-CxccCurrent) { + Write-Host "cxcc $Version is already installed." + return +} + +$downloadRoot = Join-Path ([IO.Path]::GetTempPath()) ("cxcc-dotfiles-" + [guid]::NewGuid().ToString("N")) +$installer = Join-Path $downloadRoot "install.ps1" +$artifactName = "cxcc-$Version-windows-x64.zip" +$artifact = Join-Path $downloadRoot $artifactName +try { + New-Item -ItemType Directory -Path $downloadRoot | Out-Null + $installerUrl = "https://raw.githubusercontent.com/$repository/$Commit/install.ps1" + Invoke-CxccDownload -Uri $installerUrl -OutFile $installer + $actualInstallerSha256 = (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualInstallerSha256 -cne $InstallerSha256) { + throw "cxcc installer checksum mismatch. Expected $InstallerSha256, got $actualInstallerSha256." + } + + $artifactUrl = "https://github.com/$repository/releases/download/$Version/$artifactName" + Invoke-CxccDownload -Uri $artifactUrl -OutFile $artifact + & $installer -Version $Version -ArtifactPath $artifact -Sha256 $ArtifactSha256 +} finally { + if (Test-Path -LiteralPath $downloadRoot) { + Remove-Item -LiteralPath $downloadRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/scripts/install/cxcc.sh b/scripts/install/cxcc.sh new file mode 100755 index 0000000..10202b1 --- /dev/null +++ b/scripts/install/cxcc.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +repository="Tim-1e/cxcc" +version_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$' +version="${1:-}" +commit="${2:-}" +installer_sha256="${3:-}" +artifact_sha256="${4:-}" + +fail() { + echo "$*" >&2 + exit 1 +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print tolower($1)}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print tolower($1)}' + else + fail "A SHA-256 tool is required to install cxcc." + fi +} + +[[ "$version" =~ $version_pattern ]] || fail "cxcc version must be an exact release tag such as v0.1.0." +[[ "$commit" =~ ^[0-9a-f]{40}$ ]] || fail "cxcc commit must be a full lowercase Git SHA." +[[ "$installer_sha256" =~ ^[0-9a-f]{64}$ ]] || fail "cxcc installer SHA-256 pin must contain 64 lowercase hexadecimal characters." +[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]] || fail "cxcc artifact SHA-256 pin must contain 64 lowercase hexadecimal characters." + +if [ "${INSTALL_CXCC:-1}" = "0" ]; then + echo "Skipping cxcc installation because INSTALL_CXCC=0." + exit 0 +fi + +install_root="${CXCC_HOME:-$HOME/.local/share/cxcc}" + +is_current() { + local relative_path version_root="$install_root/versions/$version" + for relative_path in \ + .cxcc-root current.json load.sh load.ps1 \ + "versions/$version/VERSION" "versions/$version/.artifact-sha256" \ + "versions/$version/load.ps1" "versions/$version/load.sh" \ + "versions/$version/src/powershell/CxCc/CxCc.ps1" \ + "versions/$version/src/shell/cxcc.sh" \ + "versions/$version/src/shell/ai-health.mjs" \ + "versions/$version/src/bridge/CodexProviderBridge/CodexProviderBridge.csproj" \ + "versions/$version/templates/profiles.json"; do + [ -f "$install_root/$relative_path" ] || return 1 + done + [ "$(cat "$install_root/.cxcc-root")" = "cxcc-install-root-v1" ] && + grep -Fq '"schema":1' "$install_root/current.json" && + grep -Fq "\"version\":\"$version\"" "$install_root/current.json" && + [ "$(cat "$version_root/VERSION")" = "$version" ] && + [ "$(tr -d '\r\n' <"$version_root/.artifact-sha256")" = "$artifact_sha256" ] +} + +if is_current; then + echo "cxcc $version is already installed." + exit 0 +fi + +download_root="$(mktemp -d)" +installer="$download_root/install.sh" +artifact_name="cxcc-$version-posix.tar.gz" +artifact="$download_root/$artifact_name" +cleanup() { + rm -rf "$download_root" +} +trap cleanup EXIT + +download_file() { + local url="$1" destination="$2" + if command -v curl >/dev/null 2>&1; then + local curl_args=(--fail --silent --show-error --location --connect-timeout 15 --max-time 120 --retry 2) + if ! curl "${curl_args[@]}" --output "$destination" "$url"; then + echo "Retrying the cxcc download over IPv4." >&2 + curl --ipv4 "${curl_args[@]}" --output "$destination" "$url" + fi + elif command -v wget >/dev/null 2>&1; then + if ! wget -q -T 30 -t 2 -O "$destination" "$url"; then + echo "Retrying the cxcc download over IPv4." >&2 + wget -4 -q -T 30 -t 2 -O "$destination" "$url" + fi + else + fail "curl or wget is required to install cxcc." + fi +} + +download_file "https://raw.githubusercontent.com/$repository/$commit/install.sh" "$installer" +actual_installer_sha256="$(sha256_file "$installer")" +[ "$actual_installer_sha256" = "$installer_sha256" ] || fail "cxcc installer checksum mismatch. Expected $installer_sha256, got $actual_installer_sha256." +download_file "https://github.com/$repository/releases/download/$version/$artifact_name" "$artifact" +bash "$installer" --version "$version" --artifact "$artifact" --sha256 "$artifact_sha256" diff --git a/test/ai-env-health.ps1 b/test/ai-env-health.ps1 deleted file mode 100644 index 3d6cea4..0000000 --- a/test/ai-env-health.ps1 +++ /dev/null @@ -1,242 +0,0 @@ -[CmdletBinding()] -param( - [string]$SourceDir = (Split-Path -Parent $PSScriptRoot) -) - -# Offline health-subsystem tests. No real network: Get-AiProfileHealth is -# replaced with a fake that maps profile names to canned results, so we test -# the cache / prune / auto-select / status-mapping / command logic only. -$ErrorActionPreference = "Stop" - -$tmpRoot = Join-Path ([IO.Path]::GetTempPath()) ("ai-env-health-" + [guid]::NewGuid().ToString("N")) -$testHome = Join-Path $tmpRoot "home" -$previousAiEnvHome = $env:AI_ENV_HOME -$previousNonInteractive = $env:AI_ENV_NONINTERACTIVE -$previousHealthColumns = $env:AI_HEALTH_COLUMNS -$previousAnthropicModel = $env:ANTHROPIC_MODEL -$previousAnthropicDefaultHaikuModel = $env:ANTHROPIC_DEFAULT_HAIKU_MODEL -$env:AI_ENV_HOME = $testHome -$env:AI_ENV_NONINTERACTIVE = "1" -$env:AI_HEALTH_COLUMNS = "80" -Remove-Item Env:ANTHROPIC_MODEL -ErrorAction SilentlyContinue -Remove-Item Env:ANTHROPIC_DEFAULT_HAIKU_MODEL -ErrorAction SilentlyContinue - -$aiEnvDir = Join-Path $testHome ".ai-env" -$profilesPath = Join-Path $aiEnvDir "profiles.json" -$secretsDir = Join-Path $testHome ".ai-secrets" -$secretsPath = Join-Path $secretsDir "secrets.toml" -$healthPath = Join-Path $aiEnvDir "health.json" - -function Assert-Eq($Name, $Actual, $Expected) { - if ($Actual -ne $Expected) { throw "ASSERT $Name : expected '$Expected', got '$Actual'" } - Write-Host " ok: $Name = '$Actual'" -} -function Assert-Match($Name, $Actual, $Pattern) { - if ($Actual -notmatch $Pattern) { throw "ASSERT $Name : '$Actual' did not match /$Pattern/" } - Write-Host " ok: $Name matches /$Pattern/" -} -function Get-TestDisplayWidth([string]$Text) { - $width = 0 - foreach ($char in $Text.ToCharArray()) { - $code = [int]$char - $width += if ( - ($code -ge 0x1100 -and $code -le 0x115f) -or - ($code -ge 0x2e80 -and $code -le 0xa4cf) -or - ($code -ge 0xac00 -and $code -le 0xd7a3) -or - ($code -ge 0xf900 -and $code -le 0xfaff) -or - ($code -ge 0xfe10 -and $code -le 0xfe6f) -or - ($code -ge 0xff00 -and $code -le 0xff60) - ) { 2 } else { 1 } - } - return $width -} -function Assert-LinesBounded($Name, [string]$Output, [int]$MaxWidth) { - foreach ($line in ($Output -split "`r?`n")) { - $plain = $line -replace "`e\[[0-?]*[ -/]*[@-~]", "" - $width = Get-TestDisplayWidth $plain - if ($width -gt $MaxWidth) { throw "ASSERT $Name : line width $width exceeds $MaxWidth`: $plain" } - } - Write-Host " ok: $Name <= $MaxWidth display columns" -} - -try { - New-Item -ItemType Directory -Force -Path $aiEnvDir, $secretsDir | Out-Null - Copy-Item -LiteralPath (Join-Path $SourceDir "dot_ai-env/create_profiles.json") -Destination $profilesPath -Force - "" | Set-Content -LiteralPath $secretsPath -Encoding UTF8 - - . (Join-Path $SourceDir "Documents/PowerShell/Scripts/ai-env.ps1") - - # --- FAKE the network probe: map profile names to canned health (no HTTP) --- - $script:ProbeCalls = 0 - function Get-AiProfileHealth { - param($Tool, $Profile, $TimeoutSec = 20, $DegradedMs = 6000) - $script:ProbeCalls += 1 - switch (Get-AiProfileName -Profile $Profile) { - "hgood" { [pscustomobject]@{ Status = "healthy"; LatencyMs = 120; Method = "generation"; Error = $null } } - "hbad" { [pscustomobject]@{ Status = "down"; LatencyMs = 0; Method = "none"; Error = "POST /v1/messages HTTP 401" } } - "hslow" { [pscustomobject]@{ Status = "degraded"; LatencyMs = 9999; Method = "generation"; Error = "POST /v1/messages HTTP 429 (transient)" } } - "hcn" { [pscustomobject]@{ Status = "degraded"; LatencyMs = 10; Method = "none"; Error = 'POST /v1/messages HTTP 400 {"type":"error","error":{"message":"[1211][模型不存在,请检查模型代码。]"}}' } } - "hescaped" { [pscustomobject]@{ Status = "degraded"; LatencyMs = 10; Method = "none"; Error = ('POST /v1/messages HTTP 500 ' + [char]0x1b + '[2J{"error":{"message":"\u539f\u56e0\u8d85\u957f\uff1a\u8fd9\u662f\u4e00\u6bb5\u4e2d\u6587\u9519\u8bef\u539f\u56e0"}} trailing') } } - default { [pscustomobject]@{ Status = "down"; LatencyMs = 0; Method = "none"; Error = "POST /v1/messages HTTP 404" } } - } - } - - # Register test api profiles (no secrets needed: we only probe via the fake - # and resolve names, never actually switch into them). - cc add-api hgood --base-url https://h.test | Out-Null - cc add-api hbad --base-url https://h.test | Out-Null - cc add-api hslow --base-url https://h.test | Out-Null - cc add-api hcn --base-url https://h.test | Out-Null - cc add-api hescaped --base-url https://h.test | Out-Null - cc add-api cyc-a --base-url https://h.test | Out-Null - cc add-api cyc-b --base-url https://h.test | Out-Null - cc add-api h1m --base-url https://h.test | Out-Null - - Write-Host "[1] Format-AiHealthCell status icons" - Assert-Match "healthy cell" (Format-AiHealthCell ([pscustomobject]@{ Status = "healthy"; LatencyMs = 120; Error = $null })) "🟢120ms" - Assert-Match "degraded cell" (Format-AiHealthCell ([pscustomobject]@{ Status = "degraded"; Error = "HTTP 429 (transient)" })) "🟡429" - Assert-Match "down cell" (Format-AiHealthCell ([pscustomobject]@{ Status = "down"; Error = "HTTP 401" })) "🔴401" - Assert-Eq "skip cell" (Format-AiHealthCell ([pscustomobject]@{ Status = "skip"; Error = "x" })) "⏭" - - Write-Host "[2] probe-model set / clear" - cc probe-model hgood my-sonnet | Out-Null - $pm = (Get-AiProfileProbeTarget -Tool claude -Profile (Get-AiProfileByName -Tool claude -Name hgood)).ProbeModel - Assert-Eq "probe_model set" $pm "my-sonnet" - cc probe-model hgood | Out-Null - $pm2 = (Get-AiProfileProbeTarget -Tool claude -Profile (Get-AiProfileByName -Tool claude -Name hgood)).ProbeModel - Assert-Eq "probe_model cleared -> default haiku" $pm2 "claude-3-5-haiku-20241022" - - @( - "" - "[claude.hgood]" - 'ANTHROPIC_MODEL = "secret-sonnet"' - 'ANTHROPIC_AUTH_TOKEN = "sk-test-hgood"' - "" - "[claude.hescaped]" - 'ANTHROPIC_AUTH_TOKEN = "sk-test-hescaped"' - ) | Add-Content -LiteralPath $secretsPath -Encoding UTF8 - $pm3 = (Get-AiProfileProbeTarget -Tool claude -Profile (Get-AiProfileByName -Tool claude -Name hgood)).ProbeModel - Assert-Eq "probe_model clear -> ANTHROPIC_MODEL" $pm3 "secret-sonnet" - - cc add-api envmodel --base-url https://h.test --env ANTHROPIC_DEFAULT_HAIKU_MODEL=env-haiku | Out-Null - $pm4 = (Get-AiProfileProbeTarget -Tool claude -Profile (Get-AiProfileByName -Tool claude -Name envmodel)).ProbeModel - Assert-Eq "probe_model clear -> profile haiku env" $pm4 "env-haiku" - cc probe-model h1m 'claude-opus-4-8[1m]' | Out-Null - $h1mHeaders = (Get-AiProfileProbeTarget -Tool claude -Profile (Get-AiProfileByName -Tool claude -Name h1m)).Headers - Assert-Eq "1m probe adds beta header" $h1mHeaders["anthropic-beta"] "context-1m-2025-08-07" - cc probe-model h1m 'claude-opus-4-8' | Out-Null - $h1mPlainHeaders = (Get-AiProfileProbeTarget -Tool claude -Profile (Get-AiProfileByName -Tool claude -Name h1m)).Headers - if ($h1mPlainHeaders.ContainsKey("anthropic-beta")) { throw "plain probe_model should not add anthropic-beta" } - Write-Host " ok: plain probe_model omits beta header" - - Write-Host "[3] default show / set" - cc default hbad | Out-Null - Assert-Eq "default set" (Get-AiDefaultProfileName -Tool claude) "hbad" - Assert-Match "default show" (& { cc default } 6>&1 | Out-String) "claude default = hbad" - - Write-Host "[4] cache write / hit / fresh" - Clear-AiHealthCache - $pgood = Get-AiProfileByName -Tool claude -Name hgood - $r1 = Get-AiProfileHealthCached -Tool claude -Profile $pgood - Assert-Eq "first probe cached=false" $r1.Cached $false - Assert-Eq "first probe status=healthy" $r1.Status "healthy" - Assert-Eq "second probe cached=true" (Get-AiProfileHealthCached -Tool claude -Profile $pgood).Cached $true - Assert-Eq "fresh probe cached=false" (Get-AiProfileHealthCached -Tool claude -Profile $pgood -Fresh).Cached $false - Assert-Match "health.json written" (Get-Content -Raw -LiteralPath $healthPath) "claude.hgood" - - Write-Host "[5] orphan prune (Sync-AiHealthCache)" - $cache = Read-AiHealthCache - $cache["claude.ghost"] = [pscustomobject]@{ status = "down"; latencyMs = 0; error = "orphan"; probedAt = 1 } - $cache | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $healthPath - Sync-AiHealthCache -Tool claude - $after = Read-AiHealthCache - if ($after.ContainsKey("claude.ghost")) { throw "prune did NOT remove orphan claude.ghost" } - if (-not $after.ContainsKey("claude.hgood")) { throw "prune removed a still-valid entry claude.hgood" } - Write-Host " ok: orphan pruned, valid entry kept" - - Write-Host "[6] auto-select skips down, picks first non-down (default first)" - cc default hbad | Out-Null # default is down - Assert-Eq "auto-select picks healthy hgood" (Get-AiHealthyProfileName -Tool claude) "hgood" - - Write-Host "[7] next cycle (Get-AiNextProfileName)" - Save-AiSelectedProfile -Tool claude -Name cyc-a - Assert-Eq "next after cyc-a is cyc-b" (Get-AiNextProfileName -Tool claude) "cyc-b" - - Write-Host "[8] health command probes live; list shows cached Health WITHOUT probing" - $healthOut = (& { cc health } 6>&1 | Out-String -Width 4096) - Assert-Match "health header" $healthOut "profile health" - Assert-Match "health shows hgood" $healthOut "hgood" - # list shows a Health column (from cache) but must NOT trigger a live probe - $keysBefore = (Read-AiHealthCache).Count - $listOut = (& { cc list } 6>&1 | Out-String -Width 4096) - $keysAfter = (Read-AiHealthCache).Count - Assert-Match "list header" $listOut "Claude Code profiles" - Assert-Match "list has Health col" $listOut "Health" - if ($keysAfter -ne $keysBefore) { throw "cc list probed live (cache keys $keysBefore->$keysAfter); list must be cache-only" } - Write-Host " ok: list shows cached Health, did NOT probe (cache keys unchanged)" - - Write-Host "[9] status/switch show probe model and status --fresh probes" - Save-AiSelectedProfile -Tool claude -Name hgood - Remove-Item Env:AI_CLAUDE_LABEL -ErrorAction SilentlyContinue - $beforeStatus = $script:ProbeCalls - $statusOut = (& { cc status } 6>&1 | Out-String -Width 4096) - Assert-Match "status has probe model" $statusOut "Probe model: secret-sonnet" - Assert-Match "status has Health" $statusOut "Health:" - Assert-Eq "status cache-only probe count" $script:ProbeCalls $beforeStatus - $freshOut = (& { cc status --fresh } 6>&1 | Out-String -Width 4096) - Assert-Match "status fresh has probe model" $freshOut "Probe model:" - if ($script:ProbeCalls -le $beforeStatus) { throw "cc status --fresh did not call live probe" } - $switchOut = (& { cc hgood } 6>&1 | Out-String -Width 4096) - Assert-Match "switch has probe model" $switchOut "Probe model: secret-sonnet" - Assert-Match "switch has Health" $switchOut "Health:" - - Write-Host "[10] health table shortens Chinese unsupported model note" - Clear-AiHealthCache - $pcn = Get-AiProfileByName -Tool claude -Name hcn - $cn = Get-AiProfileHealthCached -Tool claude -Profile $pcn -Fresh - Write-AiHealthCacheEntry -Key "claude.hcn" -Entry ([pscustomobject]@{ - status = $cn.Status; latencyMs = $cn.LatencyMs; method = $cn.Method; error = $cn.Error; probedAt = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - }) - $short = ConvertTo-AiHealthDisplayError $cn.Error - Assert-Match "Chinese model note short" $short "probe model unsupported; set probe_model" - if ($cn.Error -notmatch "模型不存在") { throw "cached/status error should preserve original Chinese detail" } - Save-AiSelectedProfile -Tool claude -Name hcn - Remove-Item Env:AI_CLAUDE_LABEL -ErrorAction SilentlyContinue - $statusCn = (& { cc status --fresh } 6>&1 | Out-String -Width 4096) - Assert-Match "status shortens Chinese detail" $statusCn "probe model unsupported; set probe_model" - Assert-Match "status Chinese has probe model" $statusCn "Probe model:" - - Write-Host "[11] escaped Chinese is decoded and health output is hard-bounded" - Assert-Eq "fallback width counts Chinese cells" (Get-AiFallbackDisplayWidth "原因") 4 - $pescaped = Get-AiProfileByName -Tool claude -Name hescaped - $escaped = Get-AiProfileHealthCached -Tool claude -Profile $pescaped -Fresh - Write-AiHealthCacheEntry -Key "claude.hescaped" -Entry ([pscustomobject]@{ - status = $escaped.Status; latencyMs = $escaped.LatencyMs; method = $escaped.Method; error = $escaped.Error; probedAt = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - }) - Write-AiHealthCacheEntry -Key "claude.hgood" -Entry ([pscustomobject]@{ - status = "healthy"; latencyMs = 120; method = "generation"; error = $null; probedAt = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - }) - $boundedHealth = (& { cc health } 6>&1 | Out-String -Width 4096) - Assert-Match "health decodes escaped Chinese" $boundedHealth "原因超长" - if ($boundedHealth -match '\\u[0-9a-fA-F]{4}') { throw "health output leaked a Unicode escape: $($Matches[0])" } - if ($boundedHealth.Contains([char]0x1b)) { throw "health output leaked an ESC control character" } - Assert-LinesBounded "health output" $boundedHealth 80 - Save-AiSelectedProfile -Tool claude -Name hescaped - Remove-Item Env:AI_CLAUDE_LABEL -ErrorAction SilentlyContinue - $boundedStatus = (& { cc status --fresh } 6>&1 | Out-String -Width 4096) - Assert-Match "status decodes escaped Chinese" $boundedStatus "原因超长" - if ($boundedStatus -match '\\u[0-9a-fA-F]{4}') { throw "status output leaked a Unicode escape: $($Matches[0])" } - if ($boundedStatus.Contains([char]0x1b)) { throw "status output leaked an ESC control character" } - Assert-LinesBounded "status health line" (($boundedStatus -split "`r?`n" | Where-Object { $_ -match '^ Health:' }) -join "`n") 80 - - Write-Host "" - Write-Host "AI env health check passed." -ForegroundColor Green -} -finally { - if ($null -ne $previousAiEnvHome) { $env:AI_ENV_HOME = $previousAiEnvHome } else { Remove-Item Env:AI_ENV_HOME -ErrorAction SilentlyContinue } - if ($null -ne $previousNonInteractive) { $env:AI_ENV_NONINTERACTIVE = $previousNonInteractive } else { Remove-Item Env:AI_ENV_NONINTERACTIVE -ErrorAction SilentlyContinue } - if ($null -ne $previousHealthColumns) { $env:AI_HEALTH_COLUMNS = $previousHealthColumns } else { Remove-Item Env:AI_HEALTH_COLUMNS -ErrorAction SilentlyContinue } - if ($null -ne $previousAnthropicModel) { $env:ANTHROPIC_MODEL = $previousAnthropicModel } else { Remove-Item Env:ANTHROPIC_MODEL -ErrorAction SilentlyContinue } - if ($null -ne $previousAnthropicDefaultHaikuModel) { $env:ANTHROPIC_DEFAULT_HAIKU_MODEL = $previousAnthropicDefaultHaikuModel } else { Remove-Item Env:ANTHROPIC_DEFAULT_HAIKU_MODEL -ErrorAction SilentlyContinue } - Remove-Item -LiteralPath $tmpRoot -Recurse -Force -ErrorAction SilentlyContinue -} diff --git a/test/ai-env-mcp.ps1 b/test/ai-env-mcp.ps1 deleted file mode 100644 index 3f1d44f..0000000 --- a/test/ai-env-mcp.ps1 +++ /dev/null @@ -1,162 +0,0 @@ -[CmdletBinding()] -param( - [string]$SourceDir = (Split-Path -Parent $PSScriptRoot) -) - -# Offline MCP tests. Targets are redirected via AI_CLAUDE_JSON_PATH / -# AI_CODEX_CONFIG_PATH to temp files, so the real ~/.claude.json and -# ~/.codex/config.toml are never touched. -$ErrorActionPreference = "Stop" - -$tmpRoot = Join-Path ([IO.Path]::GetTempPath()) ("ai-env-mcp-" + [guid]::NewGuid().ToString("N")) -$home2 = Join-Path $tmpRoot "home" -$prev = @{ - H = $env:AI_ENV_HOME - CJ = $env:AI_CLAUDE_JSON_PATH - CC = $env:AI_CODEX_CONFIG_PATH - NI = $env:AI_ENV_NONINTERACTIVE -} -$env:AI_ENV_HOME = $home2 -$env:AI_ENV_NONINTERACTIVE = "1" -$env:AI_CLAUDE_JSON_PATH = Join-Path $tmpRoot "claude.json" -$env:AI_CODEX_CONFIG_PATH = Join-Path $tmpRoot "codex-config.toml" - -function Assert-Eq($n, $a, $e) { if ($a -ne $e) { throw "ASSERT $n : expected '$e', got '$a'" }; Write-Host " ok: $n = '$a'" } -function Assert-True($n, $a) { if (-not $a) { throw "ASSERT $n : expected true, got '$a'" }; Write-Host " ok: $n" } - -try { - New-Item -ItemType Directory -Force -Path (Join-Path $home2 ".ai-env"), (Join-Path $home2 ".ai-secrets") | Out-Null - "" | Set-Content (Join-Path $home2 ".ai-secrets/secrets.toml") - Copy-Item (Join-Path $SourceDir "dot_ai-env/create_profiles.json") (Join-Path $home2 ".ai-env/profiles.json") -Force - . (Join-Path $SourceDir "Documents/PowerShell/Scripts/ai-env.ps1") - - @' -[mcp.context7] -command = ["npx", "-y", "@upstash/context7-mcp"] -env = {} -sync = ["claude", "codex"] -enabled = true - -[mcp.filesystem] -command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"] -env = { ALLOW_DIR = "/tmp" } -sync = ["claude"] -enabled = true - -[mcp.figma] -url = "https://mcp.figma.com/mcp" -sync = ["codex"] -enabled = false -'@ | Set-Content (Get-AiMcpRegistryPath) - - Write-Host "[1] Read-AiMcpRegistry parses mcp.toml" - $reg = Read-AiMcpRegistry - Assert-Eq "entry count" $reg.Count 3 - Assert-Eq "context7 kind" $reg.context7.Kind "stdio" - Assert-Eq "context7 cmd0" $reg.context7.Command[0] "npx" - Assert-Eq "context7 cmd count" $reg.context7.Command.Count 3 - Assert-True "context7 enabled" $reg.context7.Enabled - Assert-Eq "filesystem env ALLOW_DIR" $reg.filesystem.Env.ALLOW_DIR "/tmp" - Assert-Eq "filesystem sync (claude only)" ($reg.filesystem.Sync -join ',') "claude" - Assert-Eq "figma kind" $reg.figma.Kind "http" - Assert-Eq "figma url" $reg.figma.Url "https://mcp.figma.com/mcp" - Assert-True "figma disabled" (-not $reg.figma.Enabled) - - Write-Host "[2] entry -> target converters" - $ce = ConvertTo-ClaudeMcpEntry $reg.context7 - Assert-Eq "claude entry command" $ce.command "npx" - Assert-Eq "claude entry args0" $ce.args[0] "-y" - $cf = ConvertTo-ClaudeMcpEntry $reg.figma - Assert-Eq "claude http type" $cf.type "http" - Assert-Eq "claude http url" $cf.url "https://mcp.figma.com/mcp" - $cb = ConvertTo-CodexMcpBlock $reg.context7 - Assert-True "codex block header" ($cb -match '\[mcp_servers\.context7\]') - Assert-True "codex block enabled true" ($cb -match 'enabled = true') - - Write-Host "[3] Claude target: upsert/remove + preserve nested keys (no -Depth truncation)" - '{"projects":{"p1":{"history":[1,2,{"deep":{"a":{"b":{"c":42}}}}]}},"mcpServers":{"oldserver":{"command":"x"}}}' | - Set-Content $env:AI_CLAUDE_JSON_PATH - Set-ClaudeMcpServer -Name "context7" -Entry $reg.context7 - $d = Get-Content -Raw $env:AI_CLAUDE_JSON_PATH | ConvertFrom-Json - Assert-True "claude has context7" ($d.mcpServers.PSObject.Properties.Name -contains 'context7') - Assert-True "claude kept oldserver" ($d.mcpServers.PSObject.Properties.Name -contains 'oldserver') - Assert-Eq "claude deep nested preserved (depth)" $d.projects.p1.history[2].deep.a.b.c 42 - Set-ClaudeMcpServer -Name "oldserver" -Entry $null - $d2 = Get-Content -Raw $env:AI_CLAUDE_JSON_PATH | ConvertFrom-Json - Assert-True "claude removed oldserver" (-not ($d2.mcpServers.PSObject.Properties.Name -contains 'oldserver')) - - Write-Host "[4] Codex target: upsert/remove + preserve other config.toml content" - @' -model = "gpt-test" -model_provider = "openai" - -[mcp_servers.userkept] -command = ["echo"] -'@ | Set-Content $env:AI_CODEX_CONFIG_PATH - Set-CodexMcpServer -Name "context7" -Block (ConvertTo-CodexMcpBlock $reg.context7) - $toml = Get-Content -Raw $env:AI_CODEX_CONFIG_PATH - Assert-True "codex has context7" ($toml -match '\[mcp_servers\.context7\]') - Assert-True "codex kept model" ($toml -match 'model = "gpt-test"') - Assert-True "codex kept userkept" ($toml -match '\[mcp_servers\.userkept\]') - Set-CodexMcpServer -Name "context7" -Block "" - $toml2 = Get-Content -Raw $env:AI_CODEX_CONFIG_PATH - Assert-True "codex removed context7" (-not ($toml2 -match '\[mcp_servers\.context7\]')) - Assert-True "codex still has userkept" ($toml2 -match '\[mcp_servers\.userkept\]') - Assert-True "codex kept model after remove" ($toml2 -match 'model = "gpt-test"') - - Write-Host "[5] Sync-AiMcp end-to-end (enabled/sync respected)" - '{"mcpServers":{}}' | Set-Content $env:AI_CLAUDE_JSON_PATH - "" | Set-Content $env:AI_CODEX_CONFIG_PATH - Sync-AiMcp | Out-Null - $cd = Get-Content -Raw $env:AI_CLAUDE_JSON_PATH | ConvertFrom-Json - Assert-True "sync claude has context7" ($cd.mcpServers.PSObject.Properties.Name -contains 'context7') - Assert-True "sync claude has filesystem" ($cd.mcpServers.PSObject.Properties.Name -contains 'filesystem') - Assert-True "sync claude NOT figma (sync=codex)" (-not ($cd.mcpServers.PSObject.Properties.Name -contains 'figma')) - $ct = Get-Content -Raw $env:AI_CODEX_CONFIG_PATH - Assert-True "sync codex has context7" ($ct -match '\[mcp_servers\.context7\]') - Assert-True "sync codex NOT filesystem (sync=claude)" (-not ($ct -match '\[mcp_servers\.filesystem\]')) - Assert-True "sync codex NOT figma (disabled)" (-not ($ct -match '\[mcp_servers\.figma\]')) - - Write-Host "[6] mcp list / get render" - Assert-True "mcp list shows context7" ((& { mcp list } 6>&1 | Out-String) -match 'context7') - Assert-True "mcp get figma shows url" ((& { mcp get figma } 6>&1 | Out-String) -match 'mcp.figma.com') - - Write-Host "[7] mcp pull: import existing servers from targets -> mcp.toml" - @' -[mcp.onlymine] -command = ["x"] -enabled = true -'@ | Set-Content (Get-AiMcpRegistryPath) - '{"mcpServers":{"shared":{"command":"scmd","args":["sarg"]},"clonly":{"command":"conly"}}}' | Set-Content $env:AI_CLAUDE_JSON_PATH - @' -[mcp_servers.shared] -command = ["scmd", "sarg"] -enabled = true - -[mcp_servers.cxonly] -command = ["cx"] -enabled = false -'@ | Set-Content $env:AI_CODEX_CONFIG_PATH - $pullOut = (& { mcp pull } 6>&1 | Out-String) - Assert-True "pull added 3" ($pullOut -match '\+3 added') - $pr = Read-AiMcpRegistry - Assert-True "pull kept onlymine" ($pr.Contains('onlymine')) - Assert-Eq "pull total 4 entries" $pr.Count 4 - Assert-Eq "shared sync (both)" ($pr.shared.Sync -join ',') 'claude,codex' - Assert-Eq "shared command (cmd+args)" ($pr.shared.Command -join ' ') 'scmd sarg' - Assert-Eq "clonly sync (claude)" ($pr.clonly.Sync -join ',') 'claude' - Assert-Eq "cxonly sync (codex)" ($pr.cxonly.Sync -join ',') 'codex' - Assert-True "cxonly disabled preserved" (-not $pr.cxonly.Enabled) - $pullOut2 = (& { mcp pull } 6>&1 | Out-String) - Assert-True "re-pull adds 0" ($pullOut2 -match '\+0 added') - - Write-Host "" - Write-Host "AI env MCP check passed." -ForegroundColor Green -} -finally { - if ($null -ne $prev.H) { $env:AI_ENV_HOME = $prev.H } else { Remove-Item Env:AI_ENV_HOME -ErrorAction SilentlyContinue } - if ($null -ne $prev.CJ) { $env:AI_CLAUDE_JSON_PATH = $prev.CJ } else { Remove-Item Env:AI_CLAUDE_JSON_PATH -ErrorAction SilentlyContinue } - if ($null -ne $prev.CC) { $env:AI_CODEX_CONFIG_PATH = $prev.CC } else { Remove-Item Env:AI_CODEX_CONFIG_PATH -ErrorAction SilentlyContinue } - if ($null -ne $prev.NI) { $env:AI_ENV_NONINTERACTIVE = $prev.NI } else { Remove-Item Env:AI_ENV_NONINTERACTIVE -ErrorAction SilentlyContinue } - Remove-Item $tmpRoot -Recurse -Force -ErrorAction SilentlyContinue -} diff --git a/test/ai-env-smoke.ps1 b/test/ai-env-smoke.ps1 deleted file mode 100644 index 06765d1..0000000 --- a/test/ai-env-smoke.ps1 +++ /dev/null @@ -1,322 +0,0 @@ -[CmdletBinding()] -param( - [string]$SourceDir = (Split-Path -Parent $PSScriptRoot) -) - -$ErrorActionPreference = "Stop" - -$tmpRoot = Join-Path ([IO.Path]::GetTempPath()) ("ai-env-smoke-" + [guid]::NewGuid().ToString("N")) -$testHome = Join-Path $tmpRoot "home" -$previousAiEnvHome = $env:AI_ENV_HOME -$previousNonInteractive = $env:AI_ENV_NONINTERACTIVE -$previousCodexConfigPath = $env:AI_CODEX_CONFIG_PATH -$previousCodexAppTokenCommand = $env:AI_CODEX_APP_TOKEN_COMMAND -$env:AI_ENV_HOME = $testHome -$env:AI_ENV_NONINTERACTIVE = "1" - -$aiEnvDir = Join-Path $testHome ".ai-env" -$codexDir = Join-Path $testHome ".codex" -$profilesPath = Join-Path $aiEnvDir "profiles.json" -$statePath = Join-Path $aiEnvDir "state.json" -$secretsDir = Join-Path $testHome ".ai-secrets" -$secretsPath = Join-Path $secretsDir "secrets.toml" -$codexConfigPath = Join-Path $codexDir "config.toml" -$codexAuthPath = Join-Path $codexDir "auth.json" -$env:AI_CODEX_CONFIG_PATH = $codexConfigPath -$env:AI_CODEX_APP_TOKEN_COMMAND = Join-Path $SourceDir "dot_codex/private_app-auth/private_codex-app-token.ps1" - -try { - New-Item -ItemType Directory -Force -Path $aiEnvDir, $codexDir, $secretsDir | Out-Null - Copy-Item -LiteralPath (Join-Path $SourceDir "dot_ai-env/create_profiles.json") -Destination $profilesPath -Force - Copy-Item -LiteralPath (Join-Path $SourceDir "dot_codex/create_sub.config.toml") -Destination (Join-Path $codexDir "sub.config.toml") -Force - Copy-Item -LiteralPath (Join-Path $SourceDir "dot_codex/create_api.config.toml") -Destination (Join-Path $codexDir "api.config.toml") -Force - @' -model = "gpt-existing" -model_reasoning_effort = "high" - -[features] -memories = true - -[mcp_servers.keep] -command = "keep-me" -'@ | Set-Content -LiteralPath $codexConfigPath -Encoding UTF8 - '{"auth_mode":"chatgpt","marker":"keep-me"}' | Set-Content -LiteralPath $codexAuthPath -Encoding UTF8 - $codexAuthBefore = Get-FileHash -Algorithm SHA256 -LiteralPath $codexAuthPath - $sessionDir = Join-Path $codexDir "sessions\2026\06\09" - New-Item -ItemType Directory -Force -Path $sessionDir | Out-Null - @' -{"timestamp":"2026-06-09T00:00:00.000Z","type":"session_meta","payload":{"id":"stats-smoke","cwd":"I:\\CodeX_desk\\dotfiles"}} -{"timestamp":"2026-06-09T00:01:00.000Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":700,"output_tokens":200,"reasoning_output_tokens":50,"total_tokens":1200}}}} -'@ | Set-Content -LiteralPath (Join-Path $sessionDir "rollout-2026-06-09T00-00-00-stats-smoke.jsonl") -Encoding UTF8 - Remove-Item -LiteralPath $statePath -ErrorAction SilentlyContinue - '{"codex":"sub","claude":"sub","updated_at":null}' | Set-Content -LiteralPath $statePath -Encoding UTF8 - @" -[codex.api] -OPENAI_API_KEY = "sk-test-codex" - -[codex.cxenvtest] -OPENAI_API_KEY = "sk-test-cxenv" - -[codex.surplus] -OPENAI_API_KEY = "sk-test-surplus" - -[codex.malformed] -OPENAI_API_KEY = "bad\q" - -[claude.api-docker] -ANTHROPIC_BASE_URL = "https://anyrouter.top" -ANTHROPIC_AUTH_TOKEN = "sk-test-token" - -[claude.envtest] -ANTHROPIC_AUTH_TOKEN = "sk-test-envtoken" -"@ | Set-Content -LiteralPath $secretsPath -Encoding UTF8 - - . (Join-Path $SourceDir "Documents/PowerShell/Scripts/ai-env.ps1") - - $arrayConfigPath = Join-Path $codexDir 'array-table.config.toml' - @' -model = "top-level" - -[[custom.entries]] # valid TOML array table -model = "nested-must-stay" -'@ | Set-Content -LiteralPath $arrayConfigPath -Encoding UTF8 - $env:AI_CODEX_CONFIG_PATH = $arrayConfigPath - Set-CodexAppConfig -TopLevelValues ([ordered]@{ model = '"updated-top-level"' }) -ProviderBlock $null | Out-Null - $arrayConfigAfterUpdate = Get-Content -Raw -LiteralPath $arrayConfigPath - if ($arrayConfigAfterUpdate -notmatch '(?m)^model = "updated-top-level"$' -or $arrayConfigAfterUpdate -notmatch '(?m)^model = "nested-must-stay"$') { - throw 'Codex App config update rewrote an array-table value' - } - $env:AI_CODEX_CONFIG_PATH = $codexConfigPath - - $cxHelp = (& { cx help } 6>&1 | Out-String -Width 4096) - $cxList = (& { cx list } 6>&1 | Out-String -Width 4096) - $ccHelp = (& { cc help } 6>&1 | Out-String -Width 4096) - $ccList = (& { cc list } 6>&1 | Out-String -Width 4096) - $cxStats = (& { cx stats --days 365 } 6>&1 | Out-String -Width 4096) - $cxAddApi = (& { cx add-api api:test --base-url https://router.test/v1 --model gpt-test } 6>&1 | Out-String -Width 4096) - $cxAddSub = (& { cx add-sub sub:test } 6>&1 | Out-String -Width 4096) - $cxAddSurplus = (& { cx add-api surplus --base-url https://surplus.test/v1 --model gpt-surplus --provider-name Surplus } 6>&1 | Out-String -Width 4096) - $surplusProfilePath = Join-Path $codexDir 'api-surplus.config.toml' - @' -model_provider = "api-router" -model = "gpt-surplus" -model_reasoning_effort = "xhigh" -disable_response_storage = true - -[model_providers.decoy] -name = "Wrong Provider" -base_url = "https://wrong.test/v1" -env_key = "WRONG_API_KEY" - -[model_providers.api-router] -name = "Surplus" -base_url = "https://surplus.test/v1" -env_key = "OPENAI_API_KEY" -'@ | Set-Content -LiteralPath $surplusProfilePath -Encoding UTF8 - $cxAppSurplus = (& { cx app-default surplus } 6>&1 | Out-String -Width 4096) - $codexAppConfigAfterSurplus = Get-Content -Raw -LiteralPath $codexConfigPath - $codexExecutable = Get-Command codex -CommandType Application,ExternalScript -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($codexExecutable) { - $strictConfigStart = [Diagnostics.ProcessStartInfo]::new((Get-Process -Id $PID).Path) - foreach ($argument in @('-NoLogo', '-NoProfile', '-NonInteractive', '-File', $codexExecutable.Source, 'app-server', '--strict-config', '--listen', 'stdio://')) { [void]$strictConfigStart.ArgumentList.Add($argument) } - $strictConfigStart.UseShellExecute = $false - $strictConfigStart.RedirectStandardInput = $true - $strictConfigStart.RedirectStandardOutput = $true - $strictConfigStart.RedirectStandardError = $true - $strictConfigStart.Environment['CODEX_HOME'] = $codexDir - $strictConfigProcess = [Diagnostics.Process]::Start($strictConfigStart) - $strictConfigProcess.StandardInput.Close() - if (-not $strictConfigProcess.WaitForExit(15000)) { - $strictConfigProcess.Kill($true) - throw 'Codex strict-config validation timed out' - } - $strictConfigError = $strictConfigProcess.StandardError.ReadToEnd() - if ($strictConfigProcess.ExitCode -ne 0) { throw "Codex strict-config rejected App config: $strictConfigError" } - } - $codexAppRegistryAfterSurplus = Get-Content -Raw -LiteralPath $profilesPath | ConvertFrom-Json - $codexCliDefaultAfterSurplus = $codexAppRegistryAfterSurplus.defaults.codex - $codexStateAfterSurplus = Get-Content -Raw -LiteralPath $statePath | ConvertFrom-Json - $codexAppToken = (& pwsh.exe -NoLogo -NoProfile -NonInteractive -File $env:AI_CODEX_APP_TOKEN_COMMAND -SecretId codex.surplus -Key OPENAI_API_KEY | Out-String).Trim() - $cxAppSurplusAgain = (& { cx app-default surplus } 6>&1 | Out-String -Width 4096) - $codexAppConfigAfterSecondSwitch = Get-Content -Raw -LiteralPath $codexConfigPath - $configBeforeActiveRemove = Get-Content -Raw -LiteralPath $codexConfigPath - $registryBeforeActiveRemove = Get-Content -Raw -LiteralPath $profilesPath - $activeRemoveError = try { cx remove surplus --delete-config | Out-Null; '' } catch { $_.Exception.Message } - $configAfterActiveRemove = Get-Content -Raw -LiteralPath $codexConfigPath - $registryAfterActiveRemove = Get-Content -Raw -LiteralPath $profilesPath - $cxAppSub = (& { cx app-default sub } 6>&1 | Out-String -Width 4096) - $codexAppConfigAfterSub = Get-Content -Raw -LiteralPath $codexConfigPath - $codexAppRegistryAfterSub = Get-Content -Raw -LiteralPath $profilesPath - $codexAuthAfter = Get-FileHash -Algorithm SHA256 -LiteralPath $codexAuthPath - $ccAddApi = (& { cc add-api api:test --base-url https://claude.test } 6>&1 | Out-String -Width 4096) - $ccAddSub = (& { cc add-sub sub:test } 6>&1 | Out-String -Width 4096) - $cxManagedList = (& { cx list } 6>&1 | Out-String -Width 4096) - $ccManagedList = (& { cc list } 6>&1 | Out-String -Width 4096) - $codexApiTestConfig = Join-Path $codexDir "api-api-test.config.toml" - $codexApiConfigExistsAfterAdd = Test-Path -LiteralPath $codexApiTestConfig - $cxRemoveApi = (& { cx remove api:test --delete-config } 6>&1 | Out-String -Width 4096) - $cxRemoveSub = (& { cx remove sub:test --delete-config } 6>&1 | Out-String -Width 4096) - $ccRemoveApi = (& { cc remove api:test } 6>&1 | Out-String -Width 4096) - $ccRemoveSub = (& { cc remove sub:test } 6>&1 | Out-String -Width 4096) - $cxSwitch = (& { cx api } 6>&1 | Out-String -Width 4096) - $openAiKey = $env:OPENAI_API_KEY - $ccSwitch = (& { cc api:docker } 6>&1 | Out-String -Width 4096) - - if ($cxHelp -notmatch "cx - switch Codex state") { throw "cx help output missing header" } - if ($cxHelp -notmatch "Auto-select a cached healthy Codex profile") { throw "cx help output missing auto-select wording" } - if ($cxHelp -notmatch "cx edit") { throw "cx help output missing edit" } - if ($cxHelp -notmatch "cx app-default") { throw "cx help output missing app-default" } - if ($cxHelp -notmatch "cx doctor") { throw "cx help output missing doctor" } - if ($cxHelp -notmatch "cx next") { throw "cx help output missing next" } - if ($cxList -notmatch "Codex profiles") { throw "cx list output missing header" } - if ($cxList -notmatch "Health") { throw "cx list missing Health column" } - if ($ccHelp -notmatch "cc - switch Claude Code state") { throw "cc help output missing header" } - if ($ccHelp -notmatch "Auto-select a cached healthy Claude Code profile") { throw "cc help output missing auto-select wording" } - if ($ccHelp -notmatch "cc edit") { throw "cc help output missing edit" } - if ($ccHelp -notmatch "cc next") { throw "cc help output missing next" } - if ($ccList -notmatch "Claude Code profiles") { throw "cc list output missing header" } - if ($ccList -notmatch "Health") { throw "cc list missing Health column" } - if ($cxStats -notmatch "Codex local token stats") { throw "cx stats output missing header" } - if ($cxStats -notmatch "Total:\s+1\.2K \(1200\)") { throw "cx stats did not summarize fixture tokens" } - if ($cxHelp -notmatch "cx add-api NAME") { throw "cx help output missing add-api" } - if ($ccHelp -notmatch "cc add-api NAME") { throw "cc help output missing add-api" } - if ($cxAddApi -notmatch "Added Codex API profile 'api:test'") { throw "cx add-api did not report success" } - if ($cxAddSub -notmatch "Added Codex subscription profile 'sub:test'") { throw "cx add-sub did not report success" } - if ($cxAddSurplus -notmatch "Added Codex API profile 'surplus'") { throw "cx add-api surplus did not report success" } - if ($cxAppSurplus -notmatch "Codex App default = surplus") { throw "cx app-default surplus did not report success" } - if ($codexAppRegistryAfterSurplus.defaults.codex_app -ne "surplus") { throw "cx app-default did not persist defaults.codex_app" } - if ($codexAppConfigAfterSurplus -notmatch '(?m)^model_provider = "ai-env-app"$') { throw "cx app-default surplus did not select the managed App provider" } - if ($codexAppConfigAfterSurplus -notmatch '(?m)^model = "gpt-surplus"$') { throw "cx app-default surplus did not project the profile model" } - if ($codexAppConfigAfterSurplus -notmatch '(?m)^base_url = "https://surplus\.test/v1"$') { throw "cx app-default surplus did not project the profile base URL" } - if ($codexAppConfigAfterSurplus -notmatch '(?m)^\[model_providers\.ai-env-app\.auth\]$') { throw "cx app-default surplus did not configure command-backed auth" } - if ($codexAppConfigAfterSurplus -match '(?m)^\s*(env_key|requires_openai_auth|experimental_bearer_token)\s*=') { throw "cx app-default wrote a forbidden second auth method" } - if ($codexAppConfigAfterSurplus -match 'sk-test-surplus') { throw "cx app-default leaked the API key into config.toml" } - if ($codexAppConfigAfterSurplus -notmatch '(?m)^\[mcp_servers\.keep\]$') { throw "cx app-default removed unrelated Codex config" } - if ($codexAppToken -ne 'sk-test-surplus') { throw "Codex App token helper did not read codex.surplus" } - if ($codexCliDefaultAfterSurplus -ne 'sub') { throw "cx app-default changed the CLI default" } - if ($codexStateAfterSurplus.codex -ne 'sub') { throw "cx app-default changed the CLI selected state" } - if (([regex]::Matches($codexAppConfigAfterSecondSwitch, '(?m)^\[model_providers\.ai-env-app\]$')).Count -ne 1) { throw "cx app-default is not idempotent" } - if ($codexAppConfigAfterSecondSwitch -cne $codexAppConfigAfterSurplus) { throw "second cx app-default surplus changed config.toml" } - if ($cxAppSurplusAgain -notmatch "Codex App default = surplus") { throw "second cx app-default surplus did not report success" } - if ($activeRemoveError -notmatch 'while it is the Codex App default') { throw "cx remove did not protect the active Codex App profile" } - if ($configAfterActiveRemove -cne $configBeforeActiveRemove -or $registryAfterActiveRemove -cne $registryBeforeActiveRemove) { throw "failed active App profile removal changed state" } - if ($cxAppSub -notmatch "Codex App default = sub") { throw "cx app-default sub did not report success" } - if ($codexAppConfigAfterSub -notmatch '(?m)^model_provider = "openai"$') { throw "cx app-default sub did not restore the OpenAI provider" } - if ($codexAppConfigAfterSub -notmatch '(?m)^model = "gpt-5\.5"$') { throw "cx app-default sub did not restore the subscription profile model" } - if ($codexAppConfigAfterSub -notmatch '(?m)^model_reasoning_effort = "high"$') { throw "cx app-default sub did not restore the baseline reasoning effort" } - if ($codexAppConfigAfterSub -match '(?m)^disable_response_storage\s*=') { throw "cx app-default retained an unsupported storage setting" } - if ($codexAppConfigAfterSub -notmatch '(?m)^\[model_providers\.ai-env-app\]$') { throw "cx app-default sub unexpectedly removed the reusable App provider" } - if ($codexAuthBefore.Hash -ne $codexAuthAfter.Hash) { throw "cx app-default modified auth.json" } - - cx add-sub app:other | Out-Null - $configBeforeInvalidAppSwitch = Get-Content -Raw -LiteralPath $codexConfigPath - $registryBeforeInvalidAppSwitch = Get-Content -Raw -LiteralPath $profilesPath - $unknownAppError = try { cx app-default missing-profile | Out-Null; '' } catch { $_.Exception.Message } - $otherHomeError = try { cx app-default app:other | Out-Null; '' } catch { $_.Exception.Message } - if ($unknownAppError -notmatch "Unknown Codex profile 'missing-profile'") { throw "cx app-default returned the wrong unknown-profile error" } - if ($otherHomeError -notmatch 'only select profiles that share its CODEX_HOME') { throw "cx app-default did not reject a different CODEX_HOME: $otherHomeError" } - if ((Get-Content -Raw -LiteralPath $codexConfigPath) -cne $configBeforeInvalidAppSwitch) { throw "failed cx app-default changed config.toml" } - if ((Get-Content -Raw -LiteralPath $profilesPath) -cne $registryBeforeInvalidAppSwitch) { throw "failed cx app-default changed profiles.json" } - cx remove app:other --delete-config | Out-Null - if (($codexAppRegistryAfterSub | ConvertFrom-Json).defaults.codex_app -ne 'sub') { throw "cx app-default sub did not persist defaults.codex_app" } - - $validSurplusProfile = Get-Content -Raw -LiteralPath $surplusProfilePath - foreach ($invalidBaseUrl in @('http://surplus.test/v1', 'https://user@surplus.test/v1')) { - $invalidSurplusProfile = $validSurplusProfile -replace 'https://surplus\.test/v1', $invalidBaseUrl - $invalidSurplusProfile | Set-Content -LiteralPath $surplusProfilePath -Encoding UTF8 - $invalidUrlError = try { cx app-default surplus | Out-Null; '' } catch { $_.Exception.Message } - if ($invalidUrlError -notmatch 'absolute HTTPS base_url without user info') { throw "cx app-default accepted invalid base_url $invalidBaseUrl" } - if ((Get-Content -Raw -LiteralPath $codexConfigPath) -cne $configBeforeInvalidAppSwitch) { throw "invalid base_url changed config.toml" } - } - $validSurplusProfile | Set-Content -LiteralPath $surplusProfilePath -Encoding UTF8 - - $missingTokenStdout = Join-Path $tmpRoot 'missing-token.stdout' - $missingTokenStderr = Join-Path $tmpRoot 'missing-token.stderr' - & pwsh.exe -NoLogo -NoProfile -NonInteractive -File $env:AI_CODEX_APP_TOKEN_COMMAND -SecretId codex.missing -Key OPENAI_API_KEY 1> $missingTokenStdout 2> $missingTokenStderr - $missingTokenExitCode = $LASTEXITCODE - $missingTokenOutput = if (Test-Path -LiteralPath $missingTokenStdout) { Get-Content -Raw -LiteralPath $missingTokenStdout } else { '' } - $missingTokenError = if (Test-Path -LiteralPath $missingTokenStderr) { Get-Content -Raw -LiteralPath $missingTokenStderr } else { '' } - if ($missingTokenExitCode -eq 0) { throw "Codex App token helper accepted a missing secret" } - if ($missingTokenOutput) { throw "Codex App token helper wrote stdout on failure" } - if ($missingTokenError -match 'sk-test-surplus') { throw "Codex App token helper leaked a secret on failure" } - - $malformedTokenStdout = Join-Path $tmpRoot 'malformed-token.stdout' - $malformedTokenStderr = Join-Path $tmpRoot 'malformed-token.stderr' - & pwsh.exe -NoLogo -NoProfile -NonInteractive -File $env:AI_CODEX_APP_TOKEN_COMMAND -SecretId codex.malformed -Key OPENAI_API_KEY 1> $malformedTokenStdout 2> $malformedTokenStderr - $malformedTokenExitCode = $LASTEXITCODE - $malformedTokenOutput = if (Test-Path -LiteralPath $malformedTokenStdout) { Get-Content -Raw -LiteralPath $malformedTokenStdout } else { '' } - $malformedTokenError = if (Test-Path -LiteralPath $malformedTokenStderr) { Get-Content -Raw -LiteralPath $malformedTokenStderr } else { '' } - if ($malformedTokenExitCode -eq 0 -or $malformedTokenOutput) { throw "Codex App token helper accepted a malformed secret" } - if ($malformedTokenError -notmatch 'not a valid quoted TOML string' -or $malformedTokenError -match 'bad\\q') { throw "Codex App token helper exposed parser details" } - if ($ccAddApi -notmatch "Added Claude Code API profile 'api:test'") { throw "cc add-api did not report success" } - if ($ccAddSub -notmatch "Added Claude Code subscription profile 'sub:test'") { throw "cc add-sub did not report success" } - if ($cxManagedList -notmatch "api:test") { throw "cx list did not show added API profile" } - if ($cxManagedList -notmatch "sub:test") { throw "cx list did not show added sub profile" } - if ($ccManagedList -notmatch "api:test") { throw "cc list did not show added API profile" } - if ($ccManagedList -notmatch "sub:test") { throw "cc list did not show added sub profile" } - if (-not $codexApiConfigExistsAfterAdd) { throw "cx add-api did not write Codex config" } - if ($cxRemoveApi -notmatch "Removed Codex profile 'api:test'") { throw "cx remove api did not report success" } - if ($cxRemoveSub -notmatch "Removed Codex profile 'sub:test'") { throw "cx remove sub did not report success" } - if ($ccRemoveApi -notmatch "Removed Claude Code profile 'api:test'") { throw "cc remove api did not report success" } - if ($ccRemoveSub -notmatch "Removed Claude Code profile 'sub:test'") { throw "cc remove sub did not report success" } - if (Test-Path -LiteralPath $codexApiTestConfig) { throw "cx remove --delete-config did not remove Codex config" } - if ($cxSwitch -notmatch "Secret source: .*secrets\.toml#codex\.api") { throw "cx api did not load TOML secret" } - if ($cxSwitch -notmatch "API local check: profile file=True; key=True") { throw "cx api switch missing local check" } - if ($openAiKey -ne "sk-test-codex") { throw "cx api did not set OPENAI_API_KEY from TOML" } - if ($ccSwitch -notmatch "Secret source: .*secrets\.toml#claude\.api-docker") { throw "cc api:docker did not load TOML secret" } - if ($ccSwitch -notmatch "API local check: auth=True; url=True") { throw "cc api switch missing local check" } - if ($env:ANTHROPIC_AUTH_TOKEN -ne "sk-test-token") { throw "cc api:docker did not set ANTHROPIC_AUTH_TOKEN from TOML" } - if ($env:CODEX_HOME -ne $codexDir) { throw "Unexpected CODEX_HOME: $env:CODEX_HOME" } - - # --- per-profile env (--env): persisted in registry, exported on switch, cleared on switch-away --- - $ccAddEnv = (& { cc add-api envtest --base-url https://claude.test --env ANTHROPIC_DEFAULT_SONNET_MODEL=glm-test-sonnet --env CLAUDE_CODE_AUTO_COMPACT_WINDOW=987654 } 6>&1 | Out-String -Width 4096) - $cxAddEnv = (& { cx add-api cxenvtest --base-url https://router.test/v1 --env CODEX_EXTRA_FLAG=on } 6>&1 | Out-String -Width 4096) - $registryAfterEnv = Get-Content -LiteralPath $profilesPath -Raw - $ccEnvSwitch = (& { cc envtest } 6>&1 | Out-String -Width 4096) - $ccEnvSonnet = $env:ANTHROPIC_DEFAULT_SONNET_MODEL - $ccEnvWindow = $env:CLAUDE_CODE_AUTO_COMPACT_WINDOW - $ccEnvAway = (& { cc api:docker } 6>&1 | Out-String -Width 4096) - $ccEnvSonnetAfterAway = $env:ANTHROPIC_DEFAULT_SONNET_MODEL - $cxEnvSwitch = (& { cx cxenvtest } 6>&1 | Out-String -Width 4096) - $cxEnvFlag = $env:CODEX_EXTRA_FLAG - $cxEnvAway = (& { cx api } 6>&1 | Out-String -Width 4096) - $cxEnvFlagAfterAway = $env:CODEX_EXTRA_FLAG - - if ($ccAddEnv -notmatch "Added Claude Code API profile 'envtest'") { throw "cc add-api --env did not report success" } - if ($ccAddEnv -notmatch "Env:") { throw "cc add-api did not report Env summary" } - if ($registryAfterEnv -notmatch "ANTHROPIC_DEFAULT_SONNET_MODEL") { throw "registry did not persist profile env map" } - if ($ccEnvSonnet -ne "glm-test-sonnet") { throw "cc switch did not export ANTHROPIC_DEFAULT_SONNET_MODEL from profile env" } - if ($ccEnvWindow -ne "987654") { throw "cc switch did not export CLAUDE_CODE_AUTO_COMPACT_WINDOW from profile env" } - if ($ccEnvSonnetAfterAway) { throw "cc switch-away did not clear the previous profile env var (leak)" } - if ($cxEnvFlag -ne "on") { throw "cx switch did not export CODEX_EXTRA_FLAG from profile env" } - if ($cxEnvFlagAfterAway) { throw "cx switch-away did not clear the previous profile env var (leak)" } - - Write-Host "AI env PowerShell smoke check passed." -} finally { - if ($null -ne $previousAiEnvHome) { - $env:AI_ENV_HOME = $previousAiEnvHome - } else { - Remove-Item Env:AI_ENV_HOME -ErrorAction SilentlyContinue - } - if ($null -ne $previousNonInteractive) { - $env:AI_ENV_NONINTERACTIVE = $previousNonInteractive - } else { - Remove-Item Env:AI_ENV_NONINTERACTIVE -ErrorAction SilentlyContinue - } - if ($null -ne $previousCodexConfigPath) { - $env:AI_CODEX_CONFIG_PATH = $previousCodexConfigPath - } else { - Remove-Item Env:AI_CODEX_CONFIG_PATH -ErrorAction SilentlyContinue - } - if ($null -ne $previousCodexAppTokenCommand) { - $env:AI_CODEX_APP_TOKEN_COMMAND = $previousCodexAppTokenCommand - } else { - Remove-Item Env:AI_CODEX_APP_TOKEN_COMMAND -ErrorAction SilentlyContinue - } - Remove-Item -LiteralPath $tmpRoot -Recurse -Force -ErrorAction SilentlyContinue -} - -# GitHub Actions' PowerShell wrapper exits with the most recent native process -# code. The smoke test intentionally runs failing token-helper probes, so make -# the script's successful outcome explicit after all assertions and cleanup. -exit 0 diff --git a/test/ai-env-smoke.sh b/test/ai-env-smoke.sh deleted file mode 100755 index 5a4a783..0000000 --- a/test/ai-env-smoke.sh +++ /dev/null @@ -1,318 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -SOURCE_DIR="${DOTFILES_SOURCE_DIR:-$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)}" - -if ! command -v node >/dev/null 2>&1; then - echo "skipped ai-env shell smoke: node is not installed" >&2 - exit 0 -fi - -tmp_root="$(mktemp -d)" -tmp_home="$tmp_root/home" -debug_dir="$tmp_root/output" -trap 'status=$?; if [ "$status" -ne 0 ]; then echo "AI env shell smoke failed with exit $status." >&2; for file in "$debug_dir"/*; do [ -e "$file" ] || continue; echo "--- ${file##*/} ---" >&2; cat "$file" >&2; done; fi; rm -rf "$tmp_root"' EXIT - -mkdir -p "$tmp_home/.local/share/ai-env" "$tmp_home/.ai-env" "$tmp_home/.ai-secrets" "$tmp_home/.codex" "$debug_dir" -cp "$SOURCE_DIR/dot_local/share/ai-env/ai-env.sh" "$tmp_home/.local/share/ai-env/ai-env.sh" -cp "$SOURCE_DIR/dot_local/share/ai-env/ai-health.mjs" "$tmp_home/.local/share/ai-env/ai-health.mjs" -cp "$SOURCE_DIR/dot_ai-env/create_profiles.json" "$tmp_home/.ai-env/profiles.json" -cp "$SOURCE_DIR/dot_codex/create_sub.config.toml" "$tmp_home/.codex/sub.config.toml" -cp "$SOURCE_DIR/dot_codex/create_api.config.toml" "$tmp_home/.codex/api.config.toml" -mkdir -p "$tmp_home/.codex/sessions/2026/06/09" -cat >"$tmp_home/.codex/sessions/2026/06/09/rollout-2026-06-09T00-00-00-stats-smoke.jsonl" <<'EOF' -{"timestamp":"2026-06-09T00:00:00.000Z","type":"session_meta","payload":{"id":"stats-smoke","cwd":"/workspace"}} -{"timestamp":"2026-06-09T00:01:00.000Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":700,"output_tokens":200,"reasoning_output_tokens":50,"total_tokens":1200}}}} -EOF -printf '%s\n' \ - '{' \ - ' "codex": "sub",' \ - ' "claude": "api:docker"' \ - '}' >"$tmp_home/.ai-env/state.json" -cat >"$tmp_home/.ai-secrets/secrets.toml" <<'EOF' -[codex.api] -OPENAI_API_KEY = "sk-test-codex" - -[codex.cxenvtest] -OPENAI_API_KEY = "sk-test-cxenv" - -[claude.api-docker] -ANTHROPIC_BASE_URL = "https://anyrouter.top" -ANTHROPIC_AUTH_TOKEN = "sk-test-token" - -[claude.envtest] -ANTHROPIC_AUTH_TOKEN = "sk-test-envtoken" -EOF - -run_step() { - local name="$1" - shift - "$@" >"$debug_dir/$name.out" 2>"$debug_dir/$name.err" || { - echo "command failed: $*" >&2 - return 1 - } -} - -assert_contains() { - local needle="$1" file="$2" - grep -q "$needle" "$file" || { - echo "missing expected text '$needle' in $file" >&2 - return 1 - } -} - -assert_display_width() { - local file="$1" max="$2" prefix="${3:-}" - node -e ' -const fs=require("fs"),max=Number(process.argv[2]),prefix=process.argv[3]||""; -const width=(s)=>{let w=0;for(const ch of s)w+=(ch.codePointAt(0)>=0x2e80&&ch.codePointAt(0)<=0xa4cf)?2:1;return w;}; -for(const line of fs.readFileSync(process.argv[1],"utf8").split(/\r?\n/)){ - if(prefix&&!line.startsWith(prefix))continue; - if(width(line)>max)throw new Error(`line width ${width(line)} exceeds ${max}: ${line}`); -} -' "$file" "$max" "$prefix" -} - -( - export HOME="$tmp_home" - # shellcheck source=/dev/null - if ! . "$HOME/.local/share/ai-env/ai-env.sh"; then - echo "failed to source ai-env.sh" >&2 - exit 1 - fi - - run_step cx-help cx help - run_step cx-list cx list - run_step cx-stats cx stats --days 365 - run_step cc-help cc help - run_step cc-list cc list - run_step cx-add-api cx add-api api:test --base-url https://router.test/v1 --model gpt-test - run_step cx-add-sub cx add-sub sub:test - run_step cc-add-api cc add-api api:test --base-url https://claude.test - run_step cc-add-sub cc add-sub sub:test - [ -f "$tmp_home/.codex/api-api-test.config.toml" ] || { - echo "cx add-api did not write Codex config" >&2 - exit 1 - } - run_step cx-managed-list cx list - run_step cc-managed-list cc list - run_step cx-remove-api cx remove api:test --delete-config - run_step cx-remove-sub cx remove sub:test --delete-config - run_step cc-remove-api cc remove api:test - run_step cc-remove-sub cc remove sub:test - - assert_contains "cx - switch Codex state" "$debug_dir/cx-help.out" - assert_contains "Auto-select a cached healthy Codex profile" "$debug_dir/cx-help.out" - assert_contains "cx add-api NAME" "$debug_dir/cx-help.out" - assert_contains "cx edit" "$debug_dir/cx-help.out" - assert_contains "cx doctor" "$debug_dir/cx-help.out" - assert_contains "cx next" "$debug_dir/cx-help.out" - assert_contains "Codex profiles" "$debug_dir/cx-list.out" - assert_contains "Health" "$debug_dir/cx-list.out" - assert_contains "run 'cx health' to refresh" "$debug_dir/cx-list.out" - assert_contains "Codex local token stats" "$debug_dir/cx-stats.out" - assert_contains "Total: 1.2K (1200)" "$debug_dir/cx-stats.out" - assert_contains "cc - switch Claude Code state" "$debug_dir/cc-help.out" - assert_contains "Auto-select a cached healthy Claude Code profile" "$debug_dir/cc-help.out" - assert_contains "cc add-api NAME" "$debug_dir/cc-help.out" - assert_contains "cc edit" "$debug_dir/cc-help.out" - assert_contains "cc next" "$debug_dir/cc-help.out" - assert_contains "Claude Code profiles" "$debug_dir/cc-list.out" - assert_contains "Health" "$debug_dir/cc-list.out" - assert_contains "run 'cc health' to refresh" "$debug_dir/cc-list.out" - assert_contains "Added Codex API profile 'api:test'" "$debug_dir/cx-add-api.out" - assert_contains "Added Codex subscription profile 'sub:test'" "$debug_dir/cx-add-sub.out" - assert_contains "Added Claude Code API profile 'api:test'" "$debug_dir/cc-add-api.out" - assert_contains "Added Claude Code subscription profile 'sub:test'" "$debug_dir/cc-add-sub.out" - assert_contains "api:test" "$debug_dir/cx-managed-list.out" - assert_contains "sub:test" "$debug_dir/cx-managed-list.out" - assert_contains "api:test" "$debug_dir/cc-managed-list.out" - assert_contains "sub:test" "$debug_dir/cc-managed-list.out" - assert_contains "Removed Codex profile 'api:test'" "$debug_dir/cx-remove-api.out" - assert_contains "Removed Codex profile 'sub:test'" "$debug_dir/cx-remove-sub.out" - assert_contains "Removed Claude Code profile 'api:test'" "$debug_dir/cc-remove-api.out" - assert_contains "Removed Claude Code profile 'sub:test'" "$debug_dir/cc-remove-sub.out" - [ ! -f "$tmp_home/.codex/api-api-test.config.toml" ] || { - echo "cx remove --delete-config did not remove Codex config" >&2 - exit 1 - } - if [ "${CODEX_HOME}" != "$tmp_home/.codex" ]; then - echo "unexpected CODEX_HOME: got '${CODEX_HOME}', expected '$tmp_home/.codex'" >&2 - exit 1 - fi - - # per-profile env (--env): persisted in registry, exported on switch, cleared on switch-away - export AI_ENV_NONINTERACTIVE=1 - run_step cc-add-env cc add-api envtest --base-url https://claude.test --env ANTHROPIC_DEFAULT_SONNET_MODEL=glm-test-sonnet --env CLAUDE_CODE_AUTO_COMPACT_WINDOW=987654 - run_step cx-add-env cx add-api cxenvtest --base-url https://router.test/v1 --env CODEX_EXTRA_FLAG=on - assert_contains "ANTHROPIC_DEFAULT_SONNET_MODEL" "$tmp_home/.ai-env/profiles.json" - assert_contains "Env: ANTHROPIC_DEFAULT_SONNET_MODEL" "$debug_dir/cc-add-env.out" - - cc envtest >"$debug_dir/cc-envtest.out" 2>&1 - [ "${ANTHROPIC_DEFAULT_SONNET_MODEL:-}" = "glm-test-sonnet" ] || { echo "cc switch did not export profile env" >&2; exit 1; } - [ "${CLAUDE_CODE_AUTO_COMPACT_WINDOW:-}" = "987654" ] || { echo "cc switch did not export compact window" >&2; exit 1; } - cc api:docker >"$debug_dir/cc-env-away.out" 2>&1 - [ -z "${ANTHROPIC_DEFAULT_SONNET_MODEL:-}" ] || { echo "cc switch-away did not clear profile env (leak)" >&2; exit 1; } - cx cxenvtest >"$debug_dir/cx-envtest.out" 2>&1 - [ "${CODEX_EXTRA_FLAG:-}" = "on" ] || { echo "cx switch did not export profile env" >&2; exit 1; } - cx api >"$debug_dir/cx-env-away.out" 2>&1 - [ -z "${CODEX_EXTRA_FLAG:-}" ] || { echo "cx switch-away did not clear profile env (leak)" >&2; exit 1; } - assert_contains "API local check: profile file=true; key=true" "$debug_dir/cx-env-away.out" - - # --- health: mock the network probe, test cache/cell/select/probe-model/default --- - probe_log="$debug_dir/probe.log" - : >"$probe_log" - _ai_probe_health() { - printf '%s\n' "$(_ai_profile_value "$2" name "")" >>"$probe_log" - case "$(_ai_profile_value "$2" name "")" in - hgood) printf '%s' '{"status":"healthy","latencyMs":120,"method":"generation","error":null}';; - hbad) printf '%s' '{"status":"down","latencyMs":0,"method":"none","error":"HTTP 401"}';; - hslow) printf '%s' '{"status":"degraded","latencyMs":9999,"method":"generation","error":"HTTP 429 (transient)"}';; - hcn) printf '%s' '{"status":"degraded","latencyMs":10,"method":"none","error":"POST /v1/messages HTTP 400 {\"type\":\"error\",\"error\":{\"message\":\"[1211][模型不存在,请检查模型代码。]\"}}"}';; - hescaped) printf '%s' '{"status":"degraded","latencyMs":10,"method":"none","error":"POST /v1/messages HTTP 500 {\"error\":{\"message\":\"\\u539f\\u56e0\\u8d85\\u957f\\uff1a\\u8fd9\\u662f\\u4e00\\u6bb5\\u4e2d\\u6587\\u9519\\u8bef\\u539f\\u56e0\"}} trailing"}';; - *) printf '%s' '{"status":"down","latencyMs":0,"method":"none","error":"HTTP 404"}';; - esac - } - cc add-api hgood --base-url https://h.test >/dev/null 2>&1 - cc add-api hbad --base-url https://h.test >/dev/null 2>&1 - cc add-api hslow --base-url https://h.test >/dev/null 2>&1 - cc add-api hcn --base-url https://h.test >/dev/null 2>&1 - cc add-api hescaped --base-url https://h.test >/dev/null 2>&1 - cat >>"$AI_SECRETS_PATH" <<'EOF' - -[claude.hgood] -ANTHROPIC_AUTH_TOKEN = "sk-test-hgood" -EOF - cc probe-model hgood my-sonnet >/dev/null 2>&1 - [ "$(_ai_profile_value "$(_ai_profile_json claude hgood)" probe_model "")" = "my-sonnet" ] || { echo "probe-model set failed" >&2; exit 1; } - cc probe-model hgood >/dev/null 2>&1 - [ -z "$(_ai_profile_value "$(_ai_profile_json claude hgood)" probe_model "")" ] || { echo "probe-model clear failed" >&2; exit 1; } - cc add-api envmodel --base-url https://h.test --env ANTHROPIC_DEFAULT_HAIKU_MODEL=env-haiku >/dev/null 2>&1 - [ "$(_ai_profile_value "$(_ai_profile_json claude envmodel)" env "")" != "" ] || { echo "profile env missing after add-api --env" >&2; exit 1; } - cc default hbad >/dev/null 2>&1 - [ "$(_ai_default_profile claude)" = "hbad" ] || { echo "default set failed" >&2; exit 1; } - rm -f "$AI_HEALTH_PATH" - pj="$(_ai_profile_json claude hgood)" - r1="$(_ai_health_cached claude "$pj" 0)" - [ -f "$AI_HEALTH_PATH" ] || { echo "health.json not written" >&2; exit 1; } - case "$(_ai_health_cell "$r1")" in 🟢120ms) :;; *) echo "health cell wrong: $(_ai_health_cell "$r1")" >&2; exit 1;; esac - [ "$(_ai_healthy_profile claude)" = "hgood" ] || { echo "auto-select did not skip down -> hgood" >&2; exit 1; } - cc hgood >"$debug_dir/cc-switch-hgood.out" 2>&1 - assert_contains "Probe model: claude-3-5-haiku-20241022" "$debug_dir/cc-switch-hgood.out" - assert_contains "Health:" "$debug_dir/cc-switch-hgood.out" - probe_before="$(wc -l <"$probe_log")" - cc status >"$debug_dir/cc-status.out" 2>&1 - assert_contains "Probe model: claude-3-5-haiku-20241022" "$debug_dir/cc-status.out" - assert_contains "Health:" "$debug_dir/cc-status.out" - [ "$(wc -l <"$probe_log")" = "$probe_before" ] || { echo "cc status without --fresh probed live" >&2; exit 1; } - cc status --fresh >"$debug_dir/cc-status-fresh.out" 2>&1 - [ "$(wc -l <"$probe_log")" -gt "$probe_before" ] || { echo "cc status --fresh did not probe live" >&2; exit 1; } - assert_contains "Probe model:" "$debug_dir/cc-status-fresh.out" - assert_contains "Health:" "$debug_dir/cc-status-fresh.out" - short_health_note="$(_ai_health_display_error 'POST /v1/messages HTTP 400 {"type":"error","error":{"message":"[1211][模型不存在,请检查模型代码。]"}}')" - case "$short_health_note" in - *"probe model unsupported; set probe_model"*) : ;; - *) echo "Chinese unsupported model health note not shortened: $short_health_note" >&2; exit 1 ;; - esac - _ai_save_profile claude hcn - unset AI_CLAUDE_LABEL - cc status --fresh >"$debug_dir/cc-status-hcn-fresh.out" 2>&1 - assert_contains "probe model unsupported; set probe_model" "$debug_dir/cc-status-hcn-fresh.out" - assert_contains "Probe model:" "$debug_dir/cc-status-hcn-fresh.out" - - escaped_health_note="$(_ai_health_display_error 'POST /v1/messages HTTP 500 {"error":{"message":"\u539f\u56e0\u8d85\u957f\uff1a\u8fd9\u662f\u4e00\u6bb5\u4e2d\u6587\u9519\u8bef\u539f\u56e0"}} trailing')" - case "$escaped_health_note" in - *"原因超长"*) : ;; - *) echo "escaped Chinese health note was not decoded: $escaped_health_note" >&2; exit 1 ;; - esac - case "$escaped_health_note" in - *'\u'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]*) echo "Unicode escape leaked: $escaped_health_note" >&2; exit 1 ;; - esac - control_health_note="$(_ai_health_display_error $'HTTP 500 \033[2J原因超长')" - case "$control_health_note" in - *$'\033'*) echo "ESC control character leaked: $control_health_note" >&2; exit 1 ;; - *"原因超长"*) : ;; - *) echo "control sanitization damaged Chinese detail: $control_health_note" >&2; exit 1 ;; - esac - empty_health_note="$(_ai_health_display_error '' 5 ' Health: OK')" - [ "${#empty_health_note}" -le 5 ] || { echo "empty health detail bypassed width cap: $empty_health_note" >&2; exit 1; } - - _ai_save_profile claude hescaped - unset AI_CLAUDE_LABEL - COLUMNS=80 cc status --fresh >"$debug_dir/cc-status-hescaped-fresh.out" 2>&1 - assert_contains "原因超长" "$debug_dir/cc-status-hescaped-fresh.out" - if grep -Eq '\\u[0-9a-fA-F]{4}' "$debug_dir/cc-status-hescaped-fresh.out"; then - echo "status leaked a Unicode escape" >&2; exit 1 - fi - assert_display_width "$debug_dir/cc-status-hescaped-fresh.out" 80 " Health:" - - bounded_registry="$debug_dir/bounded-profiles.json" - bounded_health="$debug_dir/bounded-health.json" - printf '%s\n' '{"schema":1,"defaults":{"claude":"hescaped"},"codex":[],"claude":[{"name":"hescaped","aliases":[],"mode":"api","base_url":"https://h.test","secret_id":"claude.hescaped"}]}' >"$bounded_registry" - node -e 'const fs=require("fs");const p=process.argv[1],error="HTTP 500 \u001b[2J"+process.argv[2];fs.writeFileSync(p,JSON.stringify({"claude.hescaped":{status:"degraded",latencyMs:10,method:"none",error,probedAt:Math.floor(Date.now()/1000)}},null,2)+"\n")' "$bounded_health" '\u539f\u56e0\u8d85\u957f\uff1a\u8fd9\u662f\u4e00\u6bb5\u4e2d\u6587\u9519\u8bef\u539f\u56e0' - AI_REGISTRY_PATH="$bounded_registry" AI_HEALTH_PATH="$bounded_health" AI_HEALTH_COLUMNS=80 node "$tmp_home/.local/share/ai-env/ai-health.mjs" claude >"$debug_dir/cc-health-bounded.out" 2>&1 - assert_contains "原因超" "$debug_dir/cc-health-bounded.out" - if grep -Eq '\\u[0-9a-fA-F]{4}' "$debug_dir/cc-health-bounded.out"; then - echo "health table leaked a Unicode escape" >&2; exit 1 - fi - if grep -q $'\033' "$debug_dir/cc-health-bounded.out"; then - echo "health table leaked an ESC control character" >&2; exit 1 - fi - assert_display_width "$debug_dir/cc-health-bounded.out" 80 - _ai_save_profile claude api:docker - cc api:docker >/dev/null 2>&1 - cc api:docker >"$debug_dir/cc-switch-api-docker.out" 2>&1 - assert_contains "API local check: auth=true; url=true" "$debug_dir/cc-switch-api-docker.out" - cc health-clear; [ ! -f "$AI_HEALTH_PATH" ] || { echo "health-clear failed" >&2; exit 1; } - mcp list >/dev/null 2>&1 || { echo "mcp command failed" >&2; exit 1; } - - # --- mcp: offline (file-only, no network) --- - export AI_CLAUDE_JSON_PATH="$tmp_home/.claude.json" - export AI_CODEX_CONFIG_PATH="$tmp_home/.codex/config.toml" - cat >"$AI_MCP_PATH" <<'TOML' -[mcp.context7] -command = ["npx", "-y", "@upstash/context7-mcp"] -sync = ["claude", "codex"] -enabled = true - -[mcp.figma] -url = "https://mcp.figma.com/mcp" -sync = ["codex"] -enabled = false -TOML - echo '{"mcpServers":{}}' >"$AI_CLAUDE_JSON_PATH" - : >"$AI_CODEX_CONFIG_PATH" - mcp sync >/dev/null 2>&1 - grep -q '"context7"' "$AI_CLAUDE_JSON_PATH" || { echo "mcp sync: claude missing context7" >&2; exit 1; } - ! grep -q '"figma"' "$AI_CLAUDE_JSON_PATH" || { echo "mcp sync: claude should not have disabled figma" >&2; exit 1; } - grep -q '\[mcp_servers.context7\]' "$AI_CODEX_CONFIG_PATH" || { echo "mcp sync: codex missing context7" >&2; exit 1; } - ! grep -q '\[mcp_servers.figma\]' "$AI_CODEX_CONFIG_PATH" || { echo "mcp sync: codex should not have disabled figma" >&2; exit 1; } - grep -q context7 < <(mcp list) || { echo "mcp list missing context7" >&2; exit 1; } - rm -f "$AI_MCP_PATH" - echo '{"mcpServers":{"newone":{"command":"echo"}}}' >"$AI_CLAUDE_JSON_PATH" - : >"$AI_CODEX_CONFIG_PATH" - grep -q '+1 added' < <(mcp pull 2>&1) || { echo "mcp pull did not add newone" >&2; exit 1; } - grep -q '\[mcp.newone\]' "$AI_MCP_PATH" || { echo "mcp pull did not write newone" >&2; exit 1; } -) - -if command -v zsh >/dev/null 2>&1; then - run_step zsh-ai-env env HOME="$tmp_home" zsh -f -ic ' - source "$HOME/.local/share/ai-env/ai-env.sh" - command -v node >/dev/null - toml_base_url="$(_ai_toml_value "$HOME/.codex/api.config.toml" base_url)" - [ "$toml_base_url" = "https://api.aixhan.com/v1" ] - _ai_probe_health() { printf "{\"status\":\"skip\",\"latencyMs\":0,\"method\":null,\"error\":\"mock\"}"; } - cx list - cc status - ' - assert_contains "Codex profiles" "$debug_dir/zsh-ai-env.out" - assert_contains "Saved: api:docker" "$debug_dir/zsh-ai-env.out" - assert_contains "ANTHROPIC_AUTH_TOKEN: sk-test-...oken" "$debug_dir/zsh-ai-env.out" - if [ -s "$debug_dir/zsh-ai-env.err" ]; then - echo "unexpected zsh stderr:" >&2 - cat "$debug_dir/zsh-ai-env.err" >&2 - exit 1 - fi -fi - -echo "AI env shell smoke check passed." diff --git a/test/codex-all-provider-sessions.ps1 b/test/codex-all-provider-sessions.ps1 deleted file mode 100644 index c765ede..0000000 --- a/test/codex-all-provider-sessions.ps1 +++ /dev/null @@ -1,144 +0,0 @@ -param( - [string]$SourceDir = (Split-Path -Parent $PSScriptRoot) -) - -$ErrorActionPreference = "Stop" -$tmpRoot = Join-Path ([IO.Path]::GetTempPath()) ("codex-all-provider-test-" + [guid]::NewGuid().ToString("N")) -$testHome = Join-Path $tmpRoot "home" -$codexHome = Join-Path $testHome ".codex" -$aiEnvHome = Join-Path $testHome ".ai-env" -$previousAiEnvHome = $env:AI_ENV_HOME -$previousNonInteractive = $env:AI_ENV_NONINTERACTIVE -$previousAppServerCli = $env:AI_CODEX_APP_SERVER_CLI -$previousResumeCapture = $env:CX_TEST_RESUME_CAPTURE -$previousOpenAiApiKey = $env:OPENAI_API_KEY -$previousPath = $env:PATH - -function Write-TestRollout { - param( - [Parameter(Mandatory = $true)][string]$Path, - [Parameter(Mandatory = $true)][string]$Id, - [Parameter(Mandatory = $true)][string]$Provider, - [Parameter(Mandatory = $true)][string]$Preview, - [Parameter(Mandatory = $true)][string]$Timestamp - ) - - $meta = [ordered]@{ - timestamp = $Timestamp - type = "session_meta" - payload = [ordered]@{ - id = $Id - timestamp = $Timestamp - cwd = $SourceDir - originator = "cx-test" - cli_version = "0.144.1" - source = "cli" - model_provider = $Provider - } - } | ConvertTo-Json -Depth 8 -Compress - $message = [ordered]@{ - timestamp = $Timestamp - type = "response_item" - payload = [ordered]@{ - type = "message" - role = "user" - content = @([ordered]@{ type = "input_text"; text = $Preview }) - } - } | ConvertTo-Json -Depth 8 -Compress - [IO.File]::WriteAllText($Path, "$meta`n$message`n", [Text.UTF8Encoding]::new($false)) -} - -try { - $env:AI_ENV_HOME = $testHome - $env:AI_ENV_NONINTERACTIVE = "1" - New-Item -ItemType Directory -Force -Path $aiEnvHome, $codexHome | Out-Null - Copy-Item -LiteralPath (Join-Path $SourceDir "dot_ai-env/create_profiles.json") -Destination (Join-Path $aiEnvHome "profiles.json") - Copy-Item -LiteralPath (Join-Path $SourceDir "dot_codex/create_sub.config.toml") -Destination (Join-Path $codexHome "sub.config.toml") - [IO.File]::WriteAllText((Join-Path $codexHome "config.toml"), "model_provider = `"openai`"`n", [Text.UTF8Encoding]::new($false)) - - $sessionDir = Join-Path $codexHome "sessions/2026/07/16" - New-Item -ItemType Directory -Force -Path $sessionDir | Out-Null - $openAiId = "019f6a00-0000-7000-8000-000000000001" - $surplusId = "019f6a00-0000-7000-8000-000000000002" - $fakeBin = Join-Path $tmpRoot "bin" - New-Item -ItemType Directory -Force -Path $fakeBin | Out-Null - $fakeCodex = @' -if ($args -contains "resume") { - [IO.File]::WriteAllText($env:CX_TEST_RESUME_CAPTURE, ($args -join "|"), [Text.UTF8Encoding]::new($false)) - exit 0 -} -if ($args -contains "app-server" -and $env:OPENAI_API_KEY) { - throw "Local session listing inherited OPENAI_API_KEY" -} -while (($line = [Console]::In.ReadLine()) -ne $null) { - $request = $line | ConvertFrom-Json -Depth 50 - if ($request.method -eq "initialize") { - [Console]::Out.WriteLine(([ordered]@{ id = $request.id; result = [ordered]@{} } | ConvertTo-Json -Compress)) - [Console]::Out.Flush() - continue - } - if ($request.method -eq "thread/list") { - if ($null -eq $request.params.modelProviders -or @($request.params.modelProviders).Count -ne 0) { - [Console]::Out.WriteLine(([ordered]@{ id = $request.id; error = [ordered]@{ message = "modelProviders must be []" } } | ConvertTo-Json -Compress)) - [Console]::Out.Flush() - continue - } - $data = @( - [ordered]@{ id = $env:CX_TEST_OPENAI_ID; modelProvider = "openai"; preview = "openai session"; cwd = $env:CX_TEST_CWD; updatedAt = 1784167200 }, - [ordered]@{ id = $env:CX_TEST_SURPLUS_ID; modelProvider = "api-router"; preview = "surplus session"; cwd = $env:CX_TEST_CWD; updatedAt = 1784170800 } - ) - [Console]::Out.WriteLine(([ordered]@{ id = $request.id; result = [ordered]@{ data = $data; nextCursor = $null } } | ConvertTo-Json -Depth 10 -Compress)) - [Console]::Out.Flush() - } -} -'@ - [IO.File]::WriteAllText((Join-Path $fakeBin "codex.ps1"), $fakeCodex, [Text.UTF8Encoding]::new($false)) - $env:CX_TEST_OPENAI_ID = $openAiId - $env:CX_TEST_SURPLUS_ID = $surplusId - $env:CX_TEST_CWD = $SourceDir - $env:PATH = "$fakeBin$([IO.Path]::PathSeparator)$previousPath" - $env:AI_CODEX_APP_SERVER_CLI = Join-Path $fakeBin "codex.ps1" - $env:CX_TEST_RESUME_CAPTURE = Join-Path $tmpRoot "resume-args.txt" - Write-TestRollout -Path (Join-Path $sessionDir "rollout-2026-07-16T10-00-00-$openAiId.jsonl") -Id $openAiId -Provider "openai" -Preview "openai session" -Timestamp "2026-07-16T02:00:00Z" - Write-TestRollout -Path (Join-Path $sessionDir "rollout-2026-07-16T11-00-00-$surplusId.jsonl") -Id $surplusId -Provider "api-router" -Preview "surplus session" -Timestamp "2026-07-16T03:00:00Z" - - . (Join-Path $SourceDir "Documents/PowerShell/Scripts/ai-env.ps1") - $profile = Get-AiProfileByName -Tool "codex" -Name "sub" - $env:OPENAI_API_KEY = "must-not-reach-session-list" - $sessions = @(Get-CodexAllProviderSessions -Profile $profile) - if ($env:OPENAI_API_KEY -cne "must-not-reach-session-list") { throw "Session listing mutated the caller API key" } - $sessionIds = @($sessions | ForEach-Object { $_.id }) - if ($openAiId -notin $sessionIds -or $surplusId -notin $sessionIds) { - throw "All-provider query did not return both test sessions" - } - $providers = @($sessions | Where-Object { $_.id -in @($openAiId, $surplusId) } | ForEach-Object { $_.modelProvider } | Sort-Object -Unique) - if ($providers.Count -ne 2 -or "openai" -notin $providers -or "api-router" -notin $providers) { - throw "All-provider query lost provider metadata" - } - - $resumeArguments = @(Get-CodexResumeArguments -Profile $profile -SessionId $surplusId) - if (($resumeArguments -join "|") -cne "--profile|sub|resume|$surplusId") { - throw "Resume arguments do not force the current profile: $($resumeArguments -join ' ')" - } - Resume-CodexAllProviderSession -Arguments @($surplusId) - $capturedResumeArguments = Get-Content -LiteralPath $env:CX_TEST_RESUME_CAPTURE -Raw - if ($capturedResumeArguments -cne "--profile|sub|resume|$surplusId") { - throw "cx resume did not invoke the expected command: $capturedResumeArguments" - } - - $help = (& { cx help } 6>&1 | Out-String -Width 4096) - foreach ($command in @("cx sessions", "cx resume", "cx app-bridge")) { - if ($help -notmatch [regex]::Escape($command)) { throw "cx help is missing $command" } - } - - Write-Host "Codex all-provider session tests passed" -} finally { - if ($null -ne $previousAiEnvHome) { $env:AI_ENV_HOME = $previousAiEnvHome } else { Remove-Item Env:AI_ENV_HOME -ErrorAction SilentlyContinue } - if ($null -ne $previousNonInteractive) { $env:AI_ENV_NONINTERACTIVE = $previousNonInteractive } else { Remove-Item Env:AI_ENV_NONINTERACTIVE -ErrorAction SilentlyContinue } - if ($null -ne $previousAppServerCli) { $env:AI_CODEX_APP_SERVER_CLI = $previousAppServerCli } else { Remove-Item Env:AI_CODEX_APP_SERVER_CLI -ErrorAction SilentlyContinue } - if ($null -ne $previousResumeCapture) { $env:CX_TEST_RESUME_CAPTURE = $previousResumeCapture } else { Remove-Item Env:CX_TEST_RESUME_CAPTURE -ErrorAction SilentlyContinue } - if ($null -ne $previousOpenAiApiKey) { $env:OPENAI_API_KEY = $previousOpenAiApiKey } else { Remove-Item Env:OPENAI_API_KEY -ErrorAction SilentlyContinue } - $env:PATH = $previousPath - Remove-Item Env:CX_TEST_OPENAI_ID, Env:CX_TEST_SURPLUS_ID, Env:CX_TEST_CWD -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $tmpRoot -Recurse -Force -ErrorAction SilentlyContinue -} diff --git a/test/codex-app-bridge-management.ps1 b/test/codex-app-bridge-management.ps1 deleted file mode 100644 index a5d2b78..0000000 --- a/test/codex-app-bridge-management.ps1 +++ /dev/null @@ -1,81 +0,0 @@ -param( - [string]$SourceDir = (Split-Path -Parent $PSScriptRoot) -) - -$ErrorActionPreference = "Stop" -$tmpRoot = Join-Path ([IO.Path]::GetTempPath()) ("codex-app-bridge-management-" + [guid]::NewGuid().ToString("N")) -$testHome = Join-Path $tmpRoot "home" -$bridgeHome = Join-Path $tmpRoot "bridge" -$previous = @{ - AI_ENV_HOME = $env:AI_ENV_HOME - AI_CODEX_APP_BRIDGE_HOME = $env:AI_CODEX_APP_BRIDGE_HOME - AI_CODEX_APP_BRIDGE_PROJECT = $env:AI_CODEX_APP_BRIDGE_PROJECT - AI_CODEX_APP_REAL_CLI = $env:AI_CODEX_APP_REAL_CLI - AI_CODEX_APP_BRIDGE_ENV_TARGET = $env:AI_CODEX_APP_BRIDGE_ENV_TARGET - AI_CODEX_CONFIG_PATH = $env:AI_CODEX_CONFIG_PATH - CODEX_CLI_PATH = $env:CODEX_CLI_PATH -} - -try { - New-Item -ItemType Directory -Force -Path (Join-Path $testHome ".ai-env") | Out-Null - Copy-Item -LiteralPath (Join-Path $SourceDir "dot_ai-env/create_profiles.json") -Destination (Join-Path $testHome ".ai-env/profiles.json") - $env:AI_ENV_HOME = $testHome - $env:AI_CODEX_APP_BRIDGE_HOME = $bridgeHome - $env:AI_CODEX_APP_BRIDGE_PROJECT = Join-Path $SourceDir "tools/codex-provider-bridge/CodexProviderBridge.csproj" - $trustedBundle = Join-Path $tmpRoot "trusted-bundle" - New-Item -ItemType Directory -Force -Path $trustedBundle | Out-Null - $trustedCli = Join-Path $trustedBundle "codex.exe" - Copy-Item -LiteralPath (Get-Process -Id $PID -ErrorAction Stop).Path -Destination $trustedCli - foreach ($helper in @("codex-command-runner.exe", "codex-code-mode-host.exe", "codex-windows-sandbox-setup.exe")) { - [IO.File]::WriteAllText((Join-Path $trustedBundle $helper), "fixture-$helper", [Text.UTF8Encoding]::new($false)) - } - $env:AI_CODEX_APP_REAL_CLI = $trustedCli - $env:AI_CODEX_APP_BRIDGE_ENV_TARGET = "Process" - $env:AI_CODEX_CONFIG_PATH = Join-Path $testHome ".codex/config.toml" - New-Item -ItemType Directory -Force -Path (Split-Path -Parent $env:AI_CODEX_CONFIG_PATH) | Out-Null - [IO.File]::WriteAllText($env:AI_CODEX_CONFIG_PATH, "model_provider = `"openai`"`n", [Text.UTF8Encoding]::new($false)) - $env:CODEX_CLI_PATH = "C:\before-codex.exe" - - . (Join-Path $SourceDir "Documents/PowerShell/Scripts/ai-env.ps1") - Invoke-CodexAppBridgeCommand -Arguments @("install") | Out-Null - - $bridgePath = Join-Path $bridgeHome "codex-provider-bridge.exe" - $settingsPath = Join-Path $bridgeHome "codex-provider-bridge.json" - $activationPath = Join-Path $bridgeHome "activation.json" - foreach ($path in @($bridgePath, $settingsPath, $activationPath)) { - if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Install did not create $path" } - } - if ($env:CODEX_CLI_PATH -cne $bridgePath) { throw "Install did not activate CODEX_CLI_PATH" } - $settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json -Depth 20 - $securedRealCli = Join-Path $bridgeHome "codex.exe" - if ($settings.realCodexPath -cne $securedRealCli) { throw "Settings did not select the secured real CLI copy" } - if ($settings.realCodexSha256 -cne (Get-FileHash -LiteralPath $env:AI_CODEX_APP_REAL_CLI -Algorithm SHA256).Hash) { throw "Settings did not pin the trusted source CLI hash" } - if ((Get-FileHash -LiteralPath $securedRealCli -Algorithm SHA256).Hash -cne $settings.realCodexSha256) { throw "Secured real CLI copy does not match settings" } - foreach ($helper in @("codex-command-runner.exe", "codex-code-mode-host.exe", "codex-windows-sandbox-setup.exe")) { - $sourceHelper = Join-Path $trustedBundle $helper - $securedHelper = Join-Path $bridgeHome $helper - if (-not (Test-Path -LiteralPath $securedHelper -PathType Leaf)) { throw "Install omitted required helper $helper" } - if ((Get-FileHash -LiteralPath $securedHelper -Algorithm SHA256).Hash -cne (Get-FileHash -LiteralPath $sourceHelper -Algorithm SHA256).Hash) { throw "Secured helper differs: $helper" } - } - if ((Get-Content -LiteralPath $settingsPath -Raw) -match '(?i)(api[_-]?key|bearer|token)') { throw "Bridge settings contain secret-like fields" } - $installedStatus = Get-CodexAppBridgeStatus - if (-not $installedStatus.IsCurrentAppCli) { throw "Status did not recognize the pinned CLI as current" } - - Invoke-CodexAppBridgeCommand -Arguments @("install") | Out-Null - Invoke-CodexAppBridgeCommand -Arguments @("remove") | Out-Null - if ($env:CODEX_CLI_PATH -cne "C:\before-codex.exe") { throw "Remove did not restore the pre-install CODEX_CLI_PATH" } - - $env:CODEX_CLI_PATH = "C:\new-user-choice.exe" - Invoke-CodexAppBridgeCommand -Arguments @("remove") | Out-Null - if ($env:CODEX_CLI_PATH -cne "C:\new-user-choice.exe") { throw "Remove without activation clobbered an unrelated CODEX_CLI_PATH" } - - $status = Get-CodexAppBridgeStatus - if ($status.IsConfigured) { throw "Bridge still reports configured after remove" } - Write-Host "Codex App bridge management tests passed" -} finally { - foreach ($name in $previous.Keys) { - $value = $previous[$name] - if ($null -eq $value) { Remove-Item "Env:$name" -ErrorAction SilentlyContinue } else { Set-Item "Env:$name" $value } - } - Remove-Item -LiteralPath $tmpRoot -Recurse -Force -ErrorAction SilentlyContinue -} diff --git a/test/codex-provider-bridge.ps1 b/test/codex-provider-bridge.ps1 deleted file mode 100644 index b982465..0000000 --- a/test/codex-provider-bridge.ps1 +++ /dev/null @@ -1,179 +0,0 @@ -param( - [string]$SourceDir = (Split-Path -Parent $PSScriptRoot) -) - -$ErrorActionPreference = "Stop" -$projectPath = Join-Path $SourceDir "tools/codex-provider-bridge/CodexProviderBridge.csproj" -$tmpRoot = Join-Path ([IO.Path]::GetTempPath()) ("codex-provider-bridge-test-" + [guid]::NewGuid().ToString("N")) -$publishDir = Join-Path $tmpRoot "publish" -$stubbornProcess = @() - -function Start-TestProcess { - param( - [Parameter(Mandatory = $true)][string]$Executable, - [string[]]$Arguments = @(), - [string[]]$InputLines = @() - ) - - $startInfo = [Diagnostics.ProcessStartInfo]::new($Executable) - foreach ($argument in $Arguments) { [void]$startInfo.ArgumentList.Add($argument) } - $startInfo.UseShellExecute = $false - $startInfo.RedirectStandardInput = $true - $startInfo.RedirectStandardOutput = $true - $startInfo.RedirectStandardError = $true - $process = [Diagnostics.Process]::Start($startInfo) - foreach ($line in $InputLines) { $process.StandardInput.WriteLine($line) } - $process.StandardInput.Close() - if (-not $process.WaitForExit(15000)) { - $process.Kill($true) - throw "Process timed out: $Executable" - } - $result = [pscustomobject]@{ - ExitCode = $process.ExitCode - Stdout = $process.StandardOutput.ReadToEnd() - Stderr = $process.StandardError.ReadToEnd() - } - $process.Dispose() - return $result -} - -try { - New-Item -ItemType Directory -Force -Path $tmpRoot, $publishDir | Out-Null - & dotnet publish $projectPath -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -o $publishDir --nologo - if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed with exit code $LASTEXITCODE" } - - $bridgePath = Join-Path $publishDir "codex-provider-bridge.exe" - if (-not (Test-Path -LiteralPath $bridgePath -PathType Leaf)) { throw "Bridge executable was not published" } - - $fakeServerPath = Join-Path $tmpRoot "fake-app-server.ps1" - @' -$ErrorActionPreference = "Stop" -[Console]::InputEncoding = [Text.UTF8Encoding]::new($false) -[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) -[Console]::Error.WriteLine("ARGS=" + ($args | ConvertTo-Json -Compress)) -while ($null -ne ($line = [Console]::In.ReadLine())) { - [Console]::Out.WriteLine($line) - [Console]::Out.Flush() -} -'@ | Set-Content -LiteralPath $fakeServerPath -Encoding UTF8 - - $settingsPath = Join-Path $publishDir "codex-provider-bridge.json" - [ordered]@{ - realCodexPath = (Get-Process -Id $PID).Path - realCodexSha256 = (Get-FileHash -LiteralPath (Get-Process -Id $PID).Path -Algorithm SHA256).Hash - realCodexPrefixArgs = @("-NoLogo", "-NoProfile", "-NonInteractive", "-File", $fakeServerPath) - } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $settingsPath -Encoding UTF8 - - $listNull = '{"id":"list-null","method":"thread/list","params":{"modelProviders":null,"archived":false}}' - $listFiltered = '{"id":"list-filtered","method":"thread/list","params":{"modelProviders":["openai"],"archived":true}}' - $resume = '{"id":"resume","method":"thread/resume","params":{"threadId":"abc","modelProvider":"ai-env-app"}}' - $invalid = 'not-json' - $nonStringMethod = '{"id":"weird","method":7,"params":{}}' - $unicodeResume = '{"id":"unicode","method":"thread/resume","params":{"threadId":"跨-provider-✓"}}' - $result = Start-TestProcess -Executable $bridgePath -Arguments @("alpha", "with space") -InputLines @($listNull, $listFiltered, $resume, $invalid, $nonStringMethod, $unicodeResume) - if ($result.ExitCode -ne 0) { throw "Bridge failed: $($result.Stderr)" } - - $lines = @($result.Stdout -split '\r?\n' | Where-Object { $_ -ne "" }) - if ($lines.Count -ne 6) { throw "Expected six stdout lines, got $($lines.Count): $($result.Stdout)" } - foreach ($index in 0, 1) { - $message = $lines[$index] | ConvertFrom-Json -Depth 20 - if ($message.method -ne "thread/list") { throw "List request method changed" } - if ($null -eq $message.params.modelProviders -or @($message.params.modelProviders).Count -ne 0) { - throw "thread/list modelProviders was not replaced with an empty array" - } - } - if ($lines[2] -cne $resume) { throw "thread/resume was modified by the bridge" } - if ($lines[3] -cne $invalid) { throw "Malformed non-JSON input was not forwarded unchanged" } - if ($lines[4] -cne $nonStringMethod) { throw "JSON with a non-string method was not forwarded unchanged" } - if ($lines[5] -cne $unicodeResume) { throw "UTF-8 input was not forwarded unchanged" } - if ($result.Stderr -notmatch 'ARGS=\["alpha","with space"\]') { throw "Child arguments or stderr were not forwarded" } - - $stubbornServerPath = Join-Path $tmpRoot "stubborn-app-server.ps1" - $stubbornPidPath = Join-Path $tmpRoot "stubborn-app-server.pid" - @' -param([string]$PidPath) -$startInfo = [Diagnostics.ProcessStartInfo]::new((Get-Process -Id $PID).Path) -$startInfo.ArgumentList.Add("-NoLogo") -$startInfo.ArgumentList.Add("-NoProfile") -$startInfo.ArgumentList.Add("-NonInteractive") -$startInfo.ArgumentList.Add("-Command") -$startInfo.ArgumentList.Add("while (`$true) { Start-Sleep -Milliseconds 200 }") -$startInfo.UseShellExecute = $false -$startInfo.CreateNoWindow = $true -$grandchild = [Diagnostics.Process]::Start($startInfo) -"$PID,$($grandchild.Id)" | Set-Content -LiteralPath $PidPath -Encoding ascii -while ($true) { Start-Sleep -Milliseconds 200 } -'@ | Set-Content -LiteralPath $stubbornServerPath -Encoding UTF8 - [ordered]@{ - realCodexPath = (Get-Process -Id $PID).Path - realCodexSha256 = (Get-FileHash -LiteralPath (Get-Process -Id $PID).Path -Algorithm SHA256).Hash - realCodexPrefixArgs = @("-NoLogo", "-NoProfile", "-NonInteractive", "-File", $stubbornServerPath, $stubbornPidPath) - } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $settingsPath -Encoding UTF8 - - $stubbornStartInfo = [Diagnostics.ProcessStartInfo]::new($bridgePath) - $stubbornStartInfo.UseShellExecute = $false - $stubbornStartInfo.RedirectStandardInput = $true - $stubbornStartInfo.RedirectStandardOutput = $true - $stubbornStartInfo.RedirectStandardError = $true - $stubbornBridge = [Diagnostics.Process]::Start($stubbornStartInfo) - try { - $deadline = [DateTime]::UtcNow.AddSeconds(10) - while (-not (Test-Path -LiteralPath $stubbornPidPath) -and [DateTime]::UtcNow -lt $deadline) { - Start-Sleep -Milliseconds 100 - } - if (-not (Test-Path -LiteralPath $stubbornPidPath)) { throw "Stubborn downstream did not start" } - $stubbornPids = @((Get-Content -LiteralPath $stubbornPidPath -Raw).Trim() -split ',' | ForEach-Object { [int]$_ }) - if ($stubbornPids.Count -ne 2) { throw "Stubborn downstream did not report child and grandchild PIDs" } - $stubbornProcess = @(Get-Process -Id $stubbornPids -ErrorAction Stop) - if ($stubbornProcess.Count -ne 2) { throw "Stubborn downstream process tree was incomplete" } - foreach ($item in $stubbornProcess) { [void]$item.Handle } - $stubbornBridge.Kill() - if (-not $stubbornBridge.WaitForExit(5000)) { throw "Bridge did not terminate" } - $exitDeadline = [DateTime]::UtcNow.AddSeconds(5) - do { - $survivingProcesses = @($stubbornProcess | Where-Object { -not $_.HasExited }) - if ($survivingProcesses.Count -gt 0) { Start-Sleep -Milliseconds 100 } - } while ($survivingProcesses.Count -gt 0 -and [DateTime]::UtcNow -lt $exitDeadline) - if ($survivingProcesses.Count -gt 0) { throw "Downstream process tree survived bridge termination" } - } finally { - if (-not $stubbornBridge.HasExited) { $stubbornBridge.Kill($true) } - $stubbornBridge.Dispose() - } - - $missingDir = Join-Path $tmpRoot "missing-settings" - New-Item -ItemType Directory -Force -Path $missingDir | Out-Null - $missingBridge = Join-Path $missingDir "codex-provider-bridge.exe" - Copy-Item -LiteralPath $bridgePath -Destination $missingBridge - $missingResult = Start-TestProcess -Executable $missingBridge - if ($missingResult.ExitCode -eq 0 -or $missingResult.Stdout) { throw "Bridge accepted missing settings" } - if ($missingResult.Stderr -notmatch 'settings') { throw "Missing-settings error is not actionable" } - - [ordered]@{ - realCodexPath = (Get-Process -Id $PID).Path - realCodexSha256 = ('0' * 64) - realCodexPrefixArgs = @("-NoLogo", "-NoProfile", "-NonInteractive", "-File", $fakeServerPath) - } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $settingsPath -Encoding UTF8 - $hashMismatchResult = Start-TestProcess -Executable $bridgePath - if ($hashMismatchResult.ExitCode -eq 0 -or $hashMismatchResult.Stdout) { throw "Bridge accepted a downstream hash mismatch" } - if ($hashMismatchResult.Stderr -notmatch 'hash') { throw "Hash-mismatch error is not actionable" } - - [ordered]@{ - realCodexPath = $bridgePath - realCodexSha256 = (Get-FileHash -LiteralPath $bridgePath -Algorithm SHA256).Hash - realCodexPrefixArgs = @() - } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $settingsPath -Encoding UTF8 - $recursiveResult = Start-TestProcess -Executable $bridgePath - if ($recursiveResult.ExitCode -eq 0 -or $recursiveResult.Stdout) { throw "Bridge accepted a recursive downstream path" } - if ($recursiveResult.Stderr -notmatch 'itself|recursive') { throw "Recursion error is not actionable" } - - Write-Host "Codex provider bridge tests passed" -} finally { - foreach ($process in @($stubbornProcess)) { - if ($null -ne $process -and -not $process.HasExited) { - $process.Kill($true) - $process.WaitForExit() - } - if ($null -ne $process) { $process.Dispose() } - } - Remove-Item -LiteralPath $tmpRoot -Recurse -Force -ErrorAction SilentlyContinue -} diff --git a/test/cxcc-consumer-smoke.ps1 b/test/cxcc-consumer-smoke.ps1 new file mode 100644 index 0000000..2dc258b --- /dev/null +++ b/test/cxcc-consumer-smoke.ps1 @@ -0,0 +1,213 @@ +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$expectedVersion = "v0.1.0" +$expectedCommit = "dfc0bd6ef4b6aafdafff5f6d732e28cc52cfcfc0" +$expectedInstallerSha256 = "40a116c2f83a25590ed9d1d74120354c00254ed719e1adda25c429282d57f54e" +$expectedArtifactSha256 = "f8fde14b05170a635d5837650fe587ba96dcdd1164d6f3e2706497a13beced5f" +$installer = Join-Path $repoRoot "scripts\install\cxcc.ps1" +$hook = Join-Path $repoRoot "run_before_10-install-cxcc.ps1.tmpl" +$dataFile = Join-Path $repoRoot ".chezmoidata.toml" +$profileSource = Join-Path $repoRoot "Documents\PowerShell\create_Microsoft.PowerShell_profile.ps1" + +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw $Message } +} + +foreach ($required in @($installer, $hook, $dataFile, $profileSource)) { + Assert-True (Test-Path -LiteralPath $required -PathType Leaf) "Missing cxcc consumer file: $required" +} + +$dataText = Get-Content -LiteralPath $dataFile -Raw +$hookText = Get-Content -LiteralPath $hook -Raw +$profileText = Get-Content -LiteralPath $profileSource -Raw +Assert-True ($dataText -match '(?m)^version = "v0\.1\.0"$') "dotfiles does not pin cxcc v0.1.0." +Assert-True $dataText.Contains($expectedCommit) "dotfiles does not pin an immutable cxcc commit." +Assert-True $dataText.Contains($expectedInstallerSha256) "dotfiles does not pin the PowerShell installer digest." +Assert-True $dataText.Contains($expectedArtifactSha256) "dotfiles does not pin the Windows artifact digest." +Assert-True $hookText.Contains("scripts\install\cxcc.ps1") "PowerShell hook does not invoke the cxcc consumer installer." +Assert-True $hookText.Contains(".cxcc.version") "PowerShell hook does not use the shared cxcc version pin." +Assert-True $hookText.Contains(".cxcc.commit") "PowerShell hook does not use the immutable cxcc commit pin." +Assert-True $hookText.Contains(".cxcc.installerPowerShellSha256") "PowerShell hook does not use the installer digest pin." +Assert-True $hookText.Contains(".cxcc.windowsArtifactSha256") "PowerShell hook does not use the artifact digest pin." +Assert-True ($profileText -match 'CXCC_HOME') "PowerShell profile does not honor CXCC_HOME." +Assert-True ($profileText -match 'load\.ps1') "PowerShell profile does not load the stable cxcc loader." +Assert-True ($profileText -notmatch 'Scripts\\ai-env\.ps1') "PowerShell profile still loads the legacy ai-env implementation." + +$legacyPaths = @( + "Documents/PowerShell/Scripts/ai-env.ps1", + "dot_local/share/ai-env/ai-env.sh", + "dot_local/share/ai-env/ai-health.mjs", + "tools/codex-provider-bridge/ChildProcessJob.cs", + "tools/codex-provider-bridge/CodexProviderBridge.csproj", + "tools/codex-provider-bridge/Program.cs" +) +foreach ($legacyPath in $legacyPaths) { + & git -C $repoRoot ls-files --error-unmatch -- $legacyPath *> $null + $isTrackedAndPresent = $LASTEXITCODE -eq 0 -and (Test-Path -LiteralPath (Join-Path $repoRoot $legacyPath)) + Assert-True (-not $isTrackedAndPresent) "Legacy cxcc implementation is still tracked: $legacyPath" +} + +$tempRoot = Join-Path ([IO.Path]::GetTempPath()) ("cxcc-consumer-" + [guid]::NewGuid().ToString("N")) +$testHome = Join-Path $tempRoot "home" +$installRoot = Join-Path $testHome ".local\share\cxcc" +$downloadLog = Join-Path $tempRoot "download.log" +$installLog = Join-Path $tempRoot "install.log" +$envNames = @("INSTALL_CXCC", "CXCC_HOME", "AI_ENV_HOME", "CODEX_HOME", "CODEX_THREAD_ID", "CXCC_TEST_INSTALL_LOG") +$savedEnvironment = @{} +foreach ($name in $envNames) { + $item = Get-Item -LiteralPath "Env:\$name" -ErrorAction SilentlyContinue + $savedEnvironment[$name] = if ($item) { [pscustomobject]@{ Exists = $true; Value = $item.Value } } else { [pscustomobject]@{ Exists = $false; Value = $null } } +} + +try { + $statePaths = @( + (Join-Path $testHome ".ai-env\profiles.json"), + (Join-Path $testHome ".ai-env\state.json"), + (Join-Path $testHome ".ai-env\mcp.toml"), + (Join-Path $testHome ".ai-secrets\secrets.toml"), + (Join-Path $testHome ".codex\auth.json"), + (Join-Path $testHome ".claude\.credentials.json") + ) + foreach ($path in $statePaths) { New-Item -ItemType Directory -Force -Path (Split-Path -Parent $path) | Out-Null } + [IO.File]::WriteAllText($statePaths[0], '{"sentinel":"profiles"}', [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($statePaths[1], '{"sentinel":"state"}', [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($statePaths[2], "[mcp.sentinel]`nenabled = false`n", [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($statePaths[3], "[codex.sentinel]`nOPENAI_API_KEY = `"keep`"`n", [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($statePaths[4], '{"sentinel":"auth"}', [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($statePaths[5], '{"sentinel":"credentials"}', [Text.UTF8Encoding]::new($false)) + $stateHashes = @{} + foreach ($path in $statePaths) { $stateHashes[$path] = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash } + + $fakeInstallerSource = Join-Path $tempRoot "fake-install.ps1" + $fakeInstaller = @' +param([string]$Version, [string]$ArtifactPath, [string]$Sha256) +if ($Version -cne "v0.1.0") { throw "Unexpected fake installer version: $Version" } +if (-not (Test-Path -LiteralPath $ArtifactPath -PathType Leaf)) { throw "Fake artifact is missing." } +Add-Content -LiteralPath $env:CXCC_TEST_INSTALL_LOG -Value "$Version|$([IO.Path]::GetFileName($ArtifactPath))|$Sha256" +$root = $env:CXCC_HOME +$versionRoot = Join-Path $root "versions\$Version" +New-Item -ItemType Directory -Force -Path $versionRoot | Out-Null +[IO.File]::WriteAllText((Join-Path $root ".cxcc-root"), "cxcc-install-root-v1`n", [Text.UTF8Encoding]::new($false)) +[IO.File]::WriteAllText((Join-Path $versionRoot "VERSION"), $Version, [Text.UTF8Encoding]::new($false)) +[IO.File]::WriteAllText((Join-Path $versionRoot ".artifact-sha256"), "$Sha256`n", [Text.UTF8Encoding]::new($false)) +[IO.File]::WriteAllText((Join-Path $root "current.json"), "{`"schema`":1,`"version`":`"$Version`",`"previous`":null}`n", [Text.UTF8Encoding]::new($false)) +$payloadFiles = @( + "load.ps1", + "load.sh", + "src\powershell\CxCc\CxCc.ps1", + "src\shell\cxcc.sh", + "src\shell\ai-health.mjs", + "src\bridge\CodexProviderBridge\CodexProviderBridge.csproj", + "templates\profiles.json" +) +foreach ($relativePath in $payloadFiles) { + $path = Join-Path $versionRoot $relativePath + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $path) | Out-Null + [IO.File]::WriteAllText($path, "# fake payload`n", [Text.UTF8Encoding]::new($false)) +} +$loader = @( + '$global:CXCC_CONSUMER_TEST_LOADER_COUNT = [int]$global:CXCC_CONSUMER_TEST_LOADER_COUNT + 1' + 'function global:cx { }' + 'function global:cc { }' + 'function global:mcp { }' +) -join [Environment]::NewLine +[IO.File]::WriteAllText((Join-Path $root "load.ps1"), $loader, [Text.UTF8Encoding]::new($false)) +[IO.File]::WriteAllText((Join-Path $root "load.sh"), "# fake shell loader`n", [Text.UTF8Encoding]::new($false)) +'@ + [IO.File]::WriteAllText($fakeInstallerSource, $fakeInstaller, [Text.UTF8Encoding]::new($false)) + $testCommit = "1" * 40 + $testInstallerSha256 = (Get-FileHash -LiteralPath $fakeInstallerSource -Algorithm SHA256).Hash.ToLowerInvariant() + $testArtifactSha256 = "a" * 64 + $installerArguments = @{ + Version = $expectedVersion + Commit = $testCommit + InstallerSha256 = $testInstallerSha256 + ArtifactSha256 = $testArtifactSha256 + } + + function global:Invoke-WebRequest { + param([string]$Uri, [string]$OutFile, [int]$TimeoutSec) + Add-Content -LiteralPath $downloadLog -Value $Uri + if ($Uri.EndsWith("/install.ps1", [StringComparison]::Ordinal)) { + Copy-Item -LiteralPath $fakeInstallerSource -Destination $OutFile + } else { + [IO.File]::WriteAllText($OutFile, "fake artifact", [Text.UTF8Encoding]::new($false)) + } + } + + function Assert-StatePreserved { + foreach ($path in $statePaths) { + Assert-True (((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash) -ceq $stateHashes[$path]) "cxcc consumer changed user state: $path" + } + } + + $env:CXCC_HOME = $installRoot + $env:AI_ENV_HOME = $testHome + $env:CODEX_HOME = Join-Path $testHome ".codex" + $env:CODEX_THREAD_ID = "cxcc-consumer-smoke" + $env:CXCC_TEST_INSTALL_LOG = $installLog + Remove-Item -LiteralPath Env:\INSTALL_CXCC -ErrorAction SilentlyContinue + + & $installer @installerArguments + $downloadLines = @(Get-Content -LiteralPath $downloadLog) + Assert-True ($downloadLines.Count -eq 2) "PowerShell consumer did not download the installer and artifact exactly once." + Assert-True ($downloadLines[0] -ceq "https://raw.githubusercontent.com/Tim-1e/cxcc/$testCommit/install.ps1") "PowerShell consumer used a mutable installer URL." + Assert-True ($downloadLines[1] -ceq "https://github.com/Tim-1e/cxcc/releases/download/$expectedVersion/cxcc-$expectedVersion-windows-x64.zip") "PowerShell consumer used an unexpected artifact URL." + Assert-True ((Get-Content -LiteralPath $installLog -Raw).Trim() -ceq "$expectedVersion|cxcc-$expectedVersion-windows-x64.zip|$testArtifactSha256") "PowerShell consumer passed unexpected installer arguments." + $current = Get-Content -LiteralPath (Join-Path $installRoot "current.json") -Raw | ConvertFrom-Json + Assert-True ($current.schema -eq 1 -and $current.version -ceq $expectedVersion) "PowerShell consumer current.json is invalid." + Assert-True ((Get-Content -LiteralPath (Join-Path $installRoot "versions\$expectedVersion\VERSION") -Raw) -ceq $expectedVersion) "PowerShell consumer payload VERSION is invalid." + Assert-StatePreserved + + & $installer @installerArguments + Assert-True (@(Get-Content -LiteralPath $downloadLog).Count -eq 2) "Repeated PowerShell apply downloaded cxcc again." + Assert-StatePreserved + + Remove-Item -LiteralPath (Join-Path $installRoot "versions\$expectedVersion\src\powershell\CxCc\CxCc.ps1") + & $installer @installerArguments + Assert-True (@(Get-Content -LiteralPath $downloadLog).Count -eq 4) "PowerShell consumer ignored a damaged cxcc payload." + Assert-StatePreserved + + Remove-Item -LiteralPath Function:\cx, Function:\cc, Function:\mcp -ErrorAction SilentlyContinue + Remove-Variable -Name CXCC_CONSUMER_TEST_LOADER_COUNT -Scope Global -ErrorAction SilentlyContinue + . $profileSource + foreach ($name in @("cx", "cc", "mcp")) { + Assert-True ($null -ne (Get-Command $name -CommandType Function -ErrorAction SilentlyContinue)) "PowerShell profile did not define $name." + } + Assert-True ($global:CXCC_CONSUMER_TEST_LOADER_COUNT -eq 1) "PowerShell profile did not run the stable loader exactly once." + + [IO.Directory]::Delete($installRoot, $true) + $env:INSTALL_CXCC = "0" + & $installer @installerArguments + Assert-True (-not (Test-Path -LiteralPath $installRoot)) "INSTALL_CXCC=0 created an install root." + Assert-True (@(Get-Content -LiteralPath $downloadLog).Count -eq 4) "INSTALL_CXCC=0 accessed the network." + Assert-StatePreserved + + $invalidVersionFailed = $false + try { & $installer -Version "main" -Commit $testCommit -InstallerSha256 $testInstallerSha256 -ArtifactSha256 $testArtifactSha256 } catch { $invalidVersionFailed = $true } + Assert-True $invalidVersionFailed "PowerShell consumer accepted an unpinned version." + + Remove-Item -LiteralPath Env:\INSTALL_CXCC -ErrorAction SilentlyContinue + $env:CXCC_HOME = Join-Path $testHome "bad\cxcc" + $checksumFailed = $false + try { & $installer -Version $expectedVersion -Commit $testCommit -InstallerSha256 ("0" * 64) -ArtifactSha256 $testArtifactSha256 } catch { $checksumFailed = $true } + Assert-True $checksumFailed "PowerShell consumer executed an installer with the wrong checksum." + Assert-True (@(Get-Content -LiteralPath $downloadLog).Count -eq 5) "PowerShell checksum failure downloaded an artifact or retried unexpectedly." + Assert-True (@(Get-Content -LiteralPath $installLog).Count -eq 2) "PowerShell checksum failure executed the installer." + + Write-Host "cxcc PowerShell consumer smoke passed." +} finally { + Remove-Item -LiteralPath Function:\Invoke-WebRequest -ErrorAction SilentlyContinue + foreach ($name in $envNames) { + if ($savedEnvironment[$name].Exists) { Set-Item -LiteralPath "Env:\$name" -Value $savedEnvironment[$name].Value } + else { Remove-Item -LiteralPath "Env:\$name" -ErrorAction SilentlyContinue } + } + Remove-Variable -Name CXCC_CONSUMER_TEST_LOADER_COUNT -Scope Global -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $tempRoot) { Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue } +} + +# GitHub Actions propagates the most recent native exit code after dot-sourcing +# the step script. Expected failing probes above must not mask a successful test. +$global:LASTEXITCODE = 0 diff --git a/test/cxcc-consumer-smoke.sh b/test/cxcc-consumer-smoke.sh new file mode 100755 index 0000000..e935649 --- /dev/null +++ b/test/cxcc-consumer-smoke.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +REPO_ROOT="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +EXPECTED_VERSION="v0.1.0" +EXPECTED_COMMIT="dfc0bd6ef4b6aafdafff5f6d732e28cc52cfcfc0" +EXPECTED_INSTALLER_SHA256="ce6e0712c6a2c0439c334bf849b71fe618a6c296477bc25a447ede47f07e4eb7" +EXPECTED_ARTIFACT_SHA256="6ac428ce3002d6e7be8f92b26b69c172380363cdfeb8f588278f7577d06958bd" +INSTALLER="$REPO_ROOT/scripts/install/cxcc.sh" +HOOK="$REPO_ROOT/run_before_10-install-cxcc.sh.tmpl" +DATA_FILE="$REPO_ROOT/.chezmoidata.toml" +ZSHRC="$REPO_ROOT/dot_zshrc" +FULL_SMOKE="$REPO_ROOT/test/smoke.sh" + +fail() { + echo "$*" >&2 + exit 1 +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + fail "A SHA-256 tool is required for the cxcc consumer smoke." + fi +} + +for required in "$INSTALLER" "$HOOK" "$DATA_FILE" "$ZSHRC"; do + [ -f "$required" ] || fail "Missing cxcc consumer file: $required" +done + +grep -Eq '^version = "v0\.1\.0"$' "$DATA_FILE" || fail "dotfiles does not pin cxcc v0.1.0." +grep -Fq "$EXPECTED_COMMIT" "$DATA_FILE" || fail "dotfiles does not pin an immutable cxcc commit." +grep -Fq "$EXPECTED_INSTALLER_SHA256" "$DATA_FILE" || fail "dotfiles does not pin the Shell installer digest." +grep -Fq "$EXPECTED_ARTIFACT_SHA256" "$DATA_FILE" || fail "dotfiles does not pin the POSIX artifact digest." +grep -Fq 'scripts/install/cxcc.sh' "$HOOK" || fail "Shell hook does not invoke the cxcc consumer installer." +grep -Fq '.cxcc.version' "$HOOK" || fail "Shell hook does not use the shared cxcc version pin." +grep -Fq '.cxcc.commit' "$HOOK" || fail "Shell hook does not use the immutable cxcc commit pin." +grep -Fq '.cxcc.installerShellSha256' "$HOOK" || fail "Shell hook does not use the installer digest pin." +grep -Fq '.cxcc.posixArtifactSha256' "$HOOK" || fail "Shell hook does not use the artifact digest pin." +grep -Fq -- '--connect-timeout' "$INSTALLER" || fail "Shell consumer download has no connection timeout." +grep -Fq -- '--max-time' "$INSTALLER" || fail "Shell consumer download has no total timeout." +grep -Fq -- '--ipv4' "$INSTALLER" || fail "Shell consumer download has no IPv4 fallback." +grep -Fq 'INSTALL_CXCC' "$FULL_SMOKE" || fail "Full smoke does not honor INSTALL_CXCC=0." +grep -Fq 'CXCC_HOME' "$ZSHRC" || fail "Zsh profile does not honor CXCC_HOME." +grep -Fq '/load.sh' "$ZSHRC" || fail "Zsh profile does not load the stable cxcc loader." +! grep -Fq '.local/share/ai-env/ai-env.sh' "$ZSHRC" || fail "Zsh profile still loads the legacy ai-env implementation." + +legacy_paths=( + 'Documents/PowerShell/Scripts/ai-env.ps1' + 'dot_local/share/ai-env/ai-env.sh' + 'dot_local/share/ai-env/ai-health.mjs' + 'tools/codex-provider-bridge/ChildProcessJob.cs' + 'tools/codex-provider-bridge/CodexProviderBridge.csproj' + 'tools/codex-provider-bridge/Program.cs' +) +for legacy_path in "${legacy_paths[@]}"; do + if [ -e "$REPO_ROOT/$legacy_path" ] && git -C "$REPO_ROOT" ls-files --error-unmatch -- "$legacy_path" >/dev/null 2>&1; then + fail "Legacy cxcc implementation is still tracked: $legacy_path" + fi +done + +tmp_root="$(mktemp -d)" +trap 'rm -rf "$tmp_root"' EXIT +test_home="$tmp_root/home" +install_root="$test_home/.local/share/cxcc" +fake_bin="$tmp_root/bin" +curl_log="$tmp_root/curl.log" +install_log="$tmp_root/install.log" +fake_installer_source="$tmp_root/fake-install.sh" +mkdir -p "$fake_bin" "$test_home/.ai-env" "$test_home/.ai-secrets" "$test_home/.codex" "$test_home/.claude" + +printf '{"sentinel":"profiles"}\n' >"$test_home/.ai-env/profiles.json" +printf '{"sentinel":"state"}\n' >"$test_home/.ai-env/state.json" +printf '[mcp.sentinel]\nenabled = false\n' >"$test_home/.ai-env/mcp.toml" +printf '[codex.sentinel]\nOPENAI_API_KEY = "keep"\n' >"$test_home/.ai-secrets/secrets.toml" +printf '{"sentinel":"auth"}\n' >"$test_home/.codex/auth.json" +printf '{"sentinel":"credentials"}\n' >"$test_home/.claude/.credentials.json" + +state_paths=( + "$test_home/.ai-env/profiles.json" + "$test_home/.ai-env/state.json" + "$test_home/.ai-env/mcp.toml" + "$test_home/.ai-secrets/secrets.toml" + "$test_home/.codex/auth.json" + "$test_home/.claude/.credentials.json" +) +state_hashes=() +for state_path in "${state_paths[@]}"; do + state_hashes+=("$(sha256_file "$state_path")") +done + +cat >"$fake_installer_source" <<'FAKE_INSTALLER' +#!/usr/bin/env bash +set -Eeuo pipefail +printf '%s\n' "$*" >>"$CXCC_TEST_INSTALL_LOG" +version="" +artifact="" +sha256="" +while [ "$#" -gt 0 ]; do + case "$1" in + --version) version="$2"; shift 2 ;; + --artifact) artifact="$2"; shift 2 ;; + --sha256) sha256="$2"; shift 2 ;; + *) shift ;; + esac +done +[ "$version" = "v0.1.0" ] +[ -f "$artifact" ] +root="$CXCC_HOME" +mkdir -p \ + "$root/versions/$version/src/powershell/CxCc" \ + "$root/versions/$version/src/shell" \ + "$root/versions/$version/src/bridge/CodexProviderBridge" \ + "$root/versions/$version/templates" +printf 'cxcc-install-root-v1\n' >"$root/.cxcc-root" +printf '%s' "$version" >"$root/versions/$version/VERSION" +printf '%s\n' "$sha256" >"$root/versions/$version/.artifact-sha256" +for relative_path in \ + load.ps1 load.sh \ + src/powershell/CxCc/CxCc.ps1 \ + src/shell/cxcc.sh src/shell/ai-health.mjs \ + src/bridge/CodexProviderBridge/CodexProviderBridge.csproj \ + templates/profiles.json; do + printf '# fake payload\n' >"$root/versions/$version/$relative_path" +done +printf '{"schema":1,"version":"%s","previous":null}\n' "$version" >"$root/current.json" +cat >"$root/load.sh" <<'FAKE_LOADER' +CXCC_CONSUMER_TEST_LOADER_COUNT=$((${CXCC_CONSUMER_TEST_LOADER_COUNT:-0} + 1)) +cx() { :; } +cc() { :; } +mcp() { :; } +FAKE_LOADER +printf '# fake PowerShell loader\n' >"$root/load.ps1" +FAKE_INSTALLER +chmod 755 "$fake_installer_source" +test_commit="1111111111111111111111111111111111111111" +test_installer_sha256="$(sha256_file "$fake_installer_source")" +test_artifact_sha256="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +cat >"$fake_bin/curl" <<'FAKE_CURL' +#!/usr/bin/env bash +set -Eeuo pipefail +output="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + --output) output="$2"; shift 2 ;; + http://*|https://*) url="$1"; shift ;; + *) shift ;; + esac +done +[ -n "$output" ] && [ -n "$url" ] +printf '%s\n' "$url" >>"$CXCC_TEST_CURL_LOG" +case "$url" in + */install.sh) cp "$CXCC_TEST_INSTALLER_SOURCE" "$output" ;; + *) printf 'fake artifact\n' >"$output" ;; +esac +FAKE_CURL +chmod 755 "$fake_bin/curl" + +assert_state_preserved() { + local index actual + for index in "${!state_paths[@]}"; do + actual="$(sha256_file "${state_paths[$index]}")" + [ "$actual" = "${state_hashes[$index]}" ] || fail "cxcc consumer changed user state: ${state_paths[$index]}" + done +} + +run_installer() { + HOME="$test_home" \ + CXCC_HOME="$install_root" \ + CXCC_TEST_CURL_LOG="$curl_log" \ + CXCC_TEST_INSTALL_LOG="$install_log" \ + CXCC_TEST_INSTALLER_SOURCE="$fake_installer_source" \ + PATH="$fake_bin:$PATH" \ + bash "$INSTALLER" "$EXPECTED_VERSION" "$test_commit" "$test_installer_sha256" "$test_artifact_sha256" +} + +run_installer +[ "$(sed -n '1p' "$curl_log")" = "https://raw.githubusercontent.com/Tim-1e/cxcc/$test_commit/install.sh" ] || fail "Shell consumer used a mutable installer URL." +[ "$(sed -n '2p' "$curl_log")" = "https://github.com/Tim-1e/cxcc/releases/download/$EXPECTED_VERSION/cxcc-$EXPECTED_VERSION-posix.tar.gz" ] || fail "Shell consumer used an unexpected artifact URL." +grep -Eq "^--version $EXPECTED_VERSION --artifact .*/cxcc-$EXPECTED_VERSION-posix\.tar\.gz --sha256 $test_artifact_sha256$" "$install_log" || fail "Shell consumer passed unexpected installer arguments." +grep -Fq '"schema":1' "$install_root/current.json" || fail "Shell consumer current.json schema is invalid." +grep -Fq '"version":"v0.1.0"' "$install_root/current.json" || fail "Shell consumer installed the wrong version." +[ "$(cat "$install_root/versions/$EXPECTED_VERSION/VERSION")" = "$EXPECTED_VERSION" ] || fail "Shell consumer payload VERSION is invalid." +assert_state_preserved + +run_installer +[ "$(wc -l <"$curl_log" | tr -d ' ')" = "2" ] || fail "Repeated Shell apply downloaded cxcc again." +assert_state_preserved + +rm "$install_root/versions/$EXPECTED_VERSION/src/shell/cxcc.sh" +run_installer +[ "$(wc -l <"$curl_log" | tr -d ' ')" = "4" ] || fail "Shell consumer ignored a damaged cxcc payload." +assert_state_preserved + +rm -rf "$install_root" +INSTALL_CXCC=0 run_installer +[ ! -e "$install_root" ] || fail "INSTALL_CXCC=0 created an install root." +[ "$(wc -l <"$curl_log" | tr -d ' ')" = "4" ] || fail "INSTALL_CXCC=0 accessed the network." +assert_state_preserved + +if HOME="$test_home" CXCC_HOME="$install_root" PATH="$fake_bin:$PATH" bash "$INSTALLER" main "$test_commit" "$test_installer_sha256" "$test_artifact_sha256" >/dev/null 2>&1; then + fail "Shell consumer accepted an unpinned version." +fi + +if HOME="$test_home" \ + CXCC_HOME="$test_home/bad/cxcc" \ + CXCC_TEST_CURL_LOG="$curl_log" \ + CXCC_TEST_INSTALL_LOG="$install_log" \ + CXCC_TEST_INSTALLER_SOURCE="$fake_installer_source" \ + PATH="$fake_bin:$PATH" \ + bash "$INSTALLER" "$EXPECTED_VERSION" "$test_commit" "$(printf '%064d' 0)" "$test_artifact_sha256" >/dev/null 2>&1; then + fail "Shell consumer executed an installer with the wrong checksum." +fi +[ "$(wc -l <"$curl_log" | tr -d ' ')" = "5" ] || fail "Shell checksum failure downloaded an artifact or retried unexpectedly." +[ "$(wc -l <"$install_log" | tr -d ' ')" = "2" ] || fail "Shell checksum failure executed the installer." + +if command -v zsh >/dev/null 2>&1; then + mkdir -p "$install_root" + cat >"$install_root/load.sh" <<'FAKE_ZSH_LOADER' +CXCC_CONSUMER_TEST_LOADER_COUNT=$((${CXCC_CONSUMER_TEST_LOADER_COUNT:-0} + 1)) +cx() { :; } +cc() { :; } +mcp() { :; } +FAKE_ZSH_LOADER + HOME="$test_home" CXCC_HOME="$install_root" zsh -f -c ' + source "$1" >/dev/null 2>&1 + [ "$CXCC_CONSUMER_TEST_LOADER_COUNT" = "1" ] + whence -w cx | grep -q function + whence -w cc | grep -q function + whence -w mcp | grep -q function + ' _ "$ZSHRC" +fi + +echo "cxcc Shell consumer smoke passed." diff --git a/test/powershell-profile-smoke.ps1 b/test/powershell-profile-smoke.ps1 index 0614a66..ece0be3 100644 --- a/test/powershell-profile-smoke.ps1 +++ b/test/powershell-profile-smoke.ps1 @@ -18,50 +18,42 @@ function Assert-Contains { } $profileSource = Join-Path $SourceDir "Documents\PowerShell\create_Microsoft.PowerShell_profile.ps1" -$aiEnvSource = Join-Path $SourceDir "Documents\PowerShell\Scripts\ai-env.ps1" -$registrySource = Join-Path $SourceDir "dot_ai-env\create_profiles.json" $tmpRoot = Join-Path ([IO.Path]::GetTempPath()) ("powershell-profile-smoke-" + [guid]::NewGuid().ToString("N")) $testHome = Join-Path $tmpRoot "home" -$aiEnvTarget = Join-Path $testHome "Documents\PowerShell\Scripts\ai-env.ps1" -$registryTarget = Join-Path $testHome ".ai-env\profiles.json" -$stateTarget = Join-Path $testHome ".ai-env\state.json" +$cxccRoot = Join-Path $testHome ".local\share\cxcc" +$cxccLoader = Join-Path $cxccRoot "load.ps1" $envBackup = @{} -foreach ($name in @("CODEX_THREAD_ID", "AI_ENV_HOME", "AI_ENV_SCRIPT_HOME", "CODEX_HOME", "AI_CODEX_LABEL", "AI_CODEX_PROFILE", "AI_CLAUDE_LABEL", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL")) { +foreach ($name in @("CODEX_THREAD_ID", "CXCC_HOME")) { $envBackup[$name] = [Environment]::GetEnvironmentVariable($name, "Process") } try { - New-Item -ItemType Directory -Force -Path (Split-Path -Parent $aiEnvTarget), (Split-Path -Parent $registryTarget) | Out-Null - Copy-Item -LiteralPath $aiEnvSource -Destination $aiEnvTarget -Force - Copy-Item -LiteralPath $registrySource -Destination $registryTarget -Force - Remove-Item -LiteralPath $stateTarget -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $cxccRoot | Out-Null + $loader = @' +$global:CXCC_PROFILE_SMOKE_LOADER_COUNT = [int]$global:CXCC_PROFILE_SMOKE_LOADER_COUNT + 1 +function global:cx { } +function global:cc { } +function global:mcp { } +'@ + [IO.File]::WriteAllText($cxccLoader, $loader, [Text.UTF8Encoding]::new($false)) $env:CODEX_THREAD_ID = "profile-smoke" - $env:AI_ENV_HOME = $testHome - $env:AI_ENV_SCRIPT_HOME = $testHome + $env:CXCC_HOME = $cxccRoot . $profileSource - foreach ($functionName in @("act", "deact", "python", "cx", "cc")) { + foreach ($functionName in @("act", "deact", "python", "cx", "cc", "mcp")) { if (-not (Get-Command $functionName -CommandType Function -ErrorAction SilentlyContinue)) { throw "Profile did not define function: $functionName" } } + if ($global:CXCC_PROFILE_SMOKE_LOADER_COUNT -ne 1) { throw "Profile did not load cxcc exactly once." } $profileText = Get-Content -Raw -LiteralPath $profileSource Assert-Contains -Text $profileText -Pattern "chezmoi-ai-env begin" -Message "Profile is missing the ai-env begin marker." - Assert-Contains -Text $profileText -Pattern "Scripts\\ai-env\.ps1" -Message "Profile does not load Scripts\ai-env.ps1." - - $cxHelp = (& { cx help } 6>&1 | Out-String) - $ccHelp = (& { cc help } 6>&1 | Out-String) - if ($cxHelp -notmatch "cx - switch Codex state") { throw "cx help output missing header after profile load." } - if ($ccHelp -notmatch "cc - switch Claude Code state") { throw "cc help output missing header after profile load." } - - $expectedCodexHome = Join-Path $testHome ".codex" - if ($env:CODEX_HOME -ne $expectedCodexHome) { - throw "Unexpected CODEX_HOME after profile load: $env:CODEX_HOME" - } + Assert-Contains -Text $profileText -Pattern "CXCC_HOME" -Message "Profile does not honor CXCC_HOME." + Assert-Contains -Text $profileText -Pattern "load\.ps1" -Message "Profile does not load the stable cxcc loader." Write-Host "PowerShell profile smoke check passed." } finally { @@ -72,5 +64,6 @@ try { [Environment]::SetEnvironmentVariable($name, $envBackup[$name], "Process") } } + Remove-Variable -Name CXCC_PROFILE_SMOKE_LOADER_COUNT -Scope Global -ErrorAction SilentlyContinue Remove-Item -LiteralPath $tmpRoot -Recurse -Force -ErrorAction SilentlyContinue } diff --git a/test/smoke.sh b/test/smoke.sh index c1c90a0..1b11f85 100755 --- a/test/smoke.sh +++ b/test/smoke.sh @@ -55,7 +55,28 @@ else fi bash "$SOURCE_DIR/test/fonts-smoke.sh" -bash "$SOURCE_DIR/test/ai-env-smoke.sh" +if [ "${INSTALL_CXCC:-1}" = "0" ]; then + echo "skipped cxcc smoke: INSTALL_CXCC=0" >&2 +else + bash "$SOURCE_DIR/test/cxcc-consumer-smoke.sh" + + cxcc_root="${CXCC_HOME:-$HOME/.local/share/cxcc}" + check_file "$cxcc_root/.cxcc-root" + check_file "$cxcc_root/current.json" + check_file "$cxcc_root/load.sh" + check_file "$cxcc_root/load.ps1" + cxcc_version="$(sed -n 's/^.*"version":"\([^"]*\)".*$/\1/p' "$cxcc_root/current.json")" + check_file "$cxcc_root/versions/$cxcc_version/VERSION" + [ "$(cat "$cxcc_root/versions/$cxcc_version/VERSION")" = "$cxcc_version" ] || { + echo "cxcc installed version metadata is invalid" >&2 + exit 1 + } + # shellcheck source=/dev/null + source "$cxcc_root/load.sh" + cx help >/dev/null + cc help >/dev/null + mcp help >/dev/null +fi FASTFETCH_OK=0 if command -v fastfetch >/dev/null 2>&1 && fastfetch --version >/dev/null 2>&1; then diff --git a/test/windows-full-smoke.ps1 b/test/windows-full-smoke.ps1 index fd1d61a..11f5cc0 100644 --- a/test/windows-full-smoke.ps1 +++ b/test/windows-full-smoke.ps1 @@ -37,12 +37,19 @@ function Assert-Contains { Assert-Command -Name "chezmoi" $profilePath = $PROFILE.CurrentUserCurrentHost -$aiEnvPath = Join-Path $HOME "Documents\PowerShell\Scripts\ai-env.ps1" +$cxccRoot = if ($env:CXCC_HOME) { [Environment]::ExpandEnvironmentVariables($env:CXCC_HOME) } else { Join-Path $HOME ".local\share\cxcc" } +$currentPath = Join-Path $cxccRoot "current.json" $registryPath = Join-Path $HOME ".ai-env\profiles.json" $codexHome = Join-Path $HOME ".codex" Assert-File -Path $profilePath -Assert-File -Path $aiEnvPath +Assert-File -Path (Join-Path $cxccRoot ".cxcc-root") +Assert-File -Path $currentPath +Assert-File -Path (Join-Path $cxccRoot "load.ps1") +Assert-File -Path (Join-Path $cxccRoot "load.sh") +$current = Get-Content -LiteralPath $currentPath -Raw | ConvertFrom-Json +if ([string]$current.version -notmatch '^v\d+\.\d+\.\d+') { throw "Installed cxcc version metadata is invalid." } +Assert-File -Path (Join-Path $cxccRoot "versions\$($current.version)\VERSION") Assert-File -Path $registryPath Assert-File -Path (Join-Path $codexHome "config.toml") Assert-File -Path (Join-Path $codexHome "sub.config.toml") @@ -52,7 +59,7 @@ Assert-File -Path (Join-Path $codexHome "app-auth\codex-app-token.ps1") Assert-File -Path (Join-Path $HOME ".claude\settings.json") Assert-Contains -Path $profilePath -Pattern "chezmoi-ai-env begin" -Message "PowerShell profile is missing the ai-env begin marker." -Assert-Contains -Path $profilePath -Pattern "Scripts\\ai-env\.ps1" -Message "PowerShell profile does not load Scripts\ai-env.ps1." +Assert-Contains -Path $profilePath -Pattern "load\.ps1" -Message "PowerShell profile does not load the stable cxcc loader." & (Join-Path $SourceDir "test\fonts-smoke.ps1") -SourceDir (Join-Path $SourceDir "0xProto") diff --git a/tools/codex-provider-bridge/ChildProcessJob.cs b/tools/codex-provider-bridge/ChildProcessJob.cs deleted file mode 100644 index 1b9fbae..0000000 --- a/tools/codex-provider-bridge/ChildProcessJob.cs +++ /dev/null @@ -1,152 +0,0 @@ -using System.ComponentModel; -using System.Diagnostics; -using System.Runtime.InteropServices; -using Microsoft.Win32.SafeHandles; - -namespace CodexProviderBridge; - -internal sealed class ChildProcessJob : IDisposable -{ - private const uint JobObjectLimitKillOnJobClose = 0x00002000; - private readonly SafeFileHandle handle; - - private ChildProcessJob(SafeFileHandle handle) - { - this.handle = handle; - } - - public static ChildProcessJob Create() - { - if (!OperatingSystem.IsWindows()) - { - throw new PlatformNotSupportedException("The Codex provider bridge requires Windows process jobs."); - } - - var handle = CreateJobObjectW(IntPtr.Zero, null); - if (handle.IsInvalid) - { - throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create a child process job."); - } - - try - { - ConfigureKillOnClose(handle); - return new ChildProcessJob(handle); - } - catch - { - handle.Dispose(); - throw; - } - } - - public static ChildProcessJob CreateForCurrentProcess() - { - var job = Create(); - try - { - using var currentProcess = Process.GetCurrentProcess(); - job.Assign(currentProcess); - return job; - } - catch - { - job.Dispose(); - throw; - } - } - - public void Assign(Process process) - { - if (!AssignProcessToJobObject(handle, process.Handle)) - { - throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to assign Codex to the child process job."); - } - } - - public void Dispose() - { - handle.Dispose(); - } - - private static void ConfigureKillOnClose(SafeFileHandle handle) - { - var information = new JobObjectExtendedLimitInformation - { - BasicLimitInformation = new JobObjectBasicLimitInformation - { - LimitFlags = JobObjectLimitKillOnJobClose, - }, - }; - var size = Marshal.SizeOf(); - var pointer = Marshal.AllocHGlobal(size); - try - { - Marshal.StructureToPtr(information, pointer, false); - if (!SetInformationJobObject(handle, JobObjectInfoClass.ExtendedLimitInformation, pointer, (uint)size)) - { - throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to configure the child process job."); - } - } - finally - { - Marshal.FreeHGlobal(pointer); - } - } - - private enum JobObjectInfoClass - { - ExtendedLimitInformation = 9, - } - - [StructLayout(LayoutKind.Sequential)] - private struct JobObjectBasicLimitInformation - { - public long PerProcessUserTimeLimit; - public long PerJobUserTimeLimit; - public uint LimitFlags; - public nuint MinimumWorkingSetSize; - public nuint MaximumWorkingSetSize; - public uint ActiveProcessLimit; - public nuint Affinity; - public uint PriorityClass; - public uint SchedulingClass; - } - - [StructLayout(LayoutKind.Sequential)] - private struct IoCounters - { - public ulong ReadOperationCount; - public ulong WriteOperationCount; - public ulong OtherOperationCount; - public ulong ReadTransferCount; - public ulong WriteTransferCount; - public ulong OtherTransferCount; - } - - [StructLayout(LayoutKind.Sequential)] - private struct JobObjectExtendedLimitInformation - { - public JobObjectBasicLimitInformation BasicLimitInformation; - public IoCounters IoInfo; - public nuint ProcessMemoryLimit; - public nuint JobMemoryLimit; - public nuint PeakProcessMemoryUsed; - public nuint PeakJobMemoryUsed; - } - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern SafeFileHandle CreateJobObjectW(IntPtr jobAttributes, string? name); - - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool SetInformationJobObject( - SafeFileHandle job, - JobObjectInfoClass informationClass, - IntPtr information, - uint informationLength); - - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool AssignProcessToJobObject(SafeFileHandle job, IntPtr process); -} diff --git a/tools/codex-provider-bridge/CodexProviderBridge.csproj b/tools/codex-provider-bridge/CodexProviderBridge.csproj deleted file mode 100644 index 7393f6e..0000000 --- a/tools/codex-provider-bridge/CodexProviderBridge.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - Exe - net8.0 - enable - enable - codex-provider-bridge - CodexProviderBridge - true - - diff --git a/tools/codex-provider-bridge/Program.cs b/tools/codex-provider-bridge/Program.cs deleted file mode 100644 index adb6eff..0000000 --- a/tools/codex-provider-bridge/Program.cs +++ /dev/null @@ -1,214 +0,0 @@ -using System.Diagnostics; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace CodexProviderBridge; - -internal sealed record BridgeSettings(string RealCodexPath, string RealCodexSha256, string[] RealCodexPrefixArgs); - -internal static class Program -{ - private const string SettingsFileName = "codex-provider-bridge.json"; - private const string ActiveEnvironmentVariable = "CODEX_PROVIDER_BRIDGE_ACTIVE"; - private static ChildProcessJob? processLifetimeJob; - - public static async Task Main(string[] args) - { - try - { - if (Environment.GetEnvironmentVariable(ActiveEnvironmentVariable) == "1") - { - throw new InvalidOperationException("Recursive Codex provider bridge launch was rejected."); - } - - Console.InputEncoding = new UTF8Encoding(false); - // Joining the bridge itself before Process.Start makes all later - // Codex descendants inherit the kill-on-close job atomically. - // Keep the handle rooted until process teardown; closing it early - // would intentionally terminate this process as a job member. - processLifetimeJob = ChildProcessJob.CreateForCurrentProcess(); - var settings = LoadSettings(); - using var child = StartCodex(settings, args); - return await ProxyAsync(child); - } - catch (Exception exception) - { - await Console.Error.WriteLineAsync($"Codex provider bridge error: {exception.Message}"); - return 2; - } - } - - private static BridgeSettings LoadSettings() - { - var settingsPath = Path.Combine(AppContext.BaseDirectory, SettingsFileName); - if (!File.Exists(settingsPath)) - { - throw new FileNotFoundException($"Bridge settings file is missing: {settingsPath}"); - } - - using var document = JsonDocument.Parse(File.ReadAllText(settingsPath)); - var root = document.RootElement; - var realCodexPath = root.TryGetProperty("realCodexPath", out var pathElement) - ? pathElement.GetString()?.Trim() - : null; - if (string.IsNullOrWhiteSpace(realCodexPath) || !Path.IsPathFullyQualified(realCodexPath)) - { - throw new InvalidDataException("Bridge settings realCodexPath must be an absolute path."); - } - - var fullRealPath = Path.GetFullPath(realCodexPath); - if (!File.Exists(fullRealPath)) - { - throw new FileNotFoundException($"Configured Codex executable is missing: {fullRealPath}"); - } - RejectRecursivePath(fullRealPath); - var expectedHash = ReadSha256(root); - VerifySha256(fullRealPath, expectedHash); - - var prefixArgs = ReadStringArray(root, "realCodexPrefixArgs"); - return new BridgeSettings(fullRealPath, expectedHash, prefixArgs); - } - - private static string ReadSha256(JsonElement root) - { - var hash = root.TryGetProperty("realCodexSha256", out var hashElement) - ? hashElement.GetString()?.Trim().ToUpperInvariant() - : null; - if (hash is null || hash.Length != 64 || hash.Any(character => !Uri.IsHexDigit(character))) - { - throw new InvalidDataException("Bridge settings realCodexSha256 must be a 64-character SHA256 hash."); - } - return hash; - } - - private static void VerifySha256(string path, string expectedHash) - { - using var stream = File.OpenRead(path); - var actualHash = Convert.ToHexString(SHA256.HashData(stream)); - if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidDataException("Configured Codex executable hash does not match bridge settings."); - } - } - - private static string[] ReadStringArray(JsonElement root, string propertyName) - { - if (!root.TryGetProperty(propertyName, out var element)) - { - return []; - } - if (element.ValueKind != JsonValueKind.Array) - { - throw new InvalidDataException($"Bridge settings {propertyName} must be an array."); - } - - return element.EnumerateArray().Select(item => - { - if (item.ValueKind != JsonValueKind.String) - { - throw new InvalidDataException($"Bridge settings {propertyName} entries must be strings."); - } - return item.GetString() ?? string.Empty; - }).ToArray(); - } - - private static void RejectRecursivePath(string realCodexPath) - { - var bridgePath = Environment.ProcessPath; - if (bridgePath is null) - { - return; - } - - if (string.Equals( - Path.GetFullPath(bridgePath), - realCodexPath, - OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) - { - throw new InvalidOperationException("The bridge cannot launch itself as the real Codex executable."); - } - } - - private static Process StartCodex(BridgeSettings settings, IReadOnlyList forwardedArgs) - { - var startInfo = new ProcessStartInfo(settings.RealCodexPath) - { - UseShellExecute = false, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - StandardInputEncoding = new UTF8Encoding(false), - }; - foreach (var argument in settings.RealCodexPrefixArgs) - { - startInfo.ArgumentList.Add(argument); - } - foreach (var argument in forwardedArgs) - { - startInfo.ArgumentList.Add(argument); - } - startInfo.Environment.Remove("CODEX_CLI_PATH"); - startInfo.Environment[ActiveEnvironmentVariable] = "1"; - - return Process.Start(startInfo) - ?? throw new InvalidOperationException("Failed to start the configured Codex executable."); - } - - private static async Task ProxyAsync(Process child) - { - var inputTask = PumpInputAsync(child.StandardInput); - var outputTask = child.StandardOutput.BaseStream.CopyToAsync(Console.OpenStandardOutput()); - var errorTask = child.StandardError.BaseStream.CopyToAsync(Console.OpenStandardError()); - var exitTask = child.WaitForExitAsync(); - - var firstCompleted = await Task.WhenAny(inputTask, exitTask); - if (firstCompleted == inputTask) - { - await inputTask; - child.StandardInput.Close(); - await exitTask; - } - - await Task.WhenAll(outputTask, errorTask); - return child.ExitCode; - } - - private static async Task PumpInputAsync(StreamWriter childInput) - { - string? line; - while ((line = await Console.In.ReadLineAsync()) is not null) - { - await childInput.WriteLineAsync(TransformRequest(line)); - await childInput.FlushAsync(); - } - } - - private static string TransformRequest(string line) - { - JsonNode? root; - try - { - root = JsonNode.Parse(line); - } - catch (JsonException) - { - return line; - } - - if (root is not JsonObject message || - message["method"] is not JsonValue methodValue || - !methodValue.TryGetValue(out var method) || - method != "thread/list") - { - return line; - } - - var parameters = message["params"] as JsonObject ?? new JsonObject(); - parameters["modelProviders"] = new JsonArray(); - message["params"] = parameters; - return message.ToJsonString(new JsonSerializerOptions { WriteIndented = false }); - } -}