feat(mosaic): add secure skill registration CLI #826

Merged
jason.woltje merged 5 commits from feat/824-mosaic-skill-cli into main 2026-07-17 23:45:36 +00:00
Showing only changes of commit d014a7830a - Show all commits

View File

@@ -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');