fix(#2808): SKILL.md files use hyphen name form (gsd-cmd not gsd:cmd) (#2819)

* fix(#2808): SKILL.md name uses hyphen form for Claude Code autocomplete

skillFrontmatterName() was converting gsd-<cmd> to gsd:<cmd> (colon) so
installed SKILL.md files had name: gsd:add-phase etc. Claude Code surfaces
this name in autocomplete, showing the deprecated colon form to users even
though the hyphen form is canonical everywhere else.

Root cause: the colon form was needed because workflows called
Skill(skill="gsd:<cmd>"). All 4 remaining colon-form Skill() calls in
autonomous.md and execute-phase.md are updated to hyphen form.

skillFrontmatterName() now returns the hyphen dir name unchanged.
Updated 4 existing tests that asserted colon form.

Regression test: tests/bug-2808-skill-hyphen-name.test.cjs

* fix(#2808): address CodeRabbit — bash/text fences, structured test assertions, fail-loud on errors
This commit is contained in:
Tom Boucher
2026-04-28 20:22:37 -04:00
committed by GitHub
parent fa78692167
commit 5fe1f00a0d
7 changed files with 196 additions and 39 deletions

View File

@@ -1123,21 +1123,21 @@ function convertClaudeCommandToCopilotSkill(content, skillName, isGlobal = false
/**
* Map a skill directory name (gsd-<cmd>) to the frontmatter `name:` used
* by Claude Code as the skill identity. Workflows emit `Skill(skill="gsd:<cmd>")`
* (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.
* by Claude Code as the skill identity. Emits the hyphen form (gsd-<cmd>)
* so Claude Code autocomplete shows the canonical invocation form, not the
* deprecated colon form. See #2808.
*
* Historical note: this previously returned `gsd:<cmd>` (colon) because
* workflows called Skill(skill="gsd:<cmd>"). Those calls have been updated
* to use hyphen form (#2808) so the colon rewrite is no longer needed.
*
* Codex must NOT use this helper: its adapter invokes skills as `$gsd-<cmd>`
* (shell-var syntax) and a colon would terminate the variable name. Codex
* keeps the hyphen form via `yamlQuote(skillName)` directly.
* (shell-var syntax) — hyphen form is already correct there.
*/
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:');
// Return the hyphen form as-is (gsd-<cmd>) — canonical since #2808.
return skillDirName;
}
/**

View File

@@ -262,7 +262,7 @@ The discuss step in `--auto` mode MUST NOT loop. If CONTEXT.md already exists af
**If `INTERACTIVE` is set:** Run the standard discuss-phase skill inline (asks interactive questions, waits for user answers). This preserves user input on all design decisions while keeping plan+execute out of the main context:
```
Skill(skill="gsd:discuss-phase", args="${PHASE_NUM}")
Skill(skill="gsd-discuss-phase", args="${PHASE_NUM}")
```
**If `INTERACTIVE` is NOT set:** Execute the smart_discuss step for this phase (batch table proposals, auto-optimized).
@@ -367,12 +367,12 @@ CODE_REVIEW_ENABLED=$(gsd-sdk query config-get workflow.code_review 2>/dev/null
If `"false"`: display "Code review skipped (workflow.code_review=false)" and proceed to 3d.
```
Skill(skill="gsd:code-review", args="${PHASE_NUM}")
Skill(skill="gsd-code-review", args="${PHASE_NUM}")
```
Parse status from REVIEW.md frontmatter. If "clean" or "skipped": proceed to 3d. If findings found: auto-invoke:
```
Skill(skill="gsd:code-review-fix", args="${PHASE_NUM} --auto")
```text
Skill(skill="gsd-code-review-fix", args="${PHASE_NUM} --auto")
```
**Error handling:** If either Skill fails, catch the error, display as non-blocking, and proceed to 3d.

View File

@@ -1095,7 +1095,7 @@ If `CODE_REVIEW_ENABLED` is `"false"`: display "Code review skipped (workflow.co
**Invoke review:**
```
Skill(skill="gsd:code-review", args="${PHASE_NUMBER}")
Skill(skill="gsd-code-review", args="${PHASE_NUMBER}")
```
**Check results using deterministic path (not glob):**

View File

@@ -3,12 +3,15 @@
process.env.GSD_TEST_MODE = '1';
/**
* Bug #2643: workflows emit Skill(skill="gsd:<cmd>") but flat-skills install
* registers `gsd-<cmd>` 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.
* Bug #2643 / #2808: skill frontmatter name parity.
*
* The directory name stays hyphenated for Windows path safety.
* Original (#2643): workflows emitted Skill(skill="gsd:<cmd>") and the
* installer registered colon form in SKILL.md name: to match.
*
* Updated (#2808): workflows now use Skill(skill="gsd-<cmd>") (hyphen),
* and the installer emits name: gsd-<cmd> (hyphen). Claude Code autocomplete
* now shows the canonical hyphen form instead of the deprecated colon form.
* The directory name (gsd-<cmd>) is unchanged.
*/
const { test, describe } = require('node:test');
@@ -37,7 +40,15 @@ function collectFiles(dir, results) {
return results;
}
function extractSkillNames(content) {
function extractSkillNamesHyphen(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;
}
function extractSkillNamesColon(content) {
const names = new Set();
const rx = /Skill\(skill=['"]gsd:([a-z0-9-]+)['"]/gi;
let m;
@@ -45,28 +56,58 @@ function extractSkillNames(content) {
return names;
}
describe('skill frontmatter name parity (#2643)', () => {
test('skillFrontmatterName helper emits colon form', () => {
describe('skill frontmatter name parity (#2643 / #2808)', () => {
test('skillFrontmatterName helper emits hyphen form (#2808)', () => {
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');
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:<cmd>', () => {
test('convertClaudeCommandToClaudeSkill emits name: gsd-<cmd> (hyphen)', () => {
const input = '---\nname: old\ndescription: test\n---\n\nBody.';
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-execute-phase');
assert.match(result, /^---\nname: gsd:execute-phase\n/);
// Parse the frontmatter block structurally: extract the name: field value.
const frontmatterMatch = result.match(/^---\n([\s\S]*?)\n---/);
assert.ok(frontmatterMatch, 'output must have a frontmatter block delimited by ---');
const frontmatterLines = frontmatterMatch[1].split('\n');
const nameEntry = frontmatterLines.find((l) => l.startsWith('name:'));
assert.ok(nameEntry, 'frontmatter must contain a name: field');
const nameValue = nameEntry.replace(/^name:\s*/, '').trim();
assert.strictEqual(
nameValue,
'gsd-execute-phase',
`frontmatter name: must be 'gsd-execute-phase' (hyphen form), got '${nameValue}'`
);
});
test('every workflow Skill(skill="gsd:<cmd>") resolves to an emitted skill name', () => {
test('no workflow uses deprecated Skill(skill="gsd:<cmd>") colon form', () => {
const workflowFiles = collectFiles(WORKFLOWS_DIR);
const colonRefs = [];
for (const f of workflowFiles) {
const src = fs.readFileSync(f, 'utf-8');
for (const n of extractSkillNamesColon(src)) {
colonRefs.push(path.basename(f) + ': ' + n);
}
}
assert.deepStrictEqual(
colonRefs,
[],
'deprecated colon-form Skill() calls found (update to hyphen): ' + colonRefs.join(', ')
);
});
test('every workflow Skill(skill="gsd-<cmd>") 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);
for (const n of extractSkillNamesHyphen(src)) referenced.add(n);
}
assert.ok(referenced.size > 0, 'expected at least one Skill(skill="gsd:<cmd>") reference');
assert.ok(
referenced.size > 0,
`expected at least one Skill(skill="gsd-<cmd>") reference in workflows under ${WORKFLOWS_DIR}`
);
const emitted = new Set();
const cmdFiles = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md'));

View File

@@ -0,0 +1,116 @@
/**
* Regression test for bug #2808
*
* All 85 GSD SKILL.md files declared `name: gsd:<cmd>` (colon), the deprecated
* form. Claude Code surfaces the `name:` frontmatter field in autocomplete, so
* users saw `/gsd:add-phase` suggestions instead of the canonical `/gsd-add-phase`.
*
* Root cause: skillFrontmatterName() in bin/install.js converted hyphenated
* skill dir names to colon form (gsd-add-phase → gsd:add-phase) because
* workflows called Skill(skill="gsd:<cmd>"). That was the original fix for
* #2643. Since then, workflows have been updated to use hyphen form (#2808).
*
* Fix: skillFrontmatterName() now returns the hyphen form unchanged.
* Four workflow Skill() colon calls updated to hyphen.
*
* This test verifies:
* 1. skillFrontmatterName returns hyphen form (not colon).
* 2. Installed SKILL.md would emit name: gsd-<cmd> (not gsd:<cmd>).
* 3. No workflow contains a Skill(skill="gsd:<cmd>") colon call.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test } = 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 walkMd(dir) {
const files = [];
try {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, e.name);
if (e.isDirectory()) files.push(...walkMd(full));
else if (e.name.endsWith('.md')) files.push(full);
}
} catch (err) {
assert.fail(`failed to read markdown files from ${dir}: ${err.message}`);
}
return files;
}
describe('bug-2808: SKILL.md name: uses hyphen form', () => {
test('skillFrontmatterName returns hyphen form (not colon)', () => {
assert.strictEqual(skillFrontmatterName('gsd-add-phase'), 'gsd-add-phase');
assert.strictEqual(skillFrontmatterName('gsd-plan-phase'), 'gsd-plan-phase');
assert.strictEqual(skillFrontmatterName('gsd-autonomous'), 'gsd-autonomous');
});
test('generated SKILL.md contains name: gsd-<cmd> (not gsd:<cmd>)', () => {
const cmdFiles = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md'));
assert.ok(cmdFiles.length > 0, 'expected GSD command files');
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 skillContent = convertClaudeCommandToClaudeSkill(src, skillDirName);
// Parse frontmatter structurally: extract name: line from the --- block.
const fmMatch = skillContent.match(/^---\n([\s\S]*?)\n---/);
assert.ok(fmMatch, `${cmd}: generated skill content must have a frontmatter block`);
const fmLines = fmMatch[1].split('\n');
const nameEntry = fmLines.find((l) => l.startsWith('name:'));
assert.ok(nameEntry, `${cmd}: generated SKILL.md is missing required name: field`);
const name = nameEntry.replace(/^name:\s*/, '').trim();
assert.ok(
!name.includes(':'),
`${cmd}: SKILL.md name should be hyphen form, got "${name}"`
);
assert.ok(
name.startsWith('gsd-'),
`${cmd}: SKILL.md name should start with gsd-, got "${name}"`
);
}
});
test('no workflow contains Skill(skill="gsd:<cmd>") colon form', () => {
const workflowFiles = walkMd(WORKFLOWS_DIR);
assert.ok(
workflowFiles.length > 0,
`expected workflow markdown files under ${WORKFLOWS_DIR}`
);
const colonCalls = [];
for (const f of workflowFiles) {
const src = fs.readFileSync(f, 'utf-8');
// Strip HTML comments to avoid matching commented-out examples.
const stripped = src.replace(/<!--[\s\S]*?-->/g, '');
// Scan each line for Skill() calls using the colon form.
// Parsing line-by-line is more precise than a multi-line regex
// and avoids false positives from incidental matches in prose.
for (const line of stripped.split('\n')) {
const colonCallRe = /Skill\(skill=['"]gsd:([a-z0-9-]+)['"]/gi;
let m;
while ((m = colonCallRe.exec(line)) !== null) {
colonCalls.push(`${path.basename(f)}: Skill(skill="gsd:${m[1]}")`);
}
}
}
assert.deepStrictEqual(
colonCalls,
[],
'deprecated colon-form Skill() calls found — update to gsd-<cmd>: ' + colonCalls.join(', ')
);
});
});

View File

@@ -69,7 +69,7 @@ describe('convertClaudeCommandToClaudeSkill', () => {
);
});
test('emits colon-form name (gsd:<cmd>) from hyphen-form dir (#2643)', () => {
test('emits hyphen-form name (gsd-<cmd>) from hyphen-form dir (#2808)', () => {
const input = [
'---',
'name: gsd:next',
@@ -80,9 +80,9 @@ describe('convertClaudeCommandToClaudeSkill', () => {
].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.
// gsd-next (hyphen, #2808) so Claude Code autocomplete shows canonical form.
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-next');
assert.ok(result.includes('name: gsd:next'), 'frontmatter name uses colon form');
assert.ok(result.includes('name: gsd-next'), 'frontmatter name uses hyphen form (#2808)');
});
test('preserves body content unchanged', () => {

View File

@@ -66,7 +66,7 @@ describe('Qwen Code: convertClaudeCommandToClaudeSkill', () => {
);
});
test('emits colon-form name (gsd:<cmd>) from hyphen-form dir (#2643)', () => {
test('emits hyphen-form name (gsd-<cmd>) from hyphen-form dir (#2808)', () => {
const input = [
'---',
'name: gsd:next',
@@ -77,9 +77,9 @@ describe('Qwen Code: convertClaudeCommandToClaudeSkill', () => {
].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.
// gsd-next (hyphen, #2808 — canonical invocation form for Claude Code autocomplete).
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-next');
assert.ok(result.includes('name: gsd:next'), 'frontmatter name uses colon form');
assert.ok(result.includes('name: gsd-next'), 'frontmatter name uses hyphen form (#2808)');
});
test('preserves body content unchanged', () => {
@@ -154,7 +154,7 @@ describe('Qwen Code: copyCommandsAsClaudeSkills', () => {
// Verify content
const content = fs.readFileSync(skillPath, 'utf8');
assert.ok(content.includes('name: gsd:quick'), 'frontmatter name uses colon form (#2643)');
assert.ok(content.includes('name: gsd-quick'), 'frontmatter name uses hyphen form (#2808)');
assert.ok(content.includes('description:'), 'description present');
assert.ok(content.includes('allowed-tools:'), 'allowed-tools preserved');
assert.ok(content.includes('<objective>'), 'body content preserved');
@@ -274,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:'));