# 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. param( [Parameter(Mandatory = $true)][string]$RepoUrl, [string]$ClaudeSkills = "$env:USERPROFILE\.claude\skills", [string]$CodexSkills = "$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). [switch]$Force, # Report what would happen without committing/pushing/linking. [switch]$DryRun ) $ErrorActionPreference = "Stop" 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 } # ---------- Phase 1: primary checkout (~\.claude\skills) ---------- Write-Host "=== [1/2] 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) { 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. Resolve conflicts (or 'git rebase --abort') then re-run. If the transport itself is broken (see skill Phase 1), fix the server -- do not leave commits stranded." } Write-Host $r.Out $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 } # ---------- 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 } # 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") } } $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 } } 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)" } } # 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)" } } 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." } # 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) ==="