From 9435c4dd387bb67f942c3da7b780ef8905e49a10 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 11 Apr 2026 09:15:18 -0400 Subject: [PATCH] feat(init): add skill-manifest command to pre-compute skill discovery (#2101) Adds `skill-manifest` command that scans a skills directory, extracts frontmatter and trigger conditions from each SKILL.md, and outputs a compact JSON manifest. This reduces per-agent skill discovery from 36 Read operations (~6,000 tokens) to a single manifest read (~1,000 tokens). Closes #1976 Co-authored-by: Claude Sonnet 4.6 --- get-shit-done/bin/gsd-tools.cjs | 5 + get-shit-done/bin/lib/init.cjs | 101 +++++++++++++++ tests/skill-manifest.test.cjs | 219 ++++++++++++++++++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 tests/skill-manifest.test.cjs diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 540905ff0..284ec1137 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -638,6 +638,11 @@ async function runCommand(command, args, cwd, raw, defaultValue) { break; } + case 'skill-manifest': { + init.cmdSkillManifest(cwd, args, raw); + break; + } + case 'history-digest': { commands.cmdHistoryDigest(cwd, raw); break; diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index 0f50fe57c..d05882c9c 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -1513,6 +1513,105 @@ function cmdAgentSkills(cwd, agentType, raw) { process.exit(0); } +/** + * Generate a skill manifest from a skills directory. + * + * Scans the given skills directory for subdirectories containing SKILL.md, + * extracts frontmatter (name, description) and trigger conditions from the + * body text, and returns an array of skill descriptors. + * + * @param {string} skillsDir - Absolute path to the skills directory + * @returns {Array<{name: string, description: string, triggers: string[], path: string}>} + */ +function buildSkillManifest(skillsDir) { + const { extractFrontmatter } = require('./frontmatter.cjs'); + + if (!fs.existsSync(skillsDir)) return []; + + let entries; + try { + entries = fs.readdirSync(skillsDir, { withFileTypes: true }); + } catch { + return []; + } + + const manifest = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const skillMdPath = path.join(skillsDir, entry.name, 'SKILL.md'); + if (!fs.existsSync(skillMdPath)) continue; + + let content; + try { + content = fs.readFileSync(skillMdPath, 'utf-8'); + } catch { + continue; + } + + const frontmatter = extractFrontmatter(content); + const name = frontmatter.name || entry.name; + const description = frontmatter.description || ''; + + // Extract trigger lines from body text (after frontmatter) + const triggers = []; + const bodyMatch = content.match(/^---[\s\S]*?---\s*\n([\s\S]*)$/); + if (bodyMatch) { + const body = bodyMatch[1]; + const triggerLines = body.match(/^TRIGGER\s+when:\s*(.+)$/gmi); + if (triggerLines) { + for (const line of triggerLines) { + const m = line.match(/^TRIGGER\s+when:\s*(.+)$/i); + if (m) triggers.push(m[1].trim()); + } + } + } + + manifest.push({ + name, + description, + triggers, + path: entry.name, + }); + } + + // Sort by name for deterministic output + manifest.sort((a, b) => a.name.localeCompare(b.name)); + return manifest; +} + +/** + * Command: generate skill manifest JSON. + * + * Options: + * --skills-dir Path to skills directory (required) + * --write Also write to .planning/skill-manifest.json + */ +function cmdSkillManifest(cwd, args, raw) { + const skillsDirIdx = args.indexOf('--skills-dir'); + const skillsDir = skillsDirIdx >= 0 && args[skillsDirIdx + 1] + ? args[skillsDirIdx + 1] + : null; + + if (!skillsDir) { + output([], raw); + return; + } + + const manifest = buildSkillManifest(skillsDir); + + // Optionally write to .planning/skill-manifest.json + if (args.includes('--write')) { + const planningDir = path.join(cwd, '.planning'); + if (fs.existsSync(planningDir)) { + const manifestPath = path.join(planningDir, 'skill-manifest.json'); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8'); + } + } + + output(manifest, raw); +} + module.exports = { cmdInitExecutePhase, cmdInitPlanPhase, @@ -1533,4 +1632,6 @@ module.exports = { detectChildRepos, buildAgentSkillsBlock, cmdAgentSkills, + buildSkillManifest, + cmdSkillManifest, }; diff --git a/tests/skill-manifest.test.cjs b/tests/skill-manifest.test.cjs new file mode 100644 index 000000000..cb18110a4 --- /dev/null +++ b/tests/skill-manifest.test.cjs @@ -0,0 +1,219 @@ +/** + * Tests for skill-manifest command + * TDD: RED phase — tests written before implementation + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +describe('skill-manifest', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('skill-manifest command exists and returns JSON', () => { + // Create a skills directory with one skill + const skillDir = path.join(tmpDir, '.claude', 'skills', 'test-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [ + '---', + 'name: test-skill', + 'description: A test skill', + '---', + '', + '# Test Skill', + ].join('\n')); + + const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills')], tmpDir); + assert.ok(result.success, `Command should succeed: ${result.error || result.output}`); + + const manifest = JSON.parse(result.output); + assert.ok(Array.isArray(manifest), 'Manifest should be an array'); + }); + + test('generates manifest with correct structure from SKILL.md frontmatter', () => { + const skillDir = path.join(tmpDir, '.claude', 'skills', 'my-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [ + '---', + 'name: my-skill', + 'description: Does something useful', + '---', + '', + '# My Skill', + '', + 'TRIGGER when: user asks about widgets', + ].join('\n')); + + const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills')], tmpDir); + assert.ok(result.success, `Command should succeed: ${result.error || result.output}`); + + const manifest = JSON.parse(result.output); + assert.strictEqual(manifest.length, 1); + assert.strictEqual(manifest[0].name, 'my-skill'); + assert.strictEqual(manifest[0].description, 'Does something useful'); + assert.strictEqual(manifest[0].path, 'my-skill'); + }); + + test('empty skills directory produces empty manifest', () => { + const skillsDir = path.join(tmpDir, '.claude', 'skills'); + fs.mkdirSync(skillsDir, { recursive: true }); + + const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir); + assert.ok(result.success, `Command should succeed: ${result.error || result.output}`); + + const manifest = JSON.parse(result.output); + assert.ok(Array.isArray(manifest), 'Manifest should be an array'); + assert.strictEqual(manifest.length, 0); + }); + + test('skills without SKILL.md are skipped', () => { + const skillsDir = path.join(tmpDir, '.claude', 'skills'); + // Skill with SKILL.md + const goodDir = path.join(skillsDir, 'good-skill'); + fs.mkdirSync(goodDir, { recursive: true }); + fs.writeFileSync(path.join(goodDir, 'SKILL.md'), [ + '---', + 'name: good-skill', + 'description: Has a SKILL.md', + '---', + '', + '# Good Skill', + ].join('\n')); + + // Skill without SKILL.md (just a directory) + const badDir = path.join(skillsDir, 'bad-skill'); + fs.mkdirSync(badDir, { recursive: true }); + fs.writeFileSync(path.join(badDir, 'README.md'), '# No SKILL.md here'); + + const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir); + assert.ok(result.success, `Command should succeed: ${result.error || result.output}`); + + const manifest = JSON.parse(result.output); + assert.strictEqual(manifest.length, 1); + assert.strictEqual(manifest[0].name, 'good-skill'); + }); + + test('manifest includes frontmatter fields from SKILL.md', () => { + const skillDir = path.join(tmpDir, '.claude', 'skills', 'rich-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [ + '---', + 'name: rich-skill', + 'description: A richly documented skill', + '---', + '', + '# Rich Skill', + '', + 'TRIGGER when: user mentions databases', + 'DO NOT TRIGGER when: user asks about frontend', + ].join('\n')); + + const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills')], tmpDir); + assert.ok(result.success, `Command should succeed: ${result.error || result.output}`); + + const manifest = JSON.parse(result.output); + assert.strictEqual(manifest.length, 1); + + const skill = manifest[0]; + assert.strictEqual(skill.name, 'rich-skill'); + assert.strictEqual(skill.description, 'A richly documented skill'); + assert.strictEqual(skill.path, 'rich-skill'); + // triggers extracted from body text + assert.ok(Array.isArray(skill.triggers), 'triggers should be an array'); + assert.ok(skill.triggers.length > 0, 'triggers should have at least one entry'); + assert.ok(skill.triggers.some(t => t.includes('databases')), 'triggers should mention databases'); + }); + + test('multiple skills are all included in manifest', () => { + const skillsDir = path.join(tmpDir, '.claude', 'skills'); + + for (const name of ['alpha', 'beta', 'gamma']) { + const dir = path.join(skillsDir, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'SKILL.md'), [ + '---', + `name: ${name}`, + `description: The ${name} skill`, + '---', + '', + `# ${name}`, + ].join('\n')); + } + + const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir); + assert.ok(result.success, `Command should succeed: ${result.error || result.output}`); + + const manifest = JSON.parse(result.output); + assert.strictEqual(manifest.length, 3); + const names = manifest.map(s => s.name).sort(); + assert.deepStrictEqual(names, ['alpha', 'beta', 'gamma']); + }); + + test('writes manifest to .planning/skill-manifest.json when --write flag is used', () => { + const skillDir = path.join(tmpDir, '.claude', 'skills', 'write-test'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [ + '---', + 'name: write-test', + 'description: Tests write mode', + '---', + '', + '# Write Test', + ].join('\n')); + + const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills'), '--write'], tmpDir); + assert.ok(result.success, `Command should succeed: ${result.error || result.output}`); + + const manifestPath = path.join(tmpDir, '.planning', 'skill-manifest.json'); + assert.ok(fs.existsSync(manifestPath), 'skill-manifest.json should be written to .planning/'); + + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + assert.strictEqual(manifest.length, 1); + assert.strictEqual(manifest[0].name, 'write-test'); + }); + + test('nonexistent skills directory returns empty manifest', () => { + const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, 'nonexistent')], tmpDir); + assert.ok(result.success, `Command should succeed: ${result.error || result.output}`); + + const manifest = JSON.parse(result.output); + assert.ok(Array.isArray(manifest), 'Manifest should be an array'); + assert.strictEqual(manifest.length, 0); + }); + + test('files in skills directory are ignored (only subdirectories scanned)', () => { + const skillsDir = path.join(tmpDir, '.claude', 'skills'); + fs.mkdirSync(skillsDir, { recursive: true }); + // A file, not a directory + fs.writeFileSync(path.join(skillsDir, 'not-a-skill.md'), '# Not a skill'); + + // A valid skill directory + const skillDir = path.join(skillsDir, 'real-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [ + '---', + 'name: real-skill', + 'description: A real skill', + '---', + '', + '# Real Skill', + ].join('\n')); + + const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir); + assert.ok(result.success, `Command should succeed: ${result.error || result.output}`); + + const manifest = JSON.parse(result.output); + assert.strictEqual(manifest.length, 1); + assert.strictEqual(manifest[0].name, 'real-skill'); + }); +});