feat(#1596): ship GSD skills via .claude-plugin/plugin.json

Phase B-provide of epic #1258. Adds a build-generated skills/ dir +
a skills manifest field so plugin-installed GSD exposes gsd-core:<skill>
the native Claude Code way. Closes the gap where plugin-only installs
lacked the skill surface because bin/install.js never ran.

- scripts/gen-plugin-skills.cjs: build step converting commands/gsd/*.md
  to skills/gsd-<stem>/SKILL.md via convertClaudeCommandToClaudeSkill
- .claude-plugin/plugin.json: add "skills": "./skills/"
- package.json: add skills to files, gen:plugin-skills to build chain
- tests/issue-766-plugin-manifest.test.cjs: Section H conformance
  (manifest field + dir + frontmatter + count parity) + C2 skills symlink
- docs/adr/766-*.md: dated amendment adding skills surface row
- .changeset/rapid-bears-hum.md: type Added
- skills/: 69 generated gsd-<stem>/SKILL.md files (build-committed)

Closes #1596
This commit is contained in:
Tom Boucher
2026-06-22 17:29:50 -04:00
parent 69de3bca23
commit da4a86d8c1
75 changed files with 3744 additions and 2 deletions

View File

@@ -376,6 +376,7 @@ describe('C: plugin.json schema validation', () => {
fs.copyFileSync(PLUGIN_JSON_PATH, path.join(pluginRoot, '.claude-plugin', 'plugin.json'));
fs.symlinkSync(path.join(ROOT, 'commands'), path.join(pluginRoot, 'commands'), 'dir');
fs.symlinkSync(path.join(ROOT, 'hooks'), path.join(pluginRoot, 'hooks'), 'dir');
fs.symlinkSync(path.join(ROOT, 'skills'), path.join(pluginRoot, 'skills'), 'dir');
const result = spawnSync('claude', ['plugin', 'validate', pluginRoot, '--strict'], {
cwd: ROOT,
@@ -891,3 +892,61 @@ describe('G: #997 ensureCanonicalPath() behavioural regression', () => {
);
});
});
// ─── Section H: skills surface projection (#1596 — Phase B-provide) ──────────
//
// ADR-766 originally projected commands + hooks but NOT skills. Phase B-provide
// (#1596) adds a build-generated `skills/` dir + a `skills` manifest field so
// plugin-installed GSD exposes `gsd-core:<skill>` the native Claude Code way.
// The skills are generated from `commands/gsd/*.md` by
// `scripts/gen-plugin-skills.cjs` using `convertClaudeCommandToClaudeSkill`.
describe('H: skills surface projection (#1596)', () => {
const SKILLS_DIR = path.resolve(ROOT, 'skills');
test('plugin.json declares skills: "./skills/"', () => {
const manifest = JSON.parse(fs.readFileSync(PLUGIN_JSON_PATH, 'utf-8'));
assert.equal(
manifest.skills, './skills/',
'plugin.json must declare "skills": "./skills/" so Claude Code discovers plugin skills (#1596)'
);
});
test('skills/ dir exists with at least one gsd-*/SKILL.md', () => {
assert.ok(fs.existsSync(SKILLS_DIR), `skills/ dir must exist (run: npm run gen:plugin-skills -- --write): ${SKILLS_DIR}`);
const entries = fs.readdirSync(SKILLS_DIR, { withFileTypes: true });
const skillDirs = entries.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.ok(skillDirs.length > 0, 'skills/ must contain at least one gsd-*/ directory');
// Each must have a SKILL.md
for (const dir of skillDirs) {
const skillMd = path.join(SKILLS_DIR, dir.name, 'SKILL.md');
assert.ok(fs.existsSync(skillMd), `${dir.name}/SKILL.md must exist`);
}
});
test('every generated SKILL.md has name: and description: frontmatter', () => {
assert.ok(fs.existsSync(SKILLS_DIR), 'skills/ must exist');
const skillDirs = fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.ok(skillDirs.length > 0, 'must have at least one skill dir');
for (const dir of skillDirs) {
const raw = fs.readFileSync(path.join(SKILLS_DIR, dir.name, 'SKILL.md'), 'utf-8');
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
assert.ok(fmMatch, `${dir.name}/SKILL.md must have frontmatter`);
const fm = fmMatch[1];
assert.ok(/^\s*name:\s*\S/m.test(fm), `${dir.name}/SKILL.md frontmatter must have a name: field`);
assert.ok(/^\s*description:\s*\S/m.test(fm), `${dir.name}/SKILL.md frontmatter must have a description: field`);
}
});
test('parity: one skill dir per command file (DEFECT.GENERATIVE-FIX)', () => {
const commandsDir = path.resolve(ROOT, 'commands', 'gsd');
const commandFiles = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
const skillDirs = fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.equal(
skillDirs.length, commandFiles.length,
`skills/gsd-*/ count (${skillDirs.length}) must equal commands/gsd/*.md count (${commandFiles.length}). ` +
`Run: npm run gen:plugin-skills -- --write`
);
});
});