445 lines
16 KiB
TypeScript
445 lines
16 KiB
TypeScript
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,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readlinkSync,
|
|
readFileSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import {
|
|
listSkills,
|
|
registerSkill,
|
|
registerSkillCommand,
|
|
syncClaudeSkills,
|
|
unregisterSkill,
|
|
type SkillPaths,
|
|
} from './skill.js';
|
|
|
|
const LEGACY_SYNC_SCRIPT = fileURLToPath(
|
|
new URL('../../framework/tools/_scripts/mosaic-sync-skills', import.meta.url),
|
|
);
|
|
const CONTEXT_REFRESH_SOURCE_SKILL = fileURLToPath(
|
|
new URL('../../framework/skills/mosaic-context-refresh/SKILL.md', import.meta.url),
|
|
);
|
|
|
|
describe('Claude skill bridge', () => {
|
|
let root: string;
|
|
let paths: SkillPaths;
|
|
|
|
beforeEach(() => {
|
|
root = mkdtempSync(join(tmpdir(), 'mosaic-skill-cli-'));
|
|
paths = {
|
|
mosaicSkillsDir: join(root, '.config', 'mosaic', 'skills'),
|
|
claudeSkillsDir: join(root, '.claude', 'skills'),
|
|
};
|
|
mkdirSync(paths.mosaicSkillsDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
function createSkill(name: string): string {
|
|
const skillDir = join(paths.mosaicSkillsDir, name);
|
|
mkdirSync(skillDir, { recursive: true });
|
|
writeFileSync(join(skillDir, 'SKILL.md'), `# ${name}\n`);
|
|
return skillDir;
|
|
}
|
|
|
|
function expectCorrectLink(name: string): void {
|
|
const linkPath = join(paths.claudeSkillsDir, name);
|
|
expect(lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
|
expect(readlinkSync(linkPath)).toBe(join(paths.mosaicSkillsDir, name));
|
|
}
|
|
|
|
describe('name validation', () => {
|
|
const invalidNames = [
|
|
'../../etc',
|
|
'/abs/path',
|
|
'a/b',
|
|
String.raw`a\b`,
|
|
'-rf',
|
|
'..',
|
|
'safe.',
|
|
'space name',
|
|
'line\nbreak',
|
|
'escape\u001B[31m',
|
|
];
|
|
|
|
for (const name of invalidNames) {
|
|
it(`rejects ${JSON.stringify(name)} before register can escape its roots`, () => {
|
|
expect(() => registerSkill(name, paths)).toThrow(/invalid skill name/i);
|
|
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
|
|
});
|
|
|
|
it(`rejects ${JSON.stringify(name)} before unregister can escape its roots`, () => {
|
|
expect(() => unregisterSkill(name, paths)).toThrow(/invalid skill name/i);
|
|
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('CLI validation errors', () => {
|
|
let previousExitCode: number | string | null | undefined;
|
|
|
|
beforeEach(() => {
|
|
previousExitCode = process.exitCode;
|
|
process.exitCode = undefined;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.exitCode = previousExitCode;
|
|
});
|
|
|
|
it.each(['register', 'unregister'])(
|
|
'reports invalid %s names on stderr and sets a nonzero exit status',
|
|
async (subcommand) => {
|
|
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
const program = new Command().exitOverride();
|
|
registerSkillCommand(program, paths);
|
|
|
|
await program.parseAsync(['node', 'mosaic', 'skill', subcommand, '../../etc']);
|
|
|
|
expect(error).toHaveBeenCalledWith(expect.stringMatching(/invalid skill name/i));
|
|
expect(process.exitCode).toBe(1);
|
|
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
|
|
error.mockRestore();
|
|
},
|
|
);
|
|
});
|
|
|
|
describe('CLI status output', () => {
|
|
let previousExitCode: number | string | null | undefined;
|
|
|
|
beforeEach(() => {
|
|
previousExitCode = process.exitCode;
|
|
process.exitCode = undefined;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.exitCode = previousExitCode;
|
|
});
|
|
|
|
async function run(...args: string[]): Promise<void> {
|
|
const program = new Command().exitOverride();
|
|
registerSkillCommand(program, paths);
|
|
await program.parseAsync(['node', 'mosaic', 'skill', ...args]);
|
|
}
|
|
|
|
it('reports register repair/idempotency and unregister idempotency statuses', async () => {
|
|
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
createSkill('status-skill');
|
|
|
|
await run('register', 'status-skill');
|
|
await run('register', 'status-skill');
|
|
rmSync(join(paths.claudeSkillsDir, 'status-skill'));
|
|
symlinkSync(
|
|
join(paths.mosaicSkillsDir, 'retired'),
|
|
join(paths.claudeSkillsDir, 'status-skill'),
|
|
);
|
|
await run('register', 'status-skill');
|
|
await run('unregister', 'status-skill');
|
|
await run('unregister', 'status-skill');
|
|
|
|
expect(log.mock.calls.flat()).toEqual([
|
|
'status-skill: registered',
|
|
'status-skill: already registered',
|
|
'status-skill: repaired dangling registration',
|
|
'status-skill: unregistered',
|
|
'status-skill: already unregistered',
|
|
]);
|
|
log.mockRestore();
|
|
});
|
|
|
|
it('reports empty and populated skill lists', async () => {
|
|
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
|
|
await run('list');
|
|
createSkill('listed');
|
|
await run('list');
|
|
|
|
expect(log).toHaveBeenCalledWith('No Mosaic or Claude Code skills found.');
|
|
expect(log).toHaveBeenCalledWith(expect.stringMatching(/^unregistered\s+listed$/));
|
|
log.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('registerSkill', () => {
|
|
it('creates the exact canonical symlink and is idempotent', () => {
|
|
createSkill('new-skill');
|
|
|
|
expect(registerSkill('new-skill', paths).status).toBe('registered');
|
|
expectCorrectLink('new-skill');
|
|
|
|
expect(registerSkill('new-skill', paths).status).toBe('already-registered');
|
|
expectCorrectLink('new-skill');
|
|
});
|
|
|
|
it('repairs a dangling Mosaic-owned symlink', () => {
|
|
createSkill('new-skill');
|
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
|
symlinkSync(
|
|
join(paths.mosaicSkillsDir, 'retired-skill'),
|
|
join(paths.claudeSkillsDir, 'new-skill'),
|
|
);
|
|
|
|
expect(registerSkill('new-skill', paths).status).toBe('repaired');
|
|
expectCorrectLink('new-skill');
|
|
});
|
|
|
|
it.each(['file', 'directory', 'symlink'] as const)(
|
|
'refuses to clobber a foreign %s at the target',
|
|
(kind) => {
|
|
createSkill('protected');
|
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
|
const target = join(paths.claudeSkillsDir, 'protected');
|
|
const foreign = join(root, 'foreign');
|
|
|
|
if (kind === 'file') writeFileSync(target, 'keep me\n');
|
|
if (kind === 'directory') mkdirSync(target);
|
|
if (kind === 'symlink') {
|
|
writeFileSync(foreign, 'keep me\n');
|
|
symlinkSync(foreign, target);
|
|
}
|
|
|
|
expect(() => registerSkill('protected', paths)).toThrow(/foreign|refus/i);
|
|
if (kind === 'file') expect(lstatSync(target).isFile()).toBe(true);
|
|
if (kind === 'directory') expect(lstatSync(target).isDirectory()).toBe(true);
|
|
if (kind === 'symlink') expect(readlinkSync(target)).toBe(foreign);
|
|
},
|
|
);
|
|
|
|
it('refuses a symlinked Claude skills ancestor instead of writing outside the bridge root', () => {
|
|
createSkill('protected');
|
|
const externalClaude = join(root, 'external-claude');
|
|
mkdirSync(externalClaude);
|
|
symlinkSync(externalClaude, join(root, '.claude'));
|
|
|
|
expect(() => registerSkill('protected', paths)).toThrow(
|
|
/symlink.*ancestor|ancestor.*symlink/i,
|
|
);
|
|
expect(existsSync(join(externalClaude, 'skills', 'protected'))).toBe(false);
|
|
});
|
|
|
|
it('refuses a symlinked canonical skills root instead of registering an external source', () => {
|
|
rmSync(paths.mosaicSkillsDir, { recursive: true });
|
|
const externalSkills = join(root, 'external-skills');
|
|
mkdirSync(join(externalSkills, 'protected'), { recursive: true });
|
|
symlinkSync(externalSkills, paths.mosaicSkillsDir);
|
|
|
|
expect(() => registerSkill('protected', paths)).toThrow(
|
|
/symlink.*ancestor|ancestor.*symlink/i,
|
|
);
|
|
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
|
|
});
|
|
|
|
it('refuses a dangling foreign symlink rather than treating it as repairable', () => {
|
|
createSkill('protected');
|
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
|
const foreignMissing = join(root, 'foreign-missing');
|
|
const target = join(paths.claudeSkillsDir, 'protected');
|
|
symlinkSync(foreignMissing, target);
|
|
|
|
expect(() => registerSkill('protected', paths)).toThrow(/foreign|refus/i);
|
|
expect(readlinkSync(target)).toBe(foreignMissing);
|
|
});
|
|
});
|
|
|
|
describe('unregisterSkill', () => {
|
|
it('removes a Mosaic-owned symlink and is idempotent when absent', () => {
|
|
createSkill('removable');
|
|
registerSkill('removable', paths);
|
|
|
|
expect(unregisterSkill('removable', paths).status).toBe('unregistered');
|
|
expect(existsSync(join(paths.claudeSkillsDir, 'removable'))).toBe(false);
|
|
|
|
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');
|
|
const foreign = join(root, 'foreign');
|
|
|
|
if (kind === 'file') writeFileSync(target, 'keep me\n');
|
|
if (kind === 'directory') mkdirSync(target);
|
|
if (kind === 'symlink') {
|
|
writeFileSync(foreign, 'keep me\n');
|
|
symlinkSync(foreign, target);
|
|
}
|
|
|
|
expect(() => unregisterSkill('protected', paths)).toThrow(/foreign|refus/i);
|
|
expect(lstatSync(target)).toBeDefined();
|
|
if (kind === 'symlink') expect(readlinkSync(target)).toBe(foreign);
|
|
});
|
|
});
|
|
|
|
describe('listSkills', () => {
|
|
it('flags registered, unregistered, Mosaic-owned dangling, and foreign entries', () => {
|
|
createSkill('registered');
|
|
createSkill('unregistered');
|
|
registerSkill('registered', paths);
|
|
symlinkSync(join(paths.mosaicSkillsDir, 'retired'), join(paths.claudeSkillsDir, 'dangling'));
|
|
writeFileSync(join(paths.claudeSkillsDir, 'foreign-file'), 'keep me\n');
|
|
symlinkSync(join(root, 'missing-foreign'), join(paths.claudeSkillsDir, 'foreign-link'));
|
|
|
|
expect(listSkills(paths)).toEqual([
|
|
expect.objectContaining({ name: 'dangling', status: 'dangling' }),
|
|
expect.objectContaining({ name: 'foreign-file', status: 'foreign' }),
|
|
expect.objectContaining({ name: 'foreign-link', status: 'foreign-dangling' }),
|
|
expect.objectContaining({ name: 'registered', status: 'registered' }),
|
|
expect.objectContaining({ name: 'unregistered', status: 'unregistered' }),
|
|
]);
|
|
});
|
|
});
|
|
|
|
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');
|
|
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('projects the durable context-refresh skill through the #824 bridge in a tmp root', () => {
|
|
expect(existsSync(CONTEXT_REFRESH_SOURCE_SKILL)).toBe(true);
|
|
const source = readFileSync(CONTEXT_REFRESH_SOURCE_SKILL, 'utf8');
|
|
expect(source).toContain('recover-context.py');
|
|
expect(source).toContain('middle drop is not represented as receipt-detectable');
|
|
const skillDir = createSkill('mosaic-context-refresh');
|
|
writeFileSync(join(skillDir, 'SKILL.md'), source);
|
|
|
|
const result = syncClaudeSkills(paths);
|
|
|
|
expect(result).toEqual({
|
|
registered: ['mosaic-context-refresh'],
|
|
repaired: [],
|
|
unchanged: [],
|
|
conflicts: [],
|
|
});
|
|
expectCorrectLink('mosaic-context-refresh');
|
|
});
|
|
|
|
it('generically creates every missing canonical link and repairs managed broken links', () => {
|
|
createSkill('added-after-setup');
|
|
createSkill('another-new-skill');
|
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
|
symlinkSync(
|
|
join(paths.mosaicSkillsDir, 'retired'),
|
|
join(paths.claudeSkillsDir, 'added-after-setup'),
|
|
);
|
|
|
|
const result = syncClaudeSkills(paths);
|
|
|
|
expect(result).toEqual({
|
|
registered: ['another-new-skill'],
|
|
repaired: ['added-after-setup'],
|
|
unchanged: [],
|
|
conflicts: [],
|
|
});
|
|
expectCorrectLink('added-after-setup');
|
|
expectCorrectLink('another-new-skill');
|
|
});
|
|
|
|
it('escapes an invalid filesystem-derived name in conflict output', () => {
|
|
createSkill('line\nbreak');
|
|
|
|
const result = syncClaudeSkills(paths);
|
|
|
|
expect(result.registered).toEqual([]);
|
|
expect(result.conflicts).toEqual([
|
|
expect.objectContaining({
|
|
name: '"line\\nbreak"',
|
|
reason: expect.stringMatching(/invalid/i),
|
|
}),
|
|
]);
|
|
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
|
|
});
|
|
|
|
it('continues syncing other skills without clobbering foreign entries', () => {
|
|
createSkill('blocked');
|
|
createSkill('link-me');
|
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
|
const blocked = join(paths.claudeSkillsDir, 'blocked');
|
|
writeFileSync(blocked, 'keep me\n');
|
|
|
|
const result = syncClaudeSkills(paths);
|
|
|
|
expect(result.registered).toEqual(['link-me']);
|
|
expect(result.conflicts).toEqual([
|
|
expect.objectContaining({
|
|
name: 'blocked',
|
|
reason: expect.stringMatching(/foreign|refus/i),
|
|
}),
|
|
]);
|
|
expect(readlinkSync(join(paths.claudeSkillsDir, 'link-me'))).toBe(
|
|
join(paths.mosaicSkillsDir, 'link-me'),
|
|
);
|
|
expect(lstatSync(blocked).isFile()).toBe(true);
|
|
});
|
|
});
|
|
});
|