[CmdletBinding()] param( [ValidateSet('Sync', 'Status', 'Diff', 'Pin', 'Unpin', 'Rollback', 'Doctor')] [string]$Command = 'Status', [string]$BaseUrl = 'https://abap-agent.iabap.space', [ValidateSet('Codex', 'Proma', 'Claude', 'Cursor', 'Generic')] [string]$Client = 'Codex', [string]$TargetPath, [string]$PromaWorkspace, [string]$PromaRoot, [ValidateSet('stable', 'beta')] [string]$Channel = 'stable', [string]$Version, [switch]$SkipSkill, [string]$StateRoot = (Join-Path $env:LOCALAPPDATA 'AbapAgentStandards') ) $ErrorActionPreference = 'Stop' $beginMarker = '' $endMarker = '' function Ensure-HttpsUrl([string]$Url) { $uri = [Uri]$Url if ($uri.Scheme -ne 'https' -and -not $uri.IsLoopback) { throw 'BaseUrl must use HTTPS unless it points to localhost.' } return $Url.TrimEnd('/') } function Get-CodexRoot { if ($env:CODEX_HOME) { return $env:CODEX_HOME } if (-not $env:USERPROFILE) { throw 'USERPROFILE is unavailable and CODEX_HOME is not set.' } return (Join-Path $env:USERPROFILE '.codex') } function Get-PromaRoot { if ($PromaRoot) { return [IO.Path]::GetFullPath($PromaRoot) } if (-not $env:USERPROFILE) { throw 'USERPROFILE is unavailable and -PromaRoot was not provided.' } return (Join-Path $env:USERPROFILE '.proma') } function Get-PromaWorkspaceInfo { if (-not $PromaWorkspace) { throw 'Proma requires -PromaWorkspace with the workspace name or slug.' } $root = Get-PromaRoot $registryPath = Join-Path $root 'agent-workspaces.json' $registryMatches = @() if (Test-Path -LiteralPath $registryPath) { $registry = Read-JsonFile $registryPath $registryMatches = @($registry.workspaces | Where-Object { ([string]$_.slug).Equals($PromaWorkspace, [StringComparison]::OrdinalIgnoreCase) -or ([string]$_.name).Equals($PromaWorkspace, [StringComparison]::OrdinalIgnoreCase) }) if ($registryMatches.Count -gt 1) { throw "Proma workspace '$PromaWorkspace' is ambiguous; use its slug." } } if ($registryMatches.Count -eq 1) { $entry = $registryMatches[0] $slug = [string]$entry.slug $workspaceDirectory = Join-Path $root "agent-workspaces\$slug" $projectRoot = if ($entry.projectRootPath) { [IO.Path]::GetFullPath([string]$entry.projectRootPath) } else { Join-Path $workspaceDirectory 'workspace-files' } return [pscustomobject]@{ Name = [string]$entry.name Slug = $slug Directory = $workspaceDirectory ProjectRoot = $projectRoot SkillsRoot = (Join-Path $workspaceDirectory 'skills') } } $available = if (Test-Path -LiteralPath $registryPath) { (@($registry.workspaces | ForEach-Object { "$($_.name) [$($_.slug)]" }) -join ', ') } else { '(registry unavailable)' } throw "Proma workspace '$PromaWorkspace' was not found in agent-workspaces.json. Available: $available" } function Resolve-TargetPath { if ($TargetPath) { return [IO.Path]::GetFullPath($TargetPath) } switch ($Client) { 'Codex' { return (Join-Path (Get-CodexRoot) 'AGENTS.md') } 'Proma' { return (Join-Path (Get-PromaWorkspaceInfo).ProjectRoot 'AGENTS.md') } 'Claude' { return (Join-Path $env:USERPROFILE '.claude\CLAUDE.md') } 'Cursor' { return (Join-Path (Get-Location) '.cursor\rules\abap-agent-standards.mdc') } default { throw 'Generic client requires -TargetPath.' } } } function Get-SettingsPath { return (Join-Path $StateRoot 'settings.json') } function Get-InstalledPath { $key = $Client.ToLowerInvariant() if ($Client -eq 'Proma') { $key += '-' + (Get-PromaWorkspaceInfo).Slug.ToLowerInvariant() } return (Join-Path $StateRoot "installed-$key.json") } function Read-JsonFile([string]$Path) { if (-not (Test-Path -LiteralPath $Path)) { return $null } return (Get-Content -Raw -Encoding UTF8 -LiteralPath $Path | ConvertFrom-Json) } function Write-JsonFile([object]$Value, [string]$Path) { $parent = Split-Path -Parent $Path New-Item -ItemType Directory -Force -Path $parent | Out-Null $Value | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $Path -Encoding utf8 } function Get-AdapterRelativePath { switch ($Client) { 'Codex' { return 'adapters/codex.md' } 'Claude' { return 'adapters/claude.md' } 'Cursor' { return 'adapters/cursor.mdc' } default { return 'adapters/generic.md' } } } function Test-ReleaseDirectory([string]$ReleaseDirectory, [object]$Manifest) { foreach ($entry in $Manifest.files) { $path = Join-Path $ReleaseDirectory ($entry.path.Replace('/', [IO.Path]::DirectorySeparatorChar)) if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $false } $file = Get-Item -LiteralPath $path if ($file.Length -ne [int64]$entry.size) { return $false } $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $path).Hash.ToLowerInvariant() if ($hash -ne ([string]$entry.sha256).ToLowerInvariant()) { return $false } } return $true } function Get-Release([string]$RootUrl) { New-Item -ItemType Directory -Force -Path $StateRoot | Out-Null $settings = Read-JsonFile (Get-SettingsPath) $selectedVersion = $null if ($settings -and $settings.pinned_version) { $selectedVersion = [string]$settings.pinned_version $manifestUrl = "$RootUrl/releases/$selectedVersion/manifest.json" } else { $channelUrl = "$RootUrl/channels/$Channel.json" $channelDocument = Invoke-RestMethod -Uri $channelUrl -Method Get $selectedVersion = [string]$channelDocument.version $manifestUrl = "$RootUrl$($channelDocument.manifest)" } $manifest = Invoke-RestMethod -Uri $manifestUrl -Method Get if ([string]$manifest.version -ne $selectedVersion) { throw 'Channel and manifest version mismatch.' } $releaseDirectory = Join-Path $StateRoot "releases\$selectedVersion" if (Test-Path -LiteralPath $releaseDirectory) { if (-not (Test-ReleaseDirectory $releaseDirectory $manifest)) { throw "Cached immutable release $selectedVersion failed verification. Remove it manually after inspection." } return [pscustomobject]@{ Version = $selectedVersion; Directory = $releaseDirectory; Manifest = $manifest } } $stageDirectory = Join-Path $StateRoot "staging\$([guid]::NewGuid().ToString('N'))" New-Item -ItemType Directory -Force -Path $stageDirectory | Out-Null try { foreach ($entry in $manifest.files) { $relative = ([string]$entry.path).Replace('/', [IO.Path]::DirectorySeparatorChar) $destination = Join-Path $stageDirectory $relative New-Item -ItemType Directory -Force -Path (Split-Path -Parent $destination) | Out-Null Invoke-WebRequest -Uri "$RootUrl/releases/$selectedVersion/$($entry.path)" -OutFile $destination } if (-not (Test-ReleaseDirectory $stageDirectory $manifest)) { throw 'Downloaded release failed SHA-256 or size verification.' } $manifest | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $stageDirectory 'manifest.json') -Encoding utf8 New-Item -ItemType Directory -Force -Path (Split-Path -Parent $releaseDirectory) | Out-Null Move-Item -LiteralPath $stageDirectory -Destination $releaseDirectory } finally { if (Test-Path -LiteralPath $stageDirectory) { Remove-Item -LiteralPath $stageDirectory -Recurse -Force } } return [pscustomobject]@{ Version = $selectedVersion; Directory = $releaseDirectory; Manifest = $manifest } } function Backup-Target([string]$Path) { if (-not (Test-Path -LiteralPath $Path)) { return $null } $backupDirectory = Join-Path $StateRoot 'backups' New-Item -ItemType Directory -Force -Path $backupDirectory | Out-Null $safeClient = $Client.ToLowerInvariant() $backup = Join-Path $backupDirectory "$safeClient-$([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmssfff')).bak" Copy-Item -LiteralPath $Path -Destination $backup return $backup } function Set-ManagedRules([string]$Path, [string]$Rules) { New-Item -ItemType Directory -Force -Path (Split-Path -Parent $Path) | Out-Null [void](Backup-Target $Path) if ($Client -eq 'Cursor') { Set-Content -LiteralPath $Path -Value $Rules.TrimEnd() -Encoding utf8 return } $block = "$beginMarker`n$($Rules.Trim())`n$endMarker" $existing = if (Test-Path -LiteralPath $Path) { Get-Content -Raw -Encoding UTF8 -LiteralPath $Path } else { '' } $pattern = '(?s)' + [regex]::Escape($beginMarker) + '.*?' + [regex]::Escape($endMarker) if ([regex]::IsMatch($existing, $pattern)) { $updated = [regex]::Replace($existing, $pattern, [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $block }) } elseif ([string]::IsNullOrWhiteSpace($existing)) { $updated = $block } else { $updated = $existing.TrimEnd() + "`n`n" + $block } Set-Content -LiteralPath $Path -Value $updated.TrimEnd() -Encoding utf8 } function Get-SkillsRoot { switch ($Client) { 'Codex' { return (Join-Path (Get-CodexRoot) 'skills') } 'Proma' { return (Get-PromaWorkspaceInfo).SkillsRoot } default { return $null } } } function Install-AgentSkill([string]$ReleaseDirectory, [string]$ReleaseVersion) { if ($SkipSkill) { return $null } $skillsRoot = Get-SkillsRoot if (-not $skillsRoot) { return $null } $zip = Join-Path $ReleaseDirectory 'abap-engineering.zip' $extract = Join-Path $StateRoot "extract\$([guid]::NewGuid().ToString('N'))" New-Item -ItemType Directory -Force -Path $extract | Out-Null try { Expand-Archive -LiteralPath $zip -DestinationPath $extract $source = Join-Path $extract 'abap-engineering' if (-not (Test-Path -LiteralPath (Join-Path $source 'SKILL.md'))) { throw 'Skill archive is missing abap-engineering/SKILL.md.' } $target = Join-Path $skillsRoot 'abap-engineering' New-Item -ItemType Directory -Force -Path $skillsRoot | Out-Null if (Test-Path -LiteralPath $target) { $backupRoot = Join-Path $StateRoot 'skill-backups' New-Item -ItemType Directory -Force -Path $backupRoot | Out-Null $backup = Join-Path $backupRoot "abap-engineering-$ReleaseVersion-$([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmssfff'))" Move-Item -LiteralPath $target -Destination $backup } Copy-Item -LiteralPath $source -Destination $target -Recurse return $target } finally { if (Test-Path -LiteralPath $extract) { Remove-Item -LiteralPath $extract -Recurse -Force } } } function Apply-Release([object]$Release, [string]$RootUrl) { $target = Resolve-TargetPath $adapter = Join-Path $Release.Directory ((Get-AdapterRelativePath).Replace('/', [IO.Path]::DirectorySeparatorChar)) $rules = Get-Content -Raw -Encoding UTF8 -LiteralPath $adapter $previous = Read-JsonFile (Get-InstalledPath) Set-ManagedRules $target $rules $skillTarget = Install-AgentSkill $Release.Directory $Release.Version $previousVersion = $null if ($previous -and $previous.current_version -ne $Release.Version) { $previousVersion = $previous.current_version } elseif ($previous) { $previousVersion = $previous.previous_version } $installed = [ordered]@{ schema_version = 1 client = $Client target_path = $target skill_path = $skillTarget proma_workspace = if ($Client -eq 'Proma') { (Get-PromaWorkspaceInfo).Slug } else { $null } current_version = $Release.Version previous_version = $previousVersion base_url = $RootUrl channel = $Channel installed_at = [DateTimeOffset]::UtcNow.ToString('o') } Write-JsonFile $installed (Get-InstalledPath) Write-Host "Installed ABAP Agent Standards $($Release.Version) for $Client" Write-Host "Target: $target" if ($skillTarget) { Write-Host "Skill: $skillTarget" } } function Show-Status { $installed = Read-JsonFile (Get-InstalledPath) $settings = Read-JsonFile (Get-SettingsPath) if (-not $installed) { Write-Host "No installation record for $Client."; return } $targetExists = Test-Path -LiteralPath ([string]$installed.target_path) [pscustomobject]@{ Client = $installed.client CurrentVersion = $installed.current_version PreviousVersion = $installed.previous_version Channel = $installed.channel PinnedVersion = if ($settings) { $settings.pinned_version } else { $null } Target = $installed.target_path TargetExists = $targetExists Skill = $installed.skill_path SkillExists = if ($installed.skill_path) { Test-Path -LiteralPath ([string]$installed.skill_path) } else { $null } InstalledAt = $installed.installed_at } | Format-List } function Show-Diff([object]$Release) { $installed = Read-JsonFile (Get-InstalledPath) if (-not $installed) { Write-Host "Not installed; candidate version is $($Release.Version)."; return } $currentDirectory = Join-Path $StateRoot "releases\$($installed.current_version)" $currentAdapter = Join-Path $currentDirectory ((Get-AdapterRelativePath).Replace('/', [IO.Path]::DirectorySeparatorChar)) $candidateAdapter = Join-Path $Release.Directory ((Get-AdapterRelativePath).Replace('/', [IO.Path]::DirectorySeparatorChar)) if (-not (Test-Path -LiteralPath $currentAdapter)) { throw 'Current cached adapter is unavailable.' } Write-Host "Current: $($installed.current_version) Candidate: $($Release.Version)" Compare-Object (Get-Content -Encoding UTF8 -LiteralPath $currentAdapter) (Get-Content -Encoding UTF8 -LiteralPath $candidateAdapter) | Format-Table -AutoSize } function Invoke-Rollback { $installed = Read-JsonFile (Get-InstalledPath) if (-not $installed -or -not $installed.previous_version) { throw 'No previous installed version is recorded.' } $previousDirectory = Join-Path $StateRoot "releases\$($installed.previous_version)" $manifestPath = Join-Path $previousDirectory 'manifest.json' if (-not (Test-Path -LiteralPath $manifestPath)) { throw 'Previous release is not present in the local cache.' } $manifest = Read-JsonFile $manifestPath if (-not (Test-ReleaseDirectory $previousDirectory $manifest)) { throw 'Previous cached release failed verification.' } $release = [pscustomobject]@{ Version = [string]$installed.previous_version; Directory = $previousDirectory; Manifest = $manifest } Apply-Release $release ([string]$installed.base_url) } function Invoke-Doctor { $installed = Read-JsonFile (Get-InstalledPath) if (-not $installed) { throw "No installation record for $Client." } $target = [string]$installed.target_path if (-not (Test-Path -LiteralPath $target)) { throw "Target is missing: $target" } $releaseDirectory = Join-Path $StateRoot "releases\$($installed.current_version)" $manifest = Read-JsonFile (Join-Path $releaseDirectory 'manifest.json') if (-not $manifest -or -not (Test-ReleaseDirectory $releaseDirectory $manifest)) { throw 'Installed release cache failed verification.' } $text = Get-Content -Raw -Encoding UTF8 -LiteralPath $target if ($Client -ne 'Cursor' -and ($text -notmatch [regex]::Escape($beginMarker) -or $text -notmatch [regex]::Escape($endMarker))) { throw 'Managed markers are missing from the target rules file.' } if ($Client -in @('Codex', 'Proma') -and -not $SkipSkill) { $skillEntry = Join-Path (Get-SkillsRoot) 'abap-engineering\SKILL.md' if (-not (Test-Path -LiteralPath $skillEntry)) { throw "$Client skill is missing: $skillEntry" } } Write-Host "Doctor passed for $Client version $($installed.current_version)." } New-Item -ItemType Directory -Force -Path $StateRoot | Out-Null switch ($Command) { 'Sync' { $rootUrl = Ensure-HttpsUrl $BaseUrl Apply-Release (Get-Release $rootUrl) $rootUrl } 'Status' { Show-Status } 'Diff' { $rootUrl = Ensure-HttpsUrl $BaseUrl Show-Diff (Get-Release $rootUrl) } 'Pin' { if (-not $Version -or $Version -notmatch '^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$') { throw 'Pin requires a semantic -Version.' } Write-JsonFile ([ordered]@{ schema_version = 1; pinned_version = $Version }) (Get-SettingsPath) Write-Host "Pinned future syncs to $Version." } 'Unpin' { Write-JsonFile ([ordered]@{ schema_version = 1; pinned_version = $null }) (Get-SettingsPath) Write-Host 'Removed version pin.' } 'Rollback' { Invoke-Rollback } 'Doctor' { Invoke-Doctor } }