feat(scripts): add cross-platform build and installation tools

This commit is contained in:
Julian lechner
2026-09-11 14:43:18 +02:00
parent b65af63e7e
commit 396f62a503
10 changed files with 651 additions and 0 deletions
+78
View File
@@ -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'
+89
View File
@@ -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'
+17
View File
@@ -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'
+28
View File
@@ -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'
+58
View File
@@ -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' }))
+72
View File
@@ -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
+119
View File
@@ -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"
}
}
}
+89
View File
@@ -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"
}
+48
View File
@@ -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'
+53
View File
@@ -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'