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)."