test/skill-families.test.js
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, readFileSync, existsSync, readdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import {
scanFamilies, readMap, familyOverrides, jobSkillFamilies, withSkillFamilies, writeFamiliesPlugin,
ungroupedNotice, reportUngrouped, frontMatter, sourceFamily, oneLine, DEFAULT_MAP,
missingPlugins, missingPluginsNote, knownFamilies,
} from '../server/skill-families.js';
import { resolveJobSkills, createJob } from '../lib/jobs.js';
import { parseListing } from '../server/skill-listing.js';
import { removeTempDir } from './temp-dir.js';
let root, claudeDir, agentsDir, mapFile, pluginDir;
const skill = (dir, name, description) => {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\nbody\n`);
};
const homes = () => ({ claudeDir, agentsDir });
// What this machine's Claude Code listed (server/skill-listing.js): a captured request.
const VERSION = '2.1.295 (Claude Code)';
const listing = { version: VERSION, skills: parseListing(readFileSync(join(import.meta.dirname, 'fixtures', 'skill-probe-request.json'), 'utf8')) };
const scan = (probed = listing) => scanFamilies({ claudeDir, agentsDir, map: readMap(mapFile), listing: probed });
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'a007-families-'));
claudeDir = join(root, '.claude');
agentsDir = join(root, '.agents');
mapFile = join(root, 'skill-families.json');
pluginDir = join(root, 'plugin');
const skills = join(claudeDir, 'skills');
// Two from a lock-file source, one installed in ~/.agents and linked in.
skill(join(skills, 'copywriting'), 'copywriting', 'Write marketing copy. Also headlines.');
skill(join(skills, 'seo-audit'), 'seo-audit', 'Audit SEO.');
skill(join(agentsDir, 'skills', 'hyperframes-cli'), 'hyperframes-cli', '>\n Render HyperFrames\n videos from the CLI.');
symlinkSync(join(agentsDir, 'skills', 'hyperframes-cli'), join(skills, 'hyperframes-cli'), 'dir');
// gstack: its skills' SKILL.md are links into its own folder.
skill(join(skills, 'gstack'), 'gstack', 'Router. (gstack)');
for (const [name, d] of [['ship', 'Ship workflow. (gstack)'], ['browse', 'Drive a browser. (gstack)']]) {
skill(join(skills, 'gstack', name), name, d);
mkdirSync(join(skills, name));
symlinkSync(join(skills, 'gstack', name, 'SKILL.md'), join(skills, name, 'SKILL.md'));
}
skill(join(skills, 'csv-summarizer'), 'csv-summarizer', 'Summarise a CSV.');
writeFileSync(join(agentsDir, '.skill-lock.json'), JSON.stringify({ version: 3, skills: {
copywriting: { source: 'coreyhaines31/marketingskills' },
'seo-audit': { source: 'coreyhaines31/marketingskills' },
'hyperframes-cli': { source: 'heygen-com/hyperframes' },
} }));
});
afterEach(() => removeTempDir(root));
describe('grouping', () => {
it('groups by lock-file source and gstack folder, carves engineering out, and leaves the rest ungrouped', () => {
const s = scan();
const names = (f) => (s.families.get(f) || []).map(m => m.name);
expect(names('marketing')).toEqual(['copywriting', 'seo-audit']);
expect(names('hyperframes')).toEqual(['hyperframes-cli']);
expect(names('gstack')).toEqual(['browse', 'gstack']);
// ship from gstack, plus Claude Code's code-review.
expect(names('engineering')).toEqual(['code-review', 'ship']);
expect(s.ungrouped).toEqual(['csv-summarizer']);
expect(s.families.get('marketing')[0].line).toBe('Write marketing copy.');
expect(s.families.get('hyperframes')[0].line).toBe('Render HyperFrames videos from the CLI.');
});
it("takes the owner's map over the defaults: a skill moved, a source renamed, null kept listed", () => {
writeFileSync(mapFile, JSON.stringify({
skills: { 'csv-summarizer': 'data', browse: null },
sources: { 'heygen-com/hyperframes': 'video', 'coreyhaines31/marketingskills': null },
}));
const s = scan();
expect(s.families.get('data').map(m => m.name)).toEqual(['csv-summarizer']);
expect(s.families.get('video').map(m => m.name)).toEqual(['hyperframes-cli']);
expect(s.families.get('gstack').map(m => m.name)).toEqual(['gstack']);
expect(s.families.has('marketing')).toBe(false);
expect(s.ungrouped).toEqual([]);
});
it("files what Claude Code lists and no skill folder holds under built-in, with the listing's line", () => {
skill(join(claudeDir, 'skills', 'loop'), 'loop', 'My own loop skill, not the bundled one.');
mkdirSync(join(claudeDir, 'commands'), { recursive: true });
writeFileSync(join(claudeDir, 'commands', 'name-only-skill.md'), 'a command');
const s = scan();
const builtIn = s.families.get('built-in');
expect(builtIn.map(m => m.name)).toEqual(['anthropic-skills:pdf', 'claude-api', 'dataviz']);
expect(builtIn.find(m => m.name === 'claude-api').line).toBe('Reference for the Claude API / Anthropic SDK — model ids, pricing, params, streaming, tool use, MCP, agents, caching, token counting, model migration.');
// code-review stays in engineering; a plugin's skill, an installed one and a command are not built-in.
expect(s.families.get('engineering').map(m => m.name)).toContain('code-review');
expect(s.ungrouped).toEqual(['csv-summarizer', 'loop']);
// Owner settings still win: a source kept listed.
writeFileSync(mapFile, JSON.stringify({ sources: { 'anthropic-skills': null } }));
expect(scan().families.get('built-in').map(m => m.name)).not.toContain('anthropic-skills:pdf');
});
it('adds what a same-version transcript listed that the probe could not see, minus that repo\'s own', () => {
const repo = join(root, 'repo');
skill(join(repo, '.claude', 'skills', 'repo-skill'), 'repo-skill', 'Repo only.');
const project = join(claudeDir, 'projects', '-repo');
mkdirSync(project, { recursive: true });
const transcript = (version, entrypoint = 'cli') => writeFileSync(join(project, 's.jsonl'), [
JSON.stringify({ type: 'user', message: 'hi' }),
JSON.stringify({ type: 'attachment', entrypoint, version, cwd: repo, attachment: { type: 'skill_listing',
content: '- artifact-design: Design guidance for any Artifact page. More.\n- dataviz: Charts.',
names: ['copywriting', 'artifact-design', 'repo-skill', 'ponytail:ponytail', 'dataviz'] } }),
].join('\n'));
transcript('2.1.295');
const builtIn = scan().families.get('built-in');
expect(builtIn.map(m => m.name)).toEqual(['anthropic-skills:pdf', 'artifact-design', 'claude-api', 'dataviz', 'loop', 'name-only-skill']);
expect(builtIn.find(m => m.name === 'artifact-design').line).toBe('Design guidance for any Artifact page.');
expect(builtIn.find(m => m.name === 'dataviz').line).toMatch(/^Use this skill whenever/);
transcript('2.1.200');
expect(scan().families.get('built-in').map(m => m.name)).not.toContain('artifact-design');
transcript('2.1.295', 'sdk-cli');
expect(scan().families.get('built-in').map(m => m.name)).not.toContain('artifact-design');
});
it('has no built-in family when the probe failed or has not run, and hides nothing of Claude Code\'s', () => {
for (const probed of [{ version: VERSION, error: 'timed out' }, null]) {
const s = scan(probed);
expect(s.families.has('built-in')).toBe(false);
expect(s.families.get('engineering').map(m => m.name)).toEqual(['ship']);
expect(familyOverrides(s, [])).not.toHaveProperty('dataviz');
}
});
it('makes no families when no skills are installed', () => {
expect(scanFamilies({ claudeDir: join(root, 'none'), agentsDir, map: readMap(mapFile) }).families.size).toBe(0);
});
it('reads names, folded descriptions and source names', () => {
expect(frontMatter('---\nname: "x"\ndescription: |\n a\n b\n---\n')).toEqual({ name: 'x', description: 'a b' });
expect(sourceFamily('Paramchoudhary/ResumeSkills')).toBe('resume');
expect(sourceFamily('heygen-com/hyperframes')).toBe('hyperframes');
expect(oneLine('Pre-landing PR review. (gstack)')).toBe('Pre-landing PR review.');
expect(oneLine('x'.repeat(400)).length).toBe(160);
});
});
describe('the family skills', () => {
it('writes one catalog skill per family and removes a family that is gone', () => {
writeFamiliesPlugin(scan(), pluginDir);
expect(JSON.parse(readFileSync(join(pluginDir, '.claude-plugin', 'plugin.json'), 'utf8')).name).toBe('families');
const text = readFileSync(join(pluginDir, 'skills', 'marketing', 'SKILL.md'), 'utf8');
expect(frontMatter(text)).toEqual({ name: 'marketing', description: `${DEFAULT_MAP.summaries.marketing} A family of 2 skills; read this for the catalog.` });
expect(text).toContain('- `copywriting`: Write marketing copy.');
writeFileSync(mapFile, JSON.stringify({ skills: { 'hyperframes-cli': null } }));
writeFamiliesPlugin(scan(), pluginDir);
expect(existsSync(join(pluginDir, 'skills', 'hyperframes'))).toBe(false);
expect(readdirSync(join(pluginDir, 'skills')).sort()).toEqual(['built-in', 'engineering', 'gstack', 'marketing']);
});
});
describe('settings per agent', () => {
it('sets every member name-only except the families and skills switched on, and leaves the owner\'s own overrides', () => {
const s = scan();
const plain = familyOverrides(s, []);
expect(plain).toMatchObject({ copywriting: 'name-only', ship: 'name-only', 'code-review': 'name-only' });
expect(plain).not.toHaveProperty('csv-summarizer');
const card = familyOverrides(s, ['hyperframes', 'engineering']);
expect(card).not.toHaveProperty('hyperframes-cli');
expect(card).not.toHaveProperty('ship');
expect(card).toHaveProperty('copywriting', 'name-only');
expect(familyOverrides(s, ['review', 'ship'])).not.toHaveProperty('ship');
expect(familyOverrides(s, [], { copywriting: 'off' })).not.toHaveProperty('copywriting');
writeFileSync(mapFile, JSON.stringify({ skills: { 'csv-summarizer': 'Bad Name' } }));
expect(familyOverrides(scan(), [])).not.toHaveProperty('csv-summarizer');
});
it('adds the plugin and merges into the spawn\'s --settings JSON', () => {
const args = withSkillFamilies(['--settings', JSON.stringify({ permissions: { deny: ['x'] } }), 'task'], ['marketing'], { homes: homes(), pluginDir, mapFile, env: {} });
expect(args.slice(0, 3)).toEqual(['--plugin-dir', pluginDir, '--settings']);
const settings = JSON.parse(args[3]);
expect(settings.permissions).toEqual({ deny: ['x'] });
expect(settings.skillOverrides.copywriting).toBeUndefined();
expect(settings.skillOverrides.ship).toBe('name-only');
expect(args.at(-1)).toBe('task');
expect(args.filter(a => a === '--settings')).toHaveLength(1);
});
it('adds --settings when there is none, stays out beside a settings file and when switched off', () => {
const fresh = withSkillFamilies(['task'], [], { homes: homes(), pluginDir, mapFile, env: {} });
expect(fresh[2]).toBe('--settings');
expect(JSON.parse(fresh[3]).skillOverrides['hyperframes-cli']).toBe('name-only');
expect(withSkillFamilies(['--settings', '/my/settings.json'], [], { homes: homes(), pluginDir, mapFile, env: {} }))
.toEqual(['--settings', '/my/settings.json']);
expect(withSkillFamilies(['task'], [], { homes: homes(), pluginDir, mapFile, env: { SKILL_FAMILIES: 'off' } })).toEqual(['task']);
});
});
describe('marketplace plugins', () => {
const VANTA = 'vanta-mcp-plugin@claude-plugins-official';
const settingsOf = (args) => JSON.parse(args[args.indexOf('--settings') + 1]);
it('switches the map\'s plugins off, on for a card that names them, and leaves the rest alone', () => {
const plain = settingsOf(withSkillFamilies(['task'], [], { homes: homes(), pluginDir, mapFile, env: {} }));
expect(plain.enabledPlugins).toEqual({ [VANTA]: false });
const card = settingsOf(withSkillFamilies(['task'], ['vanta'], { homes: homes(), pluginDir, mapFile, env: {} }));
expect(card.enabledPlugins).toEqual({ [VANTA]: true });
// The caller's own entries (a board worker's channel plugins) win and stay.
const worker = settingsOf(withSkillFamilies(['--settings', JSON.stringify({ enabledPlugins: { 'telegram@claude-plugins-official': false } })], [], { homes: homes(), pluginDir, mapFile, env: {} }));
expect(worker.enabledPlugins).toEqual({ [VANTA]: false, 'telegram@claude-plugins-official': false });
// The map adds one and drops the default.
writeFileSync(mapFile, JSON.stringify({ plugins: { vanta: null, linear: 'linear@market' } }));
expect(settingsOf(withSkillFamilies(['task'], ['linear'], { homes: homes(), pluginDir, mapFile, env: {} })).enabledPlugins).toEqual({ 'linear@market': true });
// Two names for one plugin: either one switches it on.
writeFileSync(mapFile, JSON.stringify({ plugins: { vanta: VANTA, compliance: VANTA } }));
expect(settingsOf(withSkillFamilies(['task'], ['vanta'], { homes: homes(), pluginDir, mapFile, env: {} })).enabledPlugins).toEqual({ [VANTA]: true });
});
it('switches plugins even with no skills installed to group', () => {
const args = withSkillFamilies(['task'], [], { homes: { claudeDir: join(root, 'none'), agentsDir }, pluginDir, mapFile, env: {} });
expect(args).toEqual(['--settings', JSON.stringify({ enabledPlugins: { [VANTA]: false } }), 'task']);
});
it('names a card\'s plugins this machine lacks', () => {
const map = readMap(mapFile);
expect(missingPlugins(['vanta', 'marketing'], { map, claudeDir, env: {} })).toEqual(['vanta']);
expect(missingPluginsNote(['vanta'])).toMatch(/^vanta plugin is not installed on this machine/);
expect(missingPluginsNote([])).toBeNull();
mkdirSync(join(claudeDir, 'plugins'), { recursive: true });
writeFileSync(join(claudeDir, 'plugins', 'installed_plugins.json'), JSON.stringify({ version: 2, plugins: { [VANTA]: [{ scope: 'user' }] } }));
expect(missingPlugins(['vanta'], { map, claudeDir, env: {} })).toEqual([]);
expect(missingPlugins(['vanta'], { map, claudeDir: join(root, 'none'), env: { SKILL_FAMILIES: '0' } })).toEqual([]);
});
it('offers plugin short names in the card form', () => {
expect(knownFamilies({}, mapFile)).toContain('vanta');
});
});
describe('defaults', () => {
it('gives a pull-request card engineering, a no-PR card nothing, and adds the card\'s own', () => {
expect(jobSkillFamilies({}, true)).toEqual(['engineering']);
expect(jobSkillFamilies({}, false)).toEqual([]);
expect(jobSkillFamilies({ skills: ['hyperframes'] }, true)).toEqual(['hyperframes', 'engineering']);
expect(jobSkillFamilies({ skills: ['engineering'] }, true)).toEqual(['engineering']);
});
it('gives Billion marketing and the review skills unless the map says otherwise', () => {
expect(readMap(mapFile).billion).toEqual(['marketing', 'review', 'code-review', 'security-review', 'cso']);
writeFileSync(mapFile, JSON.stringify({ billion: ['marketing'] }));
expect(readMap(mapFile).billion).toEqual(['marketing']);
});
it('checks a card\'s skills field', () => {
expect(resolveJobSkills(undefined)).toEqual({ skills: null });
expect(resolveJobSkills([])).toEqual({ skills: null });
expect(resolveJobSkills(' Marketing, hyperframes,marketing')).toEqual({ skills: ['marketing', 'hyperframes'] });
expect(resolveJobSkills([1]).error).toMatch(/list of skill family names/);
expect(resolveJobSkills(['../x']).error).toMatch(/not a skill family name/);
expect(createJob({ title: 't', repoPath: '/r', skills: ['hyperframes'] }).job.skills).toEqual(['hyperframes']);
});
});
describe('the ungrouped notice', () => {
it('names the unfiled skills and how to file them, once each', () => {
const notice = ungroupedNotice(['csv-summarizer'], mapFile);
expect(notice.headline).toMatch(/1 installed skill fit no skill family/);
expect(notice.lines.join('\n')).toContain(mapFile);
expect(ungroupedNotice([])).toBeNull();
withSkillFamilies([], [], { homes: homes(), pluginDir, mapFile, env: {} });
const sent = [];
const send = (session, headline, lines) => { sent.push(lines[0]); return true; };
expect(reportUngrouped(null, send)).toBe(false);
expect(reportUngrouped({ id: 'b' }, send)).toBe(true);
expect(sent).toEqual(['Unfiled: csv-summarizer']);
expect(reportUngrouped({ id: 'b' }, send)).toBe(false);
});
});
describe('a card\'s skills on the board', () => {
it('is taken by every door, edited, carried to its runs and handed to the worker\'s spawn', async () => {
const { config, sessions } = await import('../server/state.js');
const { addJob, updateJob, postJobForAgent, editJobForAgent, allJobs, boardSettings, dispatchOnce } = await import('../server/jobs.js');
const { createRunJob } = await import('../lib/jobs.js');
const repo = mkdtempSync(join(tmpdir(), 'a007-families-repo-'));
config.repos = [{ path: repo }];
config.jobs = [];
config.jobBoard = null;
boardSettings();
sessions.clear();
const noop = () => {};
try {
expect(postJobForAgent({ title: 't', repo, skills: 'nope!' }, noop).error).toMatch(/not a skill family name/);
const { job } = postJobForAgent({ title: 'video', repo, skills: ['hyperframes'] }, noop);
expect(job.skills).toEqual(['hyperframes']);
expect(editJobForAgent({ id: job.id, skills: ['hyperframes', 'marketing'] }, noop).changed).toEqual(['skills']);
expect(updateJob(job.id, { skills: [] }).job.skills).toBeNull();
updateJob(job.id, { skills: ['hyperframes'] });
expect(createRunJob({ ...job, schedule: '@daily' }).job.skills).toEqual(['hyperframes']);
addJob({ title: 'research', repoPath: repo, requiresPr: false }, noop);
const metas = [];
await dispatchOnce(async (command, name, repoPath, branch, owner, meta) => {
metas.push(meta.skills);
const session = { id: `s${metas.length}`, name: `A${metas.length}`, command, repoPath, branchName: branch, exited: false };
sessions.set(session.id, session);
return { session };
}, noop);
expect(metas).toEqual([['hyperframes', 'engineering'], []]);
expect(allJobs()).toHaveLength(2);
} finally {
sessions.clear();
config.jobs = [];
removeTempDir(repo);
}
});
});