Payload/scripts/harness/sync-cursor-ai.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS Sync Cursor AI surfaces from the canonical .github/ Copilot tree (Option A+). .DESCRIPTION - Converts .github/agents/*.agent.md -> .cursor/agents/*.md - Copies .github/agents/knowledge/ -> .cursor/agents/knowledge/ - Copies .github/skills/ -> .cursor/skills/ - Converts .github/prompts/*.prompt.md -> .cursor/commands/*.md Run from repo root or pass -RepoRoot. Safe to re-run; overwrites generated outputs. .EXAMPLE .\scripts\sync-cursor-ai.ps1 #> [CmdletBinding()] param( [string]$RepoRoot = '' ) if ([string]::IsNullOrWhiteSpace($RepoRoot)) { $scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path } $RepoRoot = (Resolve-Path (Join-Path $scriptDir '..\..')).Path } $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'lib\HarnessContract.psm1') -Force $GitHubAgents = Join-Path $RepoRoot '.github\agents' $GitHubSkills = Join-Path $RepoRoot '.github\skills' $GitHubPrompts = Join-Path $RepoRoot '.github\prompts' $CursorAgents = Join-Path $RepoRoot '.cursor\agents' $CursorSkills = Join-Path $RepoRoot '.cursor\skills' $CursorCommands = Join-Path $RepoRoot '.cursor\commands' $ManifestPath = Join-Path $RepoRoot '.cursor\.sync-manifest' $CursorDelegationBlock = @' ## Cursor Delegation When running in Cursor, delegate to specialized agents using the **Task** tool with a prompt that names the target agent file under `.cursor/agents/`. There is no `agent/runSubagent` tool. Confirm expected output artifacts exist on disk before advancing pipeline phases. Log review iterations to `docs/harness/features/<slug>/.review-loop.json` per the Producer / Reviewer Pairing section. '@ function Split-Frontmatter { param([string]$Content) if ($Content -match '(?s)^---\r?\n(.*?)\r?\n---\r?\n(.*)$') { return @{ Frontmatter = $Matches[1]; Body = $Matches[2] } } return @{ Frontmatter = ''; Body = $Content } } function Get-FrontmatterValue { param([string]$Frontmatter, [string]$Key) if ($Frontmatter -match "(?m)^$([regex]::Escape($Key)):\s*(.+)$") { return $Matches[1].Trim().Trim('"').Trim("'") } return $null } function Test-ReadonlyAgent { param([string]$Name) return Test-HarnessCursorAgentReadonly -Name $Name } function Convert-HarnessPathRewrite { param([string]$Content) $result = $Content $result = $result -replace 'docs/wip/features/', 'docs/harness/features/' $result = $result -replace 'docs/wip/quality/', 'docs/harness/quality/' $result = $result -replace 'docs/wip/security/', 'docs/harness/security/' $result = $result -replace 'docs/wip/specs/', 'docs/harness/specs/' $result = $result -replace 'docs/wip/project-planning/', 'docs/harness/planning/' $result = $result -replace 'docs/wip/spec-planning/', 'docs/harness/planning/' $result = $result -replace 'docs/wip/product-direction/', 'docs/harness/planning/' $result = $result -replace 'wip-artifact-paths', 'artifact-paths' $result = $result -replace 'WIP Artifact Paths', 'Artifact Paths' $result = $result -replace 'standard WIP paths', 'canonical harness paths' return $result } function Convert-AgentBody { param([string]$Body, [string]$AgentName) $result = $Body $result = Convert-HarnessPathRewrite -Content $result # Harness artifact paths (canonical: docs/harness/features/) $result = $result -replace 'docs/features/', 'docs/harness/features/' # Host-neutral wording $result = $result -replace 'GitHub Copilot', 'Cursor' $result = $result -replace 'agent/runSubagent', 'the Task tool' $result = $result -replace 'VS Code Copilot', 'Cursor' $result = $result -replace 'via ``the Task tool``', 'via the Task tool' $result = $result -replace 'if ``the Task tool``', 'if the Task tool' # Path rewrites: agents $result = $result -replace '\.agent\.md', '.md' $result = $result -replace '\]\(\.\./agents/', '](../agents/' $result = $result -replace '\]\(agents/', '](../agents/' $result = $result -replace '\]\(\.\./\.\./agents/', '](../agents/' # Skills: .github/skills -> ../skills (from .cursor/agents) $result = $result -replace '\]\(\.\./\.\./skills/', '](../skills/' $result = $result -replace '\]\(\.\./skills/', '](../skills/' $result = $result -replace '\]\(\.github/skills/', '](../skills/' # Prompts -> commands $result = $result -replace '\.prompt\.md', '.md' $result = $result -replace '\]\(\.\./prompts/', '](../commands/' $result = $result -replace '\]\(\.\./\.\./prompts/', '](../commands/' $result = $result -replace '\]\(agent-audit\.prompt\.md\)', '](../commands/agent-audit.md)' $result = $result -replace '\]\(feature-start\.prompt\.md\)', '](../commands/feature-start.md)' $result = $result -replace '\]\(feature-resume\.prompt\.md\)', '](../commands/feature-resume.md)' $result = $result -replace '\]\(\.\./\.\./templates/review-loop\.json\)', 'TEMP_REVIEW_LOOP_LINK' $result = $result -replace '\]\(\.\./templates/review-loop\.json\)', 'TEMP_REVIEW_LOOP_LINK' $result = $result -replace '\]\(\.\./\.\./templates/', '](.github/templates/' $result = $result -replace '\]\(\.\./templates/', '](.github/templates/' $result = $result -replace 'TEMP_REVIEW_LOOP_LINK', '](../../templates/review-loop.json)' if ($AgentName -eq 'feature-orchestrator' -and $result -notmatch '## Cursor Delegation') { if ($result -match '(?s)^(You are the feature-orchestrator.*?\r?\n\r?\n)') { $result = $Matches[1] + $CursorDelegationBlock + "`n" + $result.Substring($Matches[1].Length) } else { $result = $CursorDelegationBlock + $result } } return $result } function Format-CursorAgentFrontmatter { param( [string]$SourceFrontmatter, [string]$Name ) $description = Get-FrontmatterValue -Frontmatter $SourceFrontmatter -Key 'description' if (-not $description) { throw "Agent '$Name' is missing required description frontmatter." } $readonly = Test-ReadonlyAgent -Name $Name $model = if ($readonly) { 'fast' } else { 'inherit' } $lines = @( '---', "name: $Name", "description: $description", "model: $model", "readonly: $($readonly.ToString().ToLower())", 'is_background: false', '---' ) return ($lines -join "`n") } function Write-FileIfContentChanged { param( [string]$Path, [string]$Content ) if (Test-Path $Path) { $existing = [System.IO.File]::ReadAllText($Path) if (($existing -replace "`r`n", "`n") -eq ($Content -replace "`r`n", "`n")) { return $false } } $parent = Split-Path $Path -Parent if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } $utf8NoBom = New-Object System.Text.UTF8Encoding $false [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom) return $true } function Convert-AgentFile { param([string]$SourcePath, [string]$DestPath) $raw = Get-Content -Path $SourcePath -Raw -Encoding UTF8 $parts = Split-Frontmatter -Content $raw $name = Get-FrontmatterValue -Frontmatter $parts.Frontmatter -Key 'name' if (-not $name) { $name = [System.IO.Path]::GetFileNameWithoutExtension($SourcePath) -replace '\.agent$','' } $fm = Format-CursorAgentFrontmatter -SourceFrontmatter $parts.Frontmatter -Name $name $body = Convert-AgentBody -Body $parts.Body -AgentName $name $out = $fm + "`n" + $body Write-FileIfContentChanged -Path $DestPath -Content $out | Out-Null } function Convert-SkillContent { param([string]$Content) $result = $Content $result = $result -replace '\]\(\.\./\.\./prompts/([^)#]+)\.prompt\.md', '](../../commands/$1.md' $result = $result -replace '\]\(\.\./\.\./agents/([^)]+)\.agent\.md', '](../../agents/$1.md' $result = Convert-HarnessPathRewrite -Content $result $result = $result -replace '\]\(\.\./\.\./templates/review-loop\.json\)', 'TEMP_REVIEW_LOOP_LINK' $result = $result -replace '\]\(\.\./\.\./templates/', '](.github/templates/' $result = $result -replace 'TEMP_REVIEW_LOOP_LINK', '](../../templates/review-loop.json)' return $result } function Convert-AgentKnowledgeContent { param([string]$Content) $result = $Content # knowledge/*.md lives at .cursor/agents/knowledge/ - skills at ../../skills/ $result = $result -replace '\]\(\.\./\.\./skills/', '](../../skills/' $result = $result -replace '\]\(\.\./skills/', '](../../skills/' return $result } function Copy-AgentKnowledgeTree { param( [string]$Source, [string]$Destination ) if (-not (Test-Path $Source)) { return 0 } if (-not (Test-Path $Destination)) { New-Item -ItemType Directory -Path $Destination -Force | Out-Null } $fileCount = 0 Get-ChildItem -Path $Source -Recurse -File -Force | ForEach-Object { $relative = $_.FullName.Substring($Source.Length).TrimStart('\') $target = Join-Path $Destination $relative $parent = Split-Path $target -Parent if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } if ($_.Extension -eq '.md') { $raw = Get-Content -Path $_.FullName -Raw -Encoding UTF8 $converted = Convert-AgentKnowledgeContent -Content $raw Write-FileIfContentChanged -Path $target -Content $converted | Out-Null } else { Copy-Item -Path $_.FullName -Destination $target -Force } $fileCount++ } return $fileCount } function Copy-SkillsTree { param([string]$Source, [string]$Destination) if (-not (Test-Path $Destination)) { New-Item -ItemType Directory -Path $Destination -Force | Out-Null } $excludeDirs = @('__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache', 'node_modules') Get-ChildItem -Path $Source -Recurse -Force | ForEach-Object { $relative = $_.FullName.Substring($Source.Length).TrimStart('\') foreach ($dir in $excludeDirs) { if ($relative -match "(^|[\\/])$([regex]::Escape($dir))([\\/]|$)") { return } } $target = Join-Path $Destination $relative if ($_.PSIsContainer) { if (-not (Test-Path $target)) { New-Item -ItemType Directory -Path $target -Force | Out-Null } } else { $parent = Split-Path $target -Parent if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } if ($_.Extension -eq '.md') { $raw = Get-Content -Path $_.FullName -Raw -Encoding UTF8 $converted = Convert-SkillContent -Content $raw Write-FileIfContentChanged -Path $target -Content $converted | Out-Null } else { Copy-Item -Path $_.FullName -Destination $target -Force } } } } function Convert-PromptFile { param([string]$SourcePath, [string]$DestPath) $raw = Get-Content -Path $SourcePath -Raw -Encoding UTF8 $parts = Split-Frontmatter -Content $raw $description = Get-FrontmatterValue -Frontmatter $parts.Frontmatter -Key 'description' if (-not $description) { $description = 'Cursor command workflow' } $body = $parts.Body $body = Convert-HarnessPathRewrite -Content $body $body = $body -replace 'docs/features/', 'docs/harness/features/' $body = $body -replace '\.agent\.md', '.md' $body = $body -replace '\]\(\.\./agents/', '](../agents/' $body = $body -replace '\]\(\.\./skills/', '](../skills/' $body = $body -replace '\]\(\.\./templates/review-loop\.json\)', 'TEMP_REVIEW_LOOP_LINK' $body = $body -replace '\]\(\.\./templates/', '](.github/templates/' $body = $body -replace 'TEMP_REVIEW_LOOP_LINK', '](../../templates/review-loop.json)' $body = $body -replace '\.prompt\.md', '.md' $body = $body -replace '\]\(\.\./prompts/', '](../commands/' $body = $body -replace 'feature-resume\.prompt\.md', 'feature-resume.md' $body = $body -replace 'feature-start\.prompt\.md', 'feature-start.md' $body = $body -replace 'agent-audit\.prompt\.md', 'agent-audit.md' $body = $body -replace 'Invoke the \[`([^`]+)`\]\(\.\./agents/([^)]+)\) agent', 'Delegate to the [$1](../agents/$2) agent using the Task tool' $body = $body -replace 'Invoke the \[([^\]]+)\]\(\.\./agents/([^)]+)\) agent', 'Delegate to the [$1](../agents/$2) agent using the Task tool' $cursorNote = @' ## Cursor Notes - Entry point: type `/` and select this command, or @-mention `.cursor/commands/$(Split-Path -Leaf $DestPath)`. - Delegate to agents via the **Task** tool; reference `.cursor/agents/<name>.md` in the delegation prompt. '@ $fm = @( '---', "description: $description", '---' ) -join "`n" $out = $fm + "`n" + $body + $cursorNote Write-FileIfContentChanged -Path $DestPath -Content $out | Out-Null } # --- Main --- @($CursorAgents, $CursorSkills, $CursorCommands) | ForEach-Object { if (-not (Test-Path $_)) { New-Item -ItemType Directory -Path $_ -Force | Out-Null } } $agentCount = 0 Get-ChildItem -Path $GitHubAgents -Filter '*.agent.md' -File | ForEach-Object { $destName = $_.Name -replace '\.agent\.md$','.md' $dest = Join-Path $CursorAgents $destName Convert-AgentFile -SourcePath $_.FullName -DestPath $dest $agentCount++ } $knowledgeSource = Join-Path $GitHubAgents 'knowledge' $knowledgeDest = Join-Path $CursorAgents 'knowledge' $knowledgeCount = Copy-AgentKnowledgeTree -Source $knowledgeSource -Destination $knowledgeDest Copy-SkillsTree -Source $GitHubSkills -Destination $CursorSkills $commandCount = 0 Get-ChildItem -Path $GitHubPrompts -Filter '*.prompt.md' -File | ForEach-Object { $destName = $_.Name -replace '\.prompt\.md$','.md' $dest = Join-Path $CursorCommands $destName Convert-PromptFile -SourcePath $_.FullName -DestPath $dest $commandCount++ } $manifest = @{ synced_at_utc = (Get-Date).ToUniversalTime().ToString('o') source = '.github/' targets = @('.cursor/agents', '.cursor/agents/knowledge', '.cursor/skills', '.cursor/commands') agents = $agentCount knowledge = $knowledgeCount commands = $commandCount script = 'scripts/sync-cursor-ai.ps1' } | ConvertTo-Json -Depth 3 $utf8NoBom = New-Object System.Text.UTF8Encoding $false [System.IO.File]::WriteAllText($ManifestPath, $manifest, $utf8NoBom) Write-Output "Sync complete: $agentCount agents, $knowledgeCount knowledge file(s), $commandCount commands, skills tree copied." Write-Output "Manifest: $ManifestPath" |