Files
MyAiConfiguration/scripts/commands/build.ps1
T

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