# tools-sync: mechanical git sync of the two skills checkouts. # Claude side (~\.claude\skills) is the primary working copy: commit -> pull --rebase -> push. # Codex side (~\.codex\skills) is a mirror checkout of the same repo: bootstrap if needed, then pull. # Judgment calls (conflicts, codex-only skills 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", # Bootstrap of the codex checkout stops when local files differ from the repo; # -Force lets it overwrite them (caller must have rescued codex-only edits first). [switch]$Force, # Report what would happen without committing/pushing/checking out. [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: $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" } if (-not $DryRun) { $r = Invoke-Git $ClaudeSkills @("pull", "--rebase", "origin", $Branch) if ($r.Code -ne 0) { Write-Host $r.Out Fail "pull --rebase failed on primary. Resolve conflicts (or 'git rebase --abort') then re-run." } 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)." } Write-Host "primary in sync with origin/$Branch" } # ---------- Phase 2: mirror checkout (~\.codex\skills) ---------- Write-Host "=== [2/2] mirror: $CodexSkills ===" if (-not (Test-Path $CodexSkills)) { New-Item -ItemType Directory -Force $CodexSkills | Out-Null } # Bootstrap is needed when there's no repo yet, or a previous bootstrap died before # origin/main ever arrived (init succeeded, fetch didn't). $needBootstrap = -not (Test-Path (Join-Path $CodexSkills ".git")) if (-not $needBootstrap) { # Upstream config is written as the very last bootstrap step, so its absence # means a previous bootstrap died partway — redo it (all steps are idempotent). $r = Invoke-Git $CodexSkills @("config", "--get", "branch.$Branch.merge") if ($r.Code -ne 0) { $needBootstrap = $true } } if ($needBootstrap) { Write-Host "bootstrapping mirror as a checkout of $RepoUrl" if ($DryRun) { Write-Host "(dry-run) would git init + fetch + align to origin/$Branch"; exit 0 } Invoke-Git $CodexSkills @("init") | Out-Null Invoke-Git $CodexSkills @("remote", "add", "origin", $RepoUrl) | Out-Null Invoke-Git $CodexSkills @("remote", "set-url", "origin", $RepoUrl) | Out-Null $r = Invoke-Git $CodexSkills @("fetch", "origin", $Branch) if ($r.Code -ne 0) { # Flaky server on a full first fetch is common; the primary checkout already # holds every object locally, so borrow them from disk instead. Write-Host "server fetch failed, retrying from local primary checkout..." $r = Invoke-Git $CodexSkills @("fetch", $ClaudeSkills, "+refs/heads/${Branch}:refs/remotes/origin/$Branch") if ($r.Code -ne 0) { Write-Host $r.Out; Fail "fetch failed during bootstrap (server and local)." } } # Point index at origin/main without touching files, so status shows local-vs-repo drift. Invoke-Git $CodexSkills @("reset", "origin/$Branch") | Out-Null Invoke-Git $CodexSkills @("branch", "-f", $Branch, "origin/$Branch") | Out-Null Invoke-Git $CodexSkills @("checkout", $Branch) 2>$null | Out-Null $drift = Invoke-Git $CodexSkills @("status", "--porcelain") # Only M is real local drift worth rescuing; D just means the file hasn't been # materialized in this checkout yet, so overwriting loses nothing. $modified = ($drift.Out -split "`n" | Where-Object { $_ -match '^\s*M' }) if ($modified -and -not $Force) { Write-Host "these tracked files differ locally from the repo:" $modified | ForEach-Object { Write-Host " $_" } Fail "codex dir has local drift. Rescue anything worth keeping into the primary checkout, then re-run with -Force." } $r = Invoke-Git $CodexSkills @("checkout", "-f", $Branch) if ($r.Code -ne 0) { Write-Host $r.Out; Fail "checkout failed during bootstrap." } Invoke-Git $CodexSkills @("branch", "--set-upstream-to=origin/$Branch", $Branch) | Out-Null Write-Host "mirror bootstrapped at origin/$Branch (untracked codex-local files left untouched)" } else { if ($DryRun) { Invoke-Git $CodexSkills @("fetch", "origin", $Branch) | Out-Null $r = Invoke-Git $CodexSkills @("log", "--oneline", "HEAD..origin/$Branch") Write-Host "(dry-run) mirror is behind by:" if ($r.Out) { Write-Host $r.Out } else { Write-Host " (nothing, or not yet a checkout)" } } else { $st2 = Invoke-Git $CodexSkills @("status", "--porcelain") if ($st2.Out) { Write-Host "WARNING: mirror has local changes (should be edited on the primary side instead):" Write-Host $st2.Out } $r = Invoke-Git $CodexSkills @("pull", "--rebase", "origin", $Branch) if ($r.Code -ne 0) { Write-Host $r.Out; Fail "pull failed on mirror." } Write-Host $r.Out Write-Host "mirror in sync with origin/$Branch" } } Write-Host "=== sync_repos done ==="