diff --git a/bin/install.js b/bin/install.js index e5a6a4d5..737e7cd6 100755 --- a/bin/install.js +++ b/bin/install.js @@ -1113,11 +1113,31 @@ function convertClaudeCommandToCopilotSkill(content, skillName, isGlobal = false return `${fm}\n${body}`; } +/** + * Map a skill directory name (gsd-) to the frontmatter `name:` used + * by Claude Code as the skill identity. Workflows emit `Skill(skill="gsd:")` + * (colon form) and Claude Code resolves skills by frontmatter `name:`, not + * directory name — so emit colon form here. Directory stays hyphenated for + * Windows path safety. See #2643. + * + * Codex must NOT use this helper: its adapter invokes skills as `$gsd-` + * (shell-var syntax) and a colon would terminate the variable name. Codex + * keeps the hyphen form via `yamlQuote(skillName)` directly. + */ +function skillFrontmatterName(skillDirName) { + if (typeof skillDirName !== 'string') return skillDirName; + // Idempotent on already-colon form. + if (skillDirName.includes(':')) return skillDirName; + // Only rewrite the first hyphen after the `gsd` prefix. + return skillDirName.replace(/^gsd-/, 'gsd:'); +} + /** * Convert a Claude command (.md) to a Claude skill (SKILL.md). * Claude Code is the native format, so minimal conversion needed — - * preserve allowed-tools as YAML multiline list, preserve argument-hint, - * convert name from gsd:xxx to gsd-xxx format. + * preserve allowed-tools as YAML multiline list, preserve argument-hint. + * Emits `name: gsd:` (colon) so Skill(skill="gsd:") calls in + * workflows resolve on flat-skills installs — see #2643. */ function convertClaudeCommandToClaudeSkill(content, skillName) { const { frontmatter, body } = extractFrontmatterAndBody(content); @@ -1137,7 +1157,8 @@ function convertClaudeCommandToClaudeSkill(content, skillName) { } // Reconstruct frontmatter in Claude skill format - let fm = `---\nname: ${skillName}\ndescription: ${yamlQuote(description)}\n`; + const frontmatterName = skillFrontmatterName(skillName); + let fm = `---\nname: ${frontmatterName}\ndescription: ${yamlQuote(description)}\n`; if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`; if (agent) fm += `agent: ${agent}\n`; if (toolsBlock) fm += toolsBlock; @@ -7265,6 +7286,7 @@ if (process.env.GSD_TEST_MODE) { convertClaudeAgentToAntigravityAgent, copyCommandsAsAntigravitySkills, convertClaudeCommandToClaudeSkill, + skillFrontmatterName, copyCommandsAsClaudeSkills, convertClaudeToWindsurfMarkdown, convertClaudeCommandToWindsurfSkill, diff --git a/tests/bug-2643-skill-frontmatter-name.test.cjs b/tests/bug-2643-skill-frontmatter-name.test.cjs new file mode 100644 index 00000000..a5983fb8 --- /dev/null +++ b/tests/bug-2643-skill-frontmatter-name.test.cjs @@ -0,0 +1,90 @@ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #2643: workflows emit Skill(skill="gsd:") but flat-skills install + * registers `gsd-` as the frontmatter `name:`. Claude Code uses the + * frontmatter name (not dir name) as the skill identity — so the emitted + * `name:` must match the colon form used by workflow Skill() calls. + * + * The directory name stays hyphenated for Windows path safety. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const { + convertClaudeCommandToClaudeSkill, + skillFrontmatterName, +} = require(path.join(ROOT, 'bin', 'install.js')); + +const WORKFLOWS_DIR = path.join(ROOT, 'get-shit-done', 'workflows'); +const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); + +function collectFiles(dir, results) { + if (!results) results = []; + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) collectFiles(full, results); + else if (e.name.endsWith('.md')) results.push(full); + } + return results; +} + +function extractSkillNames(content) { + const names = new Set(); + const rx = /Skill\(skill=['"]gsd:([a-z0-9-]+)['"]/gi; + let m; + while ((m = rx.exec(content)) !== null) names.add('gsd:' + m[1]); + return names; +} + +describe('skill frontmatter name parity (#2643)', () => { + test('skillFrontmatterName helper emits colon form', () => { + assert.strictEqual(typeof skillFrontmatterName, 'function'); + assert.strictEqual(skillFrontmatterName('gsd-execute-phase'), 'gsd:execute-phase'); + assert.strictEqual(skillFrontmatterName('gsd-plan-phase'), 'gsd:plan-phase'); + assert.strictEqual(skillFrontmatterName('gsd:next'), 'gsd:next'); + }); + + test('convertClaudeCommandToClaudeSkill emits name: gsd:', () => { + const input = '---\nname: old\ndescription: test\n---\n\nBody.'; + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-execute-phase'); + assert.match(result, /^---\nname: gsd:execute-phase\n/); + }); + + test('every workflow Skill(skill="gsd:") resolves to an emitted skill name', () => { + const workflowFiles = collectFiles(WORKFLOWS_DIR); + const referenced = new Set(); + for (const f of workflowFiles) { + const src = fs.readFileSync(f, 'utf-8'); + for (const n of extractSkillNames(src)) referenced.add(n); + } + assert.ok(referenced.size > 0, 'expected at least one Skill(skill="gsd:") reference'); + + const emitted = new Set(); + const cmdFiles = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md')); + for (const cmd of cmdFiles) { + const base = cmd.replace(/\.md$/, ''); + const skillDirName = 'gsd-' + base; + const src = fs.readFileSync(path.join(COMMANDS_DIR, cmd), 'utf-8'); + const out = convertClaudeCommandToClaudeSkill(src, skillDirName); + const m = out.match(/^---\nname:\s*(.+)$/m); + if (m) emitted.add(m[1].trim()); + } + + const missing = []; + for (const r of referenced) if (!emitted.has(r)) missing.push(r); + assert.deepStrictEqual( + missing, + [], + 'workflow refs not emitted as skill names: ' + missing.join(', '), + ); + }); +}); diff --git a/tests/claude-skills-migration.test.cjs b/tests/claude-skills-migration.test.cjs index ceec0392..64c36023 100644 --- a/tests/claude-skills-migration.test.cjs +++ b/tests/claude-skills-migration.test.cjs @@ -69,7 +69,7 @@ describe('convertClaudeCommandToClaudeSkill', () => { ); }); - test('converts name format from gsd:xxx to skill naming', () => { + test('emits colon-form name (gsd:) from hyphen-form dir (#2643)', () => { const input = [ '---', 'name: gsd:next', @@ -79,9 +79,10 @@ describe('convertClaudeCommandToClaudeSkill', () => { 'Body.', ].join('\n'); + // Directory name is gsd-next (hyphen, Windows-safe), frontmatter name is + // gsd:next (colon) so Claude Code resolves `/gsd:next` against the skill. const result = convertClaudeCommandToClaudeSkill(input, 'gsd-next'); - assert.ok(result.includes('name: gsd-next'), 'name uses skill naming convention'); - assert.ok(!result.includes('name: gsd:next'), 'old name format removed'); + assert.ok(result.includes('name: gsd:next'), 'frontmatter name uses colon form'); }); test('preserves body content unchanged', () => { diff --git a/tests/qwen-skills-migration.test.cjs b/tests/qwen-skills-migration.test.cjs index 10cce4b1..f226898b 100644 --- a/tests/qwen-skills-migration.test.cjs +++ b/tests/qwen-skills-migration.test.cjs @@ -66,7 +66,7 @@ describe('Qwen Code: convertClaudeCommandToClaudeSkill', () => { ); }); - test('converts name format from gsd:xxx to skill naming', () => { + test('emits colon-form name (gsd:) from hyphen-form dir (#2643)', () => { const input = [ '---', 'name: gsd:next', @@ -76,9 +76,10 @@ describe('Qwen Code: convertClaudeCommandToClaudeSkill', () => { 'Body.', ].join('\n'); + // Directory name is gsd-next (hyphen, Windows-safe), frontmatter name is + // gsd:next (colon) so Claude Code resolves `/gsd:next` against the skill. const result = convertClaudeCommandToClaudeSkill(input, 'gsd-next'); - assert.ok(result.includes('name: gsd-next'), 'name uses skill naming convention'); - assert.ok(!result.includes('name: gsd:next'), 'old name format removed'); + assert.ok(result.includes('name: gsd:next'), 'frontmatter name uses colon form'); }); test('preserves body content unchanged', () => { @@ -153,7 +154,7 @@ describe('Qwen Code: copyCommandsAsClaudeSkills', () => { // Verify content const content = fs.readFileSync(skillPath, 'utf8'); - assert.ok(content.includes('name: gsd-quick'), 'skill name converted'); + assert.ok(content.includes('name: gsd:quick'), 'frontmatter name uses colon form (#2643)'); assert.ok(content.includes('description:'), 'description present'); assert.ok(content.includes('allowed-tools:'), 'allowed-tools preserved'); assert.ok(content.includes(''), 'body content preserved'); @@ -273,7 +274,7 @@ describe('Qwen Code: SKILL.md format validation', () => { assert.ok(fmMatch, 'has frontmatter block'); const fmLines = fmMatch[1].split('\n'); - const hasName = fmLines.some(l => l.startsWith('name: gsd-review')); + const hasName = fmLines.some(l => l.startsWith('name: gsd:review')); const hasDesc = fmLines.some(l => l.startsWith('description:')); const hasAgent = fmLines.some(l => l.startsWith('agent:')); const hasTools = fmLines.some(l => l.startsWith('allowed-tools:'));