param( [Parameter(Mandatory = $true)] [string]$Source, [string]$Kind, [string]$Name, [switch]$Force ) $ErrorActionPreference = 'Stop' $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $RepoRoot = Split-Path -Parent $ScriptDir $StateDir = if ($env:COPILOT_RESOURCES_STATE_DIR) { $env:COPILOT_RESOURCES_STATE_DIR } else { Join-Path $HOME '.copilot-resources-state' } function Normalize-Stem { param([string]$Value) $Normalized = $Value.ToLowerInvariant() $Normalized = [regex]::Replace($Normalized, '[\s_]+', '-') $Normalized = [regex]::Replace($Normalized, '[^a-z0-9-]+', '-') $Normalized = [regex]::Replace($Normalized, '-+', '-') $Normalized = $Normalized.Trim('-') if ([string]::IsNullOrWhiteSpace($Normalized)) { return 'resource' } return $Normalized } function Strip-KindSuffix { param( [string]$Kind, [string]$Value ) switch ($Kind) { 'prompt' { return ($Value -replace '\.prompt\.md$', '') } 'instruction' { return ($Value -replace '\.instructions\.md$', '') } 'agent' { return ($Value -replace '\.agent\.md$', '') } 'hook' { return ($Value -replace '\.json$', '') } default { return $Value } } } function Get-KindSuffix { param([string]$Kind) switch ($Kind) { 'prompt' { return '.prompt.md' } 'instruction' { return '.instructions.md' } 'agent' { return '.agent.md' } 'hook' { return '.json' } 'skill' { return '' } default { throw "Unsupported kind: $Kind" } } } function Get-KindRoot { param([string]$Kind) switch ($Kind) { 'skill' { return (Join-Path $RepoRoot 'resources\skills') } 'prompt' { return (Join-Path $RepoRoot 'resources\prompts') } 'instruction' { return (Join-Path $RepoRoot 'resources\instructions') } 'agent' { return (Join-Path $RepoRoot 'resources\agents') } 'hook' { return (Join-Path $RepoRoot 'resources\hooks') } default { throw "Unsupported kind: $Kind" } } } function Get-FrontmatterField { param( [string]$Path, [string]$Field ) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } $Lines = Get-Content -LiteralPath $Path if ($Lines.Count -eq 0 -or $Lines[0].Trim() -ne '---') { return $null } $Pattern = '^[\s]*' + [regex]::Escape($Field) + ':[\s]*(.+?)\s*$' for ($Index = 1; $Index -lt $Lines.Count; $Index++) { if ($Lines[$Index].Trim() -eq '---') { break } if ($Lines[$Index] -match $Pattern) { $Value = $Matches[1] -replace '\s+#.*$', '' return $Value.Trim('"', "'") } } return $null } function Set-SkillNameField { param( [string]$Path, [string]$Name ) $Lines = [System.Collections.Generic.List[string]]::new() $Lines.AddRange([string[]](Get-Content -LiteralPath $Path)) if ($Lines.Count -eq 0 -or $Lines[0].Trim() -ne '---') { throw "Skill frontmatter must start with ---: $Path" } $FrontmatterEnd = -1 for ($Index = 1; $Index -lt $Lines.Count; $Index++) { if ($Lines[$Index].Trim() -eq '---') { $FrontmatterEnd = $Index break } } if ($FrontmatterEnd -lt 0) { throw "Skill frontmatter is incomplete: $Path" } $Replaced = $false for ($Index = 1; $Index -lt $FrontmatterEnd; $Index++) { if ($Lines[$Index] -match '^\s*name\s*:') { $Lines[$Index] = "name: $Name" $Replaced = $true break } } if (-not $Replaced) { $Lines.Insert(1, "name: $Name") } Set-Content -LiteralPath $Path -Value $Lines } function Get-StringHash { param([string]$Value) $Bytes = [System.Text.Encoding]::UTF8.GetBytes($Value) $Sha = [System.Security.Cryptography.SHA256]::Create() try { return ([System.Convert]::ToHexString($Sha.ComputeHash($Bytes))).ToLowerInvariant() } finally { $Sha.Dispose() } } function Get-ArtifactFingerprint { param([string]$Path) if (Test-Path -LiteralPath $Path -PathType Container) { $Manifest = foreach ($File in (Get-ChildItem -LiteralPath $Path -Recurse -File | Sort-Object FullName)) { $Relative = [System.IO.Path]::GetRelativePath($Path, $File.FullName).Replace('\', '/') $Hash = (Get-FileHash -LiteralPath $File.FullName -Algorithm SHA256).Hash.ToLowerInvariant() "$Hash $Relative" } return Get-StringHash -Value ($Manifest -join "`n") } return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } function Get-ArtifactDisplayName { param( [string]$Kind, [string]$Path ) switch ($Kind) { 'skill' { $Name = Get-FrontmatterField -Path (Join-Path $Path 'SKILL.md') -Field 'name' if ($Name) { return $Name } return (Split-Path -Leaf $Path) } 'prompt' { $Name = Get-FrontmatterField -Path $Path -Field 'name' if ($Name) { return $Name } return Strip-KindSuffix -Kind $Kind -Value (Split-Path -Leaf $Path) } 'instruction' { $Name = Get-FrontmatterField -Path $Path -Field 'name' if ($Name) { return $Name } return Strip-KindSuffix -Kind $Kind -Value (Split-Path -Leaf $Path) } 'agent' { $Name = Get-FrontmatterField -Path $Path -Field 'name' if ($Name) { return $Name } return Strip-KindSuffix -Kind $Kind -Value (Split-Path -Leaf $Path) } 'hook' { return Strip-KindSuffix -Kind $Kind -Value (Split-Path -Leaf $Path) } default { throw "Unsupported kind: $Kind" } } } function Get-KindArtifacts { param([string]$Kind) $Root = Get-KindRoot -Kind $Kind if (-not (Test-Path -LiteralPath $Root)) { return @() } switch ($Kind) { 'skill' { return (Get-ChildItem -LiteralPath $Root -Directory | Sort-Object FullName | Select-Object -ExpandProperty FullName) } 'prompt' { return (Get-ChildItem -LiteralPath $Root -File -Filter '*.prompt.md' | Sort-Object FullName | Select-Object -ExpandProperty FullName) } 'instruction' { return (Get-ChildItem -LiteralPath $Root -File -Filter '*.instructions.md' | Sort-Object FullName | Select-Object -ExpandProperty FullName) } 'agent' { return (Get-ChildItem -LiteralPath $Root -File -Filter '*.agent.md' | Sort-Object FullName | Select-Object -ExpandProperty FullName) } 'hook' { return (Get-ChildItem -LiteralPath $Root -File -Filter '*.json' | Sort-Object FullName | Select-Object -ExpandProperty FullName) } default { throw "Unsupported kind: $Kind" } } } function Write-PublishLog { param( [string]$Kind, [string]$Source, [string]$Target, [string]$Origin, [string]$Fingerprint, [string]$Outcome ) New-Item -ItemType Directory -Path $StateDir -Force | Out-Null $Timestamp = [DateTime]::UtcNow.ToString('o') "$Timestamp`t$Kind`t$Source`t$Target`t$Origin`t$Fingerprint`t$Outcome" | Add-Content -LiteralPath (Join-Path $StateDir 'publish-log.tsv') } function Detect-Kind { param([string]$Path) if ((Test-Path -LiteralPath $Path -PathType Container) -and (Test-Path -LiteralPath (Join-Path $Path 'SKILL.md'))) { return 'skill' } switch -Regex ((Split-Path -Leaf $Path)) { '\.prompt\.md$' { return 'prompt' } '\.instructions\.md$' { return 'instruction' } '\.agent\.md$' { return 'agent' } '\.json$' { return 'hook' } '^SKILL\.md$' { return 'skill' } default { throw "Could not detect resource kind for: $Path" } } } if (-not (Test-Path -LiteralPath $Source)) { throw "Source does not exist: $Source" } if (-not $Kind) { $Kind = Detect-Kind -Path $Source } $TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $TempDir -Force | Out-Null try { switch ($Kind) { 'skill' { if (Test-Path -LiteralPath $Source -PathType Leaf) { $Source = Split-Path -Parent $Source } if (-not (Test-Path -LiteralPath (Join-Path $Source 'SKILL.md'))) { throw "Skill publish requires a directory containing SKILL.md" } if (-not $Name) { $Name = Get-FrontmatterField -Path (Join-Path $Source 'SKILL.md') -Field 'name' } if (-not $Name) { $Name = Split-Path -Leaf $Source } $NormalizedName = Normalize-Stem -Value $Name $Target = Join-Path $RepoRoot (Join-Path 'resources\skills' $NormalizedName) $Candidate = Join-Path $TempDir $NormalizedName Copy-Item -LiteralPath $Source -Destination $Candidate -Recurse -Force Set-SkillNameField -Path (Join-Path $Candidate 'SKILL.md') -Name $NormalizedName } 'prompt' { if (-not $Name) { $Name = Split-Path -Leaf $Source } $NormalizedName = Normalize-Stem -Value (Strip-KindSuffix -Kind $Kind -Value $Name) $Target = Join-Path $RepoRoot (Join-Path 'resources\prompts' ($NormalizedName + (Get-KindSuffix -Kind $Kind))) $Candidate = Join-Path $TempDir ($NormalizedName + (Get-KindSuffix -Kind $Kind)) Copy-Item -LiteralPath $Source -Destination $Candidate -Force } 'instruction' { if (-not $Name) { $Name = Split-Path -Leaf $Source } $NormalizedName = Normalize-Stem -Value (Strip-KindSuffix -Kind $Kind -Value $Name) $Target = Join-Path $RepoRoot (Join-Path 'resources\instructions' ($NormalizedName + (Get-KindSuffix -Kind $Kind))) $Candidate = Join-Path $TempDir ($NormalizedName + (Get-KindSuffix -Kind $Kind)) Copy-Item -LiteralPath $Source -Destination $Candidate -Force } 'agent' { if (-not $Name) { $Name = Split-Path -Leaf $Source } $NormalizedName = Normalize-Stem -Value (Strip-KindSuffix -Kind $Kind -Value $Name) $Target = Join-Path $RepoRoot (Join-Path 'resources\agents' ($NormalizedName + (Get-KindSuffix -Kind $Kind))) $Candidate = Join-Path $TempDir ($NormalizedName + (Get-KindSuffix -Kind $Kind)) Copy-Item -LiteralPath $Source -Destination $Candidate -Force } 'hook' { if (-not $Name) { $Name = Split-Path -Leaf $Source } $NormalizedName = Normalize-Stem -Value (Strip-KindSuffix -Kind $Kind -Value $Name) $Target = Join-Path $RepoRoot (Join-Path 'resources\hooks' ($NormalizedName + (Get-KindSuffix -Kind $Kind))) $Candidate = Join-Path $TempDir ($NormalizedName + (Get-KindSuffix -Kind $Kind)) Copy-Item -LiteralPath $Source -Destination $Candidate -Force } default { throw "Unsupported kind: $Kind" } } $CandidateFingerprint = Get-ArtifactFingerprint -Path $Candidate $CandidateDisplayName = Get-ArtifactDisplayName -Kind $Kind -Path $Candidate if (Test-Path -LiteralPath $Target) { $ExistingTargetFingerprint = Get-ArtifactFingerprint -Path $Target if ($ExistingTargetFingerprint -eq $CandidateFingerprint) { Write-PublishLog -Kind $Kind -Source $Source -Target $Target -Origin 'local-first' -Fingerprint $CandidateFingerprint -Outcome 'noop' Write-Host "No publish needed. Shared artifact already exists at: $Target" Write-Host "Fingerprint: $CandidateFingerprint" Write-Host "Display name: $CandidateDisplayName" return } if (-not $Force) { throw "Target already exists with different content: $Target" } } foreach ($ExistingPath in (Get-KindArtifacts -Kind $Kind)) { if ($ExistingPath -eq $Target) { continue } $ExistingFingerprint = Get-ArtifactFingerprint -Path $ExistingPath if ($ExistingFingerprint -eq $CandidateFingerprint) { throw "Duplicate $Kind content already exists at: $ExistingPath" } $ExistingDisplayName = Get-ArtifactDisplayName -Kind $Kind -Path $ExistingPath if ($ExistingDisplayName -eq $CandidateDisplayName) { throw "Duplicate $Kind display name '$CandidateDisplayName' already exists at: $ExistingPath" } } New-Item -ItemType Directory -Path (Split-Path -Parent $Target) -Force | Out-Null if (Test-Path -LiteralPath $Target) { Remove-Item -LiteralPath $Target -Recurse -Force } if (Test-Path -LiteralPath $Candidate -PathType Container) { Copy-Item -LiteralPath $Candidate -Destination $Target -Recurse -Force } else { Copy-Item -LiteralPath $Candidate -Destination $Target -Force } Write-PublishLog -Kind $Kind -Source $Source -Target $Target -Origin 'local-first' -Fingerprint $CandidateFingerprint -Outcome 'published' Write-Host "Published $Kind into shared repo: $Target" Write-Host "Fingerprint: $CandidateFingerprint" Write-Host "Display name: $CandidateDisplayName" } finally { if (Test-Path -LiteralPath $TempDir) { Remove-Item -LiteralPath $TempDir -Recurse -Force } }