* 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:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):**
|
||||
|
||||
@@ -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'));
|
||||
|
||||
116
tests/bug-2808-skill-hyphen-name.test.cjs
Normal file
116
tests/bug-2808-skill-hyphen-name.test.cjs
Normal 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(', ')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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:'));
|
||||
|
||||
Reference in New Issue
Block a user