129 lines
5.8 KiB
PowerShell
129 lines
5.8 KiB
PowerShell
# 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"
|
|
}
|
|
|
|
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: 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) ==="
|