373 lines
13 KiB
PowerShell
373 lines
13 KiB
PowerShell
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
|
|
}
|
|
}
|