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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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> 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,
|
||||
};
|
||||
|
||||
219
tests/skill-manifest.test.cjs
Normal file
219
tests/skill-manifest.test.cjs
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user