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 Get-ManifestPathValues {
  param([object]$Value)
  if ($null -eq $Value) {
    return @()
  }
  if ($Value -is [string]) {
    return @([string]$Value)
  }
  $values = @()
  foreach ($item in @($Value)) {
    if ($item -is [string]) {
      $values += [string]$item
    }
  }
  return $values
}

function Resolve-ManifestRelativePath {
  param([string]$Path)
  $raw = $Path.Trim()
  if (-not $raw.StartsWith("./")) {
    return $null
  }
  $normalized = $raw.Substring(2).Split("#")[0].Split("?")[0].Replace("\", "/").Trim("/")
  if (-not $normalized) {
    return $null
  }
  Assert-RelativePathValue -Path $normalized
  return $normalized
}

function Copy-ManifestDeclaredPath {
  param(
    [string]$SourceRoot,
    [string]$PluginDir,
    [string]$Path,
    [string]$Within,
    [switch]$CopyParentForFile
  )
  $relative = Resolve-ManifestRelativePath -Path $Path
  if (-not $relative) {
    return
  }
  $sourcePath = Join-Path $SourceRoot $relative
  if ($CopyParentForFile -and (Test-Path -LiteralPath $sourcePath -PathType Leaf)) {
    $parent = Split-Path -Path $relative -Parent
    if ($parent) {
      $relative = $parent
      $sourcePath = Join-Path $SourceRoot $relative
    }
  }
  if (Test-Path -LiteralPath $sourcePath) {
    Copy-PathIfExists -Source $sourcePath -Destination (Join-Path $PluginDir $relative) -Within $Within
  }
}

function Copy-CodexManifestDeclaredPaths {
  param(
    [string]$SourceRoot,
    [string]$PluginDir,
    [string]$Within
  )
  $manifestPath = Join-Path $SourceRoot ".codex-plugin\plugin.json"
  if (-not (Test-Path -LiteralPath $manifestPath)) {
    return
  }
  $manifest = Read-JsonFile $manifestPath
  foreach ($field in @("skills", "mcpServers", "apps")) {
    foreach ($path in Get-ManifestPathValues $manifest.$field) {
      Copy-ManifestDeclaredPath -SourceRoot $SourceRoot -PluginDir $PluginDir -Path $path -Within $Within
    }
  }
  foreach ($path in Get-ManifestPathValues $manifest.hooks) {
    Copy-ManifestDeclaredPath -SourceRoot $SourceRoot -PluginDir $PluginDir -Path $path -Within $Within -CopyParentForFile
  }

  $interface = $manifest.interface
  if ($null -eq $interface) {
    return
  }
  foreach ($field in @("logo", "composerIcon")) {
    foreach ($path in Get-ManifestPathValues $interface.$field) {
      Copy-ManifestDeclaredPath -SourceRoot $SourceRoot -PluginDir $PluginDir -Path $path -Within $Within
    }
  }
  foreach ($screenshot in @($interface.screenshots)) {
    if ($screenshot -is [string]) {
      Copy-ManifestDeclaredPath -SourceRoot $SourceRoot -PluginDir $PluginDir -Path $screenshot -Within $Within
    } elseif ($null -ne $screenshot) {
      foreach ($key in @("src", "path", "url")) {
        foreach ($path in Get-ManifestPathValues $screenshot.$key) {
          Copy-ManifestDeclaredPath -SourceRoot $SourceRoot -PluginDir $PluginDir -Path $path -Within $Within
        }
      }
    }
  }
}

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
  }
  Copy-CodexManifestDeclaredPaths -SourceRoot $sourcePath -PluginDir $pluginDir -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 Sync-SkillCollection {
  param(
    [object]$Source,
    [string]$ClonePath,
    [string]$Commit,
    [string]$PluginsRoot,
    [string]$RepoRoot,
    [string]$SyncedAt
  )
  $skillsPath = "skills"
  if ($Source.PSObject.Properties.Name -contains "skillsPath" -and $Source.skillsPath) {
    $skillsPath = $Source.skillsPath
  } elseif ($Source.PSObject.Properties.Name -contains "skillPath" -and $Source.skillPath) {
    $skillsPath = $Source.skillPath
  }
  if ($skillsPath -eq ".") {
    $sourceSkillsRoot = $ClonePath
  } else {
    $sourceSkillsRoot = Join-Path $ClonePath $skillsPath
  }
  if (-not (Test-Path -LiteralPath $sourceSkillsRoot)) {
    throw "skill-collection adapter requires a skills directory: $sourceSkillsRoot"
  }
  $skillDirs = @(Get-ChildItem -LiteralPath $sourceSkillsRoot -Directory | Where-Object {
      Test-Path -LiteralPath (Join-Path $_.FullName "SKILL.md")
    } | Sort-Object Name)
  if ($skillDirs.Count -eq 0) {
    throw "skill-collection adapter found no child SKILL.md files: $sourceSkillsRoot"
  }

  $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
  $skillsRoot = Join-Path $pluginDir "skills"
  New-Item -ItemType Directory -Force -Path $skillsRoot | Out-Null
  foreach ($skillDir in $skillDirs) {
    Copy-DirectoryFiltered -Source $skillDir.FullName -Destination (Join-Path $skillsRoot $skillDir.Name) -Within $RepoRoot
  }

  $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 $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

  $metadata = @{}
  if ($Source.PSObject.Properties.Name -contains "metadata" -and $Source.metadata) {
    $metadata = ConvertTo-Hashtable $Source.metadata
  }
  $description = $metadata.description
  if (-not $description) {
    $description = "$pluginName skill collection 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 $skillsPath
}

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
    }
    "skill-collection" {
      Sync-SkillCollection -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)."
