[CmdletBinding()] param( [string] $SourceRoot = 'C:\Users\dkucinski\.scout\m-skills', [string] $RepositoryRoot = $PSScriptRoot ) $ErrorActionPreference = 'Stop' $repositoryRoot = (Resolve-Path -LiteralPath $RepositoryRoot).Path $sourceRoot = (Resolve-Path -LiteralPath $SourceRoot).Path $logDirectory = Join-Path $repositoryRoot 'logs' $logPath = Join-Path $logDirectory 'sync.log' $allowedExtensions = @('.gitignore', '.js', '.json', '.mjs', '.ps1', '.psm1', '.yaml', '.yml') $excludedDirectories = @('.git', 'cache', 'caches', 'logs', 'node_modules') $excludedFiles = @('.token-cache.json', 'skills-metadata.json') New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null function Write-SyncLog { param( [ValidateSet('INFO', 'WARN', 'ERROR')] [string] $Level, [string] $Message ) $entry = '{0:o} [{1}] {2}' -f (Get-Date), $Level, $Message Add-Content -LiteralPath $logPath -Value $entry Write-Output $entry } function Invoke-Git { param([string[]] $Arguments) & git -C $repositoryRoot @Arguments if ($LASTEXITCODE -ne 0) { throw "Git command failed: git $($Arguments -join ' ')" } } function Test-AllowedSourceFile { param( [System.IO.FileInfo] $File, [string] $SkillRoot ) $relativePath = $File.FullName.Substring($SkillRoot.Length).TrimStart('\') $segments = $relativePath -split '\\' if ($segments | Where-Object { $_ -like '.pw-profile*' -or $excludedDirectories -contains $_ }) { return $false } if ($excludedFiles -contains $File.Name) { return $false } return $File.Name -eq 'SKILL.md' -or $allowedExtensions -contains $File.Extension.ToLowerInvariant() } function Get-SkillDescription { param([string] $SkillFile) $content = Get-Content -LiteralPath $SkillFile $descriptionLine = $content | Select-String -Pattern '^\s*description:\s*["'']?(.*?)["'']?\s*$' | Select-Object -First 1 if ($null -eq $descriptionLine) { return 'No description provided.' } return $descriptionLine.Matches[0].Groups[1].Value.Trim() } $stagingRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("PromptWorks-{0}" -f [guid]::NewGuid()) try { if (-not (Test-Path -LiteralPath (Join-Path $repositoryRoot '.git'))) { throw "'$repositoryRoot' is not a Git repository." } Write-SyncLog -Level INFO -Message "Starting sync from '$sourceRoot'." Invoke-Git -Arguments @('fetch', 'origin', 'main') $remoteAhead = [int](& git -C $repositoryRoot rev-list --count 'main..origin/main') if ($LASTEXITCODE -ne 0) { throw 'Unable to determine whether the remote branch is ahead.' } if ($remoteAhead -gt 0) { throw "Remote branch origin/main is ahead by $remoteAhead commit(s); merge it before syncing." } New-Item -ItemType Directory -Path $stagingRoot | Out-Null $sourceSkills = Get-ChildItem -LiteralPath $sourceRoot -Directory | Sort-Object Name foreach ($skill in $sourceSkills) { $stagingSkillRoot = Join-Path $stagingRoot $skill.Name $copiedFiles = 0 Get-ChildItem -LiteralPath $skill.FullName -Recurse -File | Where-Object { Test-AllowedSourceFile -File $_ -SkillRoot $skill.FullName } | ForEach-Object { $relativePath = $_.FullName.Substring($skill.FullName.Length).TrimStart('\') $destination = Join-Path $stagingSkillRoot $relativePath New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null Copy-Item -LiteralPath $_.FullName -Destination $destination $copiedFiles++ } if (-not (Test-Path -LiteralPath (Join-Path $stagingSkillRoot 'SKILL.md'))) { throw "Skill '$($skill.Name)' has no allowed SKILL.md file." } Write-SyncLog -Level INFO -Message "Staged skill '$($skill.Name)' ($copiedFiles files)." } $existingSkills = Get-ChildItem -LiteralPath $repositoryRoot -Directory | Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'SKILL.md') } foreach ($skill in $existingSkills) { if ($skill.Name -notin $sourceSkills.Name) { Remove-Item -LiteralPath $skill.FullName -Recurse -Force Write-SyncLog -Level INFO -Message "Removed stale skill '$($skill.Name)'." } } foreach ($skill in $sourceSkills) { $destination = Join-Path $repositoryRoot $skill.Name if (Test-Path -LiteralPath $destination) { Remove-Item -LiteralPath $destination -Recurse -Force } Move-Item -LiteralPath (Join-Path $stagingRoot $skill.Name) -Destination $destination } $readmeLines = @( '# PromptWorks', '', 'Private version-controlled mirror of AI skills used in my daily workflow.', '', 'Generated by `sync-skills.ps1`; do not edit manually.', '', '## Skills', '', '| Skill | Description |', '| --- | --- |' ) foreach ($skill in $sourceSkills) { $description = (Get-SkillDescription -SkillFile (Join-Path (Join-Path $repositoryRoot $skill.Name) 'SKILL.md')).Replace('|', '\|') $readmeLines += "| [$($skill.Name)]($($skill.Name)/SKILL.md) | $description |" } Set-Content -LiteralPath (Join-Path $repositoryRoot 'README.md') -Value $readmeLines Invoke-Git -Arguments @('add', '--all') & git -C $repositoryRoot diff --cached --quiet if ($LASTEXITCODE -eq 0) { Write-SyncLog -Level INFO -Message 'Sync completed with no changes.' exit 0 } if ($LASTEXITCODE -ne 1) { throw 'Unable to determine whether staged changes exist.' } $commitMessage = 'chore: sync Scout skills {0:yyyy-MM-dd}' -f (Get-Date) Invoke-Git -Arguments @('commit', '-m', $commitMessage) Invoke-Git -Arguments @('push', '-u', 'origin', 'main') Write-SyncLog -Level INFO -Message "Sync completed and pushed commit '$commitMessage'." } catch { Write-SyncLog -Level ERROR -Message $_.Exception.Message throw } finally { if (Test-Path -LiteralPath $stagingRoot) { Remove-Item -LiteralPath $stagingRoot -Recurse -Force } }