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); } }); });