diff --git a/scripts/build.ps1 b/scripts/build.ps1 new file mode 100644 index 0000000..b690180 --- /dev/null +++ b/scripts/build.ps1 @@ -0,0 +1,78 @@ +[CmdletBinding()] +param() +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +$shared = Join-Path $root 'shared' +$output = Join-Path $root 'generated' +$pluginManifest = Join-Path $root 'adapters/plugins.tsv' +if (-not (Test-Path -LiteralPath $pluginManifest)) { throw "Missing plugin manifest: $pluginManifest" } +$pluginEntries = @(Import-Csv -LiteralPath $pluginManifest -Delimiter ([char]9)) +if ($pluginEntries.Count -ne 4) { throw 'Plugin manifest must define exactly four plugins.' } +foreach ($pluginEntry in $pluginEntries) { + foreach ($field in @('claude_plugin','codex_plugin')) { + if ([string]::IsNullOrWhiteSpace($pluginEntry.$field)) { throw "Missing $field for plugin $($pluginEntry.name)." } + } +} +Remove-Item $output -Recurse -Force -ErrorAction SilentlyContinue +New-Item $output -ItemType Directory -Force | Out-Null +function Copy-Directory($source, $destination) { + New-Item $destination -ItemType Directory -Force | Out-Null + Get-ChildItem $source -File -Recurse | ForEach-Object { + $relative = $_.FullName.Substring($source.Length).TrimStart([char[]]@('\','/')) + $target = Join-Path $destination $relative + New-Item (Split-Path $target -Parent) -ItemType Directory -Force | Out-Null + Copy-Item $_.FullName $target -Force + } +} +function Read-Field($path, $name) { + $line = Get-Content $path | Where-Object { $_ -match ('^' + [regex]::Escape($name) + ':\s*(.*)$') } | Select-Object -First 1 + if (-not $line) { throw "Missing $name in $path" } + return ([regex]::Match($line, '^' + [regex]::Escape($name) + ':\s*(.*)$')).Groups[1].Value.Trim() +} +function Quote-Toml($value) { + return ('"' + $value.Replace('\','\\').Replace('"','\"').Replace("`r",'').Replace("`n",'\n') + '"') +} +& (Join-Path $root 'shared/hooks/scripts/Test-SessionConfig.ps1') -RepositoryRoot $root +foreach ($platform in @('windows','linux')) { +Copy-Directory (Join-Path $shared 'skills') (Join-Path $output "codex-$platform/skills") +Copy-Directory (Join-Path $shared 'skills') (Join-Path $output "claude-$platform/skills") +Copy-Directory (Join-Path $shared 'rules') (Join-Path $output "codex-$platform/rules") +Copy-Directory (Join-Path $shared 'rules') (Join-Path $output "claude-$platform/rules") +New-Item (Join-Path $output "codex-$platform") -ItemType Directory -Force | Out-Null +New-Item (Join-Path $output "claude-$platform") -ItemType Directory -Force | Out-Null +Copy-Directory (Join-Path $shared 'hooks') (Join-Path $output "codex-$platform/hooks") +Copy-Directory (Join-Path $shared 'hooks') (Join-Path $output "claude-$platform/hooks") +Copy-Directory (Join-Path $shared 'statusline') (Join-Path $output "claude-$platform/statusline") +Copy-Item (Join-Path $shared 'global-instructions.md') (Join-Path $output "codex-$platform/AGENTS.md") -Force +Copy-Item (Join-Path $shared 'global-instructions.md') (Join-Path $output "claude-$platform/CLAUDE.md") -Force +Copy-Item (Join-Path $root 'adapters/codex/config/config.toml') (Join-Path $output "codex-$platform/config.toml") -Force +Copy-Item (Join-Path $root 'adapters/claude/config/settings.json') (Join-Path $output "claude-$platform/settings.json") -Force +foreach ($client in @('codex','claude')) { + $agentsOutput = Join-Path $output "$client-$platform/agents" + New-Item $agentsOutput -ItemType Directory -Force | Out-Null + Get-ChildItem (Join-Path $shared 'agents') -Directory | Sort-Object Name | ForEach-Object { + $metadata = Join-Path $_.FullName 'agent.yml' + $name = Read-Field $metadata 'name' + $description = Read-Field $metadata 'description' + $instructions = Get-Content (Join-Path $_.FullName 'instructions.md') -Raw + if ($client -eq 'codex') { + Set-Content (Join-Path $agentsOutput "$name.toml") "name = $(Quote-Toml $name)`r`ndescription = $(Quote-Toml $description)`r`ndeveloper_instructions = $(Quote-Toml $instructions)" -Encoding UTF8 + } else { + Set-Content (Join-Path $agentsOutput "$name.md") "---`r`nname: $name`r`ndescription: $description`r`n---`r`n`r`n$instructions" -Encoding UTF8 + } + } +} + +foreach ($client in @('codex','claude')) { + $file = if ($client -eq 'codex') { 'config.toml' } else { 'settings.json' } + $path = Join-Path $output "$client-$platform/$file" + $command = if ($platform -eq 'windows') { 'powershell -NoProfile -ExecutionPolicy Bypass -File' } else { 'bash' } + $script = if ($platform -eq 'windows') { 'flashbang.ps1' } else { 'flashbang.sh' } + $statusLineScript = if ($platform -eq 'windows') { 'statusline.ps1' } else { 'statusline.sh' } + $content = Get-Content -LiteralPath $path -Raw + $content = $content.Replace('__HOOK_COMMAND__', $command).Replace('__WINDOWS_HOOK_COMMAND__', $command).Replace('__HOOK_SCRIPT__', $script).Replace('__WINDOWS_HOOK_SCRIPT__', $script).Replace('__STATUSLINE_COMMAND__', $command).Replace('__STATUSLINE_SCRIPT__', $statusLineScript) + Set-Content -LiteralPath $path -Value $content -Encoding UTF8 +} +} +Write-Output 'PASS build: codex-windows, claude-windows, codex-linux, claude-linux' + diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 0000000..684fbee --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +shared="$root/shared" +output="$root/generated" +plugin_manifest="$root/adapters/plugins.tsv" +[ -f "$plugin_manifest" ] || { printf 'Missing plugin manifest: %s\n' "$plugin_manifest" >&2; exit 1; } +[ "$(awk 'NR > 1 && NF == 5 { count++ } END { print count + 0 }' "$plugin_manifest")" -eq 4 ] || { printf 'Plugin manifest must define exactly four plugins.\n' >&2; exit 1; } + +copy_directory() { + local source=$1 destination=$2 + mkdir -p "$destination" + cp -R "$source"/. "$destination"/ +} + +read_field() { + local path=$1 name=$2 value + value=$(sed -n "s/^${name}:[[:space:]]*//p" "$path" | head -n 1) + [ -n "$value" ] || { printf 'Missing %s in %s\n' "$name" "$path" >&2; exit 1; } + printf '%s' "$value" +} + +quote_toml() { + local value=$1 + value=${value//\\/\\\\} + value=${value//\"/\\\"} + value=${value//$'\r'/} + value=${value//$'\n'/\\n} + printf '"%s"' "$value" +} + +bash "$shared/hooks/scripts/test-session-config.sh" "$root" +rm -rf -- "$output" +for platform in windows linux; do +mkdir -p "$output/codex-$platform" "$output/claude-$platform" +copy_directory "$shared/skills" "$output/codex-$platform/skills" +copy_directory "$shared/skills" "$output/claude-$platform/skills" +copy_directory "$shared/rules" "$output/codex-$platform/rules" +copy_directory "$shared/rules" "$output/claude-$platform/rules" +copy_directory "$shared/hooks" "$output/codex-$platform/hooks" +copy_directory "$shared/hooks" "$output/claude-$platform/hooks" +copy_directory "$shared/statusline" "$output/claude-$platform/statusline" + +cp "$shared/global-instructions.md" "$output/codex-$platform/AGENTS.md" +cp "$shared/global-instructions.md" "$output/claude-$platform/CLAUDE.md" +cp "$root/adapters/codex/config/config.toml" "$output/codex-$platform/config.toml" +cp "$root/adapters/claude/config/settings.json" "$output/claude-$platform/settings.json" + +for client in codex claude; do + agents_output="$output/$client-$platform/agents" + mkdir -p "$agents_output" + for agent in "$shared"/agents/*; do + metadata="$agent/agent.yml" + name=$(read_field "$metadata" name) + description=$(read_field "$metadata" description) + instructions=$(<"$agent/instructions.md") + if [ "$client" = codex ]; then + printf 'name = %s\ndescription = %s\ndeveloper_instructions = %s\n' "$(quote_toml "$name")" "$(quote_toml "$description")" "$(quote_toml "$instructions")" > "$agents_output/$name.toml" + else + printf '%s\nname: %s\ndescription: %s\n%s\n\n%s\n' '---' "$name" "$description" '---' "$instructions" > "$agents_output/$name.md" + fi + done +done + +for client in codex claude; do + file=config.toml + [ "$client" != claude ] || file=settings.json + command=bash + script=flashbang.sh + statusline_script=statusline.sh + if [ "$platform" = windows ]; then + command='powershell -NoProfile -ExecutionPolicy Bypass -File' + script=flashbang.ps1 + statusline_script=statusline.ps1 + fi + path="$output/$client-$platform/$file" + content=$(<"$path") + content=${content//__HOOK_COMMAND__/$command} + content=${content//__WINDOWS_HOOK_COMMAND__/$command} + content=${content//__HOOK_SCRIPT__/$script} + content=${content//__WINDOWS_HOOK_SCRIPT__/$script} + content=${content//__STATUSLINE_COMMAND__/$command} + content=${content//__STATUSLINE_SCRIPT__/$statusline_script} + printf '%s\n' "$content" > "$path" +done +done +printf 'PASS build: codex-windows, claude-windows, codex-linux, claude-linux\n' + diff --git a/scripts/doctor.ps1 b/scripts/doctor.ps1 new file mode 100644 index 0000000..043263e --- /dev/null +++ b/scripts/doctor.ps1 @@ -0,0 +1,17 @@ +[CmdletBinding()] +param() +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +$homePath = [Environment]::GetFolderPath('UserProfile') +$fail = $false +function Result($state, $message) { Write-Output ("$state $message"); if ($state -eq 'FAIL') { $script:fail = $true } } +foreach ($tool in @('codex','claude')) { if (Get-Command $tool -ErrorAction SilentlyContinue) { $version = & $tool --version 2>&1 | Select-Object -First 1; Result 'PASS' "$tool available ($version)" } else { Result 'WARN' "$tool unavailable" } } +if ($PSVersionTable.PSVersion.Major -ge 6 -and -not $IsWindows) { if (Get-Command jq -ErrorAction SilentlyContinue) { Result 'PASS' 'jq available for Claude status line' } else { Result 'WARN' 'jq unavailable; Claude status line is disabled' } } +foreach ($platform in @('windows','linux')) { +if (Test-Path (Join-Path $root "generated/codex-$platform/AGENTS.md")) { Result 'PASS' 'generated output present' } else { Result 'FAIL' 'generated output missing; run build' } +if ((Test-Path (Join-Path $root "generated/codex-$platform/hooks/scripts/Validate-CommandSafety.ps1")) -and (Test-Path (Join-Path $root "generated/codex-$platform/hooks/scripts/validate-command-safety.sh")) -and (Test-Path (Join-Path $root "generated/claude-$platform/hooks/scripts/Validate-CommandSafety.ps1")) -and (Test-Path (Join-Path $root "generated/claude-$platform/hooks/scripts/validate-command-safety.sh"))) { Result 'PASS' 'generated hooks present' } else { Result 'FAIL' 'generated hooks missing; run build' } +} +foreach ($path in @('.codex/AGENTS.md','.codex/config.toml','.claude/CLAUDE.md','.claude/settings.json')) { if (Test-Path (Join-Path $homePath $path)) { Result 'PASS' "installed $path" } else { Result 'WARN' "not installed $path" } } +foreach ($path in @('shared/rules','shared/skills','shared/agents','shared/hooks/scripts')) { if (Test-Path (Join-Path $root $path)) { Result 'PASS' "source $path" } else { Result 'FAIL' "missing $path" } } +if ($fail) { exit 1 } +Write-Output 'PASS doctor' diff --git a/scripts/doctor.sh b/scripts/doctor.sh new file mode 100644 index 0000000..6df9214 --- /dev/null +++ b/scripts/doctor.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +home_path=${HOME:?HOME is required} +failed=false + +result() { + printf '%s %s\n' "$1" "$2" + [ "$1" != FAIL ] || failed=true +} + +for tool in codex claude; do + if command -v "$tool" >/dev/null; then result PASS "$tool available ($($tool --version 2>&1 | head -n 1))"; else result WARN "$tool unavailable"; fi +done +command -v jq >/dev/null && result PASS 'jq available for Claude status line' || result WARN 'jq unavailable; Claude status line is disabled' +for platform in windows linux; do +[ -f "$root/generated/codex-$platform/AGENTS.md" ] && result PASS 'generated output present' || result FAIL 'generated output missing; run build' +[ -f "$root/generated/codex-$platform/hooks/scripts/Validate-CommandSafety.ps1" ] && [ -f "$root/generated/codex-$platform/hooks/scripts/validate-command-safety.sh" ] && [ -f "$root/generated/claude-$platform/hooks/scripts/Validate-CommandSafety.ps1" ] && [ -f "$root/generated/claude-$platform/hooks/scripts/validate-command-safety.sh" ] && result PASS 'generated hooks present' || result FAIL 'generated hooks missing; run build' +done +for path in .codex/AGENTS.md .codex/config.toml .claude/CLAUDE.md .claude/settings.json; do + [ -f "$home_path/$path" ] && result PASS "installed $path" || result WARN "not installed $path" +done +for path in shared/rules shared/skills shared/agents shared/hooks/scripts; do + [ -e "$root/$path" ] && result PASS "source $path" || result FAIL "missing $path" +done +"$failed" && exit 1 +printf 'PASS doctor\n' diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..30b558f --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,58 @@ +[CmdletBinding()] +param([switch]$DryRun) +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +. (Join-Path $PSScriptRoot 'lib/plugins.ps1') +$buildScript = Join-Path $PSScriptRoot 'build.ps1' +& $buildScript +if (-not $?) { throw 'Build failed. Installation was not started.' } +$generated = Join-Path $root 'generated' +if (-not (Test-Path $generated)) { throw 'Generated output is missing after a successful build.' } +Write-Output 'Select target platform:' +Write-Output '1) Windows' +Write-Output '2) Linux' +do { $platformSelection = Read-Host 'Selection [1-2]' } while ($platformSelection -notin @('1','2')) +$platform = @{'1'='windows'; '2'='linux'}[$platformSelection] +Write-Output 'Select installation target:' +Write-Output '1) Codex' +Write-Output '2) Claude' +Write-Output '3) Both' +do { $selection = Read-Host 'Selection [1-3]' } while ($selection -notin @('1','2','3')) +$Client = @{'1'='Codex'; '2'='Claude'; '3'='Both'}[$selection] +$stamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$homePath = [Environment]::GetFolderPath('UserProfile') +$shellCommand = 'pwsh -NoProfile -ExecutionPolicy Bypass -File' +$windowsShellCommand = 'powershell -NoProfile -ExecutionPolicy Bypass -File' +if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) { $shellCommand = $windowsShellCommand } +$targets = @() +if ($Client -in @('Codex','Both')) { $targets += @{ Source=(Join-Path $generated "codex-$platform"); Destination=(Join-Path $homePath '.codex') } } +if ($Client -in @('Codex','Both')) { $targets += @{ Source=(Join-Path $generated "codex-$platform/skills"); Destination=(Join-Path $homePath '.agents/skills') } } +if ($Client -in @('Claude','Both')) { $targets += @{ Source=(Join-Path $generated "claude-$platform"); Destination=(Join-Path $homePath '.claude') } } +foreach ($item in $targets) { + $backupRoot = Join-Path $item.Destination 'backups' + Get-ChildItem $item.Source -File -Recurse | ForEach-Object { + $relative = $_.FullName.Substring($item.Source.Length).TrimStart([char[]]@('\','/')) + $target = Join-Path $item.Destination $relative + if ($DryRun) { Write-Output "DRYRUN $($_.FullName) -> $target"; return } + if (Test-Path $target) { + $backup = Join-Path $backupRoot $relative + New-Item (Split-Path $backup -Parent) -ItemType Directory -Force | Out-Null + Copy-Item $target "$backup.$stamp" -Force + } + New-Item (Split-Path $target -Parent) -ItemType Directory -Force | Out-Null + $content = Get-Content $_.FullName -Raw + if ($content.Contains('__AI_CONFIG_ROOT__') -or $content.Contains('__HOOK_COMMAND__') -or $content.Contains('__WINDOWS_HOOK_COMMAND__')) { + $replacement = $item.Destination.Replace('\','/') + $content = $content.Replace('__AI_CONFIG_ROOT__', $replacement) + $content = $content.Replace('__HOOK_COMMAND__', $shellCommand) + $content = $content.Replace('__WINDOWS_HOOK_COMMAND__', $windowsShellCommand) + $content = $content.Replace('__HOOK_SCRIPT__', 'flashbang.ps1') + $content = $content.Replace('__WINDOWS_HOOK_SCRIPT__', 'flashbang.ps1') + [System.IO.File]::WriteAllText($target, $content, (New-Object System.Text.UTF8Encoding($false))) + } + else { Copy-Item $_.FullName $target -Force } + Write-Output "INSTALL $target" + } +} +Install-ConfiguredPlugins -RepositoryRoot $root -Client $Client -DryRun:$DryRun -Update +Write-Output ($(if ($DryRun) { 'PASS install dry-run' } else { 'PASS install' })) diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..1d42047 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +dry_run=false +for argument in "$@"; do + [ "$argument" != "--dry-run" ] || dry_run=true +done +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +. "$root/scripts/lib/plugins.sh" +bash "$root/scripts/build.sh" +generated="$root/generated" +[ -d "$generated" ] || { printf 'Generated output is missing after a successful build.\n' >&2; exit 1; } + +printf 'Select target platform:\n1) Windows\n2) Linux\n' +while :; do + read -r -p 'Selection [1-2] ' platform_selection + case "$platform_selection" in 1) platform=windows; break;; 2) platform=linux; break;; esac +done +printf 'Select installation target:\n1) Codex\n2) Claude\n3) Both\n' +while :; do + read -r -p 'Selection [1-3] ' selection + case "$selection" in 1|2|3) break;; esac +done + +stamp=$(date +%Y%m%d-%H%M%S) +home_path=${HOME:?HOME is required} +targets=() +case "$selection" in + 1|3) targets+=("$generated/codex-$platform|$home_path/.codex" "$generated/codex-$platform/skills|$home_path/.agents/skills");; +esac +case "$selection" in + 2|3) targets+=("$generated/claude-$platform|$home_path/.claude");; +esac + +for target_pair in "${targets[@]}"; do + source=${target_pair%%|*} + destination=${target_pair#*|} + while IFS= read -r -d '' source_file; do + relative=${source_file#"$source/"} + target="$destination/$relative" + if "$dry_run"; then printf 'DRYRUN %s -> %s\n' "$source_file" "$target"; continue; fi + if [ -f "$target" ]; then + backup="$destination/backups/$relative.$stamp" + mkdir -p "$(dirname -- "$backup")" + cp "$target" "$backup" + fi + mkdir -p "$(dirname -- "$target")" + content=$(<"$source_file") + if [[ "$content" == *'__AI_CONFIG_ROOT__'* || "$content" == *'__HOOK_COMMAND__'* || "$content" == *'__WINDOWS_HOOK_COMMAND__'* ]]; then + replacement=$destination + if command -v cygpath >/dev/null; then replacement=$(cygpath -m "$destination"); fi + content=${content//__AI_CONFIG_ROOT__/$replacement} + content=${content//__HOOK_COMMAND__/bash} + content=${content//__WINDOWS_HOOK_COMMAND__/bash} + content=${content//__HOOK_SCRIPT__/flashbang.sh} + content=${content//__WINDOWS_HOOK_SCRIPT__/flashbang.sh} + printf '%s' "$content" > "$target" + else + cp "$source_file" "$target" + fi + printf 'INSTALL %s\n' "$target" + done < <(find "$source" -type f -print0) +done + +client_selection=both +case "$selection" in + 1) client_selection=codex;; + 2) client_selection=claude;; +esac +install_configured_plugins "$root" "$client_selection" "$dry_run" true + +if "$dry_run"; then printf 'PASS install dry-run\n'; else printf 'PASS install\n'; fi diff --git a/scripts/lib/plugins.ps1 b/scripts/lib/plugins.ps1 new file mode 100644 index 0000000..22a92b9 --- /dev/null +++ b/scripts/lib/plugins.ps1 @@ -0,0 +1,119 @@ +function Invoke-PluginCommand { + param( + [Parameter(Mandatory=$true)][string]$Command, + [Parameter(Mandatory=$true)][string[]]$Arguments, + [switch]$DryRun + ) + $display = "$Command $($Arguments -join ' ')" + if ($DryRun) { Write-Output "DRYRUN $display"; return } + & $Command @Arguments + if ($LASTEXITCODE -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) + if ($Client -eq 'Claude') { + if (-not (Get-Command claude -ErrorAction SilentlyContinue)) { throw 'Claude Code CLI is required to manage Claude plugins.' } + $items = (claude plugin list --json | Out-String) | ConvertFrom-Json + return @($items) + } + if (-not (Get-Command codex -ErrorAction SilentlyContinue)) { throw 'Codex CLI is required to manage Codex plugins.' } + $items = (codex plugin list --json | Out-String) | ConvertFrom-Json + return @($items.installed) +} + +function Ensure-CodexMarketplace { + param( + [Parameter(Mandatory=$true)][string]$Source, + [Parameter(Mandatory=$true)][string]$MarketplaceName, + [switch]$DryRun + ) + if ($DryRun) { + Write-Output "DRYRUN codex plugin marketplace add $Source" + return + } + $previousErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $output = @(& codex plugin marketplace add $Source 2>&1) + $exitCode = $LASTEXITCODE + } catch { + $output = @($_) + $exitCode = 1 + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } + $output | ForEach-Object { Write-Output $_ } + if ($exitCode -eq 0) { return } + + $text = $output -join [Environment]::NewLine + if ($text -match 'marketplace .* already added from a different source') { + Write-Output "WARN Codex marketplace '$MarketplaceName' exists from another source; replacing it with: $Source" + Invoke-PluginCommand -Command 'codex' -Arguments @('plugin','marketplace','remove',$MarketplaceName) + Invoke-PluginCommand -Command 'codex' -Arguments @('plugin','marketplace','add',$Source) + return + } + throw "Plugin command failed: codex plugin marketplace add $Source" +} + +function Install-ConfiguredPlugins { + param( + [Parameter(Mandatory=$true)][string]$RepositoryRoot, + [ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client, + [switch]$DryRun, + [switch]$Update + ) + $entries = @(Get-ConfiguredPluginEntries -RepositoryRoot $RepositoryRoot) + $clients = if ($Client -eq 'Both') { @('Claude','Codex') } else { @($Client) } + + foreach ($selectedClient in $clients) { + $installed = if ($DryRun) { @() } else { @(Get-InstalledPlugins -Client $selectedClient) } + foreach ($entry in $entries) { + $marketplace = if ($selectedClient -eq 'Claude') { $entry.claude_marketplace.Trim() } else { $entry.codex_marketplace.Trim() } + if ($marketplace -eq '-') { $marketplace = '' } + $plugin = if ($selectedClient -eq 'Claude') { $entry.claude_plugin } else { $entry.codex_plugin } + if ([string]::IsNullOrWhiteSpace($plugin)) { throw ('Plugin selector missing for {0}: {1}' -f $selectedClient, $entry.name) } + + $installedItem = if ($selectedClient -eq 'Claude') { $installed | Where-Object { $_.id -eq $plugin } | Select-Object -First 1 } else { $installed | Where-Object { $_.pluginId -eq $plugin } | Select-Object -First 1 } + if ($Update -and $selectedClient -eq 'Claude' -and $null -ne $installedItem) { + Invoke-PluginCommand -Command 'claude' -Arguments @('plugin','update',$plugin) -DryRun:$DryRun + if (-not $installedItem.enabled) { + Invoke-PluginCommand -Command 'claude' -Arguments @('plugin','enable',$plugin) -DryRun:$DryRun + } + continue + } + if (-not $Update -and $null -ne $installedItem) { + if ($selectedClient -eq 'Claude' -and -not $installedItem.enabled) { + Invoke-PluginCommand -Command 'claude' -Arguments @('plugin','enable',$plugin) -DryRun:$DryRun + } + Write-Output "PASS $selectedClient plugin already installed: $plugin" + continue + } + + if ($selectedClient -eq 'Claude') { + if (-not [string]::IsNullOrWhiteSpace($marketplace)) { + Invoke-PluginCommand -Command 'claude' -Arguments @('plugin','marketplace','add',$marketplace) -DryRun:$DryRun + } + Invoke-PluginCommand -Command 'claude' -Arguments @('plugin','install',$plugin,'--scope','user') -DryRun:$DryRun + } else { + if (-not [string]::IsNullOrWhiteSpace($marketplace) -and $marketplace -ne 'openai-curated-remote') { + $marketplaceName = ($plugin -split '@')[-1] + Ensure-CodexMarketplace -Source $marketplace -MarketplaceName $marketplaceName -DryRun:$DryRun + } + Invoke-PluginCommand -Command 'codex' -Arguments @('plugin','add',$plugin) -DryRun:$DryRun + } + Write-Output "PASS $selectedClient plugin ensured: $plugin" + } + } +} diff --git a/scripts/lib/plugins.sh b/scripts/lib/plugins.sh new file mode 100644 index 0000000..10f1da0 --- /dev/null +++ b/scripts/lib/plugins.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +plugin_installed() { + local client=$1 plugin=$2 + if [ "$client" = claude ]; then + claude plugin list --json 2>/dev/null | grep -Fq "\"id\": \"$plugin\"" + else + codex plugin list --json 2>/dev/null | grep -Fq "\"pluginId\": \"$plugin\"" + fi +} + +run_plugin_command() { + local dry_run=$1 + shift + if [ "$dry_run" = true ]; then + printf 'DRYRUN %s\n' "$*" + else + "$@" + fi +} + +ensure_codex_marketplace() { + local dry_run=$1 source=$2 marketplace_name=$3 output + if [ "$dry_run" = true ]; then + printf 'DRYRUN codex plugin marketplace add %s\n' "$source" + return 0 + fi + if output=$(codex plugin marketplace add "$source" 2>&1); then + [ -z "$output" ] || printf '%s\n' "$output" + return 0 + fi + printf '%s\n' "$output" >&2 + if printf '%s\n' "$output" | grep -Eiq 'marketplace .* already added from a different source'; then + printf "WARN Codex marketplace '%s' exists from another source; replacing it with: %s\n" "$marketplace_name" "$source" + codex plugin marketplace remove "$marketplace_name" + codex plugin marketplace add "$source" + return 0 + fi + return 1 +} + +install_configured_plugins() { + local root=$1 client_selection=$2 dry_run=$3 update=$4 + local manifest="$root/adapters/plugins.tsv" + [ -f "$manifest" ] || { printf 'Plugin manifest is missing: %s\n' "$manifest" >&2; return 1; } + + local clients=() + case "$client_selection" in + codex) clients=(codex);; + claude) clients=(claude);; + both) clients=(claude codex);; + *) printf 'Unknown plugin client selection: %s\n' "$client_selection" >&2; return 1;; + esac + + local client name claude_marketplace claude_plugin codex_marketplace codex_plugin marketplace plugin + while IFS=$'\t' read -r name claude_marketplace claude_plugin codex_marketplace codex_plugin; do + [ "$name" = name ] && continue + [ -n "$name" ] || continue + [ "$claude_marketplace" = - ] && claude_marketplace= + [ "$codex_marketplace" = - ] && codex_marketplace= + for client in "${clients[@]}"; do + if [ "$client" = claude ]; then marketplace=$claude_marketplace; plugin=$claude_plugin; else marketplace=$codex_marketplace; plugin=$codex_plugin; fi + [ -n "$plugin" ] || { printf 'Plugin selector missing for %s: %s\n' "$client" "$name" >&2; return 1; } + if [ "$dry_run" = false ] && plugin_installed "$client" "$plugin"; then + if [ "$update" = true ] && [ "$client" = claude ]; then + run_plugin_command "$dry_run" claude plugin update "$plugin" + run_plugin_command "$dry_run" claude plugin enable "$plugin" + elif [ "$update" = true ] && [ "$client" = codex ]; then + run_plugin_command "$dry_run" codex plugin add "$plugin" + else + printf 'PASS %s plugin already installed: %s\n' "$client" "$plugin" + fi + continue + fi + if [ "$client" = claude ]; then + [ -z "$marketplace" ] || run_plugin_command "$dry_run" claude plugin marketplace add "$marketplace" + run_plugin_command "$dry_run" claude plugin install "$plugin" --scope user + else + if [ -n "$marketplace" ] && [ "$marketplace" != openai-curated-remote ]; then + marketplace_name=${plugin##*@} + ensure_codex_marketplace "$dry_run" "$marketplace" "$marketplace_name" + fi + run_plugin_command "$dry_run" codex plugin add "$plugin" + fi + printf 'PASS %s plugin ensured: %s\n' "$client" "$plugin" + done + done < "$manifest" +} diff --git a/scripts/test-platform-packages.ps1 b/scripts/test-platform-packages.ps1 new file mode 100644 index 0000000..45fd318 --- /dev/null +++ b/scripts/test-platform-packages.ps1 @@ -0,0 +1,48 @@ +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +$powerShellHook = Get-Content -LiteralPath (Join-Path $root 'shared/hooks/flashbang.ps1') -Raw +$bashHook = Get-Content -LiteralPath (Join-Path $root 'shared/hooks/flashbang.sh') -Raw +if ($powerShellHook -notmatch '\$HoldMs\s*=\s*250' -or $powerShellHook -notmatch '\$FadeMs\s*=\s*250' -or ([regex]::Matches($bashHook, 'default=250').Count -lt 2)) { throw 'Flashbang defaults must total 500 milliseconds on Windows and Linux.' } +$statusInput = '{"model":{"display_name":"Test Model"},"effort":{"level":"high"},"workspace":{"current_dir":"' + $root.Replace('\','\\') + '","repo":{"name":"TestRepo"}},"context_window":{"context_window_size":200000,"used_percentage":8.5,"total_input_tokens":15500,"total_output_tokens":1200}}' +$branch = & git -C $root branch --show-current +$statusOutput = $statusInput | & (Join-Path $root 'shared/statusline/statusline.ps1') +if ($statusOutput -ne "Model: Test Model | Effort: high | Repo: TestRepo | Branch: $branch | Max Context: 200000 | Used Context: 9% | Used Tokens: 16700") { throw 'PowerShell status line output is incorrect.' } +foreach ($platform in @('windows','linux')) { + foreach ($client in @('codex','claude')) { + $package = Join-Path $root "generated/$client-$platform" + $file = if ($client -eq 'codex') { 'config.toml' } else { 'settings.json' } + $content = Get-Content -LiteralPath (Join-Path $package $file) -Raw + if ($client -eq 'claude') { $settings = $content | ConvertFrom-Json } + if (@(Get-ChildItem "$package/agents" -File).Count -ne 5) { throw "Missing agents in $package" } + if (@(Get-ChildItem "$package/skills" -Filter SKILL.md -Recurse).Count -ne 22) { throw "Missing skills in $package" } + $expected = if ($platform -eq 'windows') { 'powershell .*flashbang.ps1' } else { 'bash .*flashbang.sh' } + if ($content -notmatch $expected -or $content -match '__HOOK_|__WINDOWS_HOOK_') { throw "Incorrect platform command in $package" } + if ($platform -eq 'windows' -and $content -match 'WindowStyle\s+Hidden') { throw "Windows hook hides the terminal in $package" } + if ($client -eq 'codex') { + $events = @([regex]::Matches($content, '(?m)^\[\[hooks\.([^.\]]+)\]\]\s*$') | ForEach-Object { $_.Groups[1].Value }) + if ($events.Count -ne 1 -or $events[0] -ne 'Stop' -or $content -match 'flashbang-if-input' -or $content -match '(?m)^async\s*=\s*true\s*$') { throw "Codex finish hook is incorrect in $package" } + if ($content -notmatch 'approvals_reviewer\s*=\s*"auto_review"') { throw "Codex auto review is missing in $package" } + if ($content -notmatch 'status_line\s*=\s*\["model", "reasoning", "project-name", "git-branch", "context-window-size", "context-used", "used-tokens"\]') { throw "Codex status line is incorrect in $package" } + } elseif (@($settings.hooks.PSObject.Properties.Name).Count -ne 1 -or $settings.hooks.PSObject.Properties.Name -ne 'Stop' -or $content -match 'flashbang-if-input' -or $settings.hooks.Stop[0].hooks[0].async) { + throw "Claude finish hook is incorrect in $package" + } elseif ($content -notmatch '"defaultMode"\s*:\s*"auto"') { + throw "Claude auto permission mode is missing in $package" + } elseif (-not (Test-Path -LiteralPath (Join-Path $package "statusline/statusline.$(if ($platform -eq 'windows') { 'ps1' } else { 'sh' })")) -or $settings.statusLine.command -notmatch "statusline\.$(if ($platform -eq 'windows') { 'ps1' } else { 'sh' })") { + throw "Claude status line is incorrect in $package" + } + } +} +foreach ($platformSelection in @('1','2')) { + foreach ($clientSelection in @('1','2','3')) { + $script:answers = [System.Collections.Generic.Queue[string]]::new() + $script:answers.Enqueue($platformSelection) + $script:answers.Enqueue($clientSelection) + function Read-Host { param($Prompt) $answers.Dequeue() } + $output = & "$PSScriptRoot/install.ps1" -DryRun + if ($output -notcontains 'PASS install dry-run') { throw 'Dry-run did not finish.' } + $platform = if ($platformSelection -eq '1') { 'windows' } else { 'linux' } + if (($output -join "`n") -notmatch "-$platform") { throw 'Incorrect selected package.' } + } +} +Write-Output 'PASS four platform packages and six PowerShell selections' + diff --git a/scripts/test-platform-packages.sh b/scripts/test-platform-packages.sh new file mode 100644 index 0000000..7f34774 --- /dev/null +++ b/scripts/test-platform-packages.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +grep -Eq '\$HoldMs[[:space:]]*=[[:space:]]*250' "$root/shared/hooks/flashbang.ps1" +grep -Eq '\$FadeMs[[:space:]]*=[[:space:]]*250' "$root/shared/hooks/flashbang.ps1" +[ "$(grep -c 'default=250' "$root/shared/hooks/flashbang.sh")" -ge 2 ] +branch=$(git -C "$root" branch --show-current) +status_output=$(printf '{"model":{"display_name":"Test Model"},"effort":{"level":"high"},"workspace":{"current_dir":"%s","repo":{"name":"TestRepo"}},"context_window":{"context_window_size":200000,"used_percentage":8.5,"total_input_tokens":15500,"total_output_tokens":1200}}' "$root" | bash "$root/shared/statusline/statusline.sh") +[ "$status_output" = "Model: Test Model | Effort: high | Repo: TestRepo | Branch: $branch | Max Context: 200000 | Used Context: 9% | Used Tokens: 16700" ] +for platform in windows linux; do + for client in codex claude; do + package="$root/generated/$client-$platform" + file=config.toml + [ "$client" != claude ] || file=settings.json + test -f "$package/$file" + test "$(find "$package/agents" -type f | wc -l)" -eq 5 + test "$(find "$package/skills" -name SKILL.md | wc -l)" -eq 22 + if [ "$platform" = windows ]; then + grep -q 'powershell .*flashbang.ps1' "$package/$file" + ! grep -Eq 'WindowStyle[[:space:]]+Hidden' "$package/$file" + else + grep -q 'bash .*flashbang.sh' "$package/$file" + fi + if [ "$client" = codex ]; then + [ "$(sed -n 's/^\[\[hooks\.\([^].]*\)\]\]$/\1/p' "$package/$file")" = Stop ] + ! grep -q 'flashbang-if-input' "$package/$file" + ! grep -Eq '^async[[:space:]]*=[[:space:]]*true[[:space:]]*$' "$package/$file" + grep -q 'approvals_reviewer[[:space:]]*=[[:space:]]*"auto_review"' "$package/$file" + grep -Fq 'status_line = ["model", "reasoning", "project-name", "git-branch", "context-window-size", "context-used", "used-tokens"]' "$package/$file" + else + [ "$(awk '/^ "hooks": \{/{inside=1; next} inside && /^ \}/{inside=0} inside && /^ "[^"]+": \[$/{gsub(/^ "|": \[$/, ""); print}' "$package/$file")" = Stop ] + ! grep -q 'flashbang-if-input' "$package/$file" + ! grep -q '"async"[[:space:]]*:[[:space:]]*true' "$package/$file" + grep -q '"defaultMode"[[:space:]]*:[[:space:]]*"auto"' "$package/$file" + statusline_extension=sh + [ "$platform" != windows ] || statusline_extension=ps1 + test -f "$package/statusline/statusline.$statusline_extension" + grep -q "statusline.$statusline_extension" "$package/$file" + fi + if grep -Eq '__HOOK_|__WINDOWS_HOOK_' "$package/$file"; then exit 1; fi + done +done +for platform_selection in 1 2; do + for client_selection in 1 2 3; do + output=$(printf '%s\n%s\n' "$platform_selection" "$client_selection" | bash "$root/scripts/install.sh" --dry-run) + [[ "$output" == *'PASS install dry-run'* ]] + platform=windows + [ "$platform_selection" != 2 ] || platform=linux + [[ "$output" == *"-$platform/"* ]] + done +done +printf 'PASS four platform packages and six Bash selections\n' +