260 lines
17 KiB
PowerShell
260 lines
17 KiB
PowerShell
[CmdletBinding()]
|
|
param([switch]$Summary)
|
|
$ErrorActionPreference = 'Stop'
|
|
$root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
|
$shared = Join-Path $root 'shared'
|
|
$final = Join-Path $root 'generated'
|
|
# Keep the name short: it lengthens every generated path, and Windows PowerShell 5.1 fails beyond 260 characters.
|
|
$output = Join-Path $root ("generated.build-ps$PID-" + [Guid]::NewGuid().ToString('N').Substring(0, 6))
|
|
# A trap covers the whole script scope, so every terminating error discards this run's tree.
|
|
trap { Remove-Item -LiteralPath $output -Recurse -Force -ErrorAction SilentlyContinue; break }
|
|
$sandboxSupported = [Environment]::OSVersion.Platform -eq [PlatformID]::Unix
|
|
if ($sandboxSupported) {
|
|
$kernel = (& uname -r 2>$null | Out-String).Trim()
|
|
if ($kernel -match '(?i)microsoft') { $sandboxSupported = $kernel -match '(?i)microsoft-standard-WSL2' }
|
|
}
|
|
$claudeSandbox = if ($sandboxSupported) { '{"enabled": true, "allowUnsandboxedCommands": false, "failIfUnavailable": true, "network": {"allowedDomains": ["mcp.context7.com"]}}' } else { '{"enabled": false}' }
|
|
$pluginManifest = Join-Path $root 'adapters/plugins.tsv'
|
|
$capabilityManifest = Join-Path $root 'adapters/claude/capabilities.tsv'
|
|
|
|
if (-not (Test-Path -LiteralPath $pluginManifest)) { throw "Missing plugin manifest: $pluginManifest" }
|
|
$pluginEntries = @(Import-Csv -LiteralPath $pluginManifest -Delimiter ([char]9))
|
|
if ($pluginEntries.Count -eq 0) { throw 'Plugin manifest must define at least one plugin.' }
|
|
$pluginNames = @()
|
|
foreach ($pluginEntry in $pluginEntries) {
|
|
foreach ($field in @('name','claude_plugin','codex_method')) {
|
|
if ([string]::IsNullOrWhiteSpace($pluginEntry.$field)) { throw "Missing $field for plugin $($pluginEntry.name)." }
|
|
}
|
|
if ($pluginNames -contains $pluginEntry.name) { throw "Duplicate plugin name in manifest: $($pluginEntry.name)" }
|
|
$pluginNames += $pluginEntry.name
|
|
}
|
|
|
|
if (-not (Test-Path -LiteralPath $capabilityManifest)) { throw "Missing capability manifest: $capabilityManifest" }
|
|
$agentTools = @{}
|
|
foreach ($entry in @(Import-Csv -LiteralPath $capabilityManifest -Delimiter ([char]9))) {
|
|
if ([string]::IsNullOrWhiteSpace($entry.role) -or [string]::IsNullOrWhiteSpace($entry.tools)) { throw 'Invalid capability manifest row.' }
|
|
$agentTools[$entry.role] = $entry.tools
|
|
}
|
|
|
|
# Build into a private sibling directory and swap it into place only after validation, so a failed
|
|
# or parallel build never leaves generated/ partial. Leftovers of crashed runs are removed here:
|
|
# old trees always, build trees only when their PowerShell owner process has exited.
|
|
# ponytail: Bash-owned leftovers are left to the Bash build, since PIDs are not comparable across shells.
|
|
foreach ($leftover in @(Get-ChildItem -LiteralPath $root -Directory -Filter 'generated.*')) {
|
|
if ($leftover.Name -like 'generated.old-*' -or ($leftover.Name -match '^generated\.build-ps(\d+)-' -and -not (Get-Process -Id ([int]$Matches[1]) -ErrorAction SilentlyContinue))) {
|
|
Remove-Item -LiteralPath $leftover.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
New-Item $output -ItemType Directory | 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') + '"')
|
|
}
|
|
function Test-CodexSchema($configPath) {
|
|
if (-not (Get-Command codex -ErrorAction SilentlyContinue)) {
|
|
if ($env:REQUIRE_CODEX_SCHEMA -eq 'true') { throw 'Codex CLI is required for schema validation.' }
|
|
Write-Warning 'Codex CLI unavailable; skipped Codex schema validation.'
|
|
return
|
|
}
|
|
$tempHome = Join-Path ([System.IO.Path]::GetTempPath()) ("codex-schema-" + [Guid]::NewGuid())
|
|
$previousCodexHome = $env:CODEX_HOME
|
|
try {
|
|
New-Item $tempHome -ItemType Directory -Force | Out-Null
|
|
Copy-Item $configPath (Join-Path $tempHome 'config.toml') -Force
|
|
$env:CODEX_HOME = $tempHome
|
|
$previousErrorActionPreference = $ErrorActionPreference
|
|
try {
|
|
$ErrorActionPreference = 'Continue'
|
|
$schemaOutput = @(& codex --strict-config --help 2>&1)
|
|
$schemaExitCode = $LASTEXITCODE
|
|
} finally {
|
|
$ErrorActionPreference = $previousErrorActionPreference
|
|
}
|
|
if ($schemaExitCode -ne 0) { throw "Codex schema validation failed: $configPath`n$($schemaOutput -join [Environment]::NewLine)" }
|
|
} finally {
|
|
$env:CODEX_HOME = $previousCodexHome
|
|
Remove-Item $tempHome -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
& (Join-Path $root 'shared/hooks/scripts/Test-SessionConfig.ps1') -RepositoryRoot $root | Where-Object { -not $Summary -or $_ -ne 'PASS session configuration' }
|
|
|
|
$sourceAgentDirs = @(Get-ChildItem (Join-Path $shared 'agents') -Directory | Sort-Object Name)
|
|
if ($sourceAgentDirs.Count -eq 0) { throw 'No agents found under shared/agents.' }
|
|
$agentNames = @()
|
|
foreach ($agentDir in $sourceAgentDirs) {
|
|
$name = Read-Field (Join-Path $agentDir.FullName 'agent.yml') 'name'
|
|
if ($agentNames -contains $name) { throw "Duplicate agent name: $name" }
|
|
$agentNames += $name
|
|
}
|
|
|
|
foreach ($shell in @('powershell','bash')) {
|
|
New-Item (Join-Path $output "codex-$shell") -ItemType Directory -Force | Out-Null
|
|
New-Item (Join-Path $output "claude-$shell") -ItemType Directory -Force | Out-Null
|
|
Copy-Directory (Join-Path $shared 'skills') (Join-Path $output "codex-$shell/skills")
|
|
# Claude discovers only skills/<name>/SKILL.md, so flatten the categorized source tree.
|
|
$claudeSkills = Join-Path $output "claude-$shell/skills"
|
|
New-Item $claudeSkills -ItemType Directory -Force | Out-Null
|
|
Get-ChildItem (Join-Path $shared 'skills') -File | ForEach-Object { Copy-Item $_.FullName (Join-Path $claudeSkills $_.Name) -Force }
|
|
$claudeSkillNames = @{}
|
|
foreach ($skillFile in Get-ChildItem (Join-Path $shared 'skills') -File -Filter 'SKILL.md' -Recurse) {
|
|
$skillName = $skillFile.Directory.Name
|
|
if (@(Get-Content -LiteralPath $skillFile.FullName -TotalCount 6) -notcontains "name: $skillName") { throw "Skill name must match its directory: $($skillFile.FullName)" }
|
|
if ($claudeSkillNames.ContainsKey($skillName)) { throw "Duplicate Claude skill name: $skillName" }
|
|
$claudeSkillNames[$skillName] = $true
|
|
Copy-Directory $skillFile.DirectoryName (Join-Path $claudeSkills $skillName)
|
|
}
|
|
Copy-Directory (Join-Path $shared 'rules') (Join-Path $output "codex-$shell/rules")
|
|
Copy-Directory (Join-Path $shared 'hooks') (Join-Path $output "codex-$shell/hooks")
|
|
Copy-Directory (Join-Path $shared 'hooks') (Join-Path $output "claude-$shell/hooks")
|
|
Copy-Directory (Join-Path $shared 'statusline') (Join-Path $output "claude-$shell/statusline")
|
|
|
|
$rulesRoot = Join-Path $shared 'rules'
|
|
$ruleSkills = @()
|
|
foreach ($ruleFile in Get-ChildItem $rulesRoot -File -Filter '*.md' | Where-Object Name -ne 'general.md') {
|
|
$ruleSkills += [pscustomobject]@{ Name = "rules-$($ruleFile.BaseName)"; Body = $ruleFile.FullName; References = $null }
|
|
}
|
|
foreach ($ruleDirectory in Get-ChildItem $rulesRoot -Directory) {
|
|
foreach ($ruleFile in Get-ChildItem $ruleDirectory.FullName -File -Filter '*.md' -Recurse) {
|
|
$relativeRulePath = $ruleFile.FullName.Substring($ruleDirectory.FullName.Length).TrimStart([char[]]@('\','/'))
|
|
if ($relativeRulePath -eq 'index.md') {
|
|
$ruleName = "rules-$($ruleDirectory.Name)"
|
|
$references = Join-Path $ruleDirectory.FullName 'references'
|
|
} else {
|
|
$ruleParts = $relativeRulePath.Substring(0, $relativeRulePath.Length - 3).Split([char[]]@('\','/'))
|
|
if ($ruleParts[0] -eq 'references') { $ruleParts = @($ruleParts | Select-Object -Skip 1) }
|
|
$ruleName = "rules-$($ruleDirectory.Name)-$($ruleParts -join '-')"
|
|
$references = $null
|
|
}
|
|
if ($ruleSkills.Name -contains $ruleName -or $claudeSkillNames.ContainsKey($ruleName)) { throw "Duplicate generated rule skill name: $ruleName" }
|
|
$ruleSkills += [pscustomobject]@{ Name = $ruleName; Body = $ruleFile.FullName; References = $references }
|
|
}
|
|
}
|
|
foreach ($ruleSkill in $ruleSkills | Sort-Object Name) {
|
|
$skillDir = Join-Path $claudeSkills $ruleSkill.Name
|
|
New-Item $skillDir -ItemType Directory -Force | Out-Null
|
|
$ruleContent = Get-Content $ruleSkill.Body -Raw
|
|
$ruleTitle = (Get-Content $ruleSkill.Body | Where-Object { $_ -match '^#+\s+' } | Select-Object -First 1) -replace '^#+\s+', ''
|
|
if ([string]::IsNullOrWhiteSpace($ruleTitle)) { throw "Missing rule title in $($ruleSkill.Body)" }
|
|
$ruleDescription = "Use when the task concerns $ruleTitle."
|
|
$skillBody = "---`r`nname: $($ruleSkill.Name)`r`ndescription: $(Quote-Toml $ruleDescription)`r`n---`r`n`r`n$ruleContent"
|
|
Set-Content (Join-Path $skillDir 'SKILL.md') $skillBody -Encoding UTF8
|
|
if ($ruleSkill.References -and (Test-Path -LiteralPath $ruleSkill.References)) {
|
|
Copy-Directory $ruleSkill.References (Join-Path $skillDir 'references')
|
|
}
|
|
}
|
|
|
|
$sharedTemplate = Get-Content (Join-Path $shared 'global-instructions.md') -Raw
|
|
$generalContent = Get-Content (Join-Path $shared 'rules/general.md') -Raw
|
|
$credentialHelperExtension = if ($shell -eq 'powershell') { 'ps1' } else { 'sh' }
|
|
|
|
$agentsContent = $sharedTemplate.Replace('__CLIENT__', 'codex').Replace('__SHELL__', $credentialHelperExtension)
|
|
$agentsContent = $agentsContent.TrimEnd() + "`r`n`r`n---`r`n`r`n$generalContent"
|
|
Set-Content (Join-Path $output "codex-$shell/AGENTS.md") $agentsContent -Encoding UTF8
|
|
|
|
$claudeContent = $sharedTemplate.Replace('__CLIENT__', 'claude').Replace('__SHELL__', $credentialHelperExtension)
|
|
$claudeContent = $claudeContent.TrimEnd() + "`r`n`r`n---`r`n`r`n$generalContent"
|
|
Set-Content (Join-Path $output "claude-$shell/CLAUDE.md") $claudeContent -Encoding UTF8
|
|
|
|
Copy-Item (Join-Path $root 'adapters/codex/config/config.toml') (Join-Path $output "codex-$shell/config.toml") -Force
|
|
Copy-Item (Join-Path $root 'adapters/claude/config/settings.json') (Join-Path $output "claude-$shell/settings.json") -Force
|
|
|
|
foreach ($client in @('codex','claude')) {
|
|
$agentsOutput = Join-Path $output "$client-$shell/agents"
|
|
New-Item $agentsOutput -ItemType Directory -Force | Out-Null
|
|
foreach ($agentDir in $sourceAgentDirs) {
|
|
$metadata = Join-Path $agentDir.FullName 'agent.yml'
|
|
$name = Read-Field $metadata 'name'
|
|
$description = Read-Field $metadata 'description'
|
|
$loader = (Get-Content (Join-Path $root "adapters/$client/orchestration-loader.txt") -Raw -Encoding UTF8).Trim()
|
|
$instructions = $loader + "`n`n" + (Get-Content (Join-Path $agentDir.FullName 'instructions.md') -Raw -Encoding UTF8)
|
|
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 {
|
|
$shellTool = if ($shell -eq 'powershell') { 'PowerShell' } else { 'Bash' }
|
|
$tools = $agentTools[$name]
|
|
if ($tools) { $tools = $tools.Replace('__SHELL__', $shellTool) }
|
|
$frontmatter = "---`r`nname: $name`r`ndescription: $description`r`n"
|
|
if ($tools) { $frontmatter += "tools: $tools`r`n" }
|
|
Set-Content (Join-Path $agentsOutput "$name.md") ($frontmatter + "---`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-$shell/$file"
|
|
$command = if ($shell -eq 'powershell') { '__POWERSHELL_COMMAND__' } else { 'bash' }
|
|
$script = if ($shell -eq 'powershell') { 'flashbang.ps1' } else { 'flashbang.sh' }
|
|
$statusLineScript = if ($shell -eq 'powershell') { 'statusline.ps1' } else { 'statusline.sh' }
|
|
$content = Get-Content -LiteralPath $path -Raw
|
|
# Claude can offer both shell tools on Windows, so every package denies the destructive commands in both.
|
|
$denyRules = @('PowerShell(git reset --hard*)','PowerShell(git clean*)','PowerShell(Remove-Item *-Recurse*)','Bash(git reset --hard*)','Bash(git clean*)','Bash(rm -rf*)')
|
|
$denyRulesJson = ConvertTo-Json -InputObject $denyRules -Compress
|
|
$shellTool = if ($shell -eq 'powershell') { 'PowerShell' } else { 'Bash' }
|
|
$allowRulesJson = ConvertTo-Json -InputObject @('Agent', "$shellTool(mcporter call context7.*)") -Compress
|
|
$compactScript = if ($shell -eq 'powershell') { 'Record-Compact.ps1' } else { 'record-compact.sh' }
|
|
$pointerScript = if ($shell -eq 'powershell') { 'Show-SessionStatePointer.ps1' } else { 'show-session-state-pointer.sh' }
|
|
$qmdWarmScript = if ($shell -eq 'powershell') { 'Start-QmdWarm.ps1' } else { 'qmd-warm.sh' }
|
|
$content = $content.Replace('__HOOK_COMMAND__', $command).Replace('__POWERSHELL_HOOK_COMMAND__', $command).Replace('__HOOK_SCRIPT__', $script).Replace('__POWERSHELL_HOOK_SCRIPT__', $script).Replace('__COMPACT_SCRIPT__', $compactScript).Replace('__SESSION_POINTER_SCRIPT__', $pointerScript).Replace('__QMD_WARM_SCRIPT__', $qmdWarmScript).Replace('__STATUSLINE_COMMAND__', $command).Replace('__STATUSLINE_SCRIPT__', $statusLineScript).Replace('__CLAUDE_SANDBOX__', $claudeSandbox).Replace('__CLAUDE_DENY_RULES__', $denyRulesJson).Replace('__CLAUDE_ALLOW_RULES__', $allowRulesJson)
|
|
Set-Content -LiteralPath $path -Value $content -Encoding UTF8
|
|
}
|
|
|
|
$tomlPath = Join-Path $output "codex-$shell/config.toml"
|
|
$settingsPath = Join-Path $output "claude-$shell/settings.json"
|
|
& python (Join-Path $root 'scripts/checks/validate-config.py') $tomlPath $settingsPath
|
|
if ($LASTEXITCODE -ne 0) { throw "Generated configuration validation failed for $shell." }
|
|
Test-CodexSchema $tomlPath
|
|
}
|
|
|
|
$leftoverPlaceholders = @(Get-ChildItem $output -File -Recurse | ForEach-Object {
|
|
# __AI_CONFIG_ROOT__ is resolved at install time, once the destination is known; it is
|
|
# expected to remain in generated output. -cmatch keeps this case-sensitive so Python
|
|
# dunder names (__main__, __name__) in flashbang.sh are not mistaken for placeholders.
|
|
$content = (Get-Content -LiteralPath $_.FullName -Raw) -replace '__AI_CONFIG_ROOT__|__POWERSHELL_COMMAND__|__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__', ''
|
|
if ($content -cmatch '__[A-Z0-9_]*[A-Z0-9][A-Z0-9_]*__') { $_.FullName }
|
|
})
|
|
if ($leftoverPlaceholders.Count) { throw "Unresolved template placeholders in: $($leftoverPlaceholders -join ', ')" }
|
|
|
|
$lockPath = Join-Path $root 'generated.lock'
|
|
$lockDeadline = (Get-Date).AddSeconds(30)
|
|
while ($true) {
|
|
try { [System.IO.File]::Open($lockPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None).Dispose(); break }
|
|
catch [System.IO.IOException], [System.UnauthorizedAccessException] {
|
|
if ((Get-Date) -gt $lockDeadline) { throw "Another build holds $lockPath; wait for it to finish, or remove the file if no build is running." }
|
|
Start-Sleep -Milliseconds 200
|
|
}
|
|
}
|
|
$retired = $null
|
|
try {
|
|
if (Test-Path -LiteralPath $final) {
|
|
$retired = Join-Path $root ("generated.old-ps$PID-" + [Guid]::NewGuid().ToString('N').Substring(0, 6))
|
|
[System.IO.Directory]::Move($final, $retired)
|
|
}
|
|
try { [System.IO.Directory]::Move($output, $final) }
|
|
catch {
|
|
if ($retired) { [System.IO.Directory]::Move($retired, $final); $retired = $null }
|
|
throw
|
|
}
|
|
} finally {
|
|
[System.IO.File]::Delete($lockPath)
|
|
}
|
|
if ($retired) { Remove-Item -LiteralPath $retired -Recurse -Force -ErrorAction SilentlyContinue }
|
|
|
|
if ($Summary) { Write-Output 'Build: PASS | 4 packages' } else { Write-Output 'PASS build: codex-powershell, claude-powershell, codex-bash, claude-bash' }
|
|
exit 0
|