private/Write-WayforgeClaudeAdapter.ps1
|
function Write-WayforgeClaudeAdapter { <# .SYNOPSIS Projects a workflow's gates into .claude/settings.json. .DESCRIPTION Registers the shared gate shim at PreToolUse / Stop for gates whose `on` includes pre-tool / stop, and projects `forbid` gates into permissions.deny (fail-closed). Only the stages Claude can enforce are consumed here; pre-commit/pre-push/ci belong to the git + CI adapter. #> param([string] $ProjectRoot, [string] $WorkflowName) $set = Get-WayforgeGateSet -Root $ProjectRoot -WorkflowName $WorkflowName $gates = $set.Gates $hasPreTool = $false $hasStop = $false $denies = [System.Collections.Generic.List[string]]::new() foreach ($gate in $gates) { $on = @(Get-WayforgeField $gate 'on') if ('pre-tool' -in $on) { $hasPreTool = $true } if ('stop' -in $on) { $hasStop = $true } $forbid = Get-WayforgeField (Get-WayforgeField $gate 'check') 'forbid' if ($forbid) { $paths = @(Get-WayforgeField $forbid 'path') $tools = @(Get-WayforgeField $forbid 'tool') if (-not $tools) { $tools = @('edit', 'write') } foreach ($path in $paths) { foreach ($tool in $tools) { $cap = switch ($tool) { 'edit' { 'Edit' } 'write' { 'Write' } 'bash' { 'Bash' } default { (Get-Culture).TextInfo.ToTitleCase($tool) } } $denies.Add("$cap($path)") } } } } # Literal ${CLAUDE_PROJECT_DIR} must survive into the JSON (single-quoted). $shim = '"${CLAUDE_PROJECT_DIR}/.workflow/hooks/gate.ps1"' $mkCommand = { param($stage) "pwsh -NoProfile -File $shim -Stage $stage -AsHook claude" } $hooks = [ordered]@{} if ($hasPreTool) { $hooks['PreToolUse'] = @( [ordered]@{ matcher = 'Edit|Write|MultiEdit|NotebookEdit|Bash' hooks = @([ordered]@{ type = 'command'; command = (& $mkCommand 'pre-tool') }) } ) } if ($hasStop) { $hooks['Stop'] = @( [ordered]@{ hooks = @([ordered]@{ type = 'command'; command = (& $mkCommand 'stop') }) } ) } # Merge into any existing settings.json rather than replacing it, so unrelated # hooks, permissions, and other keys the user maintains are preserved. Our own # entries (identified by the gate.ps1 command) are refreshed idempotently. $claudeDir = Join-Path $ProjectRoot '.claude' $settingsPath = Join-Path $claudeDir 'settings.json' $settings = @{} if (Test-Path $settingsPath -PathType Leaf) { try { $settings = Get-Content $settingsPath -Raw | ConvertFrom-Json -AsHashtable } catch { Write-Warning "Existing .claude/settings.json is not valid JSON; it will be replaced."; $settings = @{} } } if ($null -eq $settings) { $settings = @{} } if ($denies.Count -gt 0) { if ($settings['permissions'] -isnot [System.Collections.IDictionary]) { $settings['permissions'] = @{} } $merged = [System.Collections.Generic.List[string]]::new() foreach ($d in @($settings['permissions']['deny'])) { if ($d -and -not $merged.Contains($d)) { $merged.Add($d) | Out-Null } } foreach ($d in $denies) { if (-not $merged.Contains($d)) { $merged.Add($d) | Out-Null } } $settings['permissions']['deny'] = $merged.ToArray() } if ($hooks.Count -gt 0) { if ($settings['hooks'] -isnot [System.Collections.IDictionary]) { $settings['hooks'] = @{} } foreach ($event in $hooks.Keys) { # Keep the user's own entries for this event; drop any prior Wayforge # entry (whose command references gate.ps1) before adding the current one. $kept = foreach ($entry in (@($settings['hooks'][$event]) | Where-Object { $_ })) { $cmds = @($entry.hooks) | ForEach-Object { $_.command } if ($cmds -match 'gate\.ps1') { continue } $entry } $settings['hooks'][$event] = @(@($kept) + @($hooks[$event])) | Where-Object { $_ } } } if (-not (Test-Path $claudeDir)) { New-Item -ItemType Directory -Path $claudeDir -Force | Out-Null } ($settings | ConvertTo-Json -Depth 8) | Set-Content -Path $settingsPath -Encoding utf8NoBOM return $settingsPath } |