mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
Flat-skills installs write SKILL.md files under gsd-<cmd>/ dirs, but Claude Code resolves skills by their frontmatter `name:`, not directory name. PR #2595 normalized every `/gsd-<cmd>` to `/gsd:<cmd>` across workflows — including inside `Skill(skill="...")` args — but the installer still emitted `name: gsd-<cmd>`, so every Skill() call on a flat-skills install resolved to nothing. Fix: emit `name: gsd:<cmd>` (colon form) in `convertClaudeCommandToClaudeSkill`. Keep the hyphen-form directory name for Windows path safety. Codex stays on hyphen form: its adapter invokes skills as `$gsd-<cmd>` (shell-var syntax) and a colon would terminate the variable name. `convertClaudeCommandToCodexSkill` uses `yamlQuote(skillName)` directly and is untouched. - Extract `skillFrontmatterName(dirName)` helper (exported for tests). - Update claude-skills-migration and qwen-skills-migration assertions that encoded the old hyphen emission. - Add `tests/bug-2643-skill-frontmatter-name.test.cjs` asserting every `Skill(skill="gsd:<cmd>")` reference in workflows resolves to an emitted frontmatter name. Full suite: 5452/5452 passing. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1113,11 +1113,31 @@ function convertClaudeCommandToCopilotSkill(content, skillName, isGlobal = false
|
|||||||
return `${fm}\n${body}`;
|
return `${fm}\n${body}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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).
|
* Convert a Claude command (.md) to a Claude skill (SKILL.md).
|
||||||
* Claude Code is the native format, so minimal conversion needed —
|
* Claude Code is the native format, so minimal conversion needed —
|
||||||
* preserve allowed-tools as YAML multiline list, preserve argument-hint,
|
* preserve allowed-tools as YAML multiline list, preserve argument-hint.
|
||||||
* convert name from gsd:xxx to gsd-xxx format.
|
* Emits `name: gsd:<cmd>` (colon) so Skill(skill="gsd:<cmd>") calls in
|
||||||
|
* workflows resolve on flat-skills installs — see #2643.
|
||||||
*/
|
*/
|
||||||
function convertClaudeCommandToClaudeSkill(content, skillName) {
|
function convertClaudeCommandToClaudeSkill(content, skillName) {
|
||||||
const { frontmatter, body } = extractFrontmatterAndBody(content);
|
const { frontmatter, body } = extractFrontmatterAndBody(content);
|
||||||
@@ -1137,7 +1157,8 @@ function convertClaudeCommandToClaudeSkill(content, skillName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reconstruct frontmatter in Claude skill format
|
// 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 (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`;
|
||||||
if (agent) fm += `agent: ${agent}\n`;
|
if (agent) fm += `agent: ${agent}\n`;
|
||||||
if (toolsBlock) fm += toolsBlock;
|
if (toolsBlock) fm += toolsBlock;
|
||||||
@@ -7265,6 +7286,7 @@ if (process.env.GSD_TEST_MODE) {
|
|||||||
convertClaudeAgentToAntigravityAgent,
|
convertClaudeAgentToAntigravityAgent,
|
||||||
copyCommandsAsAntigravitySkills,
|
copyCommandsAsAntigravitySkills,
|
||||||
convertClaudeCommandToClaudeSkill,
|
convertClaudeCommandToClaudeSkill,
|
||||||
|
skillFrontmatterName,
|
||||||
copyCommandsAsClaudeSkills,
|
copyCommandsAsClaudeSkills,
|
||||||
convertClaudeToWindsurfMarkdown,
|
convertClaudeToWindsurfMarkdown,
|
||||||
convertClaudeCommandToWindsurfSkill,
|
convertClaudeCommandToWindsurfSkill,
|
||||||
|
|||||||
90
tests/bug-2643-skill-frontmatter-name.test.cjs
Normal file
90
tests/bug-2643-skill-frontmatter-name.test.cjs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* 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:<cmd>', () => {
|
||||||
|
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:<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);
|
||||||
|
}
|
||||||
|
assert.ok(referenced.size > 0, 'expected at least one Skill(skill="gsd:<cmd>") 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(', '),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -69,7 +69,7 @@ describe('convertClaudeCommandToClaudeSkill', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('converts name format from gsd:xxx to skill naming', () => {
|
test('emits colon-form name (gsd:<cmd>) from hyphen-form dir (#2643)', () => {
|
||||||
const input = [
|
const input = [
|
||||||
'---',
|
'---',
|
||||||
'name: gsd:next',
|
'name: gsd:next',
|
||||||
@@ -79,9 +79,10 @@ describe('convertClaudeCommandToClaudeSkill', () => {
|
|||||||
'Body.',
|
'Body.',
|
||||||
].join('\n');
|
].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');
|
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'), 'frontmatter name uses colon form');
|
||||||
assert.ok(!result.includes('name: gsd:next'), 'old name format removed');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('preserves body content unchanged', () => {
|
test('preserves body content unchanged', () => {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ describe('Qwen Code: convertClaudeCommandToClaudeSkill', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('converts name format from gsd:xxx to skill naming', () => {
|
test('emits colon-form name (gsd:<cmd>) from hyphen-form dir (#2643)', () => {
|
||||||
const input = [
|
const input = [
|
||||||
'---',
|
'---',
|
||||||
'name: gsd:next',
|
'name: gsd:next',
|
||||||
@@ -76,9 +76,10 @@ describe('Qwen Code: convertClaudeCommandToClaudeSkill', () => {
|
|||||||
'Body.',
|
'Body.',
|
||||||
].join('\n');
|
].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');
|
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'), 'frontmatter name uses colon form');
|
||||||
assert.ok(!result.includes('name: gsd:next'), 'old name format removed');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('preserves body content unchanged', () => {
|
test('preserves body content unchanged', () => {
|
||||||
@@ -153,7 +154,7 @@ describe('Qwen Code: copyCommandsAsClaudeSkills', () => {
|
|||||||
|
|
||||||
// Verify content
|
// Verify content
|
||||||
const content = fs.readFileSync(skillPath, 'utf8');
|
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('description:'), 'description present');
|
||||||
assert.ok(content.includes('allowed-tools:'), 'allowed-tools preserved');
|
assert.ok(content.includes('allowed-tools:'), 'allowed-tools preserved');
|
||||||
assert.ok(content.includes('<objective>'), 'body content preserved');
|
assert.ok(content.includes('<objective>'), 'body content preserved');
|
||||||
@@ -273,7 +274,7 @@ describe('Qwen Code: SKILL.md format validation', () => {
|
|||||||
assert.ok(fmMatch, 'has frontmatter block');
|
assert.ok(fmMatch, 'has frontmatter block');
|
||||||
|
|
||||||
const fmLines = fmMatch[1].split('\n');
|
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 hasDesc = fmLines.some(l => l.startsWith('description:'));
|
||||||
const hasAgent = fmLines.some(l => l.startsWith('agent:'));
|
const hasAgent = fmLines.some(l => l.startsWith('agent:'));
|
||||||
const hasTools = fmLines.some(l => l.startsWith('allowed-tools:'));
|
const hasTools = fmLines.some(l => l.startsWith('allowed-tools:'));
|
||||||
|
|||||||
Reference in New Issue
Block a user