Separate Chinese display from plugin selector
Keep the installable plugin selector ASCII while restoring the Chinese user-facing display name for the patent drafting skill.\n\nConstraint: Codex marketplace entry name is the install selector and must remain ASCII kebab-case.\nRejected: Put Chinese in marketplace.json name | Codex list/add skips or fails to resolve that selector.\nConfidence: high\nScope-risk: narrow\nDirective: Use interface.displayName for Chinese aliases; never put localized text in plugin ids, directories, manifest name, or marketplace entry name.\nTested: python scripts/validate_marketplace.py; python /Users/eapil/.codex/skills/.system/plugin-creator/scripts/validate_plugin.py plugins/codex/plugins/patent-application-drafting-skill; temporary CODEX_HOME marketplace list and plugin add smoke test\nNot-tested: Interactive in-chat skill invocation was not rerun for this display-only change
This commit is contained in:
@@ -0,0 +1,816 @@
|
||||
param(
|
||||
[string]$ConfigPath = "config/external-sources.json",
|
||||
[string]$WorkDir = "",
|
||||
[string]$Only = "",
|
||||
[string]$Proxy = "",
|
||||
[switch]$KeepTemp
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if ($Proxy) {
|
||||
$env:HTTP_PROXY = $Proxy
|
||||
$env:HTTPS_PROXY = $Proxy
|
||||
$env:ALL_PROXY = $Proxy
|
||||
}
|
||||
|
||||
function Invoke-CheckedGit {
|
||||
param([string[]]$GitArgs)
|
||||
& git @GitArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git $($GitArgs -join ' ') failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-RepoRoot {
|
||||
$root = git rev-parse --show-toplevel
|
||||
if (-not $root) {
|
||||
throw "Run this script from inside a git repository."
|
||||
}
|
||||
return (Resolve-Path $root.Trim()).Path
|
||||
}
|
||||
|
||||
function Assert-ChildPath {
|
||||
param(
|
||||
[string]$Parent,
|
||||
[string]$Child
|
||||
)
|
||||
$parentPath = (Resolve-Path $Parent).Path
|
||||
$childPath = $Child
|
||||
if (Test-Path -LiteralPath $Child) {
|
||||
$childPath = (Resolve-Path $Child).Path
|
||||
} else {
|
||||
$childPath = [System.IO.Path]::GetFullPath($Child)
|
||||
}
|
||||
if (-not $childPath.StartsWith($parentPath, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Refusing to write outside $parentPath`: $childPath"
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-PathIfExists {
|
||||
param([string]$Path, [string]$Within)
|
||||
if (Test-Path -LiteralPath $Path) {
|
||||
Assert-ChildPath -Parent $Within -Child $Path
|
||||
Remove-Item -LiteralPath $Path -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
function Copy-PathIfExists {
|
||||
param(
|
||||
[string]$Source,
|
||||
[string]$Destination,
|
||||
[string]$Within
|
||||
)
|
||||
if (Test-Path -LiteralPath $Source) {
|
||||
Assert-ChildPath -Parent $Within -Child $Destination
|
||||
Copy-Item -LiteralPath $Source -Destination $Destination -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
function Copy-DirectoryFiltered {
|
||||
param(
|
||||
[string]$Source,
|
||||
[string]$Destination,
|
||||
[string]$Within
|
||||
)
|
||||
$ignoredNames = @(".git", "__pycache__", ".DS_Store")
|
||||
Assert-ChildPath -Parent $Within -Child $Destination
|
||||
New-Item -ItemType Directory -Force -Path $Destination | Out-Null
|
||||
foreach ($item in Get-ChildItem -LiteralPath $Source -Force) {
|
||||
if ($ignoredNames -contains $item.Name) {
|
||||
continue
|
||||
}
|
||||
$target = Join-Path $Destination $item.Name
|
||||
if ($item.PSIsContainer) {
|
||||
Copy-DirectoryFiltered -Source $item.FullName -Destination $target -Within $Within
|
||||
} else {
|
||||
Copy-Item -LiteralPath $item.FullName -Destination $target -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-RelativePathValue {
|
||||
param([string]$Path)
|
||||
$trimmed = $Path.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($Path) -or $trimmed -eq "." -or [System.IO.Path]::IsPathRooted($Path)) {
|
||||
throw "Invalid external source exclude path: $Path"
|
||||
}
|
||||
$parts = $trimmed -split '[\\/]'
|
||||
if ($parts -contains "..") {
|
||||
throw "Invalid external source exclude path: $Path"
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-ConfiguredExcludes {
|
||||
param(
|
||||
[string]$Root,
|
||||
[object]$ExcludePaths,
|
||||
[string]$Within
|
||||
)
|
||||
if ($null -eq $ExcludePaths) {
|
||||
return
|
||||
}
|
||||
foreach ($exclude in @($ExcludePaths)) {
|
||||
$relative = [string]$exclude
|
||||
Assert-RelativePathValue -Path $relative
|
||||
Remove-PathIfExists -Path (Join-Path $Root $relative) -Within $Within
|
||||
}
|
||||
}
|
||||
|
||||
function Get-MarkdownImageTarget {
|
||||
param([string]$Line)
|
||||
$match = [regex]::Match($Line, '^\s*!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)\s*$')
|
||||
if (-not $match.Success) {
|
||||
return $null
|
||||
}
|
||||
return $match.Groups[1].Value.Trim().Trim("<", ">").Replace("\", "/").TrimStart([char[]]@(".", "/"))
|
||||
}
|
||||
|
||||
function Remove-ReadmeImageReferences {
|
||||
param(
|
||||
[string]$Root,
|
||||
[object]$ImagePathPrefixes
|
||||
)
|
||||
if ($null -eq $ImagePathPrefixes) {
|
||||
return
|
||||
}
|
||||
$prefixes = @(
|
||||
foreach ($prefix in @($ImagePathPrefixes)) {
|
||||
$normalized = ([string]$prefix).Trim().Replace("\", "/").Trim("/")
|
||||
if ($normalized) {
|
||||
$normalized
|
||||
}
|
||||
}
|
||||
)
|
||||
if ($prefixes.Count -eq 0) {
|
||||
return
|
||||
}
|
||||
foreach ($readme in Get-ChildItem -LiteralPath $Root -Recurse -Filter "README*.md" | Where-Object { -not $_.PSIsContainer }) {
|
||||
$lines = @([System.IO.File]::ReadAllLines($readme.FullName, [System.Text.Encoding]::UTF8))
|
||||
$updated = New-Object System.Collections.Generic.List[string]
|
||||
$changed = $false
|
||||
foreach ($line in $lines) {
|
||||
$target = Get-MarkdownImageTarget -Line $line
|
||||
$strip = $false
|
||||
if ($target) {
|
||||
foreach ($prefix in $prefixes) {
|
||||
if ($target -eq $prefix -or $target.StartsWith("$prefix/")) {
|
||||
$strip = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($strip) {
|
||||
$changed = $true
|
||||
} else {
|
||||
$updated.Add($line)
|
||||
}
|
||||
}
|
||||
if ($changed) {
|
||||
Write-Utf8NoBomText -Path $readme.FullName -Text (($updated -join "`n").TrimEnd() + "`n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-ReadmeLines {
|
||||
param(
|
||||
[string]$Root,
|
||||
[object]$LineExcludes
|
||||
)
|
||||
if ($null -eq $LineExcludes) {
|
||||
return
|
||||
}
|
||||
$needles = @(
|
||||
foreach ($lineExclude in @($LineExcludes)) {
|
||||
$needle = [string]$lineExclude
|
||||
if ($needle) {
|
||||
$needle
|
||||
}
|
||||
}
|
||||
)
|
||||
if ($needles.Count -eq 0) {
|
||||
return
|
||||
}
|
||||
foreach ($readme in Get-ChildItem -LiteralPath $Root -Recurse -Filter "README*.md" | Where-Object { -not $_.PSIsContainer }) {
|
||||
$lines = @([System.IO.File]::ReadAllLines($readme.FullName, [System.Text.Encoding]::UTF8))
|
||||
$updated = New-Object System.Collections.Generic.List[string]
|
||||
$changed = $false
|
||||
foreach ($line in $lines) {
|
||||
$strip = $false
|
||||
foreach ($needle in $needles) {
|
||||
if ($line.Contains($needle)) {
|
||||
$strip = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($strip) {
|
||||
$changed = $true
|
||||
} else {
|
||||
$updated.Add($line)
|
||||
}
|
||||
}
|
||||
if ($changed) {
|
||||
Write-Utf8NoBomText -Path $readme.FullName -Text (($updated -join "`n").TrimEnd() + "`n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Test-TextReferenceFile {
|
||||
param([string]$Path)
|
||||
$extension = [System.IO.Path]::GetExtension($Path).ToLowerInvariant()
|
||||
return @(".md", ".json", ".yaml", ".yml", ".txt", ".html", ".css", ".js", ".ts", ".py") -contains $extension
|
||||
}
|
||||
|
||||
function Update-ConvertedImageReferences {
|
||||
param(
|
||||
[string]$Root,
|
||||
[hashtable]$Converted
|
||||
)
|
||||
if ($Converted.Count -eq 0) {
|
||||
return
|
||||
}
|
||||
$replacements = [ordered]@{}
|
||||
foreach ($key in $Converted.Keys) {
|
||||
$oldRel = $key.Replace("\", "/")
|
||||
$newRel = $Converted[$key].Replace("\", "/")
|
||||
$replacements[$oldRel] = $newRel
|
||||
$replacements[[System.IO.Path]::GetFileName($oldRel)] = [System.IO.Path]::GetFileName($newRel)
|
||||
}
|
||||
$replacements["PNG files"] = "WebP files"
|
||||
$replacements["PNGs"] = "WebP files"
|
||||
$replacements[".png"] = ".webp"
|
||||
|
||||
foreach ($file in Get-ChildItem -LiteralPath $Root -Recurse -File) {
|
||||
if (-not (Test-TextReferenceFile -Path $file.FullName)) {
|
||||
continue
|
||||
}
|
||||
$text = [System.IO.File]::ReadAllText($file.FullName, [System.Text.Encoding]::UTF8)
|
||||
$updated = $text
|
||||
foreach ($key in ($replacements.Keys | Sort-Object Length -Descending)) {
|
||||
$updated = $updated.Replace($key, $replacements[$key])
|
||||
}
|
||||
if ($updated -ne $text) {
|
||||
Write-Utf8NoBomText -Path $file.FullName -Text $updated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Compress-ImagesToWebp {
|
||||
param(
|
||||
[string]$Root,
|
||||
[object]$CompressPaths
|
||||
)
|
||||
if ($null -eq $CompressPaths) {
|
||||
return
|
||||
}
|
||||
$cwebp = Get-Command cwebp -ErrorAction SilentlyContinue
|
||||
if (-not $cwebp) {
|
||||
throw "webpCompressPaths requires cwebp to be installed."
|
||||
}
|
||||
foreach ($entry in @($CompressPaths)) {
|
||||
if ($entry -is [string]) {
|
||||
$relative = $entry
|
||||
$quality = 90
|
||||
} else {
|
||||
$relative = [string]$entry.path
|
||||
if ($entry.PSObject.Properties.Name -contains "quality" -and $entry.quality) {
|
||||
$quality = [int]$entry.quality
|
||||
} else {
|
||||
$quality = 90
|
||||
}
|
||||
}
|
||||
Assert-RelativePathValue -Path $relative
|
||||
if ($quality -lt 1 -or $quality -gt 100) {
|
||||
throw "Invalid webpCompressPaths quality: $quality"
|
||||
}
|
||||
$target = Join-Path $Root $relative
|
||||
if (-not (Test-Path -LiteralPath $target)) {
|
||||
continue
|
||||
}
|
||||
$candidates = @()
|
||||
$referenceRoot = $target
|
||||
$targetItem = Get-Item -LiteralPath $target
|
||||
if ($targetItem.PSIsContainer) {
|
||||
$candidates = @(Get-ChildItem -LiteralPath $target -Recurse -File | Where-Object { @(".png", ".jpg", ".jpeg") -contains $_.Extension.ToLowerInvariant() })
|
||||
} elseif (@(".png", ".jpg", ".jpeg") -contains $targetItem.Extension.ToLowerInvariant()) {
|
||||
$candidates = @($targetItem)
|
||||
$referenceRoot = $targetItem.DirectoryName
|
||||
}
|
||||
$targetConverted = @{}
|
||||
foreach ($image in $candidates) {
|
||||
$output = [System.IO.Path]::ChangeExtension($image.FullName, ".webp")
|
||||
& $cwebp.Source -quiet -q $quality $image.FullName -o $output
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "WebP compression failed: $($image.FullName)"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $output)) {
|
||||
throw "WebP compression did not create: $output"
|
||||
}
|
||||
$oldRel = [System.IO.Path]::GetRelativePath($Root, $image.FullName).Replace("\", "/")
|
||||
$newRel = [System.IO.Path]::GetRelativePath($Root, $output).Replace("\", "/")
|
||||
Remove-Item -LiteralPath $image.FullName -Force
|
||||
$targetConverted[$oldRel] = $newRel
|
||||
}
|
||||
if ($targetConverted.Count -gt 0) {
|
||||
Update-ConvertedImageReferences -Root $referenceRoot -Converted $targetConverted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Read-JsonFile {
|
||||
param([string]$Path)
|
||||
$resolvedPath = (Resolve-Path -LiteralPath $Path).Path
|
||||
return [System.IO.File]::ReadAllText($resolvedPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Write-JsonFile {
|
||||
param([string]$Path, [object]$Value)
|
||||
$json = $Value | ConvertTo-Json -Depth 20
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($Path, "$json`n", $utf8NoBom)
|
||||
}
|
||||
|
||||
function Write-Utf8NoBomText {
|
||||
param([string]$Path, [string]$Text)
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($Path, $Text, $utf8NoBom)
|
||||
}
|
||||
|
||||
function ConvertTo-Hashtable {
|
||||
param([object]$InputObject)
|
||||
if ($null -eq $InputObject) {
|
||||
return $null
|
||||
}
|
||||
if ($InputObject -is [System.Collections.IDictionary]) {
|
||||
$hash = [ordered]@{}
|
||||
foreach ($key in $InputObject.Keys) {
|
||||
$hash[$key] = ConvertTo-Hashtable $InputObject[$key]
|
||||
}
|
||||
return $hash
|
||||
}
|
||||
if ($InputObject -is [System.Collections.IEnumerable] -and -not ($InputObject -is [string]) -and -not ($InputObject -is [System.Management.Automation.PSCustomObject])) {
|
||||
$items = @()
|
||||
foreach ($item in $InputObject) {
|
||||
$items += ConvertTo-Hashtable $item
|
||||
}
|
||||
return ,$items
|
||||
}
|
||||
if ($InputObject -is [System.Management.Automation.PSCustomObject]) {
|
||||
$hash = [ordered]@{}
|
||||
foreach ($property in $InputObject.PSObject.Properties) {
|
||||
$hash[$property.Name] = ConvertTo-Hashtable $property.Value
|
||||
}
|
||||
return $hash
|
||||
}
|
||||
return $InputObject
|
||||
}
|
||||
|
||||
function Get-SkillFrontmatter {
|
||||
param([string]$SkillFile)
|
||||
$lines = [System.IO.File]::ReadAllLines((Resolve-Path -LiteralPath $SkillFile).Path, [System.Text.Encoding]::UTF8)
|
||||
if ($lines.Count -lt 3 -or $lines[0] -ne "---") {
|
||||
return @{}
|
||||
}
|
||||
$frontmatter = @{}
|
||||
for ($i = 1; $i -lt $lines.Count; $i++) {
|
||||
if ($lines[$i] -eq "---") {
|
||||
break
|
||||
}
|
||||
if ($lines[$i] -match "^\s*([^:]+):\s*(.*)\s*$") {
|
||||
$key = $Matches[1].Trim()
|
||||
$value = $Matches[2].Trim().Trim('"')
|
||||
$frontmatter[$key] = $value
|
||||
}
|
||||
}
|
||||
return $frontmatter
|
||||
}
|
||||
|
||||
function Merge-Hashtable {
|
||||
param(
|
||||
[object]$Base,
|
||||
[object]$Override
|
||||
)
|
||||
$baseHash = ConvertTo-Hashtable $Base
|
||||
$overrideHash = ConvertTo-Hashtable $Override
|
||||
foreach ($key in $overrideHash.Keys) {
|
||||
if ($baseHash.Contains($key) -and $baseHash[$key] -is [System.Collections.IDictionary] -and $overrideHash[$key] -is [System.Collections.IDictionary]) {
|
||||
$baseHash[$key] = Merge-Hashtable -Base $baseHash[$key] -Override $overrideHash[$key]
|
||||
} else {
|
||||
$baseHash[$key] = $overrideHash[$key]
|
||||
}
|
||||
}
|
||||
return $baseHash
|
||||
}
|
||||
|
||||
function Get-SourceCategory {
|
||||
param([object]$Source)
|
||||
if ($Source.PSObject.Properties.Name -contains "marketplace" -and $Source.marketplace -and $Source.marketplace.category) {
|
||||
return $Source.marketplace.category
|
||||
}
|
||||
return $Source.category
|
||||
}
|
||||
|
||||
function ConvertTo-YamlDoubleQuoted {
|
||||
param([string]$Value)
|
||||
$escaped = $Value.Replace("\", "\\").Replace('"', '\"')
|
||||
return "`"$escaped`""
|
||||
}
|
||||
|
||||
function Set-SkillFrontmatterValue {
|
||||
param(
|
||||
[string]$SkillFile,
|
||||
[string]$Key,
|
||||
[string]$Value
|
||||
)
|
||||
$lines = @([System.IO.File]::ReadAllLines((Resolve-Path -LiteralPath $SkillFile).Path, [System.Text.Encoding]::UTF8))
|
||||
if ($lines.Count -lt 3 -or $lines[0] -ne "---") {
|
||||
throw "Cannot update frontmatter in $SkillFile"
|
||||
}
|
||||
|
||||
$endIndex = -1
|
||||
for ($i = 1; $i -lt $lines.Count; $i++) {
|
||||
if ($lines[$i] -eq "---") {
|
||||
$endIndex = $i
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($endIndex -lt 0) {
|
||||
throw "Cannot find frontmatter end in $SkillFile"
|
||||
}
|
||||
|
||||
$replacement = "$Key`: $(ConvertTo-YamlDoubleQuoted $Value)"
|
||||
$updated = $false
|
||||
for ($i = 1; $i -lt $endIndex; $i++) {
|
||||
if ($lines[$i] -match "^\s*$([regex]::Escape($Key))\s*:") {
|
||||
$lines[$i] = $replacement
|
||||
$updated = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $updated) {
|
||||
$before = @()
|
||||
if ($endIndex -gt 1) {
|
||||
$before = $lines[0..($endIndex - 1)]
|
||||
} else {
|
||||
$before = @($lines[0])
|
||||
}
|
||||
$after = $lines[$endIndex..($lines.Count - 1)]
|
||||
$lines = @($before + $replacement + $after)
|
||||
}
|
||||
|
||||
Write-Utf8NoBomText -Path $SkillFile -Text (($lines -join "`n") + "`n")
|
||||
}
|
||||
|
||||
function Apply-LocalOverrides {
|
||||
param(
|
||||
[string]$PluginDir,
|
||||
[object]$Source
|
||||
)
|
||||
|
||||
if ($Source.PSObject.Properties.Name -contains "manifestOverrides" -and $Source.manifestOverrides) {
|
||||
$manifestPath = Join-Path $PluginDir ".codex-plugin\plugin.json"
|
||||
$manifest = ConvertTo-Hashtable (Read-JsonFile $manifestPath)
|
||||
$manifest = Merge-Hashtable -Base $manifest -Override $Source.manifestOverrides
|
||||
Write-JsonFile -Path $manifestPath -Value $manifest
|
||||
}
|
||||
|
||||
if ($Source.PSObject.Properties.Name -contains "skillDescriptions" -and $Source.skillDescriptions) {
|
||||
$skillsRoot = Join-Path $PluginDir "skills"
|
||||
foreach ($property in $Source.skillDescriptions.PSObject.Properties) {
|
||||
$skillFile = Join-Path (Join-Path $skillsRoot $property.Name) "SKILL.md"
|
||||
if (-not (Test-Path -LiteralPath $skillFile)) {
|
||||
throw "skillDescriptions references missing skill: $skillFile"
|
||||
}
|
||||
Set-SkillFrontmatterValue -SkillFile $skillFile -Key "description" -Value $property.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Clone {
|
||||
param(
|
||||
[object]$Source,
|
||||
[string]$CloneRoot
|
||||
)
|
||||
$clonePath = Join-Path $CloneRoot $Source.id
|
||||
Remove-PathIfExists -Path $clonePath -Within $CloneRoot
|
||||
$cloneArgs = @("clone", "--depth", "1")
|
||||
$sparseCheckout = @()
|
||||
if ($Source.PSObject.Properties.Name -contains "sparseCheckout" -and $Source.sparseCheckout) {
|
||||
$cloneArgs += @("--filter=blob:none", "--sparse")
|
||||
$sparseCheckout = @($Source.sparseCheckout)
|
||||
}
|
||||
$cloneArgs += @($Source.repo, $clonePath)
|
||||
Invoke-CheckedGit $cloneArgs
|
||||
if ($Source.ref) {
|
||||
Invoke-CheckedGit @("-C", $clonePath, "fetch", "--depth", "1", "origin", $Source.ref)
|
||||
Invoke-CheckedGit @("-C", $clonePath, "checkout", "FETCH_HEAD")
|
||||
}
|
||||
if ($sparseCheckout.Count -gt 0) {
|
||||
$sparseArgs = @("-C", $clonePath, "sparse-checkout", "set", "--no-cone") + $sparseCheckout
|
||||
Invoke-CheckedGit $sparseArgs
|
||||
}
|
||||
$commit = (git -C $clonePath rev-parse HEAD).Trim()
|
||||
return [ordered]@{
|
||||
path = $clonePath
|
||||
commit = $commit
|
||||
}
|
||||
}
|
||||
|
||||
function New-AuthorObject {
|
||||
param([object]$Author)
|
||||
if ($null -eq $Author) {
|
||||
return [ordered]@{ name = "Unknown" }
|
||||
}
|
||||
if ($Author -is [string]) {
|
||||
return [ordered]@{ name = $Author }
|
||||
}
|
||||
$authorHash = ConvertTo-Hashtable $Author
|
||||
if (-not $authorHash.Contains("name")) {
|
||||
$authorHash["name"] = "Unknown"
|
||||
}
|
||||
return $authorHash
|
||||
}
|
||||
|
||||
function Write-Provenance {
|
||||
param(
|
||||
[string]$PluginDir,
|
||||
[object]$Source,
|
||||
[string]$Commit,
|
||||
[string]$SyncedAt,
|
||||
[string]$SourcePath
|
||||
)
|
||||
$provenance = [ordered]@{
|
||||
sourceId = $Source.id
|
||||
repo = $Source.repo
|
||||
ref = $Source.ref
|
||||
commit = $Commit
|
||||
adapter = $Source.adapter
|
||||
sourcePath = $SourcePath
|
||||
syncedAt = $SyncedAt
|
||||
}
|
||||
Write-JsonFile -Path (Join-Path $PluginDir "THIRD_PARTY_SOURCE.json") -Value $provenance
|
||||
}
|
||||
|
||||
function Sync-CodexPlugin {
|
||||
param(
|
||||
[object]$Source,
|
||||
[string]$ClonePath,
|
||||
[string]$Commit,
|
||||
[string]$PluginsRoot,
|
||||
[string]$RepoRoot,
|
||||
[string]$SyncedAt
|
||||
)
|
||||
$relativeSourcePath = "."
|
||||
if ($Source.PSObject.Properties.Name -contains "sourcePath" -and $Source.sourcePath) {
|
||||
$relativeSourcePath = $Source.sourcePath
|
||||
}
|
||||
$sourcePath = Join-Path $ClonePath $relativeSourcePath
|
||||
$manifestPath = Join-Path $sourcePath ".codex-plugin\plugin.json"
|
||||
if (-not (Test-Path -LiteralPath $manifestPath)) {
|
||||
throw "codex-plugin adapter requires .codex-plugin/plugin.json: $manifestPath"
|
||||
}
|
||||
$pluginDir = Join-Path $PluginsRoot $Source.pluginName
|
||||
Remove-PathIfExists -Path $pluginDir -Within $RepoRoot
|
||||
New-Item -ItemType Directory -Force -Path $pluginDir | Out-Null
|
||||
foreach ($include in $Source.include) {
|
||||
$src = Join-Path $sourcePath $include
|
||||
$dst = Join-Path $pluginDir $include
|
||||
Copy-PathIfExists -Source $src -Destination $dst -Within $RepoRoot
|
||||
}
|
||||
Remove-ConfiguredExcludes -Root $pluginDir -ExcludePaths $Source.excludePaths -Within $RepoRoot
|
||||
Remove-ReadmeImageReferences -Root $pluginDir -ImagePathPrefixes $Source.readmeImageExcludes
|
||||
Remove-ReadmeLines -Root $pluginDir -LineExcludes $Source.readmeLineExcludes
|
||||
Compress-ImagesToWebp -Root $pluginDir -CompressPaths $Source.webpCompressPaths
|
||||
Apply-LocalOverrides -PluginDir $pluginDir -Source $Source
|
||||
Write-Provenance -PluginDir $pluginDir -Source $Source -Commit $Commit -SyncedAt $SyncedAt -SourcePath $relativeSourcePath
|
||||
}
|
||||
|
||||
function Sync-ClaudeSkill {
|
||||
param(
|
||||
[object]$Source,
|
||||
[string]$ClonePath,
|
||||
[string]$Commit,
|
||||
[string]$PluginsRoot,
|
||||
[string]$RepoRoot,
|
||||
[string]$SyncedAt
|
||||
)
|
||||
$skillSource = Join-Path $ClonePath $Source.skillPath
|
||||
$skillFile = Join-Path $skillSource "SKILL.md"
|
||||
if (-not (Test-Path -LiteralPath $skillFile)) {
|
||||
throw "claude-skill adapter requires SKILL.md: $skillFile"
|
||||
}
|
||||
|
||||
$metadata = @{}
|
||||
if ($Source.metadataPath) {
|
||||
$metadataPath = Join-Path $ClonePath $Source.metadataPath
|
||||
if (Test-Path -LiteralPath $metadataPath) {
|
||||
$metadata = ConvertTo-Hashtable (Read-JsonFile $metadataPath)
|
||||
}
|
||||
}
|
||||
$frontmatter = Get-SkillFrontmatter -SkillFile $skillFile
|
||||
|
||||
$pluginName = $Source.pluginName
|
||||
$pluginDir = Join-Path $PluginsRoot $pluginName
|
||||
Remove-PathIfExists -Path $pluginDir -Within $RepoRoot
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $pluginDir ".codex-plugin") | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $pluginDir "skills") | Out-Null
|
||||
$skillDest = Join-Path (Join-Path $pluginDir "skills") (Split-Path -Leaf $skillSource)
|
||||
Copy-DirectoryFiltered -Source $skillSource -Destination $skillDest -Within $RepoRoot
|
||||
|
||||
$materializePaths = @()
|
||||
if ($Source.PSObject.Properties.Name -contains "materializePaths" -and $Source.materializePaths) {
|
||||
$materializePaths = @($Source.materializePaths)
|
||||
}
|
||||
foreach ($materialize in $materializePaths) {
|
||||
$materializeSource = Join-Path $ClonePath $materialize.source
|
||||
$materializeDestination = Join-Path $skillDest $materialize.destination
|
||||
if (-not (Test-Path -LiteralPath $materializeSource)) {
|
||||
throw "materializePaths source does not exist: $materializeSource"
|
||||
}
|
||||
Remove-PathIfExists -Path $materializeDestination -Within $RepoRoot
|
||||
Copy-Item -LiteralPath $materializeSource -Destination $materializeDestination -Recurse -Force
|
||||
}
|
||||
|
||||
$includeRootFiles = @()
|
||||
if ($Source.PSObject.Properties.Name -contains "includeRootFiles" -and $Source.includeRootFiles) {
|
||||
$includeRootFiles = @($Source.includeRootFiles)
|
||||
}
|
||||
foreach ($rootFile in $includeRootFiles) {
|
||||
Copy-PathIfExists -Source (Join-Path $ClonePath $rootFile) -Destination (Join-Path $pluginDir $rootFile) -Within $RepoRoot
|
||||
}
|
||||
Remove-ConfiguredExcludes -Root $skillDest -ExcludePaths $Source.excludePaths -Within $RepoRoot
|
||||
Remove-ReadmeImageReferences -Root $pluginDir -ImagePathPrefixes $Source.readmeImageExcludes
|
||||
Remove-ReadmeLines -Root $pluginDir -LineExcludes $Source.readmeLineExcludes
|
||||
Compress-ImagesToWebp -Root $skillDest -CompressPaths $Source.webpCompressPaths
|
||||
|
||||
$description = $metadata.description
|
||||
if (-not $description) {
|
||||
$description = $frontmatter["description"]
|
||||
}
|
||||
if (-not $description) {
|
||||
$description = "$pluginName skill imported from $($Source.repo)."
|
||||
}
|
||||
$displayName = $metadata.displayName
|
||||
if (-not $displayName) {
|
||||
$displayName = $pluginName
|
||||
}
|
||||
$version = $metadata.version
|
||||
if (-not $version) {
|
||||
$version = "0.1.0"
|
||||
}
|
||||
$homepage = $metadata.homepage
|
||||
if (-not $homepage) {
|
||||
$homepage = $Source.repo -replace "\.git$", ""
|
||||
}
|
||||
$repository = $metadata.repository
|
||||
if (-not $repository) {
|
||||
$repository = $Source.repo -replace "\.git$", ""
|
||||
}
|
||||
$license = $metadata.license
|
||||
if (-not $license) {
|
||||
$license = "UNKNOWN"
|
||||
}
|
||||
|
||||
$manifest = [ordered]@{
|
||||
name = $pluginName
|
||||
version = $version
|
||||
description = $description
|
||||
author = New-AuthorObject $metadata.author
|
||||
homepage = $homepage
|
||||
repository = $repository
|
||||
license = $license
|
||||
keywords = @($metadata.keywords)
|
||||
skills = "./skills/"
|
||||
interface = [ordered]@{
|
||||
displayName = $displayName
|
||||
shortDescription = $description
|
||||
longDescription = $description
|
||||
developerName = (New-AuthorObject $metadata.author).name
|
||||
category = (Get-SourceCategory $Source)
|
||||
capabilities = @("Read", "Write")
|
||||
defaultPrompt = @("Use $displayName for this task.")
|
||||
websiteURL = $homepage
|
||||
privacyPolicyURL = $repository
|
||||
termsOfServiceURL = $repository
|
||||
brandColor = $(if ($Source.PSObject.Properties.Name -contains "brandColor" -and $Source.brandColor) { $Source.brandColor } else { "#2563EB" })
|
||||
screenshots = @()
|
||||
}
|
||||
}
|
||||
Write-JsonFile -Path (Join-Path $pluginDir ".codex-plugin\plugin.json") -Value $manifest
|
||||
Apply-LocalOverrides -PluginDir $pluginDir -Source $Source
|
||||
Write-Provenance -PluginDir $pluginDir -Source $Source -Commit $Commit -SyncedAt $SyncedAt -SourcePath $Source.skillPath
|
||||
}
|
||||
|
||||
function Update-Marketplace {
|
||||
param(
|
||||
[string]$MarketplacePath,
|
||||
[object[]]$Sources,
|
||||
[string]$PathPrefix
|
||||
)
|
||||
$marketplace = ConvertTo-Hashtable (Read-JsonFile $MarketplacePath)
|
||||
$sourceNames = @($Sources | ForEach-Object { $_.pluginName })
|
||||
$plugins = @()
|
||||
foreach ($plugin in $marketplace.plugins) {
|
||||
if ($sourceNames -notcontains $plugin.name) {
|
||||
$plugins += $plugin
|
||||
}
|
||||
}
|
||||
foreach ($source in $Sources) {
|
||||
$plugins += [ordered]@{
|
||||
name = $source.pluginName
|
||||
source = [ordered]@{
|
||||
source = "local"
|
||||
path = "$PathPrefix/$($source.pluginName)"
|
||||
}
|
||||
policy = [ordered]@{
|
||||
installation = "AVAILABLE"
|
||||
authentication = "ON_INSTALL"
|
||||
}
|
||||
category = (Get-SourceCategory $source)
|
||||
}
|
||||
}
|
||||
$marketplace.plugins = $plugins
|
||||
Write-JsonFile -Path $MarketplacePath -Value $marketplace
|
||||
}
|
||||
|
||||
$repoRoot = Resolve-RepoRoot
|
||||
$configFile = Join-Path $repoRoot $ConfigPath
|
||||
$config = Read-JsonFile $configFile
|
||||
$pluginsRoot = Join-Path $repoRoot "plugins\codex\plugins"
|
||||
$syncedAt = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
$lockPath = Join-Path $repoRoot "config\external-sources.lock.json"
|
||||
$existingLock = [ordered]@{ sources = @() }
|
||||
if (Test-Path -LiteralPath $lockPath) {
|
||||
$existingLock = ConvertTo-Hashtable (Read-JsonFile $lockPath)
|
||||
}
|
||||
$existingLockById = @{}
|
||||
foreach ($entry in @($existingLock.sources)) {
|
||||
$existingLockById[$entry.id] = $entry
|
||||
}
|
||||
|
||||
if (-not $WorkDir) {
|
||||
$WorkDir = Join-Path ([System.IO.Path]::GetTempPath()) "codexmarket-external-sync"
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
|
||||
$workRoot = (Resolve-Path $WorkDir).Path
|
||||
|
||||
$sources = @($config.sources)
|
||||
if ($Only) {
|
||||
$ids = $Only.Split(",") | ForEach-Object { $_.Trim() } | Where-Object { $_ }
|
||||
$sources = @($sources | Where-Object { $ids -contains $_.id })
|
||||
if ($sources.Count -eq 0) {
|
||||
throw "No external sources matched -Only '$Only'."
|
||||
}
|
||||
}
|
||||
|
||||
$lockSources = @()
|
||||
foreach ($source in $sources) {
|
||||
Write-Host "Syncing $($source.id) from $($source.repo)@$($source.ref)"
|
||||
$clone = Get-Clone -Source $source -CloneRoot $workRoot
|
||||
$sourceSyncedAt = $syncedAt
|
||||
if ($existingLockById.ContainsKey($source.id) -and $existingLockById[$source.id].commit -eq $clone.commit -and $existingLockById[$source.id].syncedAt) {
|
||||
$sourceSyncedAt = $existingLockById[$source.id].syncedAt
|
||||
}
|
||||
switch ($source.adapter) {
|
||||
"codex-plugin" {
|
||||
Sync-CodexPlugin -Source $source -ClonePath $clone.path -Commit $clone.commit -PluginsRoot $pluginsRoot -RepoRoot $repoRoot -SyncedAt $sourceSyncedAt
|
||||
}
|
||||
"claude-skill" {
|
||||
Sync-ClaudeSkill -Source $source -ClonePath $clone.path -Commit $clone.commit -PluginsRoot $pluginsRoot -RepoRoot $repoRoot -SyncedAt $sourceSyncedAt
|
||||
}
|
||||
default {
|
||||
throw "Unknown adapter '$($source.adapter)' for $($source.id)."
|
||||
}
|
||||
}
|
||||
$lockSources += [ordered]@{
|
||||
id = $source.id
|
||||
pluginName = $source.pluginName
|
||||
repo = $source.repo
|
||||
ref = $source.ref
|
||||
adapter = $source.adapter
|
||||
commit = $clone.commit
|
||||
syncedAt = $sourceSyncedAt
|
||||
}
|
||||
}
|
||||
|
||||
Update-Marketplace -MarketplacePath (Join-Path $repoRoot ".agents\plugins\marketplace.json") -Sources $sources -PathPrefix "./plugins/codex/plugins"
|
||||
Update-Marketplace -MarketplacePath (Join-Path $repoRoot "plugins\codex\.agents\plugins\marketplace.json") -Sources $sources -PathPrefix "./plugins"
|
||||
|
||||
$updatedIds = @($lockSources | ForEach-Object { $_.id })
|
||||
$mergedLockSources = @()
|
||||
foreach ($entry in @($existingLock.sources)) {
|
||||
if ($updatedIds -notcontains $entry.id) {
|
||||
$mergedLockSources += $entry
|
||||
}
|
||||
}
|
||||
$mergedLockSources += $lockSources
|
||||
Write-JsonFile -Path $lockPath -Value ([ordered]@{ sources = $mergedLockSources })
|
||||
|
||||
if (-not $KeepTemp) {
|
||||
foreach ($source in $sources) {
|
||||
Remove-PathIfExists -Path (Join-Path $workRoot $source.id) -Within $workRoot
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Synced $($sources.Count) external source(s)."
|
||||
Reference in New Issue
Block a user