sync: BOWEI 2026-09-07 tools-sync 改为 junction 模型(registry 双向 ingest + 修复)
- sync_repos.ps1: 每个 skill 只在 ~\.claude\skills 有一份物理副本,其余 registry 一律 junction;新增 registry-only skill 的自动 ingest、MISDIRECTED 链接修复、 同名内容分叉的 CONFLICT 报告,以及只对 -MirrorRegistry 做全量镜像。 - SKILL.md: 重写阶段 1/2,补 Codex MCP 存储模型(config.toml 惰性, codex mcp list --json 是唯一权威口径)与 CLI 升级会清空注册表的事实。 - sync_mcp_registrations.ps1 + references/codex_mcp_wanted_keys.json: 报告 codex-cli 无接口可设的键(tool_timeout_sec / env_vars)为 "registered, but incomplete" 而非失败。 - tests/test_sync_repos_registries.ps1: registry 对账的回归测试。 - .gitignore: orchestration(Orca 自带的发现桩,同 orca-cli)列为 local-only。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01438Zbcx49e4o46uSA71mFF
This commit is contained in:
@@ -1,6 +1,20 @@
|
||||
# tools-sync: audit and reconcile user-configured MCP registrations between
|
||||
# Claude Code and Codex. Managed connectors, plugins, and Codex runtime MCPs are
|
||||
# intentionally excluded because they are owned by their host application.
|
||||
#
|
||||
# Codex storage model (measured on codex-cli 0.150.1, not documented anywhere):
|
||||
# * `codex mcp list --json` / `add` / `remove` are the ONLY working interface.
|
||||
# * config.toml's [mcp_servers.*] tables are INERT. Editing node_repl's startup_timeout_sec
|
||||
# there from 120 to 137 left `codex mcp list` reporting 120, and a section written by hand
|
||||
# never appeared at all. Hand-writing TOML sections -- what this skill used to instruct --
|
||||
# silently does nothing. The sections still in config.toml are legacy leftovers.
|
||||
# * `codex mcp add` persists outside config.toml and has no flag for tool_timeout_sec or
|
||||
# env_vars, so those keys currently have no setting entry point at all.
|
||||
# * That store is not durable across CLI upgrades: seven user MCPs registered on 2026-08-29
|
||||
# were gone after the upgrade to 0.150.1. Re-audit Codex MCPs on every sync; ~/skills_env.md
|
||||
# is the durable record to re-register from.
|
||||
#
|
||||
# Run from PowerShell, NOT git-bash: MSYS rewrites `/c`-style args and Windows paths.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
@@ -10,12 +24,21 @@ param(
|
||||
[ValidateSet("None", "Claude", "Codex")]
|
||||
[string]$ConflictAuthority = "None",
|
||||
[string[]]$ExcludeName = @(),
|
||||
[string]$PythonCommand = "python"
|
||||
[string]$PythonCommand = "python",
|
||||
# Where to read Codex's registrations. Auto = `codex mcp list --json`, falling back to
|
||||
# parsing -CodexConfig when the CLI is unavailable (older codex, or a test fixture).
|
||||
# Toml forces the fixture path so tests can isolate from the real machine.
|
||||
[ValidateSet("Auto", "Cli", "Toml")]
|
||||
[string]$CodexSource = "Auto",
|
||||
# Codex-side keys some MCPs need that codex-cli offers no way to set (see the note above).
|
||||
# Resolved after the param block: $PSScriptRoot is not yet bound in default expressions here.
|
||||
[string]$OverridesFile
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$script:CodexBackupMade = $false
|
||||
$script:Failures = @()
|
||||
$script:Unsupported = @()
|
||||
$CodexOnlyEnv = @("PYTHONIOENCODING", "PYTHONUTF8")
|
||||
|
||||
function Test-TomllibPython([string]$Candidate) {
|
||||
@@ -53,6 +76,19 @@ function Resolve-PythonCommand([string]$Requested, [bool]$Explicit) {
|
||||
|
||||
$PythonCommand = Resolve-PythonCommand $PythonCommand $PSBoundParameters.ContainsKey('PythonCommand')
|
||||
|
||||
if (-not $OverridesFile) {
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$OverridesFile = Join-Path (Split-Path -Parent $here) "references\codex_mcp_wanted_keys.json"
|
||||
}
|
||||
|
||||
$script:Overrides = @{}
|
||||
if (Test-Path -LiteralPath $OverridesFile) {
|
||||
$raw = Get-Content -Raw -LiteralPath $OverridesFile | ConvertFrom-Json
|
||||
foreach ($property in $raw.psobject.Properties) {
|
||||
if ($property.Name -notlike "_*") { $script:Overrides[$property.Name] = $property.Value }
|
||||
}
|
||||
}
|
||||
|
||||
function ConvertTo-StringMap($Object) {
|
||||
$result = [ordered]@{}
|
||||
if ($null -eq $Object) { return $result }
|
||||
@@ -128,8 +164,11 @@ function Get-CodexMcpsFromCli {
|
||||
}
|
||||
|
||||
function Get-CodexMcps {
|
||||
$fromCli = Get-CodexMcpsFromCli
|
||||
if ($null -ne $fromCli) { return $fromCli }
|
||||
if ($CodexSource -ne "Toml") {
|
||||
$fromCli = Get-CodexMcpsFromCli
|
||||
if ($null -ne $fromCli) { return $fromCli }
|
||||
if ($CodexSource -eq "Cli") { throw "codex mcp list --json unavailable and -CodexSource Cli forbids the TOML fallback" }
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $CodexConfig)) {
|
||||
throw "Codex config not found: $CodexConfig"
|
||||
@@ -234,6 +273,7 @@ function Add-ToCodex($Mcp) {
|
||||
$arguments = @("mcp", "add", $Mcp.name)
|
||||
$envMap = [ordered]@{}
|
||||
foreach ($key in $Mcp.env.Keys) { $envMap[$key] = $Mcp.env[$key] }
|
||||
# Without these, Codex mangles non-ASCII stdio output on Windows.
|
||||
$envMap["PYTHONIOENCODING"] = "utf-8"
|
||||
$envMap["PYTHONUTF8"] = "1"
|
||||
foreach ($key in $envMap.Keys) { $arguments += @("--env", "$key=$($envMap[$key])") }
|
||||
@@ -241,6 +281,7 @@ function Add-ToCodex($Mcp) {
|
||||
$arguments += $Mcp.command
|
||||
$arguments += @($Mcp.args)
|
||||
Invoke-External "codex add $($Mcp.name)" "codex" $arguments
|
||||
Report-UnsupportedKeys $Mcp.name
|
||||
return
|
||||
}
|
||||
if ($Mcp.headers.Count -gt 0) {
|
||||
@@ -248,6 +289,17 @@ function Add-ToCodex($Mcp) {
|
||||
}
|
||||
Backup-CodexConfig
|
||||
Invoke-External "codex add $($Mcp.name)" "codex" @("mcp", "add", $Mcp.name, "--url", $Mcp.url)
|
||||
Report-UnsupportedKeys $Mcp.name
|
||||
}
|
||||
|
||||
# Some MCPs need Codex keys that `codex mcp add` has no flag for and that config.toml
|
||||
# cannot supply either (see the storage-model note at the top). Surface them rather than
|
||||
# pretending the registration is complete.
|
||||
function Report-UnsupportedKeys([string]$Name) {
|
||||
if (-not $script:Overrides.ContainsKey($Name)) { return }
|
||||
$keys = @($script:Overrides[$Name].psobject.Properties | Where-Object { $_.Name -notlike "_*" } |
|
||||
ForEach-Object { "$($_.Name)=$($_.Value)" })
|
||||
if ($keys) { $script:Unsupported += "$Name wants $($keys -join ', ') - no interface in codex-cli to set these" }
|
||||
}
|
||||
|
||||
function Add-ToClaude($Mcp) {
|
||||
@@ -344,6 +396,20 @@ if ($Apply) {
|
||||
}
|
||||
}
|
||||
|
||||
if ($Apply) {
|
||||
# Codex re-reads config.toml on every invocation, so this confirms what it will actually load.
|
||||
$after = Get-PortableMcps (Get-CodexMcps) "Codex"
|
||||
$stillMissing = @($claude.Keys | Where-Object { -not $after.ContainsKey($_) } | Sort-Object)
|
||||
Write-Host "=== post-apply verification (codex mcp list) ==="
|
||||
Write-Host "Codex now serves: $(@($after.Keys | Sort-Object) -join ', ')"
|
||||
if ($stillMissing) { $script:Failures += "still absent from Codex after apply: $($stillMissing -join ', ')" }
|
||||
}
|
||||
|
||||
if ($script:Unsupported.Count -gt 0) {
|
||||
Write-Host "=== registered, but incomplete (codex-cli cannot express these keys) ==="
|
||||
$script:Unsupported | ForEach-Object { Write-Host "- $_" }
|
||||
}
|
||||
|
||||
if ($script:Failures.Count -gt 0) {
|
||||
Write-Host "=== failures ==="
|
||||
$script:Failures | ForEach-Object { Write-Host "- $_" }
|
||||
|
||||
@@ -1,29 +1,51 @@
|
||||
# tools-sync: mechanical sync of skills between Claude Code and Codex.
|
||||
# Phase 1 -- git sync of the primary checkout (~\.claude\skills): commit -> pull --rebase -> push.
|
||||
# Phase 2 -- junction reconciliation: every top-level skill dir in the primary gets a
|
||||
# directory junction of the same name in ~\.codex\skills, so both tools share
|
||||
# one physical copy and can never drift. Codex auto-registers any dir there
|
||||
# containing SKILL.md (new sessions only).
|
||||
# Judgment calls (rebase conflicts, codex-local dirs worth keeping) are left to the caller.
|
||||
# tools-sync: mechanical sync of skills across Claude Code, Codex, and the
|
||||
# cross-runtime ~\.agents\skills directory.
|
||||
#
|
||||
# Phase 1 — git sync of the primary checkout (~\.claude\skills): commit -> pull --rebase -> push.
|
||||
# Phase 2 — registry reconciliation. Every skill has exactly ONE physical home (the primary
|
||||
# checkout); every other registry holds a directory junction pointing at it, so the
|
||||
# tools cannot drift. This phase is bidirectional:
|
||||
# * a real skill dir that exists ONLY in a registry is INGESTED into the primary
|
||||
# (this is how a skill created from the Codex side enters the repo), then linked back;
|
||||
# * a link that points somewhere other than the primary is repaired;
|
||||
# * a real dir duplicating a primary skill collapses to a junction when the content
|
||||
# is identical, and is reported as a conflict when it differs.
|
||||
# Judgment calls (rebase conflicts, divergent duplicates) are left to the caller.
|
||||
#
|
||||
# Exit codes: 0 = clean, 1 = hard failure, 2 = conflicts需要人工裁决.
|
||||
#
|
||||
# Run from PowerShell, NOT git-bash: MSYS rewrites `/c`-style arguments and Windows paths.
|
||||
#
|
||||
# A server-side transport fault (bad pack header / early EOF / RPC failed) is not fatal:
|
||||
# gitea_doctor.ps1 is invoked to repair the gitea host and the push is retried once (-NoDoctor opts out).
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoUrl,
|
||||
[string]$ClaudeSkills = "$env:USERPROFILE\.claude\skills",
|
||||
[string]$CodexSkills = "$env:USERPROFILE\.codex\skills",
|
||||
# Registries mirroring the primary. ~\.codex\skills is Codex's own directory;
|
||||
# ~\.agents\skills is the cross-runtime alias Codex / Copilot CLI / Gemini CLI also scan.
|
||||
[string[]]$Registry = @("$env:USERPROFILE\.codex\skills", "$env:USERPROFILE\.agents\skills"),
|
||||
# Registry that gets a junction for EVERY primary skill. The others are only scanned for
|
||||
# ingest/repair, because linking a skill into two directories Codex scans registers it twice.
|
||||
[string]$MirrorRegistry = "$env:USERPROFILE\.codex\skills",
|
||||
[string]$Branch = "main",
|
||||
# A real (non-junction) dir in the codex side that shadows a primary skill blocks
|
||||
# linking; -Force deletes it and links (caller must have rescued unique content first).
|
||||
# Resolve divergent duplicates by declaring the primary authoritative (deletes the registry copy).
|
||||
[switch]$Force,
|
||||
# Report what would happen without committing/pushing/linking.
|
||||
[switch]$DryRun,
|
||||
# A transport failure normally aborts the run and leaves commits stranded here. By
|
||||
# default we instead log into the gitea host, repair it, and retry once. -NoDoctor
|
||||
# keeps the old abort-and-report behaviour.
|
||||
[switch]$NoDoctor,
|
||||
[switch]$AllowDestructive
|
||||
[switch]$AllowDestructive,
|
||||
# Leave registry-only skills where they are instead of moving them into the primary checkout.
|
||||
[switch]$NoIngest,
|
||||
# Report what would happen without committing/pushing/linking/moving.
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$script:Conflicts = @()
|
||||
$script:Ingested = @()
|
||||
$script:Notes = @()
|
||||
|
||||
function Invoke-Git {
|
||||
param([string]$Dir, [string[]]$GitArgs)
|
||||
@@ -68,8 +90,41 @@ function Invoke-Doctor([string]$Why) {
|
||||
return $ok
|
||||
}
|
||||
|
||||
function Get-LinkTarget($Item) {
|
||||
if (-not $Item.Target) { return $null }
|
||||
return (@($Item.Target) | Select-Object -First 1)
|
||||
}
|
||||
|
||||
function Test-IsLink($Item) {
|
||||
return [bool]($Item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)
|
||||
}
|
||||
|
||||
function Test-UnderPath([string]$Path, [string]$Root) {
|
||||
if (-not $Path -or -not $Root) { return $false }
|
||||
return $Path.TrimEnd('\').ToLowerInvariant().StartsWith($Root.TrimEnd('\').ToLowerInvariant() + '\')
|
||||
}
|
||||
|
||||
# Content fingerprint used to decide whether a duplicate is a harmless copy or a real fork.
|
||||
# Runtime junk that legitimately differs between machines is excluded, mirroring .gitignore.
|
||||
function Get-DirFingerprint([string]$Path) {
|
||||
$skip = '(\\\.git\\|\\__pycache__\\|\\\.langgraph_api\\|\.pyc$|\.pckl$|\.sqlite(-shm|-wal)?$)'
|
||||
$files = Get-ChildItem -LiteralPath $Path -Recurse -File -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FullName -notmatch $skip }
|
||||
$lines = foreach ($f in $files) {
|
||||
$rel = $f.FullName.Substring($Path.Length).TrimStart('\').ToLowerInvariant()
|
||||
"$rel $((Get-FileHash -LiteralPath $f.FullName -Algorithm SHA256).Hash)"
|
||||
}
|
||||
return (($lines | Sort-Object) -join "`n")
|
||||
}
|
||||
|
||||
function New-SkillJunction([string]$LinkPath, [string]$TargetPath, [string]$Why) {
|
||||
if ($DryRun) { Write-Host " (dry-run) would link $LinkPath -> $TargetPath [$Why]"; return }
|
||||
New-Item -ItemType Junction -Path $LinkPath -Target $TargetPath | Out-Null
|
||||
Write-Host " linked: $LinkPath -> $TargetPath [$Why]"
|
||||
}
|
||||
|
||||
# ---------- Phase 1: primary checkout (~\.claude\skills) ----------
|
||||
Write-Host "=== [1/2] primary git sync: $ClaudeSkills ==="
|
||||
Write-Host "=== [1/3] primary git sync: $ClaudeSkills ==="
|
||||
if (-not (Test-Path (Join-Path $ClaudeSkills ".git"))) {
|
||||
Fail "$ClaudeSkills is not a git repo. Clone $RepoUrl there first (new machine: git clone $RepoUrl `"$ClaudeSkills`")."
|
||||
}
|
||||
@@ -162,61 +217,152 @@ if ($DryRun) {
|
||||
}
|
||||
}
|
||||
|
||||
# ---------- Phase 2: junction reconciliation (~\.codex\skills) ----------
|
||||
Write-Host "=== [2/2] codex junctions: $CodexSkills ==="
|
||||
if (-not (Test-Path $CodexSkills)) { New-Item -ItemType Directory -Force $CodexSkills | Out-Null }
|
||||
# ---------- Phase 2: ingest + repair every registry ----------
|
||||
# Walk registries FIRST so skills that only exist on the Codex side land in the primary
|
||||
# before Phase 3 mirrors the primary back out.
|
||||
$registries = @($Registry | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\') })
|
||||
Write-Host "=== [2/3] registry ingest + repair ==="
|
||||
|
||||
# Left over from the older mirror-checkout approach; junctions need no repo here.
|
||||
if (Test-Path (Join-Path $CodexSkills ".git")) {
|
||||
Write-Host "removing stale .git from codex dir (junction model needs no second checkout)"
|
||||
if (-not $DryRun) { Remove-Item -Recurse -Force (Join-Path $CodexSkills ".git") }
|
||||
}
|
||||
foreach ($reg in $registries) {
|
||||
Write-Host "-- $reg"
|
||||
if (-not (Test-Path $reg)) { Write-Host " (absent, skipped)"; continue }
|
||||
$foreign = 0
|
||||
|
||||
$primarySkills = Get-ChildItem $ClaudeSkills -Directory | Where-Object { $_.Name -notlike ".*" }
|
||||
$blocked = @()
|
||||
foreach ($d in $primarySkills) {
|
||||
$link = Join-Path $CodexSkills $d.Name
|
||||
if (Test-Path $link) {
|
||||
$item = Get-Item $link
|
||||
if ($item.LinkType -eq "Junction") { continue }
|
||||
# A real dir shadows the primary skill; its content may be unique.
|
||||
if ($Force) {
|
||||
if ($DryRun) { Write-Host "(dry-run) would replace real dir with junction: $($d.Name)" ; continue }
|
||||
Remove-Item -Recurse -Force $link
|
||||
New-Item -ItemType Junction -Path $link -Target $d.FullName | Out-Null
|
||||
Write-Host "replaced real dir with junction: $($d.Name)"
|
||||
} else {
|
||||
$blocked += $d.Name
|
||||
# A registry carrying its own .git is a competing checkout; the junction model needs only one.
|
||||
if (Test-Path (Join-Path $reg ".git")) {
|
||||
$remote = (Invoke-Git $reg @("remote", "get-url", "origin")).Out
|
||||
$script:Notes += "$reg holds its own git checkout (origin: $remote). The junction model needs only the primary checkout; review and remove it once its unique content is ingested."
|
||||
Write-Host " NOTE: competing git checkout here (origin: $remote) - reported, not touched"
|
||||
}
|
||||
|
||||
foreach ($entry in Get-ChildItem $reg -Directory -Force | Where-Object { $_.Name -notlike ".*" }) {
|
||||
# $copyPath is the real directory to resolve; $repairLinks are registry entries that must
|
||||
# end up pointing at the primary once it does.
|
||||
$copyPath = $entry.FullName
|
||||
$repairLinks = @()
|
||||
|
||||
if (Test-IsLink $entry) {
|
||||
$target = Get-LinkTarget $entry
|
||||
if (Test-UnderPath $target $ClaudeSkills) {
|
||||
if (-not (Test-Path $target)) {
|
||||
# Skill deleted/renamed upstream; the dangling link confuses Codex.
|
||||
if ($DryRun) { Write-Host " (dry-run) would remove stale link: $($entry.Name)"; continue }
|
||||
$entry.Delete()
|
||||
Write-Host " removed stale link: $($entry.Name)"
|
||||
}
|
||||
continue # correctly pointed at the primary
|
||||
}
|
||||
|
||||
$inAnotherRegistry = $false
|
||||
foreach ($other in $registries) { if (Test-UnderPath $target $other) { $inAnotherRegistry = $true } }
|
||||
if (-not $inAnotherRegistry) {
|
||||
# Host-owned catalog (e.g. the MathWorks agentic toolkits). Not ours to manage.
|
||||
# Counted rather than listed: these run into the hundreds and drown the report.
|
||||
$foreign++
|
||||
continue
|
||||
}
|
||||
|
||||
# Points at a copy living in another registry: the exact drift this model exists to
|
||||
# prevent. Resolve that copy, then re-point this link at the primary.
|
||||
Write-Host " MISDIRECTED link: $($entry.Name) -> $target"
|
||||
$repairLinks += $entry.FullName
|
||||
$copyPath = $target
|
||||
}
|
||||
} else {
|
||||
if ($DryRun) { Write-Host "(dry-run) would link: $($d.Name)"; continue }
|
||||
New-Item -ItemType Junction -Path $link -Target $d.FullName | Out-Null
|
||||
Write-Host "linked: $($d.Name)"
|
||||
|
||||
$name = Split-Path $copyPath -Leaf
|
||||
$primaryPath = Join-Path $ClaudeSkills $name
|
||||
if (-not (Test-Path $copyPath)) { continue } # already resolved earlier this run
|
||||
$repairLinks += $copyPath
|
||||
|
||||
# --- real directory holding a skill ---
|
||||
if (-not (Test-Path (Join-Path $copyPath "SKILL.md"))) {
|
||||
Write-Host " not a skill dir (no SKILL.md), ignored: $name"
|
||||
continue
|
||||
}
|
||||
|
||||
$resolved = $false
|
||||
if (-not (Test-Path $primaryPath)) {
|
||||
if ($NoIngest) {
|
||||
$script:Conflicts += "$name exists only in $copyPath; -NoIngest kept it out of the primary."
|
||||
Write-Host " registry-only skill NOT ingested (-NoIngest): $name"
|
||||
continue
|
||||
}
|
||||
Write-Host " INGEST registry-only skill: $name ($copyPath -> $primaryPath)"
|
||||
if (-not $DryRun) { Move-Item -LiteralPath $copyPath -Destination $primaryPath }
|
||||
$script:Ingested += $name
|
||||
$resolved = $true
|
||||
}
|
||||
elseif ((Get-DirFingerprint $copyPath) -eq (Get-DirFingerprint $primaryPath)) {
|
||||
# Identical copy: collapsing it loses nothing.
|
||||
Write-Host " duplicate identical to primary, collapsing to junction: $name"
|
||||
if (-not $DryRun) { Remove-Item -Recurse -Force -LiteralPath $copyPath }
|
||||
$resolved = $true
|
||||
}
|
||||
elseif ($Force) {
|
||||
Write-Host " -Force: primary wins over divergent copy: $name"
|
||||
if (-not $DryRun) { Remove-Item -Recurse -Force -LiteralPath $copyPath }
|
||||
$resolved = $true
|
||||
}
|
||||
else {
|
||||
$script:Conflicts += "$name diverges between $primaryPath and $copyPath. Merge what you want to keep into the primary, then re-run with -Force."
|
||||
Write-Host " CONFLICT: $name diverges from primary (registry copy left untouched)"
|
||||
}
|
||||
|
||||
if ($resolved) {
|
||||
foreach ($lnk in ($repairLinks | Select-Object -Unique)) {
|
||||
if ((-not $DryRun) -and (Test-Path $lnk)) { (Get-Item $lnk).Delete() }
|
||||
New-SkillJunction $lnk $primaryPath "repaired"
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($foreign -gt 0) { Write-Host " $foreign host-owned links left alone (targets outside the managed registries)" }
|
||||
}
|
||||
|
||||
# ---------- Phase 3: mirror the primary into the mirror registry ----------
|
||||
Write-Host "=== [3/3] mirror primary -> $MirrorRegistry ==="
|
||||
if ($MirrorRegistry) {
|
||||
if (-not (Test-Path $MirrorRegistry)) {
|
||||
if ($DryRun) { Write-Host "(dry-run) would create $MirrorRegistry" }
|
||||
else { New-Item -ItemType Directory -Force $MirrorRegistry | Out-Null }
|
||||
}
|
||||
# Left over from the older mirror-checkout approach; junctions need no repo here.
|
||||
$mirrorGit = Join-Path $MirrorRegistry ".git"
|
||||
if ((Test-Path $mirrorGit) -and $MirrorRegistry -ne $ClaudeSkills) {
|
||||
Write-Host "removing stale .git from mirror registry (junction model needs no second checkout)"
|
||||
if (-not $DryRun) { Remove-Item -Recurse -Force $mirrorGit }
|
||||
}
|
||||
|
||||
$primarySkills = Get-ChildItem $ClaudeSkills -Directory | Where-Object { $_.Name -notlike ".*" }
|
||||
foreach ($d in $primarySkills) {
|
||||
$link = Join-Path $MirrorRegistry $d.Name
|
||||
if (Test-Path $link) { continue }
|
||||
New-SkillJunction $link $d.FullName "mirror"
|
||||
}
|
||||
|
||||
# Codex only registers a directory that exposes SKILL.md.
|
||||
$missing = $primarySkills | Where-Object { -not (Test-Path (Join-Path $MirrorRegistry "$($_.Name)\SKILL.md")) }
|
||||
if ($missing -and -not $DryRun) {
|
||||
Write-Host "WARNING: no SKILL.md visible through these links (Codex will ignore them):"
|
||||
$missing | ForEach-Object { Write-Host " $($_.Name)" }
|
||||
}
|
||||
}
|
||||
|
||||
# Junctions whose primary target is gone (skill deleted/renamed upstream) confuse Codex.
|
||||
foreach ($item in Get-ChildItem $CodexSkills -Directory | Where-Object { $_.LinkType -eq "Junction" }) {
|
||||
$target = ($item.Target | Select-Object -First 1)
|
||||
if ($target -and $target.StartsWith($ClaudeSkills) -and -not (Test-Path $target)) {
|
||||
if ($DryRun) { Write-Host "(dry-run) would remove stale junction: $($item.Name)"; continue }
|
||||
# A junction is just a link -- removing it never touches target content.
|
||||
$item.Delete()
|
||||
Write-Host "removed stale junction: $($item.Name)"
|
||||
# An ingested skill matching a local-only .gitignore rule never reaches the remote —
|
||||
# it would silently fail to appear on the next machine.
|
||||
foreach ($name in $script:Ingested) {
|
||||
$ci = Invoke-Git $ClaudeSkills @("check-ignore", "-v", $name)
|
||||
if ($ci.Code -eq 0) {
|
||||
$script:Notes += "ingested '$name' is excluded by .gitignore ($($ci.Out)); it stays local-only until you remove that rule."
|
||||
}
|
||||
}
|
||||
|
||||
if ($blocked) {
|
||||
Write-Host "these codex dirs are REAL directories shadowing primary skills (not linked):"
|
||||
$blocked | ForEach-Object { Write-Host " $_" }
|
||||
Fail "rescue any unique content into the primary checkout, then re-run with -Force."
|
||||
Write-Host "=== summary ==="
|
||||
if ($script:Ingested) { Write-Host "ingested into primary: $($script:Ingested -join ', ')" }
|
||||
foreach ($n in $script:Notes) { Write-Host "NOTE: $n" }
|
||||
if ($script:Conflicts) {
|
||||
Write-Host "conflicts needing a decision:"
|
||||
$script:Conflicts | ForEach-Object { Write-Host " - $_" }
|
||||
Write-Host "=== sync_repos finished with conflicts ==="
|
||||
exit 2
|
||||
}
|
||||
|
||||
# Registration sanity: every linked skill must expose a SKILL.md for Codex to register it.
|
||||
$missing = $primarySkills | Where-Object { -not (Test-Path (Join-Path $CodexSkills "$($_.Name)\SKILL.md")) }
|
||||
if ($missing) {
|
||||
Write-Host "WARNING: no SKILL.md visible through these links (Codex will ignore them):"
|
||||
$missing | ForEach-Object { Write-Host " $($_.Name)" }
|
||||
}
|
||||
|
||||
Write-Host "=== sync_repos done (Codex picks up changes on its next new session) ==="
|
||||
|
||||
Reference in New Issue
Block a user