diff --git a/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills b/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills index cf6af7c..10c128c 100755 --- a/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills +++ b/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills @@ -161,6 +161,7 @@ link_targets=( ) canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")" +local_real="$(readlink -f "$MOSAIC_LOCAL_SKILLS_DIR")" # Build an associative array from the colon-separated whitelist for O(1) lookup. # When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed. @@ -203,7 +204,14 @@ link_skill_into_target() { link_path="$target_dir/$name" if [[ -L "$link_path" ]]; then - ln -sfn "$skill_path" "$link_path" + local raw_target resolved_target + raw_target="$(readlink "$link_path")" + resolved_target="$(node -e 'const p=require("node:path"); process.stdout.write(p.resolve(p.dirname(process.argv[1]), process.argv[2]));' "$link_path" "$raw_target")" + if [[ "$resolved_target" == "$canonical_real/"* || "$resolved_target" == "$local_real/"* ]]; then + ln -sfn "$skill_path" "$link_path" + else + echo "[mosaic-skills] Preserve foreign runtime symlink: $link_path" + fi return fi diff --git a/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills.ps1 b/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills.ps1 index c2a8fd5..8bf30c5 100644 --- a/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills.ps1 +++ b/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills.ps1 @@ -79,9 +79,26 @@ function Link-SkillIntoTarget { $linkPath = Join-Path $TargetDir $name - # Already a junction/symlink — recreate + # Recreate only Mosaic-owned junctions/symlinks. Foreign reparse points are + # runtime-owned and must never be clobbered by install/upgrade auto-sync. $existing = Get-Item $linkPath -Force -ErrorAction SilentlyContinue if ($existing -and ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + $rawTarget = @($existing.Target)[0] + $candidate = if ([System.IO.Path]::IsPathRooted($rawTarget)) { + $rawTarget + } + else { + Join-Path (Split-Path $linkPath -Parent) $rawTarget + } + $resolvedTarget = [System.IO.Path]::GetFullPath($candidate) + $canonicalRoot = [System.IO.Path]::GetFullPath($MosaicSkillsDir).TrimEnd('\') + '\' + $localRoot = [System.IO.Path]::GetFullPath($MosaicLocalSkillsDir).TrimEnd('\') + '\' + $owned = $resolvedTarget.StartsWith($canonicalRoot, [System.StringComparison]::OrdinalIgnoreCase) -or + $resolvedTarget.StartsWith($localRoot, [System.StringComparison]::OrdinalIgnoreCase) + if (-not $owned) { + Write-Host "[mosaic-skills] Preserve foreign runtime symlink: $linkPath" + return + } Remove-Item $linkPath -Force } elseif ($existing) { diff --git a/packages/mosaic/src/commands/skill.spec.ts b/packages/mosaic/src/commands/skill.spec.ts index 62f194a..6877810 100644 --- a/packages/mosaic/src/commands/skill.spec.ts +++ b/packages/mosaic/src/commands/skill.spec.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Command } from 'commander'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import { existsSync, lstatSync, @@ -21,6 +23,10 @@ import { type SkillPaths, } from './skill.js'; +const LEGACY_SYNC_SCRIPT = fileURLToPath( + new URL('../../framework/tools/_scripts/mosaic-sync-skills', import.meta.url), +); + describe('Claude skill bridge', () => { let root: string; let paths: SkillPaths; @@ -235,6 +241,33 @@ describe('Claude skill bridge', () => { }); }); + describe('install linker compatibility', () => { + it('preserves live and dangling foreign Claude symlinks while linking missing skills', () => { + createSkill('dangling-foreign'); + createSkill('live-foreign'); + createSkill('missing'); + mkdirSync(paths.claudeSkillsDir, { recursive: true }); + const external = join(root, 'external'); + mkdirSync(external); + const liveLink = join(paths.claudeSkillsDir, 'live-foreign'); + const danglingLink = join(paths.claudeSkillsDir, 'dangling-foreign'); + symlinkSync(external, liveLink); + symlinkSync(join(root, 'external-missing'), danglingLink); + + const result = spawnSync('bash', [LEGACY_SYNC_SCRIPT, '--link-only'], { + encoding: 'utf8', + env: { ...process.env, HOME: root, MOSAIC_HOME: join(root, '.config', 'mosaic') }, + }); + + expect(result.status, result.stderr).toBe(0); + expect(readlinkSync(liveLink)).toBe(external); + expect(readlinkSync(danglingLink)).toBe(join(root, 'external-missing')); + expect(readlinkSync(join(paths.claudeSkillsDir, 'missing'))).toBe( + join(paths.mosaicSkillsDir, 'missing'), + ); + }); + }); + describe('syncClaudeSkills', () => { it('generically creates every missing canonical link and repairs managed broken links', () => { createSkill('added-after-setup');