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:
KeyInfo Bot
2026-06-12 14:54:38 +08:00
commit 9d6c8cded3
12817 changed files with 364951 additions and 0 deletions
+816
View File
@@ -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)."
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Validate this repository's Codex marketplace layout.
This is intentionally narrower than Codex's internal plugin validator. It checks
the structure this repo generates so sync automation can fail before
committing malformed marketplace data.
"""
from __future__ import annotations
import json
import os
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
MARKETPLACES = [
REPO_ROOT / ".agents" / "plugins" / "marketplace.json",
REPO_ROOT / "plugins" / "codex" / ".agents" / "plugins" / "marketplace.json",
]
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+([-.+][0-9A-Za-z.-]+)?$")
PLUGIN_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
def load_json(path: Path) -> object:
try:
return json.loads(path.read_text(encoding="utf-8"))
except UnicodeDecodeError as exc:
raise AssertionError(f"{path} is not valid UTF-8: {exc}") from exc
except json.JSONDecodeError as exc:
raise AssertionError(f"{path} is not valid JSON: {exc}") from exc
def require(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def resolve_manifest_path(plugin_dir: Path, manifest_path: Path, value: str, field: str) -> Path:
require(value.startswith("./"), f"{manifest_path} {field} path must start with ./")
target = (plugin_dir / value[2:]).resolve()
plugin_root = plugin_dir.resolve()
require(
os.path.commonpath([str(plugin_root), str(target)]) == str(plugin_root),
f"{manifest_path} {field} path must stay inside plugin root: {value}",
)
return target
def validate_manifest_path(plugin_dir: Path, manifest_path: Path, value: str, field: str) -> Path:
target = resolve_manifest_path(plugin_dir, manifest_path, value, field)
require(target.exists(), f"{manifest_path} {field} target does not exist: {target}")
return target
def validate_hooks_field(plugin_dir: Path, manifest_path: Path, hooks: object) -> None:
"""Validate Codex-supported plugin hook declarations.
Codex supports a hook path string, a list of path strings, an inline hooks
object, or a list containing inline hooks objects. The marketplace only
verifies path safety and basic type shape; Codex owns hook schema details.
"""
if isinstance(hooks, str):
validate_manifest_path(plugin_dir, manifest_path, hooks, "hooks")
return
if isinstance(hooks, dict):
require(bool(hooks), f"{manifest_path} hooks inline object must not be empty")
return
if isinstance(hooks, list):
require(bool(hooks), f"{manifest_path} hooks list must not be empty")
for index, item in enumerate(hooks):
if isinstance(item, str):
validate_manifest_path(plugin_dir, manifest_path, item, f"hooks[{index}]")
elif isinstance(item, dict):
require(bool(item), f"{manifest_path} hooks[{index}] inline object must not be empty")
else:
raise AssertionError(f"{manifest_path} hooks[{index}] must be a path or inline object")
return
raise AssertionError(f"{manifest_path} hooks must be a path, path list, inline object, or inline object list")
def validate_manifest(plugin_dir: Path, expected_name: str) -> None:
manifest_path = plugin_dir / ".codex-plugin" / "plugin.json"
require(manifest_path.exists(), f"Missing manifest: {manifest_path}")
manifest = load_json(manifest_path)
require(isinstance(manifest, dict), f"Manifest must be an object: {manifest_path}")
require(manifest.get("name") == expected_name, f"{manifest_path} name must be {expected_name}")
require(PLUGIN_NAME_RE.match(expected_name) is not None, f"{manifest_path} plugin name must be ASCII kebab-case")
require(SEMVER_RE.match(str(manifest.get("version", ""))) is not None, f"{manifest_path} version must be semver-like")
require(bool(manifest.get("description")), f"{manifest_path} missing description")
if "hooks" in manifest:
validate_hooks_field(plugin_dir, manifest_path, manifest["hooks"])
author = manifest.get("author")
require(isinstance(author, dict) and bool(author.get("name")), f"{manifest_path} missing author.name")
skills_rel = manifest.get("skills")
require(isinstance(skills_rel, str) and skills_rel.startswith("./"), f"{manifest_path} skills path must be relative ./...")
skills_dir = validate_manifest_path(plugin_dir, manifest_path, skills_rel, "skills")
require(skills_dir.exists(), f"{manifest_path} skills directory does not exist: {skills_dir}")
skill_dirs = [path for path in skills_dir.iterdir() if path.is_dir()]
require(bool(skill_dirs), f"{manifest_path} must contain at least one skill directory")
for skill_dir in skill_dirs:
require((skill_dir / "SKILL.md").exists(), f"Missing SKILL.md in {skill_dir}")
for optional_path_field in ["mcpServers", "apps"]:
value = manifest.get(optional_path_field)
if value is not None:
require(isinstance(value, str), f"{manifest_path} {optional_path_field} must be a path string")
validate_manifest_path(plugin_dir, manifest_path, value, optional_path_field)
interface = manifest.get("interface")
require(isinstance(interface, dict), f"{manifest_path} missing interface object")
for key in ["displayName", "shortDescription", "longDescription", "developerName", "category"]:
require(bool(interface.get(key)), f"{manifest_path} missing interface.{key}")
capabilities = interface.get("capabilities")
require(isinstance(capabilities, list), f"{manifest_path} interface.capabilities must be a list")
def resolve_marketplace_plugin_path(marketplace_path: Path, source_path: str) -> Path:
require(source_path.startswith("./"), f"{marketplace_path} plugin source.path must start with ./")
return (marketplace_path.parent.parent.parent / source_path[2:]).resolve()
def validate_marketplace(marketplace_path: Path) -> None:
require(marketplace_path.exists(), f"Missing marketplace: {marketplace_path}")
marketplace = load_json(marketplace_path)
require(isinstance(marketplace, dict), f"Marketplace must be an object: {marketplace_path}")
require(bool(marketplace.get("name")), f"{marketplace_path} missing name")
require(isinstance(marketplace.get("plugins"), list), f"{marketplace_path} plugins must be a list")
seen: set[str] = set()
for entry in marketplace["plugins"]:
require(isinstance(entry, dict), f"{marketplace_path} plugin entry must be an object")
name = entry.get("name")
require(isinstance(name, str) and name, f"{marketplace_path} plugin entry missing name")
require(PLUGIN_NAME_RE.match(name) is not None, f"{marketplace_path} plugin name must be ASCII kebab-case: {name}")
require(name not in seen, f"{marketplace_path} duplicate plugin name: {name}")
seen.add(name)
source = entry.get("source")
require(isinstance(source, dict), f"{marketplace_path} {name} missing source")
require(source.get("source") == "local", f"{marketplace_path} {name} source.source must be local")
plugin_dir = resolve_marketplace_plugin_path(marketplace_path, str(source.get("path", "")))
require(plugin_dir.exists(), f"{marketplace_path} {name} path does not exist: {plugin_dir}")
policy = entry.get("policy")
require(isinstance(policy, dict), f"{marketplace_path} {name} missing policy")
require(policy.get("installation") in {"AVAILABLE", "INSTALLED_BY_DEFAULT", "NOT_AVAILABLE"}, f"{marketplace_path} {name} invalid installation policy")
require(policy.get("authentication") in {"ON_INSTALL", "ON_USE"}, f"{marketplace_path} {name} invalid authentication policy")
require(bool(entry.get("category")), f"{marketplace_path} {name} missing category")
validate_manifest(plugin_dir, name)
def main() -> int:
try:
for marketplace in MARKETPLACES:
validate_marketplace(marketplace)
except AssertionError as exc:
print(f"Validation failed: {exc}", file=sys.stderr)
return 1
print("Marketplace validation passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())