diff --git a/tools-sync/scripts/sync_repos.ps1 b/tools-sync/scripts/sync_repos.ps1 index c548545..19968cc 100644 --- a/tools-sync/scripts/sync_repos.ps1 +++ b/tools-sync/scripts/sync_repos.ps1 @@ -117,6 +117,28 @@ function Get-DirFingerprint([string]$Path) { 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 @@ -272,6 +294,17 @@ foreach ($reg in $registries) { $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 --- @@ -287,6 +320,15 @@ foreach ($reg in $registries) { 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 @@ -332,7 +374,10 @@ if ($MirrorRegistry) { if (-not $DryRun) { Remove-Item -Recurse -Force $mirrorGit } } - $primarySkills = Get-ChildItem $ClaudeSkills -Directory | Where-Object { $_.Name -notlike ".*" } + # 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. + $primarySkills = Get-ChildItem $ClaudeSkills -Directory -Force | + Where-Object { $_.Name -notlike ".*" -and -not (Test-IsLink $_) } foreach ($d in $primarySkills) { $link = Join-Path $MirrorRegistry $d.Name if (Test-Path $link) { continue } diff --git a/tools-sync/tests/test_sync_repos_registries.ps1 b/tools-sync/tests/test_sync_repos_registries.ps1 index 05bb385..a8f1e66 100644 --- a/tools-sync/tests/test_sync_repos_registries.ps1 +++ b/tools-sync/tests/test_sync_repos_registries.ps1 @@ -27,6 +27,12 @@ try { # A link into the other registry rather than the primary: the drift case. New-Item -ItemType Junction -Path (Join-Path $codex "shared") -Target (Join-Path $agents "shared") | Out-Null + # Host-installed shape: the real directory lives in a registry and the PRIMARY is the link + # (Orca installs this way). Collapsing the registry copy here would make the link point at + # itself and delete the only copy - the reconciler must recognise it and keep its hands off. + New-Skill (Join-Path $agents "hostowned") "installed by the host" + New-Item -ItemType Junction -Path (Join-Path $primary "hostowned") -Target (Join-Path $agents "hostowned") | Out-Null + # A local bare repo stands in for origin so Phase 1 completes and Phase 2 actually runs. $origin = Join-Path $root "origin.git" & git init --quiet --bare $origin @@ -57,6 +63,15 @@ try { if ((Get-Content -Raw (Join-Path $primary "forked\SKILL.md")) -notmatch 'primary body') { throw "primary was overwritten by a conflict`n$output" } if ($output -notmatch 'not a skill dir') { throw "directory without SKILL.md not reported`n$output" } + + # The host-installed skill must survive intact, with the primary still a link at the registry. + $hostReal = Get-Item -Force (Join-Path $agents "hostowned") + if ($hostReal.LinkType -eq "Junction") { throw "host-installed skill's real dir was collapsed into a link`n$output" } + if ((Get-Content -Raw (Join-Path $agents "hostowned\SKILL.md")) -notmatch 'installed by the host') { throw "host-installed skill's content was lost`n$output" } + $hostPrimary = Get-Item -Force (Join-Path $primary "hostowned") + if (($hostPrimary.Target | Select-Object -First 1) -ne (Join-Path $agents "hostowned")) { throw "primary link to the host-installed skill was repointed`n$output" } + # Mirroring it into the registry Codex scans would register the same skill twice. + if (Test-Path (Join-Path $codex "hostowned")) { throw "host-installed skill was mirrored, double-registering it`n$output" } # Mirror registry must expose every primary skill, including the freshly ingested one. foreach ($name in @("shared", "forked", "codexborn")) { if (-not (Test-Path (Join-Path $codex "$name\SKILL.md"))) { throw "mirror missing $name`n$output" }