Browse Source

fix(scripts): Merge-sync skills in install.ps1, never delete-then-copy

The skill sync used Remove-Item -Recurse before copying. When a
registered service holds a skill dir as its CWD (Process Compose
"fleetflow" runs ff-serve.py from ~/.claude/skills/fleetflow), the
directory handle is locked on Windows: Remove-Item deletes every file
inside, then fails on the dir itself and aborts the whole run - on
2026-08-01 this destroyed machine-local unversioned files and left all
skills after fleetflow unsynced.

Now each skill is merge-synced file-by-file (works while the dir is
locked), dest-only files are listed as kept instead of deleted, a
failure on one skill or file continues to the next with a summary, and
the deprecated/renamed cleanup loops warn-and-continue on locked paths.

Tested by running install.ps1 with the fleetflow service live: full run
completes, all 17 fleetflow files sync, a planted dest-only marker
survives and is reported, 73 skills after fleetflow sync, and the
service keeps serving (HTTP 200) throughout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xDarkMatter 1 month ago
parent
commit
ac0a041730
1 changed files with 77 additions and 12 deletions
  1. 77 12
      scripts/install.ps1

+ 77 - 12
scripts/install.ps1

@@ -105,17 +105,25 @@ $renamedSkills = @(
 foreach ($oldSkill in $renamedSkills) {
     $oldPath = "$claudeDir\skills\$oldSkill"
     if (Test-Path $oldPath) {
-        Remove-Item -Path $oldPath -Recurse -Force
-        $newName = $oldSkill -replace '-patterns$', '-ops'
-        Write-Host "  Removed renamed: $oldSkill (now $newName)" -ForegroundColor Red
+        try {
+            Remove-Item -Path $oldPath -Recurse -Force -ErrorAction Stop
+            $newName = $oldSkill -replace '-patterns$', '-ops'
+            Write-Host "  Removed renamed: $oldSkill (now $newName)" -ForegroundColor Red
+        } catch {
+            Write-Host "  WARNING: could not remove $oldPath ($($_.Exception.Message)) - continuing" -ForegroundColor Yellow
+        }
     }
 }
 
 Write-Host "Cleaning up deprecated items..." -ForegroundColor Yellow
 foreach ($item in $deprecated) {
     if (Test-Path $item) {
-        Remove-Item -Path $item -Recurse -Force
-        Write-Host "  Removed: $item" -ForegroundColor Red
+        try {
+            Remove-Item -Path $item -Recurse -Force -ErrorAction Stop
+            Write-Host "  Removed: $item" -ForegroundColor Red
+        } catch {
+            Write-Host "  WARNING: could not remove $item ($($_.Exception.Message)) - continuing" -ForegroundColor Yellow
+        }
     }
 }
 Write-Host ""
@@ -137,18 +145,75 @@ Get-ChildItem -Path $commandsDir -Filter "*.md" | ForEach-Object {
 Write-Host ""
 
 # =============================================================================
-# SKILLS - Copy all skill directories
+# SKILLS - Merge-sync each skill directory (NEVER delete-then-copy)
+#
+# A registered service can hold a skill dir as its CWD (e.g. Process Compose
+# "fleetflow" runs python ff-serve.py with CWD ~/.claude/skills/fleetflow),
+# which locks the directory handle on Windows. On 2026-08-01 the old
+# Remove-Item pass half-deleted that dir (destroying machine-local unversioned
+# files) before failing, then aborted the run, leaving every skill after it
+# unsynced. So: file-level overwrite copy (works while the dir is locked),
+# dest-only files are reported but never deleted, and a failure on one skill
+# continues to the next.
 # =============================================================================
 Write-Host "Installing skills..." -ForegroundColor Cyan
 
 $skillsDir = Join-Path $projectRoot "skills"
-Get-ChildItem -Path $skillsDir -Directory | ForEach-Object {
-    $dest = "$claudeDir\skills\$($_.Name)"
-    if (Test-Path $dest) {
-        Remove-Item -Path $dest -Recurse -Force
+$failedSkills = @()
+foreach ($skill in (Get-ChildItem -Path $skillsDir -Directory)) {
+    $src = $skill.FullName
+    $dest = "$claudeDir\skills\$($skill.Name)"
+    try {
+        if (-not (Test-Path $dest)) {
+            Copy-Item -Path $src -Destination $dest -Recurse -Force -ErrorAction Stop
+            Write-Host "  $($skill.Name)/" -ForegroundColor Green
+            continue
+        }
+
+        # Merge copy: overwrite file-by-file so a locked destination still syncs.
+        $srcRel = @{}
+        $fileErrors = @()
+        foreach ($f in (Get-ChildItem -Path $src -Recurse -File)) {
+            $rel = $f.FullName.Substring($src.Length + 1)
+            $srcRel[$rel] = $true
+            try {
+                $destFile = Join-Path $dest $rel
+                $destFileDir = Split-Path -Parent $destFile
+                if (-not (Test-Path $destFileDir)) {
+                    New-Item -ItemType Directory -Path $destFileDir -Force -ErrorAction Stop | Out-Null
+                }
+                Copy-Item -Path $f.FullName -Destination $destFile -Force -ErrorAction Stop
+            } catch {
+                $fileErrors += "$rel ($($_.Exception.Message))"
+            }
+        }
+
+        # Dest-only files are machine-local (unversioned) or stale. Surface
+        # them, never delete them - deleting is the 2026-08-01 data loss.
+        $destOnly = @(Get-ChildItem -Path $dest -Recurse -File | Where-Object {
+            -not $srcRel.ContainsKey($_.FullName.Substring($dest.Length + 1))
+        })
+
+        if ($fileErrors.Count -gt 0) {
+            $failedSkills += $skill.Name
+            Write-Host "  $($skill.Name)/ - $($fileErrors.Count) file(s) failed to sync:" -ForegroundColor Red
+            foreach ($e in $fileErrors) { Write-Host "    $e" -ForegroundColor Red }
+        } elseif ($destOnly.Count -gt 0) {
+            Write-Host "  $($skill.Name)/ (kept $($destOnly.Count) dest-only file(s) not in repo)" -ForegroundColor Yellow
+            foreach ($f in $destOnly) {
+                Write-Host "    $($f.FullName.Substring($dest.Length + 1))" -ForegroundColor Yellow
+            }
+        } else {
+            Write-Host "  $($skill.Name)/" -ForegroundColor Green
+        }
+    } catch {
+        $failedSkills += $skill.Name
+        Write-Host "  WARNING: $($skill.Name)/ failed to sync ($($_.Exception.Message)) - continuing" -ForegroundColor Red
     }
-    Copy-Item $_.FullName -Destination $dest -Recurse -Force
-    Write-Host "  $($_.Name)/" -ForegroundColor Green
+}
+if ($failedSkills.Count -gt 0) {
+    Write-Host ""
+    Write-Host "  $($failedSkills.Count) skill(s) had sync failures: $($failedSkills -join ', ')" -ForegroundColor Red
 }
 Write-Host ""