Files
EapilSkillMarket/scripts/sync_external_plugins.ps1
T
AreChen bfe3275e21 Localize marketplace metadata and group Three.js skills
Constraint: Chinese users need localized plugin and skill descriptions while third-party sources must remain syncable from upstream.

Rejected: Editing third-party generated files by hand | Sync would overwrite them and make localization drift from config.

Rejected: One plugin per Three.js skill | Users should install one domain package while Codex can still select individual skills inside it.

Confidence: high

Scope-risk: moderate

Directive: Keep plugin IDs and skill names in English kebab-case; put user-facing Chinese text in manifest fields, SKILL.md descriptions, or external-source overrides.

Tested: Codex manual confirms duplicate skills are not merged; powershell sync_external_plugins.ps1 with proxy; python scripts\\validate_marketplace.py; validate_plugin.py for all plugins; codex plugin list local marketplace; codex plugin add/remove eapil-threejs.

Not-tested: Codex Desktop visual refresh after pulling the updated remote marketplace.
2026-06-10 16:12:27 +08:00

548 lines
17 KiB
PowerShell

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 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
Invoke-CheckedGit @("clone", "--depth", "1", $Source.repo, $clonePath)
if ($Source.ref) {
Invoke-CheckedGit @("-C", $clonePath, "fetch", "--depth", "1", "origin", $Source.ref)
Invoke-CheckedGit @("-C", $clonePath, "checkout", "FETCH_HEAD")
}
$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
}
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-Item -LiteralPath $skillSource -Destination (Join-Path $pluginDir "skills") -Recurse -Force
$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
}
$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)."