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) { # Claude's Edit(path) rule covers ALL file-editing tools (Edit, Write, # MultiEdit, NotebookEdit); there is no Write(path) permission rule, so # emitting one is ignored (and warned about). Paths -> Edit; commands -> Bash. foreach ($path in @(Get-WayforgeField $forbid 'path' | Where-Object { $_ })) { $rule = "Edit($path)" if (-not $denies.Contains($rule)) { $denies.Add($rule) } } foreach ($command in @(Get-WayforgeField $forbid 'command' | Where-Object { $_ })) { $rule = "Bash($command)" if (-not $denies.Contains($rule)) { $denies.Add($rule) } } } } # 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 } # Wrap the whole pipeline in @() so a single entry stays an ARRAY; # otherwise ConvertTo-Json emits an object and Claude ignores the hook. $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 } |