diff --git a/AGENTS.md b/AGENTS.md index bf82369..1e81e23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,9 @@ ## Safety invariants -- Preview is the default. Remote writes require the explicit `-Apply` switch. +- Preview is the default. Remote writes require the explicit `-apply` switch. Parameter names are case-insensitive; `-Apply` is the same switch. +- Default preview and `-apply` write instruction files only. A `codexConfig` block is inert until `-settings` is passed. `-settings` previews or applies the semantic Codex projection and must not copy instruction files in the same run. +- `-init` may write only a local starter config. It never connects or changes a remote host, and it must not overwrite an existing config. - Destination paths remain relative to the remote user's home directory. Reject absolute paths, drive-qualified paths, empty segments, and `..` traversal. - Stage every payload before replacing any destination. - Fence each replacement with the hash observed during preview. A changed destination must fail closed. @@ -20,7 +22,7 @@ ## Development - Use PowerShell 7.2 or newer. -- Keep `Sync-AgentGuidance` as the only public command unless a new public surface has a clear operator need. +- Keep `Sync-AgentGuidance` as the only public command unless a new public surface has a clear operator need. `ag-sync` is an allowed alias of that command, not a second engine. - Verify harness paths and precedence against current first-party documentation before changing compatibility claims or examples. - Run `pwsh -NoProfile -File tests/Test-AgentGuidanceSync.ps1` after behavior changes. - Tests must cover configuration validation and transaction failure paths, not only the happy path. diff --git a/AgentGuidanceSync/AgentGuidanceSync.psd1 b/AgentGuidanceSync/AgentGuidanceSync.psd1 index da56034..5b4e0f6 100644 --- a/AgentGuidanceSync/AgentGuidanceSync.psd1 +++ b/AgentGuidanceSync/AgentGuidanceSync.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'AgentGuidanceSync.psm1' - ModuleVersion = '0.3.2' + ModuleVersion = '0.4.0' GUID = 'e20e5784-ce55-49dc-be7d-a8f0ef648664' Author = 'Austin Arlt' Copyright = '(c) 2026 Austin Arlt. Licensed under the MIT License.' @@ -10,11 +10,11 @@ FunctionsToExport = @('Sync-AgentGuidance') CmdletsToExport = @() VariablesToExport = @() - AliasesToExport = @() + AliasesToExport = @('ag-sync') PrivateData = @{ PSData = @{ - Tags = @('Codex', 'Claude', 'Pi', 'OhMyPi', 'OpenCode', 'SSH', 'Configuration', 'PowerShell') + Tags = @('Codex', 'Claude', 'Grok', 'Pi', 'OhMyPi', 'OpenCode', 'SSH', 'Configuration', 'PowerShell') LicenseUri = 'https://opensource.org/license/mit' } } diff --git a/AgentGuidanceSync/AgentGuidanceSync.psm1 b/AgentGuidanceSync/AgentGuidanceSync.psm1 index a933fd5..bf79b50 100644 --- a/AgentGuidanceSync/AgentGuidanceSync.psm1 +++ b/AgentGuidanceSync/AgentGuidanceSync.psm1 @@ -34,6 +34,44 @@ $script:AgentGuidanceCodexRemovalKeys = @( 'features.terminal_resize_reflow', 'tui.tui.transcript_syntax_highlight' ) +$script:AgentGuidanceKnownFiles = @( + [pscustomobject]@{ + Name = 'Codex AGENTS.md' + SourcePath = '~/.codex/AGENTS.md' + DestinationPath = '.codex/AGENTS.md' + Starter = $true + } + [pscustomobject]@{ + Name = 'Claude CLAUDE.md' + SourcePath = '~/.claude/CLAUDE.md' + DestinationPath = '.claude/CLAUDE.md' + Starter = $true + } + [pscustomobject]@{ + Name = 'Grok AGENTS.md' + SourcePath = '~/.grok/AGENTS.md' + DestinationPath = '.grok/AGENTS.md' + Starter = $false + } + [pscustomobject]@{ + Name = 'Pi AGENTS.md' + SourcePath = '~/.pi/agent/AGENTS.md' + DestinationPath = '.pi/agent/AGENTS.md' + Starter = $false + } + [pscustomobject]@{ + Name = 'oh-my-pi AGENTS.md' + SourcePath = '~/.omp/agent/AGENTS.md' + DestinationPath = '.omp/agent/AGENTS.md' + Starter = $false + } + [pscustomobject]@{ + Name = 'OpenCode AGENTS.md' + SourcePath = '~/.config/opencode/AGENTS.md' + DestinationPath = '.config/opencode/AGENTS.md' + Starter = $false + } +) function Invoke-AgentGuidanceNative { [CmdletBinding()] @@ -200,6 +238,379 @@ function Get-AgentGuidanceDefaultConfigPath { Join-Path $profileRoot '.config/agent-guidance-sync/config.json' } +function Get-AgentGuidanceOperatorCommand { + [CmdletBinding()] + param() + + $sawLongCli = $false + foreach ($frame in Get-PSCallStack) { + $names = [Collections.Generic.List[string]]::new() + if (-not [string]::IsNullOrWhiteSpace([string] $frame.Command)) { + $names.Add([string] $frame.Command) + } + if (-not [string]::IsNullOrWhiteSpace([string] $frame.ScriptName)) { + $names.Add([IO.Path]::GetFileNameWithoutExtension([string] $frame.ScriptName)) + } + foreach ($name in $names) { + $leaf = [IO.Path]::GetFileNameWithoutExtension($name) + if ($leaf -eq 'ag-sync') { + return 'ag-sync' + } + if ($leaf -eq 'agent-guidance-sync') { + $sawLongCli = $true + } + } + } + if ($sawLongCli) { + return 'agent-guidance-sync' + } + if (Get-Command -Name ag-sync -ErrorAction SilentlyContinue) { + return 'ag-sync' + } + if (Get-Command -Name agent-guidance-sync -ErrorAction SilentlyContinue) { + return 'agent-guidance-sync' + } + + 'Sync-AgentGuidance' +} + +function ConvertTo-AgentGuidanceProfilePath { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Path, + + [Parameter(Mandatory)] + [string] $ProfileRoot + ) + + if ($Path -eq '~') { + return [IO.Path]::GetFullPath($ProfileRoot) + } + if ($Path.StartsWith('~/') -or $Path.StartsWith('~\')) { + $relativeToProfile = $Path.Substring(2).Replace('/', [IO.Path]::DirectorySeparatorChar) + return [IO.Path]::GetFullPath((Join-Path $ProfileRoot $relativeToProfile)) + } + if ([IO.Path]::IsPathRooted($Path)) { + return [IO.Path]::GetFullPath($Path) + } + + [IO.Path]::GetFullPath((Join-Path $ProfileRoot $Path)) +} + +function Get-AgentGuidanceSshConfigAliases { + [CmdletBinding()] + param( + [string] $SshConfigPath + ) + + if ([string]::IsNullOrWhiteSpace($SshConfigPath)) { + $profileRoot = [Environment]::GetFolderPath('UserProfile') + if ([string]::IsNullOrWhiteSpace($profileRoot)) { + return @() + } + $SshConfigPath = Join-Path $profileRoot '.ssh/config' + } + if (-not (Test-Path -LiteralPath $SshConfigPath -PathType Leaf)) { + return @() + } + + $aliases = [Collections.Generic.List[string]]::new() + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($rawLine in [IO.File]::ReadAllLines($SshConfigPath)) { + $line = $rawLine.Trim() + if ([string]::IsNullOrWhiteSpace($line) -or $line.StartsWith('#')) { + continue + } + if ($line -notmatch '^(?i)Host\s+(.+)$') { + continue + } + foreach ($token in @($Matches[1] -split '\s+' | Where-Object { $_ })) { + if ($token -match '[*?]' -or $token.StartsWith('!')) { + continue + } + if ($token -notmatch '^[A-Za-z0-9._-]+(?:@[A-Za-z0-9._-]+)?$') { + continue + } + if ($seen.Add($token)) { + $aliases.Add($token) + } + } + } + + @($aliases) +} + +function Select-AgentGuidanceStarterFiles { + [CmdletBinding()] + param( + [string] $ProfileRoot = ([Environment]::GetFolderPath('UserProfile')) + ) + + if ([string]::IsNullOrWhiteSpace($ProfileRoot)) { + throw 'The local user profile path is empty.' + } + + $present = @( + foreach ($entry in $script:AgentGuidanceKnownFiles) { + $localPath = ConvertTo-AgentGuidanceProfilePath -Path $entry.SourcePath -ProfileRoot $ProfileRoot + if (Test-Path -LiteralPath $localPath -PathType Leaf) { + [pscustomobject]@{ + Name = $entry.Name + SourcePath = $entry.SourcePath + DestinationPath = $entry.DestinationPath + LocalPath = $localPath + Present = $true + Fallback = $false + } + } + } + ) + if ($present.Count -gt 0) { + return $present + } + + @( + foreach ($entry in $script:AgentGuidanceKnownFiles) { + if (-not $entry.Starter) { + continue + } + [pscustomobject]@{ + Name = $entry.Name + SourcePath = $entry.SourcePath + DestinationPath = $entry.DestinationPath + LocalPath = (ConvertTo-AgentGuidanceProfilePath -Path $entry.SourcePath -ProfileRoot $ProfileRoot) + Present = $false + Fallback = $true + } + } + ) +} + +function Initialize-AgentGuidanceConfig { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $ConfigPath, + + [string] $ProfileRoot = ([Environment]::GetFolderPath('UserProfile')), + + [string] $SshConfigPath + ) + + $currentDirectory = (Get-Location).ProviderPath + $resolvedConfigPath = Resolve-AgentGuidanceSourcePath -Path $ConfigPath -BaseDirectory $currentDirectory + if (Test-Path -LiteralPath $resolvedConfigPath -PathType Leaf) { + throw "A config already exists at $resolvedConfigPath. Edit that file, or pass a different -ConfigPath." + } + + $sourceLabel = [Environment]::MachineName + if ([string]::IsNullOrWhiteSpace($sourceLabel) -or $sourceLabel -match '[\r\n|]') { + $sourceLabel = 'primary-workstation' + } + + $selectedFiles = @(Select-AgentGuidanceStarterFiles -ProfileRoot $ProfileRoot) + $document = [ordered]@{ + sourceLabel = $sourceLabel + targets = @('host-one', 'host-two') + files = @( + foreach ($entry in $selectedFiles) { + [ordered]@{ + name = $entry.Name + sourcePath = $entry.SourcePath + destinationPath = $entry.DestinationPath + } + } + ) + } + $json = ($document | ConvertTo-Json -Depth 5) + [Environment]::NewLine + + $configDirectory = [IO.Path]::GetDirectoryName($resolvedConfigPath) + if (-not [string]::IsNullOrWhiteSpace($configDirectory)) { + New-Item -ItemType Directory -Path $configDirectory -Force | Out-Null + } + [IO.File]::WriteAllText($resolvedConfigPath, $json, [Text.UTF8Encoding]::new($false)) + + $commandName = Get-AgentGuidanceOperatorCommand + $usedFallback = @($selectedFiles | Where-Object { $_.Fallback }).Count -gt 0 + Write-Host "Wrote starter config: $resolvedConfigPath" -ForegroundColor Green + Write-Host "Source label: $sourceLabel" + if ($usedFallback) { + Write-Host 'No usual guidance files were found, so the Codex + Claude starter was written.' -ForegroundColor Yellow + Write-Host 'Preview will fail until those files exist or you edit the mappings.' + } + else { + Write-Host "Included $($selectedFiles.Count) local guidance file(s):" + foreach ($entry in $selectedFiles) { + Write-Host " $($entry.Name)" + Write-Host " $($entry.SourcePath) -> $($entry.DestinationPath)" + } + } + + $sshAliases = @( + if ($PSBoundParameters.ContainsKey('SshConfigPath')) { + Get-AgentGuidanceSshConfigAliases -SshConfigPath $SshConfigPath + } + else { + Get-AgentGuidanceSshConfigAliases + } + ) + if ($sshAliases.Count -gt 0) { + Write-Host "SSH aliases in ~/.ssh/config: $($sshAliases -join ', ')" + } + + Write-Host '' + Write-Host "Next: replace host-one and host-two with your SSH aliases, then run $commandName" + Write-Host "Apply later with $commandName -apply" + + [pscustomobject]@{ + ConfigPath = $resolvedConfigPath + SourceLabel = $sourceLabel + Files = $selectedFiles + UsedFallback = $usedFallback + SshAliases = $sshAliases + } +} + +function Get-AgentGuidancePreviewSummary { + [CmdletBinding()] + param( + [AllowEmptyCollection()] + [pscustomobject[]] $Inventory = @() + ) + + $reachable = @($Inventory | Where-Object { $_.Availability -eq 'Reachable' }) + $skipped = @($Inventory | Where-Object { $_.Availability -eq 'Unavailable' }) + $current = 0 + $different = 0 + $missing = 0 + foreach ($target in $reachable) { + foreach ($item in @($target.Files)) { + switch ($item.Status) { + 'Current' { $current++ } + 'Missing' { $missing++ } + default { $different++ } + } + } + } + + [pscustomobject]@{ + ReachableCount = $reachable.Count + SkippedCount = $skipped.Count + SkippedNames = @($skipped.ComputerName) + CurrentCount = $current + DifferentCount = $different + MissingCount = $missing + ChangeCount = $different + $missing + ComparedCount = $current + $different + $missing + } +} + +function ConvertTo-AgentGuidanceCountPhrase { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [int] $Count, + + [Parameter(Mandatory)] + [string] $Singular, + + [Parameter(Mandatory)] + [string] $Plural + ) + + if ($Count -eq 1) { + return "1 $Singular" + } + + "$Count $Plural" +} + +function Resolve-AgentGuidanceRunScope { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [pscustomobject] $Config, + + [switch] $Settings + ) + + $commandName = Get-AgentGuidanceOperatorCommand + $fileCount = @($Config.Files).Count + if ($Settings) { + if ($null -eq $Config.CodexConfig) { + throw "No settings projection is configured. Add a codexConfig block to $($Config.ConfigPath), then rerun $commandName -settings." + } + return [pscustomobject]@{ + IncludeFiles = $false + IncludeSettings = $true + } + } + + if ($fileCount -eq 0) { + throw "This config only defines settings. Preview or apply them with $commandName -settings." + } + + [pscustomobject]@{ + IncludeFiles = $true + IncludeSettings = $false + } +} + +function Write-AgentGuidancePreviewSummary { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [pscustomobject] $Summary, + + [Parameter(Mandatory)] + [string] $CommandName, + + [switch] $Apply, + + [switch] $Settings + ) + + $reachablePhrase = ConvertTo-AgentGuidanceCountPhrase -Count $Summary.ReachableCount -Singular 'reachable host' -Plural 'reachable hosts' + $parts = [Collections.Generic.List[string]]::new() + if ($Summary.CurrentCount -gt 0) { + $parts.Add((ConvertTo-AgentGuidanceCountPhrase -Count $Summary.CurrentCount -Singular 'already matches' -Plural 'already match')) + } + if ($Summary.DifferentCount -gt 0) { + $parts.Add((ConvertTo-AgentGuidanceCountPhrase -Count $Summary.DifferentCount -Singular 'will update' -Plural 'will update')) + } + if ($Summary.MissingCount -gt 0) { + $parts.Add((ConvertTo-AgentGuidanceCountPhrase -Count $Summary.MissingCount -Singular 'will create' -Plural 'will create')) + } + + $summaryLine = "Summary: $reachablePhrase" + if ($Summary.SkippedCount -gt 0) { + $skippedPhrase = ConvertTo-AgentGuidanceCountPhrase -Count $Summary.SkippedCount -Singular 'skipped' -Plural 'skipped' + $summaryLine += ", $skippedPhrase ($($Summary.SkippedNames -join ', '))" + } + if ($parts.Count -gt 0) { + $summaryLine += ". $($parts -join ', ')." + } + else { + $summaryLine += '. No files were compared.' + } + + Write-Host '' + Write-Host $summaryLine -ForegroundColor Cyan + + if ($Apply) { + return + } + + if ($Summary.ChangeCount -eq 0) { + Write-Host "Preview complete. Reachable hosts already match this source. No files would change." -ForegroundColor Cyan + } + else { + $applyHint = if ($Settings) { "$CommandName -settings -apply" } else { "$CommandName -apply" } + Write-Host "Preview complete. No files were changed. Run $applyHint to write this source state." -ForegroundColor Cyan + } +} + function Resolve-AgentGuidanceSourcePath { [CmdletBinding()] param( @@ -392,7 +803,8 @@ function Import-AgentGuidanceConfig { $currentDirectory = (Get-Location).ProviderPath $resolvedConfigPath = Resolve-AgentGuidanceSourcePath -Path $ConfigPath -BaseDirectory $currentDirectory if (-not (Test-Path -LiteralPath $resolvedConfigPath -PathType Leaf)) { - throw "Agent guidance config is missing: $resolvedConfigPath. Copy config.example.json there and edit it for this machine." + $commandName = Get-AgentGuidanceOperatorCommand + throw "No config file at $resolvedConfigPath. Create a starter with $commandName -init, or pass -ConfigPath." } try { @@ -1405,12 +1817,12 @@ function Show-AgentGuidancePreview { foreach ($item in $target.Files) { $kind = if ($item.PSObject.Properties['Kind']) { [string] $item.Kind } else { 'ExactFile' } if ($item.Status -eq 'Current') { - Write-Host " $($item.Name): current" -ForegroundColor Green + Write-Host " $($item.Name): already matches" -ForegroundColor Green continue } if ($kind -eq 'CodexConfig') { - $state = if ($item.Status -eq 'Missing') { 'missing; it will be created' } else { 'settings differ' } + $state = if ($item.Status -eq 'Missing') { 'will create' } else { 'settings will change' } Write-Host " $($item.Name): $state" -ForegroundColor Magenta foreach ($change in $item.Changes) { if ($change.Action -eq 'Remove') { @@ -1424,11 +1836,11 @@ function Show-AgentGuidancePreview { } if ($item.Status -eq 'Missing') { - Write-Host " $($item.Name): missing; it will be created" -ForegroundColor Magenta + Write-Host " $($item.Name): will create" -ForegroundColor Magenta continue } - Write-Host " $($item.Name): different" -ForegroundColor Magenta + Write-Host " $($item.Name): will update" -ForegroundColor Magenta if ($target.Platform -eq 'Windows') { $remoteText = Get-AgentGuidanceWindowsContent ` -ComputerName $target.ComputerName ` @@ -1669,42 +2081,68 @@ function Sync-AgentGuidance { .DESCRIPTION Reads source files, an optional allowlisted Codex config projection, target SSH aliases, and home-relative destination paths from a JSON config. Without - -Apply, shows differences and makes no changes. With -Apply, stages every - target-specific payload first, fences against concurrent remote edits, + -apply, shows differences and makes no changes. Default preview and -apply + write instruction files only. -settings previews or applies the configured + Codex settings projection and does not copy instruction files. With -apply, + stages every selected payload first, fences against concurrent remote edits, creates timestamped backups, replaces each file atomically, and verifies SHA-256 readback. Targets with a hard SSH reachability failure during the initial probe are reported and skipped. Authentication, host-key, preflight, - staging, commit, and verification failures still stop the run. + staging, commit, and verification failures still stop the run. -init writes + a local starter config and does not connect to any host. + + .EXAMPLE + ag-sync -init + + .EXAMPLE + ag-sync .EXAMPLE - Sync-AgentGuidance + ag-sync -apply .EXAMPLE - Sync-AgentGuidance -Apply + ag-sync -settings .EXAMPLE - Sync-AgentGuidance -ComputerName host-one -Apply + ag-sync -settings -apply .EXAMPLE - Sync-AgentGuidance -ConfigPath ./lab-config.json + ag-sync -ComputerName host-one -apply #> - [CmdletBinding()] + [CmdletBinding(DefaultParameterSetName = 'Sync')] + [Alias('ag-sync')] param( + [Parameter(ParameterSetName = 'Sync')] [ValidatePattern('^[A-Za-z0-9._-]+(?:@[A-Za-z0-9._-]+)?$')] [string[]] $ComputerName, + [Parameter(ParameterSetName = 'Sync')] + [Parameter(ParameterSetName = 'Init')] [string] $ConfigPath = (Get-AgentGuidanceDefaultConfigPath), - [switch] $Apply + [Parameter(ParameterSetName = 'Sync')] + [switch] $Apply, + + [Parameter(ParameterSetName = 'Sync')] + [switch] $Settings, + + [Parameter(Mandatory, ParameterSetName = 'Init')] + [switch] $Init ) - foreach ($commandName in @('ssh', 'scp', 'git')) { - if (-not (Get-Command $commandName -ErrorAction SilentlyContinue)) { - throw "$commandName is required but is not available on PATH." + if ($Init) { + $null = Initialize-AgentGuidanceConfig -ConfigPath $ConfigPath + return + } + + foreach ($toolName in @('ssh', 'scp', 'git')) { + if (-not (Get-Command $toolName -ErrorAction SilentlyContinue)) { + throw "$toolName is required but is not on PATH. Install OpenSSH and Git, then open a new terminal." } } $config = Import-AgentGuidanceConfig -ConfigPath $ConfigPath + $scope = Resolve-AgentGuidanceRunScope -Config $config -Settings:$Settings $targets = @( Resolve-AgentGuidanceTargets ` -ConfiguredTarget $config.Targets ` @@ -1712,18 +2150,22 @@ function Sync-AgentGuidance { -UseOverride:$PSBoundParameters.ContainsKey('ComputerName') ) - $files = @($config.Files) + $files = @( + if ($scope.IncludeFiles) { + $config.Files + } + ) foreach ($item in $files) { if (-not (Test-Path -LiteralPath $item.LocalPath -PathType Leaf)) { - throw "Required source file is missing: $($item.LocalPath)" + throw "Required source file for '$($item.Name)' is missing: $($item.LocalPath). Create the file or remove that mapping from $($config.ConfigPath)." } $item | Add-Member -NotePropertyName LocalHash -NotePropertyValue ((Get-FileHash -LiteralPath $item.LocalPath -Algorithm SHA256).Hash.ToLowerInvariant()) } $inventory = @() try { - $sourceSnapshot = if ($null -ne $config.CodexConfig) { + $sourceSnapshot = if ($scope.IncludeSettings) { Get-AgentGuidanceCodexSourceSnapshot -ConfigPath $config.CodexConfig.LocalPath } else { @@ -1731,16 +2173,19 @@ function Sync-AgentGuidance { } Write-Host "Agent guidance source: $($config.SourceLabel)" -ForegroundColor Cyan Write-Host "Config: $($config.ConfigPath)" -ForegroundColor DarkGray + Write-Host $(if ($scope.IncludeSettings) { 'Mode: Codex settings projection' } else { 'Mode: instruction files' }) -ForegroundColor DarkGray $inventory = @(Get-AgentGuidanceInventory -ComputerName $targets -File $files) $reachableInventory = @($inventory | Where-Object { $_.Availability -eq 'Reachable' }) $skippedInventory = @($inventory | Where-Object { $_.Availability -eq 'Unavailable' }) - if ($null -ne $config.CodexConfig) { + if ($scope.IncludeSettings) { $inventory = @(Add-AgentGuidanceCodexConfigInventory ` -Inventory $inventory ` -CodexConfig $config.CodexConfig ` -SourceSnapshot $sourceSnapshot) } Show-AgentGuidancePreview -Inventory $inventory -SourceLabel $config.SourceLabel + $previewSummary = Get-AgentGuidancePreviewSummary -Inventory $inventory + $operatorCommand = Get-AgentGuidanceOperatorCommand if ($reachableInventory.Count -eq 0) { $unavailableNames = @($skippedInventory.ComputerName) -join ', ' @@ -1748,12 +2193,7 @@ function Sync-AgentGuidance { } if (-not $Apply) { - Write-Host '' - $previewMessage = "Preview complete for $($reachableInventory.Count) reachable target(s). No files were changed." - if ($skippedInventory.Count -gt 0) { - $previewMessage += " Skipped unavailable targets: $(@($skippedInventory.ComputerName) -join ', ')." - } - Write-Host "$previewMessage Run Sync-AgentGuidance -Apply to apply this exact source state." -ForegroundColor Cyan + Write-AgentGuidancePreviewSummary -Summary $previewSummary -CommandName $operatorCommand -Settings:$Settings return } @@ -1908,4 +2348,5 @@ function Sync-AgentGuidance { } } -Export-ModuleMember -Function Sync-AgentGuidance +Set-Alias -Name ag-sync -Value Sync-AgentGuidance +Export-ModuleMember -Function Sync-AgentGuidance -Alias ag-sync diff --git a/README.md b/README.md index 46d4c5e..67957d5 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,26 @@ Safely preview and synchronize global agent instructions and a narrow set of por It started as a way to keep Codex `AGENTS.md` and Claude `CLAUDE.md` aligned across a small fleet without passing files through chat, email, or a cloud-drive folder. The same exact-file engine supports Pi, oh-my-pi, OpenCode, and other harnesses without merging or translating their distinct instructions. An optional semantic projection keeps selected Codex behavior consistent without copying machine-local trust, tools, plugins, or credentials. +## Start here + +```powershell +npm install --global agent-guidance-sync +ag-sync -init +``` + +`-init` writes `~/.config/agent-guidance-sync/config.json`, includes any usual local guidance files it can see, and leaves `host-one` / `host-two` as placeholders. Replace those with SSH aliases from `~/.ssh/config`, then: + +```powershell +ag-sync # preview +ag-sync -apply # write +``` + +Preview is the default. `-apply` is the only way to change a remote file. Switches are case-insensitive, so `-Apply` still works. The long command `agent-guidance-sync` is the same program. + ## Why this is safer than a copy loop - Preview-only by default, with readable diffs. -- Remote writes require `-Apply`. +- Remote writes require `-apply`. - Every payload is staged on every reachable target before any destination changes. - Each write is fenced by the remote SHA-256 observed during preview; a concurrent edit aborts that write. - Existing files receive timestamped backups. @@ -58,7 +74,7 @@ Install the published CLI from npm: npm install --global agent-guidance-sync ``` -This installs the `agent-guidance-sync` command. PowerShell 7.2 or newer is still required at runtime; npm is the delivery mechanism, not a JavaScript rewrite. +This installs `ag-sync` and the longer `agent-guidance-sync` name. PowerShell 7.2 or newer is still required at runtime; npm is the delivery mechanism, not a JavaScript rewrite. A module install from this repo exports the same `ag-sync` alias. To install directly from a repository clone instead, run: @@ -76,20 +92,22 @@ The installer refuses to replace an existing `AgentGuidanceSync` installation un ## Configure -Create the default private configuration directory and put your `config.json` there: +The usual path is `ag-sync -init`. That creates the default private config directory and writes a starter `config.json` without overwriting an existing file. + +If you prefer to copy a preset by hand: ```powershell $configDirectory = Join-Path ([Environment]::GetFolderPath('UserProfile')) '.config/agent-guidance-sync' New-Item -ItemType Directory -Path $configDirectory -Force | Out-Null ``` -If you are working from a repository clone, [`config.example.json`](config.example.json) is a copy-ready starter: +From a repository clone, [`config.example.json`](config.example.json) is a copy-ready starter: ```powershell Copy-Item ./config.example.json (Join-Path $configDirectory 'config.json') ``` -For an npm installation, create `config.json` using this same starter structure: +For an npm installation, the starter has this shape: ```json { @@ -113,7 +131,14 @@ Do not commit the private config, guidance files, SSH configuration, keys, or au ### Portable Codex settings -Use [`config.codex-portable.example.json`](config.codex-portable.example.json) when one workstation should define fleet-wide Codex behavior. The projection is key-based, not a `config.toml` copy: +Use [`config.codex-portable.example.json`](config.codex-portable.example.json) when one workstation should define fleet-wide Codex behavior. The projection is key-based, not a `config.toml` copy. Putting `codexConfig` in the JSON makes those settings available; it does not include them in a normal run. + +```powershell +ag-sync -settings # preview portable Codex settings +ag-sync -settings -apply # write only that projection +``` + +A default `ag-sync -apply` still writes instruction files only. ```json { @@ -145,17 +170,18 @@ This keeps "same behavior" separate from "same machine." Raw file mappings are a ### Multi-harness configuration -[`config.multi-harness.example.json`](config.multi-harness.example.json) contains verified native mappings for five harnesses: +[`config.multi-harness.example.json`](config.multi-harness.example.json) contains verified native mappings for six harnesses: | Harness | Global instruction file | Important behavior | |---|---|---| | Codex | `~/.codex/AGENTS.md` | Codex-native global guidance. | | Claude Code | `~/.claude/CLAUDE.md` | Claude-native global guidance. | +| Grok | `~/.grok/AGENTS.md` | Grok-native global rules. Applies to every project. | | Pi | `~/.pi/agent/AGENTS.md` | Pi's global context file. | | oh-my-pi | `~/.omp/agent/AGENTS.md` | Native OMP context; it has the highest OMP discovery priority. | | OpenCode | `~/.config/opencode/AGENTS.md` | Native OpenCode rules; these take precedence over its Claude compatibility fallback. | -The Pi path and context behavior are documented in [Using Pi](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/usage.md#context-files). OMP documents its native path, provider precedence, and shadowing behavior in [Context files](https://github.com/can1357/oh-my-pi/blob/main/docs/context-files.md). OpenCode documents its global path and Claude fallback in [Rules](https://opencode.ai/docs/rules/). +The Pi path and context behavior are documented in [Using Pi](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/usage.md#context-files). OMP documents its native path, provider precedence, and shadowing behavior in [Context files](https://github.com/can1357/oh-my-pi/blob/main/docs/context-files.md). OpenCode documents its global path and Claude fallback in [Rules](https://opencode.ai/docs/rules/). Grok documents global rules under `~/.grok/` in [AGENTS.md](https://docs.x.ai/build/features/project-rules); the named home file is `~/.grok/AGENTS.md`. Copy the broader preset instead of the two-file starter when you use those harnesses: @@ -171,48 +197,45 @@ The preset intentionally excludes: - Settings, provider configuration, model catalogs, and session history. - Pi's `SYSTEM.md` and `APPEND_SYSTEM.md`, which alter the system prompt rather than serving as ordinary global guidance. - OMP's `RULES.md`, which is short, sticky, and precedence-sensitive. Add it as a separate explicit mapping only when you deliberately want identical sticky rules on every target. +- Grok's `config.toml`, `auth.json`, sessions, and `~/.grok/rules/` directory. Those are machine-local settings, credentials, or a folder of files rather than one global instruction file. OMP can read several other harness conventions, but creating its native `~/.omp/agent/AGENTS.md` changes which user-level file wins. Keep that mapping only when you maintain genuinely OMP-specific guidance. ## Use -Preview the entire configured fleet with the npm-installed CLI: +If you do not have a config yet: ```powershell -agent-guidance-sync +ag-sync -init ``` -Apply exactly what was previewed: +Preview the entire configured fleet: ```powershell -agent-guidance-sync -Apply +ag-sync ``` -Limit a run to one or more targets: +Apply exactly what was previewed: ```powershell -agent-guidance-sync -ComputerName host-one -agent-guidance-sync -ComputerName host-one,host-two -Apply +ag-sync -apply ``` -If you installed the module directly from a clone, use its PowerShell command instead: +Limit a run to one or more targets: ```powershell -Sync-AgentGuidance +ag-sync -ComputerName host-one +ag-sync -ComputerName host-one,host-two -apply ``` -Apply exactly what was previewed: +Portable Codex settings, if configured, are a separate run: ```powershell -Sync-AgentGuidance -Apply +ag-sync -settings +ag-sync -settings -apply ``` -The same parameters are available: - -```powershell -Sync-AgentGuidance -ComputerName host-one -Sync-AgentGuidance -ComputerName host-one,host-two -Apply -``` +`agent-guidance-sync` and `Sync-AgentGuidance` accept the same parameters. After a module-only install, `ag-sync` is the exported alias for `Sync-AgentGuidance`. Existing harness sessions may retain their startup instructions. Start a new session after syncing when you need the new guidance loaded immediately. diff --git a/bin/agent-guidance-sync.ps1 b/bin/agent-guidance-sync.ps1 index e7d8222..7dd9616 100644 --- a/bin/agent-guidance-sync.ps1 +++ b/bin/agent-guidance-sync.ps1 @@ -6,13 +6,22 @@ Previews or applies agent guidance synchronization from the npm-installed CLI. .EXAMPLE -agent-guidance-sync +ag-sync -init .EXAMPLE -agent-guidance-sync -Apply +ag-sync .EXAMPLE -agent-guidance-sync -ComputerName host-one -Apply +ag-sync -apply + +.EXAMPLE +ag-sync -settings + +.EXAMPLE +ag-sync -settings -apply + +.EXAMPLE +ag-sync -ComputerName host-one -apply #> [CmdletBinding()] @@ -21,7 +30,11 @@ param( [string] $ConfigPath, - [switch] $Apply + [switch] $Apply, + + [switch] $Settings, + + [switch] $Init ) Set-StrictMode -Version Latest diff --git a/config.multi-harness.example.json b/config.multi-harness.example.json index 5bc9bf6..997b4ce 100644 --- a/config.multi-harness.example.json +++ b/config.multi-harness.example.json @@ -15,6 +15,11 @@ "sourcePath": "~/.claude/CLAUDE.md", "destinationPath": ".claude/CLAUDE.md" }, + { + "name": "Grok AGENTS.md", + "sourcePath": "~/.grok/AGENTS.md", + "destinationPath": ".grok/AGENTS.md" + }, { "name": "Pi AGENTS.md", "sourcePath": "~/.pi/agent/AGENTS.md", diff --git a/install.ps1 b/install.ps1 index 02b3a76..c8305f2 100644 --- a/install.ps1 +++ b/install.ps1 @@ -90,4 +90,5 @@ Write-Host "Mode: $(if ($DevelopmentLink) { 'development link' } else { 'copy' } if ($null -ne $backupPath) { Write-Host "Previous installation preserved at $backupPath" -ForegroundColor Yellow } -Write-Host "Command: $($command.Name)" +Write-Host "Command: $($command.Name) (alias: ag-sync)" +Write-Host "Create a starter config: ag-sync -init" diff --git a/package.json b/package.json index db3583d..ffeddf7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-guidance-sync", - "version": "0.3.2", + "version": "0.4.0", "description": "Preview-first synchronization of agent guidance files and portable Codex settings over SSH.", "license": "MIT", "author": "Austin Arlt", @@ -21,6 +21,7 @@ "agent-guidance" ], "bin": { + "ag-sync": "bin/agent-guidance-sync.ps1", "agent-guidance-sync": "bin/agent-guidance-sync.ps1" }, "files": [ diff --git a/tests/Test-AgentGuidanceSync.ps1 b/tests/Test-AgentGuidanceSync.ps1 index 6727cae..ceedfa2 100644 --- a/tests/Test-AgentGuidanceSync.ps1 +++ b/tests/Test-AgentGuidanceSync.ps1 @@ -195,11 +195,27 @@ try { Test-Case 'manifest and public command surface are valid' { Assert-Equal -Expected 'AgentGuidanceSync' -Actual $manifest.Name -Because 'manifest name should match the module folder' Assert-True -Condition ($manifest.Version.ToString() -match '^\d+\.\d+\.\d+$') -Because 'the module should use a three-part release version' - $exportedCommands = @((Get-Command -Module AgentGuidanceSync).Name | Sort-Object -Unique) - Assert-Equal -Expected 1 -Actual $exportedCommands.Count -Because 'only one command should be public' - Assert-Equal -Expected 'Sync-AgentGuidance' -Actual $exportedCommands[0] -Because 'the sync command should be exported' - $applyParameter = (Get-Command Sync-AgentGuidance).Parameters['Apply'] + $exportedCommands = @(Get-Command -Module AgentGuidanceSync) + $exportedFunctions = @($exportedCommands | Where-Object { $_.CommandType -eq 'Function' } | ForEach-Object { $_.Name } | Sort-Object -Unique) + $exportedAliases = @((Get-Module AgentGuidanceSync).ExportedAliases.Keys | Sort-Object) + Assert-Equal -Expected 1 -Actual $exportedFunctions.Count -Because 'only one command should be public' + Assert-Equal -Expected 'Sync-AgentGuidance' -Actual $exportedFunctions[0] -Because 'the sync command should be exported' + Assert-Equal -Expected 1 -Actual $exportedAliases.Count -Because 'the short daily name should be an alias, not a second command' + Assert-Equal -Expected 'ag-sync' -Actual $exportedAliases[0] -Because 'ag-sync should be the exported alias' + Assert-Equal -Expected 'Sync-AgentGuidance' -Actual (Get-Command -Name ag-sync).ReferencedCommand.Name -Because 'ag-sync must resolve to Sync-AgentGuidance' + $applyParameter = (Get-Command Sync-AgentGuidance).Parameters['apply'] + Assert-Equal -Expected 'Apply' -Actual $applyParameter.Name -Because 'PowerShell parameter names are case-insensitive; -apply is -Apply' Assert-Equal -Expected ([switch].FullName) -Actual $applyParameter.ParameterType.FullName -Because 'remote writes should require an explicit switch' + $initParameter = (Get-Command Sync-AgentGuidance).Parameters['init'] + Assert-Equal -Expected 'Init' -Actual $initParameter.Name -Because '-init is the same switch as -Init' + Assert-Equal -Expected ([switch].FullName) -Actual $initParameter.ParameterType.FullName -Because 'starter generation should be an explicit switch on the same command' + $initIsOwnSet = @($initParameter.Attributes | Where-Object { $_ -is [Parameter] -and $_.ParameterSetName -eq 'Init' -and $_.Mandatory }).Count -ge 1 + Assert-True -Condition $initIsOwnSet -Because 'Init should be its own parameter set' + $settingsParameter = (Get-Command Sync-AgentGuidance).Parameters['settings'] + Assert-Equal -Expected 'Settings' -Actual $settingsParameter.Name -Because '-settings is the same switch as -Settings' + Assert-Equal -Expected ([switch].FullName) -Actual $settingsParameter.ParameterType.FullName -Because 'settings projection should require an explicit switch' + $operatorCommand = & $module { Get-AgentGuidanceOperatorCommand } + Assert-Equal -Expected 'ag-sync' -Actual $operatorCommand -Because 'operator hints should prefer the short daily name once the alias exists' } Test-Case 'npm package metadata matches the PowerShell module' { @@ -210,8 +226,13 @@ try { Assert-Equal -Expected 'public' -Actual $package.publishConfig.access -Because 'the unscoped package should be explicitly public' $cliRelativePath = $package.bin.'agent-guidance-sync' - Assert-Equal -Expected 'bin/agent-guidance-sync.ps1' -Actual $cliRelativePath -Because 'npm should expose one stable command' - Assert-True -Condition (Test-Path -LiteralPath (Join-Path $repositoryRoot $cliRelativePath) -PathType Leaf) -Because 'the npm command target should exist' + Assert-Equal -Expected 'bin/agent-guidance-sync.ps1' -Actual $cliRelativePath -Because 'the long npm name should keep working' + Assert-Equal -Expected $cliRelativePath -Actual $package.bin.'ag-sync' -Because 'ag-sync should be the same CLI, not a second implementation' + $cliPath = Join-Path $repositoryRoot $cliRelativePath + Assert-True -Condition (Test-Path -LiteralPath $cliPath -PathType Leaf) -Because 'the npm command target should exist' + $cliText = Get-Content -LiteralPath $cliPath -Raw + Assert-True -Condition ($cliText -match '\[switch\]\s+\$Init') -Because 'the npm CLI should forward -Init' + Assert-True -Condition ($cliText -match '\[switch\]\s+\$Settings') -Because 'the npm CLI should forward -Settings' } Test-Case 'literal escaping and remote directory parsing are platform-safe' { @@ -262,11 +283,12 @@ try { $multiHarnessPath = Join-Path $repositoryRoot 'config.multi-harness.example.json' $multiHarness = & $module { param($path) Import-AgentGuidanceConfig -ConfigPath $path } $multiHarnessPath - Assert-Equal -Expected 5 -Actual $multiHarness.Files.Count -Because 'the broader preset should cover five verified harnesses' + Assert-Equal -Expected 6 -Actual $multiHarness.Files.Count -Because 'the broader preset should cover six verified harnesses' $expectedDestinations = @( '.codex/AGENTS.md', '.claude/CLAUDE.md', + '.grok/AGENTS.md', '.pi/agent/AGENTS.md', '.omp/agent/AGENTS.md', '.config/opencode/AGENTS.md' @@ -299,6 +321,192 @@ try { Assert-Equal -Expected 24 -Actual $portable.CodexConfig.PortableKeys.Count -Because 'the example should own only reviewed portable settings' Assert-Equal -Expected 1 -Actual $portable.CodexConfig.WindowsKeys.Count -Because 'Windows sandbox implementation should be platform-scoped' Assert-Equal -Expected 5 -Actual $portable.CodexConfig.RemoveKeys.Count -Because 'the example should remove only reviewed stale keys' + + $catalogDestinations = @(& $module { @($script:AgentGuidanceKnownFiles | ForEach-Object { $_.DestinationPath }) }) + Assert-Equal ` + -Expected ($expectedDestinations -join '|') ` + -Actual ($catalogDestinations -join '|') ` + -Because 'the Init catalog should stay aligned with the verified multi-harness destinations' + } + + Test-Case 'Init selects existing local files and falls back to the two-file starter' { + $profileRoot = Join-Path $temporaryRoot 'init-profile' + $claudePath = Join-Path $profileRoot '.claude/CLAUDE.md' + $grokPath = Join-Path $profileRoot '.grok/AGENTS.md' + $openCodePath = Join-Path $profileRoot '.config/opencode/AGENTS.md' + Set-TestText -Path $claudePath -Content 'claude guidance' + Set-TestText -Path $grokPath -Content 'grok guidance' + Set-TestText -Path $openCodePath -Content 'opencode guidance' + + $selected = @(& $module { param($root) Select-AgentGuidanceStarterFiles -ProfileRoot $root } $profileRoot) + Assert-Equal -Expected 3 -Actual $selected.Count -Because 'only files that exist locally should be included' + Assert-Equal -Expected 'Claude CLAUDE.md' -Actual $selected[0].Name + Assert-Equal -Expected 'Grok AGENTS.md' -Actual $selected[1].Name + Assert-Equal -Expected 'OpenCode AGENTS.md' -Actual $selected[2].Name + Assert-True -Condition (-not $selected[0].Fallback) -Because 'detected files are not the fallback starter' + + $emptyProfile = Join-Path $temporaryRoot 'init-empty-profile' + New-Item -ItemType Directory -Path $emptyProfile | Out-Null + $fallback = @(& $module { param($root) Select-AgentGuidanceStarterFiles -ProfileRoot $root } $emptyProfile) + Assert-Equal -Expected 2 -Actual $fallback.Count -Because 'the empty-profile starter should stay focused on Codex and Claude' + Assert-Equal -Expected 'Codex AGENTS.md' -Actual $fallback[0].Name + Assert-Equal -Expected 'Claude CLAUDE.md' -Actual $fallback[1].Name + Assert-True -Condition $fallback[0].Fallback -Because 'missing local files should use the documented starter pair' + } + + Test-Case 'Init writes a starter config and refuses to overwrite it' { + $profileRoot = Join-Path $temporaryRoot 'init-write-profile' + Set-TestText -Path (Join-Path $profileRoot '.codex/AGENTS.md') -Content 'codex guidance' + $configPath = Join-Path $temporaryRoot 'init-write/config.json' + $sshConfigPath = Join-Path $temporaryRoot 'init-write/ssh-config' + Set-TestText -Path $sshConfigPath -Content @" +Host lab-pi work-box + User demo +Host *.example.com + User wildcard +# Host commented-out +Host github.com +"@ + + $result = & $module { + param($path, $root, $sshPath) + Initialize-AgentGuidanceConfig -ConfigPath $path -ProfileRoot $root -SshConfigPath $sshPath + } $configPath $profileRoot $sshConfigPath + + Assert-Equal -Expected ([IO.Path]::GetFullPath($configPath)) -Actual $result.ConfigPath + Assert-True -Condition (Test-Path -LiteralPath $configPath -PathType Leaf) -Because 'Init should write the starter file' + Assert-Equal -Expected 1 -Actual $result.Files.Count + Assert-Equal -Expected 'Codex AGENTS.md' -Actual $result.Files[0].Name + Assert-True -Condition (-not $result.UsedFallback) + Assert-Equal -Expected 'lab-pi|work-box|github.com' -Actual ($result.SshAliases -join '|') -Because 'SSH hints should include concrete Host aliases and skip wildcards' + + $imported = & $module { param($path) Import-AgentGuidanceConfig -ConfigPath $path } $configPath + Assert-Equal -Expected 1 -Actual $imported.Files.Count + Assert-Equal -Expected '.codex/AGENTS.md' -Actual $imported.Files[0].RemoteRelativePath + Assert-Equal -Expected 2 -Actual $imported.Targets.Count + Assert-Equal -Expected 'host-one' -Actual $imported.Targets[0] + + Assert-Throws -Pattern 'already exists' -Script { + & $module { + param($path, $root) + Initialize-AgentGuidanceConfig -ConfigPath $path -ProfileRoot $root + } $configPath $profileRoot + } + } + + Test-Case 'missing config points at -Init instead of a nearby example file' { + $missingPath = Join-Path $temporaryRoot 'does-not-exist/config.json' + Assert-Throws -Pattern '(?i)-init' -Script { + & $module { param($path) Import-AgentGuidanceConfig -ConfigPath $path } $missingPath + } + } + + Test-Case 'preview summary counts reachable, skipped, and change classes' { + $summary = & $module { + $inventory = @( + [pscustomobject]@{ + ComputerName = 'online-host' + Availability = 'Reachable' + Files = @( + [pscustomobject]@{ Status = 'Current' } + [pscustomobject]@{ Status = 'Different' } + [pscustomobject]@{ Status = 'Missing' } + ) + } + [pscustomobject]@{ + ComputerName = 'offline-host' + Availability = 'Unavailable' + Files = @() + } + ) + Get-AgentGuidancePreviewSummary -Inventory $inventory + } + + Assert-Equal -Expected 1 -Actual $summary.ReachableCount + Assert-Equal -Expected 1 -Actual $summary.SkippedCount + Assert-Equal -Expected 'offline-host' -Actual $summary.SkippedNames[0] + Assert-Equal -Expected 1 -Actual $summary.CurrentCount + Assert-Equal -Expected 1 -Actual $summary.DifferentCount + Assert-Equal -Expected 1 -Actual $summary.MissingCount + Assert-Equal -Expected 2 -Actual $summary.ChangeCount + } + + Test-Case 'Init cannot be combined with remote-write or target-override switches' { + $errorRecord = $null + try { + Sync-AgentGuidance -Init -Apply -ErrorAction Stop + } + catch { + $errorRecord = $_ + } + Assert-True -Condition ($null -ne $errorRecord) -Because '-Init -Apply should fail before any work starts' + Assert-True -Condition ($errorRecord.Exception.Message -match 'Parameter set|parameter set') -Because 'PowerShell should reject the conflicting parameter set' + + $settingsConflict = $null + try { + Sync-AgentGuidance -Init -Settings -ErrorAction Stop + } + catch { + $settingsConflict = $_ + } + Assert-True -Condition ($null -ne $settingsConflict) -Because '-Init -Settings should fail before any work starts' + Assert-True -Condition ($settingsConflict.Exception.Message -match 'Parameter set|parameter set') -Because 'starter generation must not enter the settings path' + } + + Test-Case 'default runs exclude settings and -settings excludes instruction files' { + $fileOnly = & $module { + Resolve-AgentGuidanceRunScope -Config ([pscustomobject]@{ + ConfigPath = 'C:\temp\config.json' + Files = @([pscustomobject]@{ Name = 'AGENTS.md' }) + CodexConfig = [pscustomobject]@{ Name = 'Codex config.toml settings' } + }) + } + Assert-True -Condition $fileOnly.IncludeFiles -Because 'the default run should copy instruction files' + Assert-True -Condition (-not $fileOnly.IncludeSettings) -Because 'codexConfig must stay inert without -settings' + + $settingsOnly = & $module { + Resolve-AgentGuidanceRunScope -Config ([pscustomobject]@{ + ConfigPath = 'C:\temp\config.json' + Files = @([pscustomobject]@{ Name = 'AGENTS.md' }) + CodexConfig = [pscustomobject]@{ Name = 'Codex config.toml settings' } + }) -Settings + } + Assert-True -Condition (-not $settingsOnly.IncludeFiles) -Because '-settings should not copy instruction files' + Assert-True -Condition $settingsOnly.IncludeSettings -Because '-settings should enable the Codex projection' + + Assert-Throws -Pattern '(?i)-settings' -Script { + & $module { + Resolve-AgentGuidanceRunScope -Config ([pscustomobject]@{ + ConfigPath = 'C:\temp\config.json' + Files = @([pscustomobject]@{ Name = 'AGENTS.md' }) + CodexConfig = $null + }) -Settings + } + } + + Assert-Throws -Pattern '(?i)-settings' -Script { + & $module { + Resolve-AgentGuidanceRunScope -Config ([pscustomobject]@{ + ConfigPath = 'C:\temp\config.json' + Files = @() + CodexConfig = [pscustomobject]@{ Name = 'Codex config.toml settings' } + }) + } + } + } + + Test-Case 'Sync-AgentGuidance -Init writes through the public command' { + $configPath = Join-Path $temporaryRoot 'public-init/config.json' + Sync-AgentGuidance -Init -ConfigPath $configPath + Assert-True -Condition (Test-Path -LiteralPath $configPath -PathType Leaf) -Because 'the public command should write the starter' + $imported = & $module { param($path) Import-AgentGuidanceConfig -ConfigPath $path } $configPath + Assert-True -Condition ($imported.Files.Count -ge 1 -and $imported.Files.Count -le 6) -Because 'Init should write only known instruction mappings' + foreach ($file in $imported.Files) { + Assert-True -Condition ($file.RemoteRelativePath -match 'AGENTS\.md$|CLAUDE\.md$') -Because 'Init must not invent destinations outside the known catalog' + } + Assert-Throws -Pattern 'already exists' -Script { + Sync-AgentGuidance -Init -ConfigPath $configPath + } } Test-Case 'exact-copy mappings cannot bypass sensitive-state boundaries' {