diff --git a/packages/mosaic/src/commands/skill.spec.ts b/packages/mosaic/src/commands/skill.spec.ts index 6877810..969d6f9 100644 --- a/packages/mosaic/src/commands/skill.spec.ts +++ b/packages/mosaic/src/commands/skill.spec.ts @@ -65,6 +65,7 @@ describe('Claude skill bridge', () => { String.raw`a\b`, '-rf', '..', + 'safe.', 'space name', 'line\nbreak', 'escape\u001B[31m', @@ -204,6 +205,16 @@ describe('Claude skill bridge', () => { expect(unregisterSkill('removable', paths).status).toBe('already-unregistered'); }); + it('refuses to remove a misdirected Mosaic-root symlink', () => { + createSkill('other'); + mkdirSync(paths.claudeSkillsDir, { recursive: true }); + const requested = join(paths.claudeSkillsDir, 'requested'); + symlinkSync(join(paths.mosaicSkillsDir, 'other'), requested); + + expect(() => unregisterSkill('requested', paths)).toThrow(/misdirected/i); + expect(readlinkSync(requested)).toBe(join(paths.mosaicSkillsDir, 'other')); + }); + it.each(['file', 'directory', 'symlink'] as const)('refuses to remove a foreign %s', (kind) => { mkdirSync(paths.claudeSkillsDir, { recursive: true }); const target = join(paths.claudeSkillsDir, 'protected'); @@ -242,6 +253,30 @@ describe('Claude skill bridge', () => { }); describe('install linker compatibility', () => { + it('preserves foreign-name links into Mosaic home but outside canonical skills', () => { + createSkill('missing'); + mkdirSync(paths.claudeSkillsDir, { recursive: true }); + const mosaicHome = join(root, '.config', 'mosaic'); + const liveForeignTarget = join(mosaicHome, 'foreign-non-skill-target'); + mkdirSync(liveForeignTarget); + const liveForeignLink = join(paths.claudeSkillsDir, 'foreign-tool'); + const danglingForeignLink = join(paths.claudeSkillsDir, 'unresolvable-foreign'); + symlinkSync(liveForeignTarget, liveForeignLink); + symlinkSync(join(mosaicHome, 'foreign-missing'), danglingForeignLink); + + const result = spawnSync('bash', [LEGACY_SYNC_SCRIPT, '--link-only'], { + encoding: 'utf8', + env: { ...process.env, HOME: root, MOSAIC_HOME: mosaicHome }, + }); + + expect(result.status, result.stderr).toBe(0); + expect(readlinkSync(liveForeignLink)).toBe(liveForeignTarget); + expect(readlinkSync(danglingForeignLink)).toBe(join(mosaicHome, 'foreign-missing')); + expect(readlinkSync(join(paths.claudeSkillsDir, 'missing'))).toBe( + join(paths.mosaicSkillsDir, 'missing'), + ); + }); + it('preserves live and dangling foreign Claude symlinks while linking missing skills', () => { createSkill('dangling-foreign'); createSkill('live-foreign');