# 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 that need a human decision. # # 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 } # CRs are stripped before hashing: git checkout with autocrlf turns a pulled skill into # CRLF while the registry copy stays LF, and that alone used to be reported as a CONFLICT. $sha = [System.Security.Cryptography.SHA256]::Create() $lines = foreach ($f in $files) { $rel = $f.FullName.Substring($Path.Length).TrimStart('\').ToLowerInvariant() [byte[]]$bytes = @([System.IO.File]::ReadAllBytes($f.FullName) -ne 13) "$rel $([BitConverter]::ToString($sha.ComputeHash($bytes)))" } return (($lines | Sort-Object) -join "`n") } # Agent tools each keep their own skills directory and a host installer may pick any one of them # as a skill's physical home, linking the rest at it. Finding those linkers is how we tell a # host-installed skill from one authored on the Codex side. function Get-SkillDirLinkers([string]$TargetPath) { $roots = @() foreach ($d in Get-ChildItem $env:USERPROFILE -Directory -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -like ".*" }) { foreach ($sub in @("skills", "agent\skills")) { $cand = Join-Path $d.FullName $sub if (Test-Path $cand) { $roots += $cand } } } $hits = @() foreach ($r in $roots) { if (Test-UnderPath $TargetPath $r) { continue } # the home itself $link = Join-Path $r (Split-Path $TargetPath -Leaf) $item = Get-Item -LiteralPath $link -Force -ErrorAction SilentlyContinue if ($item -and (Test-IsLink $item) -and ((Get-LinkTarget $item) -eq $TargetPath)) { $hits += $r } } return $hits } 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 # The primary entry is itself a link into a registry: this skill's physical home is the # registry, and the host installed it that way round (Orca does exactly this - it puts the # real directory in ~\.agents\skills and links every tool at it). Collapsing the registry # copy here would point the link at itself and DELETE the only copy. Leave it to its owner. $primaryItem = Get-Item -LiteralPath $primaryPath -Force -ErrorAction SilentlyContinue if ($primaryItem -and (Test-IsLink $primaryItem)) { $foreign++ continue } $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 } # Other agent tools linking at this directory mean it is a host-installed skill's # physical home, not a skill authored here. Moving it inverts the host's layout and # its own updater then fights us for the location. $linkers = Get-SkillDirLinkers $copyPath if ($linkers) { $script:Notes += "$name lives in $copyPath and $($linkers.Count) other tool registr$(if ($linkers.Count -eq 1) { 'y links' } else { 'ies link' }) at it; treated as host-installed and left alone." Write-Host " host-installed skill (linked from $($linkers.Count) other tool(s)), not ingested: $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 } } # A primary entry that is itself a link belongs to whichever host installed it (its real home # is a registry the host already exposes to Codex). Mirroring it would register it twice. $primaryDirs = Get-ChildItem $ClaudeSkills -Directory -Force | Where-Object { $_.Name -notlike ".*" -and -not (Test-IsLink $_) } # A directory without SKILL.md at its top level is not a skill -- e.g. 'synced/', the # runtime cache of account-side skills Claude Code downloads. Mirroring it is worse than # useless: its NESTED directories do carry SKILL.md, so Codex would register that whole # set a second time alongside the ones it already gets from the account. $primarySkills = $primaryDirs | Where-Object { Test-Path (Join-Path $_.FullName "SKILL.md") } $notSkills = $primaryDirs | Where-Object { -not (Test-Path (Join-Path $_.FullName "SKILL.md")) } if ($notSkills) { Write-Host " not skills (no SKILL.md), not mirrored: $(($notSkills | ForEach-Object Name) -join ', ')" } 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) ==="