From fedbc32e71bdef2d744c138336a60ec6482aa860 Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Fri, 25 Sep 2026 08:44:08 +0000 Subject: [PATCH] fix(ci): tolerate missing client CLIs --- scripts/lib/plugins.ps1 | 375 ++++++++++++++++++++-------------------- 1 file changed, 188 insertions(+), 187 deletions(-) diff --git a/scripts/lib/plugins.ps1 b/scripts/lib/plugins.ps1 index ca5f82f..f5c5aec 100644 --- a/scripts/lib/plugins.ps1 +++ b/scripts/lib/plugins.ps1 @@ -1,187 +1,188 @@ -function Invoke-PluginCommand { - param( - [Parameter(Mandatory=$true)][string]$Command, - [Parameter(Mandatory=$true)][string[]]$Arguments, - [switch]$DryRun, - [switch]$Summary - ) - $display = "$Command $($Arguments -join ' ')" - if ($DryRun) { if (-not $Summary) { Write-Output "DRYRUN $display" }; return } - $resolvedCommand = Get-Command $Command -ErrorAction Stop - if (-not $Summary) { - if ($script:PluginNonInteractive) { $null | & $resolvedCommand @Arguments } else { & $resolvedCommand @Arguments } - if ($LASTEXITCODE -ne 0) { throw "Plugin command failed: $display" } - return - } - $previousPreference = $ErrorActionPreference - try { - $ErrorActionPreference = 'Continue' - $output = if ($script:PluginNonInteractive) { @($null | & $resolvedCommand @Arguments 2>&1) } else { @(& $resolvedCommand @Arguments 2>&1) } - $exitCode = $LASTEXITCODE - } finally { $ErrorActionPreference = $previousPreference } - if ($exitCode -ne 0 -or -not $Summary -or $env:AI_CONFIG_VERBOSE -eq '1') { $output | Write-Output } else { $output | Where-Object { "$_" -match '(?i)warn|error|fail|deprecat' } } - if ($exitCode -ne 0) { throw "Plugin command failed: $display" } -} - -function Get-ConfiguredPluginEntries { - param([Parameter(Mandatory=$true)][string]$RepositoryRoot) - $manifest = Join-Path $RepositoryRoot 'adapters/plugins.tsv' - if (-not (Test-Path -LiteralPath $manifest)) { throw "Plugin manifest is missing: $manifest" } - Import-Csv -LiteralPath $manifest -Delimiter ([char]9) | ForEach-Object { - $_.claude_plugin = $_.claude_plugin.Trim() - $_.codex_plugin = $_.codex_plugin.Trim() - $_ - } -} - -function Get-InstalledPlugins { - param( - [ValidateSet('Claude','Codex')][string]$Client, - [string]$HomePath = [Environment]::GetFolderPath('UserProfile') - ) - $command = Get-Command $Client.ToLowerInvariant() -ErrorAction Stop - $environment = @{ - HOME = $HomePath - USERPROFILE = $HomePath - CODEX_HOME = (Join-Path $HomePath '.codex') - CLAUDE_CONFIG_DIR = (Join-Path $HomePath '.claude') - } - $previousEnvironment = @{} - foreach ($name in $environment.Keys) { $previousEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') } - try { - foreach ($name in $environment.Keys) { [Environment]::SetEnvironmentVariable($name, $environment[$name], 'Process') } - $global:LASTEXITCODE = 0 - $output = $null | & $command plugin list --json | Out-String - if ($LASTEXITCODE -ne 0) { throw "$Client plugin listing failed." } - $items = $output | ConvertFrom-Json - if ($Client -eq 'Claude') { return @($items) } - return @($items.installed) - } finally { - foreach ($name in $previousEnvironment.Keys) { - if ($null -eq $previousEnvironment[$name]) { Remove-Item -LiteralPath ("Env:" + $name) -ErrorAction SilentlyContinue } - else { [Environment]::SetEnvironmentVariable($name, $previousEnvironment[$name], 'Process') } - } - } -} - -function Read-PluginToggleSelection { - param( - [Parameter(Mandatory=$true)][string[]]$Names, - [Parameter(Mandatory=$true)][bool[]]$Checked - ) - $state = @($Checked) - if ([Console]::IsInputRedirected -or $env:CI -eq 'true' -or $env:AI_CONFIG_NO_INTERACTIVE -eq '1') { while($true){ Write-Host 'Select plugins (enter a number to toggle, "done" to confirm):'; for($i=0;$i -lt $Names.Count;$i++){Write-Host (" {0}) [{1}] {2}" -f ($i+1),$(if($state[$i]){'x'}else{' '}),$Names[$i])}; $answer=Read-Host 'Toggle number or "done"'; if([string]::IsNullOrWhiteSpace($answer)-or $answer -eq 'done'){return $state}; $index=0;if([int]::TryParse($answer,[ref]$index)-and $index -ge 1 -and $index -le $Names.Count){$state[$index-1]= -not $state[$index-1]} } } - $index=0; while($true){ Clear-Host; Write-Host 'Select plugins'; for($i=0;$i -lt $Names.Count;$i++){ $focus=if($i -eq $index){'>'}else{' '}; $mark=if($state[$i]){'x'}else{' '}; Write-Host "$focus [$mark] $($Names[$i])" }; Write-Host 'Up/Down Navigate Space Toggle Enter Confirm'; $key=[Console]::ReadKey($true); if($key.Key -eq 'UpArrow'){$index=($index+$Names.Count-1)%$Names.Count}elseif($key.Key -eq 'DownArrow'){$index=($index+1)%$Names.Count}elseif($key.Key -eq 'Spacebar'){$state[$index]= -not $state[$index]}elseif($key.Key -eq 'Enter'){Clear-Host;return $state} } -} - -function Select-ConfiguredPlugins { - <# - .SYNOPSIS - Lets the user toggle each configured plugin on or off. Install mode starts - with everything checked; update mode starts checked to match what the - reference client (Claude, or Codex when only Codex is targeted) already - has installed, so unchecking a currently installed plugin uninstalls it. - #> - param( - [Parameter(Mandatory=$true)][string]$RepositoryRoot, - [ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client, - [ValidateSet('Install','Update')][Parameter(Mandatory=$true)][string]$Mode, - [string]$HomePath = [Environment]::GetFolderPath('UserProfile'), - [switch]$DryRun - ) - $entries = @(Get-ConfiguredPluginEntries -RepositoryRoot $RepositoryRoot) - $toggleable = @($entries) - $fixed = @($entries | Where-Object { $toggleable -notcontains $_ }) - if ($toggleable.Count -eq 0 -or ($DryRun -and $Mode -eq 'Install')) { return @{ Selected = $entries; Deselected = @() } } - - $referenceClients = if ($Client -eq 'Both') { @('Claude','Codex') } else { @($Client) } - $installed = @{} - if ($Mode -eq 'Update') { foreach ($referenceClient in $referenceClients) { $installed[$referenceClient] = @(Get-InstalledPlugins -Client $referenceClient -HomePath $HomePath) } } - $checked = @($toggleable | ForEach-Object { - if ($Mode -eq 'Install') { $true; return } - if ($_.codex_method -eq 'cli') { return $null -ne (Get-Command $_.codex_source -ErrorAction SilentlyContinue) } - $entry = $_ - $found = $false - foreach ($referenceClient in $referenceClients) { - $selector = if ($referenceClient -eq 'Claude') { $entry.claude_plugin } else { $entry.codex_plugin } - if ($referenceClient -eq 'Codex' -and $entry.codex_method -eq 'qmd') { - $found = $found -or ($null -ne (Get-Command qmd -ErrorAction SilentlyContinue)) - } elseif ($referenceClient -eq 'Codex' -and $entry.codex_method -ne 'plugin') { - $found = $found -or (Test-Path -LiteralPath (Join-Path $HomePath ('.agents/skills/' + $entry.codex_skill))) -or (Test-Path -LiteralPath (Join-Path $HomePath ('.codex/skills/' + $entry.codex_skill))) - } else { - $idField = if ($referenceClient -eq 'Claude') { 'id' } else { 'pluginId' } - $found = $found -or [bool](@($installed[$referenceClient] | Where-Object { $_.$idField -eq $selector }).Count) - } - } - $found - }) - $names = @($toggleable | ForEach-Object { $_.name }) - if (-not $DryRun) { $checked = Read-PluginToggleSelection -Names $names -Checked $checked } - - $selected = @() - $deselected = @() - for ($i = 0; $i -lt $toggleable.Count; $i++) { - if ($checked[$i]) { $selected += $toggleable[$i] } else { $deselected += $toggleable[$i] } - } - return @{ Selected = ($selected + $fixed); Deselected = $deselected } -} - -function Invoke-ManagedExtensions { - param( - [Parameter(Mandatory=$true)][string]$RepositoryRoot, - [Parameter(Mandatory=$true)][string]$Client, - [string]$HomePath = $HOME, - [ValidateSet('sync','install','remove')][string]$Action = 'sync', - [object[]]$Entries, - [switch]$DryRun, - [switch]$Update, - [switch]$Summary - ) - $arguments = @((Join-Path $PSScriptRoot 'managed-extensions.py'), $Action, '--home', $HomePath, '--manifest', (Join-Path $RepositoryRoot 'adapters/plugins.tsv'), '--client', $Client.ToLowerInvariant()) - if ($DryRun) { $arguments += '--dry-run' } - if ($Update) { $arguments += '--update' } - if ($Summary) { $arguments += '--summary' } - if ($PSBoundParameters.ContainsKey('Entries')) { $arguments += '--selected'; $arguments += @($Entries | ForEach-Object { $_.name }) } - & python @arguments - if ($LASTEXITCODE -ne 0) { throw 'Managed extension reconciliation failed.' } -} - -function Sync-ConfiguredPlugins { - param( - [Parameter(Mandatory=$true)][string]$RepositoryRoot, - [ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client, - [string]$HomePath = $HOME, - [object[]]$Entries, - [switch]$DryRun, - [switch]$Update, - [switch]$Summary - ) - Invoke-ManagedExtensions @PSBoundParameters -Action sync -} - -function Install-ConfiguredPlugins { - param( - [Parameter(Mandatory=$true)][string]$RepositoryRoot, - [ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client, - [string]$HomePath = $HOME, - [object[]]$Entries, - [switch]$DryRun, - [switch]$Update, - [switch]$Summary - ) - if ($PSBoundParameters.ContainsKey('Entries') -and $Entries.Count -eq 0) { return } - Invoke-ManagedExtensions @PSBoundParameters -Action install -} - -function Uninstall-DeselectedPlugins { - param( - [Parameter(Mandatory=$true)][AllowEmptyCollection()][object[]]$Entries, - [ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client, - [string]$RepositoryRoot = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent), - [string]$HomePath = $HOME, - [switch]$DryRun, - [switch]$Summary - ) - if ($Entries.Count -eq 0) { return } - Invoke-ManagedExtensions -RepositoryRoot $RepositoryRoot -Client $Client -HomePath $HomePath -Entries $Entries -Action remove -DryRun:$DryRun -Summary:$Summary -} +function Invoke-PluginCommand { + param( + [Parameter(Mandatory=$true)][string]$Command, + [Parameter(Mandatory=$true)][string[]]$Arguments, + [switch]$DryRun, + [switch]$Summary + ) + $display = "$Command $($Arguments -join ' ')" + if ($DryRun) { if (-not $Summary) { Write-Output "DRYRUN $display" }; return } + $resolvedCommand = Get-Command $Command -ErrorAction Stop + if (-not $Summary) { + if ($script:PluginNonInteractive) { $null | & $resolvedCommand @Arguments } else { & $resolvedCommand @Arguments } + if ($LASTEXITCODE -ne 0) { throw "Plugin command failed: $display" } + return + } + $previousPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $output = if ($script:PluginNonInteractive) { @($null | & $resolvedCommand @Arguments 2>&1) } else { @(& $resolvedCommand @Arguments 2>&1) } + $exitCode = $LASTEXITCODE + } finally { $ErrorActionPreference = $previousPreference } + if ($exitCode -ne 0 -or -not $Summary -or $env:AI_CONFIG_VERBOSE -eq '1') { $output | Write-Output } else { $output | Where-Object { "$_" -match '(?i)warn|error|fail|deprecat' } } + if ($exitCode -ne 0) { throw "Plugin command failed: $display" } +} + +function Get-ConfiguredPluginEntries { + param([Parameter(Mandatory=$true)][string]$RepositoryRoot) + $manifest = Join-Path $RepositoryRoot 'adapters/plugins.tsv' + if (-not (Test-Path -LiteralPath $manifest)) { throw "Plugin manifest is missing: $manifest" } + Import-Csv -LiteralPath $manifest -Delimiter ([char]9) | ForEach-Object { + $_.claude_plugin = $_.claude_plugin.Trim() + $_.codex_plugin = $_.codex_plugin.Trim() + $_ + } +} + +function Get-InstalledPlugins { + param( + [ValidateSet('Claude','Codex')][string]$Client, + [string]$HomePath = [Environment]::GetFolderPath('UserProfile') + ) + $command = Get-Command $Client.ToLowerInvariant() -ErrorAction SilentlyContinue + if (-not $command) { return @() } + $environment = @{ + HOME = $HomePath + USERPROFILE = $HomePath + CODEX_HOME = (Join-Path $HomePath '.codex') + CLAUDE_CONFIG_DIR = (Join-Path $HomePath '.claude') + } + $previousEnvironment = @{} + foreach ($name in $environment.Keys) { $previousEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') } + try { + foreach ($name in $environment.Keys) { [Environment]::SetEnvironmentVariable($name, $environment[$name], 'Process') } + $global:LASTEXITCODE = 0 + $output = $null | & $command plugin list --json | Out-String + if ($LASTEXITCODE -ne 0) { throw "$Client plugin listing failed." } + $items = $output | ConvertFrom-Json + if ($Client -eq 'Claude') { return @($items) } + return @($items.installed) + } finally { + foreach ($name in $previousEnvironment.Keys) { + if ($null -eq $previousEnvironment[$name]) { Remove-Item -LiteralPath ("Env:" + $name) -ErrorAction SilentlyContinue } + else { [Environment]::SetEnvironmentVariable($name, $previousEnvironment[$name], 'Process') } + } + } +} + +function Read-PluginToggleSelection { + param( + [Parameter(Mandatory=$true)][string[]]$Names, + [Parameter(Mandatory=$true)][bool[]]$Checked + ) + $state = @($Checked) + if ([Console]::IsInputRedirected -or $env:CI -eq 'true' -or $env:AI_CONFIG_NO_INTERACTIVE -eq '1') { while($true){ Write-Host 'Select plugins (enter a number to toggle, "done" to confirm):'; for($i=0;$i -lt $Names.Count;$i++){Write-Host (" {0}) [{1}] {2}" -f ($i+1),$(if($state[$i]){'x'}else{' '}),$Names[$i])}; $answer=Read-Host 'Toggle number or "done"'; if([string]::IsNullOrWhiteSpace($answer)-or $answer -eq 'done'){return $state}; $index=0;if([int]::TryParse($answer,[ref]$index)-and $index -ge 1 -and $index -le $Names.Count){$state[$index-1]= -not $state[$index-1]} } } + $index=0; while($true){ Clear-Host; Write-Host 'Select plugins'; for($i=0;$i -lt $Names.Count;$i++){ $focus=if($i -eq $index){'>'}else{' '}; $mark=if($state[$i]){'x'}else{' '}; Write-Host "$focus [$mark] $($Names[$i])" }; Write-Host 'Up/Down Navigate Space Toggle Enter Confirm'; $key=[Console]::ReadKey($true); if($key.Key -eq 'UpArrow'){$index=($index+$Names.Count-1)%$Names.Count}elseif($key.Key -eq 'DownArrow'){$index=($index+1)%$Names.Count}elseif($key.Key -eq 'Spacebar'){$state[$index]= -not $state[$index]}elseif($key.Key -eq 'Enter'){Clear-Host;return $state} } +} + +function Select-ConfiguredPlugins { + <# + .SYNOPSIS + Lets the user toggle each configured plugin on or off. Install mode starts + with everything checked; update mode starts checked to match what the + reference client (Claude, or Codex when only Codex is targeted) already + has installed, so unchecking a currently installed plugin uninstalls it. + #> + param( + [Parameter(Mandatory=$true)][string]$RepositoryRoot, + [ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client, + [ValidateSet('Install','Update')][Parameter(Mandatory=$true)][string]$Mode, + [string]$HomePath = [Environment]::GetFolderPath('UserProfile'), + [switch]$DryRun + ) + $entries = @(Get-ConfiguredPluginEntries -RepositoryRoot $RepositoryRoot) + $toggleable = @($entries) + $fixed = @($entries | Where-Object { $toggleable -notcontains $_ }) + if ($toggleable.Count -eq 0 -or ($DryRun -and $Mode -eq 'Install')) { return @{ Selected = $entries; Deselected = @() } } + + $referenceClients = if ($Client -eq 'Both') { @('Claude','Codex') } else { @($Client) } + $installed = @{} + if ($Mode -eq 'Update') { foreach ($referenceClient in $referenceClients) { $installed[$referenceClient] = @(Get-InstalledPlugins -Client $referenceClient -HomePath $HomePath) } } + $checked = @($toggleable | ForEach-Object { + if ($Mode -eq 'Install') { $true; return } + if ($_.codex_method -eq 'cli') { return $null -ne (Get-Command $_.codex_source -ErrorAction SilentlyContinue) } + $entry = $_ + $found = $false + foreach ($referenceClient in $referenceClients) { + $selector = if ($referenceClient -eq 'Claude') { $entry.claude_plugin } else { $entry.codex_plugin } + if ($referenceClient -eq 'Codex' -and $entry.codex_method -eq 'qmd') { + $found = $found -or ($null -ne (Get-Command qmd -ErrorAction SilentlyContinue)) + } elseif ($referenceClient -eq 'Codex' -and $entry.codex_method -ne 'plugin') { + $found = $found -or (Test-Path -LiteralPath (Join-Path $HomePath ('.agents/skills/' + $entry.codex_skill))) -or (Test-Path -LiteralPath (Join-Path $HomePath ('.codex/skills/' + $entry.codex_skill))) + } else { + $idField = if ($referenceClient -eq 'Claude') { 'id' } else { 'pluginId' } + $found = $found -or [bool](@($installed[$referenceClient] | Where-Object { $_.$idField -eq $selector }).Count) + } + } + $found + }) + $names = @($toggleable | ForEach-Object { $_.name }) + if (-not $DryRun) { $checked = Read-PluginToggleSelection -Names $names -Checked $checked } + + $selected = @() + $deselected = @() + for ($i = 0; $i -lt $toggleable.Count; $i++) { + if ($checked[$i]) { $selected += $toggleable[$i] } else { $deselected += $toggleable[$i] } + } + return @{ Selected = ($selected + $fixed); Deselected = $deselected } +} + +function Invoke-ManagedExtensions { + param( + [Parameter(Mandatory=$true)][string]$RepositoryRoot, + [Parameter(Mandatory=$true)][string]$Client, + [string]$HomePath = $HOME, + [ValidateSet('sync','install','remove')][string]$Action = 'sync', + [object[]]$Entries, + [switch]$DryRun, + [switch]$Update, + [switch]$Summary + ) + $arguments = @((Join-Path $PSScriptRoot 'managed-extensions.py'), $Action, '--home', $HomePath, '--manifest', (Join-Path $RepositoryRoot 'adapters/plugins.tsv'), '--client', $Client.ToLowerInvariant()) + if ($DryRun) { $arguments += '--dry-run' } + if ($Update) { $arguments += '--update' } + if ($Summary) { $arguments += '--summary' } + if ($PSBoundParameters.ContainsKey('Entries')) { $arguments += '--selected'; $arguments += @($Entries | ForEach-Object { $_.name }) } + & python @arguments + if ($LASTEXITCODE -ne 0) { throw 'Managed extension reconciliation failed.' } +} + +function Sync-ConfiguredPlugins { + param( + [Parameter(Mandatory=$true)][string]$RepositoryRoot, + [ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client, + [string]$HomePath = $HOME, + [object[]]$Entries, + [switch]$DryRun, + [switch]$Update, + [switch]$Summary + ) + Invoke-ManagedExtensions @PSBoundParameters -Action sync +} + +function Install-ConfiguredPlugins { + param( + [Parameter(Mandatory=$true)][string]$RepositoryRoot, + [ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client, + [string]$HomePath = $HOME, + [object[]]$Entries, + [switch]$DryRun, + [switch]$Update, + [switch]$Summary + ) + if ($PSBoundParameters.ContainsKey('Entries') -and $Entries.Count -eq 0) { return } + Invoke-ManagedExtensions @PSBoundParameters -Action install +} + +function Uninstall-DeselectedPlugins { + param( + [Parameter(Mandatory=$true)][AllowEmptyCollection()][object[]]$Entries, + [ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client, + [string]$RepositoryRoot = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent), + [string]$HomePath = $HOME, + [switch]$DryRun, + [switch]$Summary + ) + if ($Entries.Count -eq 0) { return } + Invoke-ManagedExtensions -RepositoryRoot $RepositoryRoot -Client $Client -HomePath $HomePath -Entries $Entries -Action remove -DryRun:$DryRun -Summary:$Summary +}