- 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
369 lines
18 KiB
PowerShell
369 lines
18 KiB
PowerShell
# 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",
|
|
# 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",
|
|
# Resolve divergent duplicates by declaring the primary authoritative (deletes the registry copy).
|
|
[switch]$Force,
|
|
# 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,
|
|
# 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)
|
|
# PS 5.1: git writes progress/warnings to stderr; 2>&1 wraps them in ErrorRecords
|
|
# which terminate under EAP=Stop. Relax EAP locally and stringify every line.
|
|
$prev = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
$out = & git -C $Dir @GitArgs 2>&1 | ForEach-Object { "$_" }
|
|
$code = $LASTEXITCODE
|
|
$ErrorActionPreference = $prev
|
|
return @{ Code = $code; Out = (($out -join "`n")).Trim() }
|
|
}
|
|
|
|
function Fail([string]$Msg) { Write-Host "FAIL: $Msg"; exit 1 }
|
|
|
|
# git reports a server-side transport fault in the vocabulary of the pack protocol, not
|
|
# of the server. These are the shapes seen when gitea is up (auth fine, refs fine) but
|
|
# the pack plane is dead - the case gitea_doctor knows how to repair.
|
|
function Test-TransportFault([string]$GitOutput) {
|
|
return ($GitOutput -match "bad pack header|early EOF|invalid index-pack output|RPC failed|unexpected disconnect|remote end hung up|Could not read from remote repository|Connection (reset|refused|timed out)")
|
|
}
|
|
|
|
function Invoke-Doctor([string]$Why) {
|
|
if ($NoDoctor) { Write-Host "(-NoDoctor set, skipping server repair)"; return $false }
|
|
$doctor = Join-Path $PSScriptRoot "gitea_doctor.ps1"
|
|
if (-not (Test-Path $doctor)) { Write-Host "gitea_doctor.ps1 not found; cannot repair the server."; return $false }
|
|
Write-Host ""
|
|
Write-Host "=== gitea transport check/repair ($Why) ==="
|
|
# Hashtable splat, not an array: splatting an array passes every element
|
|
# positionally, so "-Fix" would be bound as a value (it lands on -SshTimeout).
|
|
$dargs = @{ RepoUrl = $RepoUrl; Fix = $true }
|
|
if ($AllowDestructive) { $dargs["AllowDestructive"] = $true }
|
|
$ok = $false
|
|
try {
|
|
& $doctor @dargs
|
|
$ok = ($LASTEXITCODE -eq 0)
|
|
} catch {
|
|
Write-Host "doctor could not be invoked: $($_.Exception.Message)"
|
|
}
|
|
Write-Host "=== doctor $(if ($ok) { 'reports the transport healthy' } else { 'could NOT restore the transport' }) ==="
|
|
Write-Host ""
|
|
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/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`")."
|
|
}
|
|
|
|
$st = Invoke-Git $ClaudeSkills @("status", "--porcelain")
|
|
if ($st.Out) {
|
|
Write-Host "local changes:"; Write-Host $st.Out
|
|
if ($DryRun) {
|
|
Write-Host "(dry-run) would commit these changes"
|
|
} else {
|
|
Invoke-Git $ClaudeSkills @("add", "-A") | Out-Null
|
|
$msg = "sync: $env:COMPUTERNAME $(Get-Date -Format yyyy-MM-dd)"
|
|
$r = Invoke-Git $ClaudeSkills @("commit", "-m", $msg)
|
|
if ($r.Code -ne 0) { Fail "commit failed:`n$($r.Out)" }
|
|
Write-Host "committed: $msg"
|
|
}
|
|
} else {
|
|
Write-Host "working tree clean"
|
|
}
|
|
|
|
# The sync is only "done" when the remote branch actually points at our commit.
|
|
# ls-remote asks for refs only, so it keeps working even when the server's pack
|
|
# service is broken -- which is exactly the outage this gate exists to survive.
|
|
function Assert-RemoteInSync {
|
|
param([string]$Dir, [string]$Branch)
|
|
$local = Invoke-Git $Dir @("rev-parse", $Branch)
|
|
if ($local.Code -ne 0) { Fail "cannot resolve local $Branch." }
|
|
$ls = Invoke-Git $Dir @("ls-remote", "origin", "refs/heads/$Branch")
|
|
if ($ls.Code -ne 0) {
|
|
Write-Host $ls.Out
|
|
Fail "ls-remote failed: cannot verify the push landed. Treat this run as NOT synced."
|
|
}
|
|
$remote = ($ls.Out -split "\s+")[0]
|
|
if ($remote -ne $local.Out) {
|
|
Write-Host " local $Branch = $($local.Out)"
|
|
if (-not $remote) {
|
|
# Empty ref: the counts below would silently be measured against HEAD.
|
|
Fail "origin has no refs/heads/$Branch. Nothing on this machine is backed up by the server."
|
|
}
|
|
Write-Host " remote $Branch = $remote"
|
|
$ahead = Invoke-Git $Dir @("rev-list", "--count", "$remote..$($local.Out)")
|
|
if ($ahead.Code -eq 0 -and [int]$ahead.Out -gt 0) {
|
|
Write-Host " $($ahead.Out) commit(s) STRANDED on this machine - they exist nowhere else."
|
|
}
|
|
$behind = Invoke-Git $Dir @("rev-list", "--count", "$($local.Out)..$remote")
|
|
if ($behind.Code -eq 0 -and [int]$behind.Out -gt 0) {
|
|
Write-Host " $($behind.Out) commit(s) from other machines not merged here."
|
|
}
|
|
Fail "primary is NOT in sync with origin/$Branch. A server-side outage is a reason, never an outcome: do not report this run as synced."
|
|
}
|
|
Write-Host "verified: origin/$Branch = $remote (this machine's work is on the server)"
|
|
}
|
|
|
|
if ($DryRun) {
|
|
# Read-only, but still worth running: silent drift is what this gate is for.
|
|
Assert-RemoteInSync $ClaudeSkills $Branch
|
|
} else {
|
|
$r = Invoke-Git $ClaudeSkills @("pull", "--rebase", "origin", $Branch)
|
|
if ($r.Code -ne 0 -and (Test-TransportFault $r.Out)) {
|
|
# A rebase may have been left half-started before the transport died.
|
|
Invoke-Git $ClaudeSkills @("rebase", "--abort") | Out-Null
|
|
if (Invoke-Doctor "pull --rebase") {
|
|
$r = Invoke-Git $ClaudeSkills @("pull", "--rebase", "origin", $Branch)
|
|
}
|
|
}
|
|
if ($r.Code -ne 0) {
|
|
Write-Host $r.Out
|
|
$stranded = Invoke-Git $ClaudeSkills @("rev-list", "--count", "origin/$Branch..$Branch")
|
|
if ($stranded.Code -eq 0 -and [int]$stranded.Out -gt 0) {
|
|
Write-Host "NOTE: $($stranded.Out) local commit(s) cannot reach the server until this is fixed."
|
|
}
|
|
Fail "pull --rebase failed on primary. Conflicts are yours to resolve (or 'git rebase --abort'); a transport fault the doctor could not repair is in the skill's Phase 1.5 ladder. Either way: do not leave commits stranded."
|
|
}
|
|
Write-Host $r.Out
|
|
$r = Invoke-Git $ClaudeSkills @("push", "origin", $Branch)
|
|
if ($r.Code -ne 0 -and (Test-TransportFault $r.Out)) {
|
|
if (Invoke-Doctor "push") { $r = Invoke-Git $ClaudeSkills @("push", "origin", $Branch) }
|
|
}
|
|
if ($r.Code -ne 0) { Write-Host $r.Out; Fail "push failed (auth? see skill Phase 1)." }
|
|
Assert-RemoteInSync $ClaudeSkills $Branch
|
|
# ls-remote only proves the ref plane works. A sync run can push happily while the
|
|
# server hands out empty packs, and then no other machine can fetch what we just
|
|
# pushed - which is precisely how the 2026-08 outage hid. The doctor's transport
|
|
# probe is a few KB and returns without touching ssh when everything is fine, so
|
|
# "in sync" is only reported once fetching actually works.
|
|
if (-not $NoDoctor) {
|
|
if (-not (Invoke-Doctor "post-sync fetchability probe")) {
|
|
Fail "this machine's work is on the server, but the server cannot serve packs: no other machine can fetch it. Not a successful sync - see the skill's Phase 1.5."
|
|
}
|
|
}
|
|
}
|
|
|
|
# ---------- 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 ==="
|
|
|
|
foreach ($reg in $registries) {
|
|
Write-Host "-- $reg"
|
|
if (-not (Test-Path $reg)) { Write-Host " (absent, skipped)"; continue }
|
|
$foreign = 0
|
|
|
|
# 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
|
|
}
|
|
|
|
$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)" }
|
|
}
|
|
}
|
|
|
|
# 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."
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
Write-Host "=== sync_repos done (Codex picks up changes on its next new session) ==="
|