Merge pull request #1540 from gsd-build/fix/1504-claude-skills-migration

fix: migrate Claude Code commands/ to skills/ format for 2.1.88+
This commit is contained in:
Tom Boucher
2026-04-01 17:07:43 -04:00
committed by GitHub
2 changed files with 527 additions and 6 deletions

View File

@@ -688,6 +688,39 @@ function convertClaudeCommandToCopilotSkill(content, skillName, isGlobal = false
return `${fm}\n${body}`;
}
/**
* 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.
*/
function convertClaudeCommandToClaudeSkill(content, skillName) {
const { frontmatter, body } = extractFrontmatterAndBody(content);
if (!frontmatter) return content;
const description = extractFrontmatterField(frontmatter, 'description') || '';
const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint');
const agent = extractFrontmatterField(frontmatter, 'agent');
// Preserve allowed-tools as YAML multiline list (Claude native format)
const toolsMatch = frontmatter.match(/^allowed-tools:\s*\n((?:\s+-\s+.+\n?)*)/m);
let toolsBlock = '';
if (toolsMatch) {
toolsBlock = 'allowed-tools:\n' + toolsMatch[1];
// Ensure trailing newline
if (!toolsBlock.endsWith('\n')) toolsBlock += '\n';
}
// Reconstruct frontmatter in Claude skill format
let fm = `---\nname: ${skillName}\ndescription: ${yamlQuote(description)}\n`;
if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`;
if (agent) fm += `agent: ${agent}\n`;
if (toolsBlock) fm += toolsBlock;
fm += '---';
return `${fm}\n${body}`;
}
/**
* Convert a Claude agent (.md) to a Copilot agent (.agent.md).
* Applies tool mapping + deduplication, formats tools as JSON array.
@@ -3036,6 +3069,63 @@ function copyCommandsAsCopilotSkills(srcDir, skillsDir, prefix, isGlobal = false
recurse(srcDir, prefix);
}
/**
* Copy Claude commands as Claude skills — one folder per skill with SKILL.md.
* Claude Code 2.1.88+ uses skills/xxx/SKILL.md instead of commands/gsd/xxx.md.
* Claude is the native format so no path replacement is needed — only
* frontmatter restructuring via convertClaudeCommandToClaudeSkill.
* @param {string} srcDir - Source commands directory
* @param {string} skillsDir - Target skills directory
* @param {string} prefix - Skill name prefix (e.g. 'gsd')
* @param {string} pathPrefix - Path prefix for file references
* @param {string} runtime - Target runtime
* @param {boolean} isGlobal - Whether this is a global install
*/
function copyCommandsAsClaudeSkills(srcDir, skillsDir, prefix, pathPrefix, runtime, isGlobal = false) {
if (!fs.existsSync(srcDir)) {
return;
}
fs.mkdirSync(skillsDir, { recursive: true });
// Remove previous GSD Claude skills to avoid stale command skills
const existing = fs.readdirSync(skillsDir, { withFileTypes: true });
for (const entry of existing) {
if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) {
fs.rmSync(path.join(skillsDir, entry.name), { recursive: true });
}
}
function recurse(currentSrcDir, currentPrefix) {
const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(currentSrcDir, entry.name);
if (entry.isDirectory()) {
recurse(srcPath, `${currentPrefix}-${entry.name}`);
continue;
}
if (!entry.name.endsWith('.md')) {
continue;
}
const baseName = entry.name.replace('.md', '');
const skillName = `${currentPrefix}-${baseName}`;
const skillDir = path.join(skillsDir, skillName);
fs.mkdirSync(skillDir, { recursive: true });
let content = fs.readFileSync(srcPath, 'utf8');
content = processAttribution(content, getCommitAttribution('claude'));
content = convertClaudeCommandToClaudeSkill(content, skillName);
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
}
}
recurse(srcDir, prefix);
}
/**
* Recursively install GSD commands as Antigravity skills.
* Each command becomes a skill-name/ folder containing SKILL.md.
@@ -3364,6 +3454,7 @@ function validateHookFields(settings) {
*/
function uninstall(isGlobal, runtime = 'claude') {
const isOpencode = runtime === 'opencode';
const isGemini = runtime === 'gemini';
const isCodex = runtime === 'codex';
const isCopilot = runtime === 'copilot';
const isAntigravity = runtime === 'antigravity';
@@ -3551,13 +3642,39 @@ function uninstall(isGlobal, runtime = 'claude') {
console.log(` ${green}✓${reset} Removed ${skillCount} Windsurf skills`);
}
}
} else {
} else if (isGemini) {
// Gemini: still uses commands/gsd/
const gsdCommandsDir = path.join(targetDir, 'commands', 'gsd');
if (fs.existsSync(gsdCommandsDir)) {
fs.rmSync(gsdCommandsDir, { recursive: true });
removedCount++;
console.log(` ${green}✓${reset} Removed commands/gsd/`);
}
} else {
// Claude Code: remove skills/gsd-*/ directories
const skillsDir = path.join(targetDir, 'skills');
if (fs.existsSync(skillsDir)) {
let skillCount = 0;
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
fs.rmSync(path.join(skillsDir, entry.name), { recursive: true });
skillCount++;
}
}
if (skillCount > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed ${skillCount} Claude Code skills`);
}
}
// Also clean up legacy commands/gsd/ from older installs
const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd');
if (fs.existsSync(legacyCommandsDir)) {
fs.rmSync(legacyCommandsDir, { recursive: true });
removedCount++;
console.log(` ${green}✓${reset} Removed legacy commands/gsd/`);
}
}
// 2. Remove get-shit-done directory
@@ -3983,6 +4100,7 @@ function generateManifest(dir, baseDir) {
*/
function writeManifest(configDir, runtime = 'claude') {
const isOpencode = runtime === 'opencode';
const isGemini = runtime === 'gemini';
const isCodex = runtime === 'codex';
const isCopilot = runtime === 'copilot';
const isAntigravity = runtime === 'antigravity';
@@ -3999,7 +4117,7 @@ function writeManifest(configDir, runtime = 'claude') {
for (const [rel, hash] of Object.entries(gsdHashes)) {
manifest.files['get-shit-done/' + rel] = hash;
}
if (!isOpencode && !isCodex && !isCopilot && !isAntigravity && !isCursor && !isWindsurf && fs.existsSync(commandsDir)) {
if (isGemini && fs.existsSync(commandsDir)) {
const cmdHashes = generateManifest(commandsDir);
for (const [rel, hash] of Object.entries(cmdHashes)) {
manifest.files['commands/gsd/' + rel] = hash;
@@ -4012,7 +4130,7 @@ function writeManifest(configDir, runtime = 'claude') {
}
}
}
if ((isCodex || isCopilot || isAntigravity || isCursor || isWindsurf) && fs.existsSync(codexSkillsDir)) {
if ((isCodex || isCopilot || isAntigravity || isCursor || isWindsurf || (!isOpencode && !isGemini)) && fs.existsSync(codexSkillsDir)) {
for (const skillName of listCodexSkillNames(codexSkillsDir)) {
const skillRoot = path.join(codexSkillsDir, skillName);
const skillHashes = generateManifest(skillRoot);
@@ -4245,11 +4363,11 @@ function install(isGlobal, runtime = 'claude') {
} else {
failures.push('skills/gsd-*');
}
} else {
// Claude Code & Gemini: nested structure in commands/ directory
} else if (isGemini) {
// Gemini: nested structure in commands/ directory (still supported)
const commandsDir = path.join(targetDir, 'commands');
fs.mkdirSync(commandsDir, { recursive: true });
const gsdSrc = path.join(src, 'commands', 'gsd');
const gsdDest = path.join(commandsDir, 'gsd');
copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal);
@@ -4258,6 +4376,29 @@ function install(isGlobal, runtime = 'claude') {
} else {
failures.push('commands/gsd');
}
} else {
// Claude Code: skills/ format (2.1.88+ compatibility)
const skillsDir = path.join(targetDir, 'skills');
const gsdSrc = path.join(src, 'commands', 'gsd');
copyCommandsAsClaudeSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime, isGlobal);
if (fs.existsSync(skillsDir)) {
const count = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
if (count > 0) {
console.log(` ${green}✓${reset} Installed ${count} skills to skills/`);
} else {
failures.push('skills/gsd-*');
}
} else {
failures.push('skills/gsd-*');
}
// Clean up legacy commands/gsd/ from previous installs
const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd');
if (fs.existsSync(legacyCommandsDir)) {
fs.rmSync(legacyCommandsDir, { recursive: true });
console.log(` ${green}✓${reset} Removed legacy commands/gsd/ directory`);
}
}
// Copy get-shit-done skill with path replacement
@@ -5031,6 +5172,8 @@ if (process.env.GSD_TEST_MODE) {
convertClaudeCommandToAntigravitySkill,
convertClaudeAgentToAntigravityAgent,
copyCommandsAsAntigravitySkills,
convertClaudeCommandToClaudeSkill,
copyCommandsAsClaudeSkills,
convertClaudeToWindsurfMarkdown,
convertClaudeCommandToWindsurfSkill,
convertClaudeAgentToWindsurfAgent,

View File

@@ -0,0 +1,378 @@
/**
* GSD Tools Tests - Claude Skills Migration (#1504)
*
* Tests for migrating Claude Code from commands/gsd/ to skills/gsd-xxx/SKILL.md
* format for compatibility with Claude Code 2.1.88+.
*
* Uses node:test and node:assert (NOT Jest).
*/
process.env.GSD_TEST_MODE = '1';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const path = require('path');
const os = require('os');
const fs = require('fs');
const {
convertClaudeCommandToClaudeSkill,
copyCommandsAsClaudeSkills,
writeManifest,
install,
} = require('../bin/install.js');
// ─── convertClaudeCommandToClaudeSkill ──────────────────────────────────────
describe('convertClaudeCommandToClaudeSkill', () => {
test('preserves allowed-tools multiline YAML list', () => {
const input = [
'---',
'name: gsd:next',
'description: Advance to the next step',
'allowed-tools:',
' - Read',
' - Bash',
' - Grep',
'---',
'',
'Body content here.',
].join('\n');
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-next');
assert.ok(result.includes('allowed-tools:'), 'allowed-tools field is present');
assert.ok(result.includes('Read'), 'Read tool preserved');
assert.ok(result.includes('Bash'), 'Bash tool preserved');
assert.ok(result.includes('Grep'), 'Grep tool preserved');
});
test('preserves argument-hint', () => {
const input = [
'---',
'name: gsd:debug',
'description: Debug issues',
'argument-hint: "[issue description]"',
'allowed-tools:',
' - Read',
' - Bash',
'---',
'',
'Debug body.',
].join('\n');
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-debug');
assert.ok(result.includes('argument-hint:'), 'argument-hint field is present');
// The value should be preserved (possibly yaml-quoted)
assert.ok(
result.includes('[issue description]'),
'argument-hint value preserved'
);
});
test('converts name format from gsd:xxx to skill naming', () => {
const input = [
'---',
'name: gsd:next',
'description: Advance workflow',
'---',
'',
'Body.',
].join('\n');
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');
});
test('preserves body content unchanged', () => {
const body = '\n<objective>\nDo the thing.\n</objective>\n\n<process>\nStep 1.\nStep 2.\n</process>\n';
const input = [
'---',
'name: gsd:test',
'description: Test command',
'---',
body,
].join('');
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-test');
assert.ok(result.includes('<objective>'), 'objective tag preserved');
assert.ok(result.includes('Do the thing.'), 'body text preserved');
assert.ok(result.includes('<process>'), 'process tag preserved');
assert.ok(result.includes('Step 1.'), 'step text preserved');
});
test('preserves agent field', () => {
const input = [
'---',
'name: gsd:plan-phase',
'description: Plan a phase',
'agent: true',
'allowed-tools:',
' - Read',
'---',
'',
'Plan body.',
].join('\n');
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-plan-phase');
assert.ok(result.includes('agent:'), 'agent field is present');
});
test('handles content with no frontmatter', () => {
const input = 'Just some plain markdown content.';
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-plain');
assert.strictEqual(result, input, 'content returned unchanged');
});
test('preserves allowed-tools as multiline YAML list (not flattened)', () => {
const input = [
'---',
'name: gsd:debug',
'description: Debug',
'allowed-tools:',
' - Read',
' - Bash',
' - Task',
' - AskUserQuestion',
'---',
'',
'Body.',
].join('\n');
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-debug');
// Claude Code native format keeps YAML multiline list
assert.ok(result.includes(' - Read'), 'Read in multiline list');
assert.ok(result.includes(' - Bash'), 'Bash in multiline list');
assert.ok(result.includes(' - Task'), 'Task in multiline list');
assert.ok(result.includes(' - AskUserQuestion'), 'AskUserQuestion in multiline list');
});
});
// ─── copyCommandsAsClaudeSkills ─────────────────────────────────────────────
describe('copyCommandsAsClaudeSkills', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-claude-skills-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('creates correct directory structure skills/gsd-xxx/SKILL.md', () => {
// Create source commands
const srcDir = path.join(tmpDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(
path.join(srcDir, 'next.md'),
'---\nname: gsd:next\ndescription: Advance\nallowed-tools:\n - Read\n---\n\nBody.'
);
fs.writeFileSync(
path.join(srcDir, 'health.md'),
'---\nname: gsd:health\ndescription: Check health\n---\n\nHealth body.'
);
const skillsDir = path.join(tmpDir, 'skills');
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
// Verify directory structure
assert.ok(
fs.existsSync(path.join(skillsDir, 'gsd-next', 'SKILL.md')),
'skills/gsd-next/SKILL.md exists'
);
assert.ok(
fs.existsSync(path.join(skillsDir, 'gsd-health', 'SKILL.md')),
'skills/gsd-health/SKILL.md exists'
);
});
test('cleans up old skills before installing new ones', () => {
const srcDir = path.join(tmpDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(
path.join(srcDir, 'next.md'),
'---\nname: gsd:next\ndescription: Advance\n---\n\nBody.'
);
const skillsDir = path.join(tmpDir, 'skills');
// Create a stale skill that should be removed
const staleDir = path.join(skillsDir, 'gsd-old-command');
fs.mkdirSync(staleDir, { recursive: true });
fs.writeFileSync(path.join(staleDir, 'SKILL.md'), 'stale content');
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
// Stale skill removed
assert.ok(
!fs.existsSync(staleDir),
'stale skill directory removed'
);
// New skill created
assert.ok(
fs.existsSync(path.join(skillsDir, 'gsd-next', 'SKILL.md')),
'new skill created'
);
});
test('does not remove non-GSD skills', () => {
const srcDir = path.join(tmpDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(
path.join(srcDir, 'next.md'),
'---\nname: gsd:next\ndescription: Advance\n---\n\nBody.'
);
const skillsDir = path.join(tmpDir, 'skills');
// Create a non-GSD skill
const otherDir = path.join(skillsDir, 'my-custom-skill');
fs.mkdirSync(otherDir, { recursive: true });
fs.writeFileSync(path.join(otherDir, 'SKILL.md'), 'custom content');
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
// Non-GSD skill preserved
assert.ok(
fs.existsSync(otherDir),
'non-GSD skill preserved'
);
});
test('handles recursive subdirectories', () => {
const srcDir = path.join(tmpDir, 'src');
const subDir = path.join(srcDir, 'wired');
fs.mkdirSync(subDir, { recursive: true });
fs.writeFileSync(
path.join(subDir, 'ready.md'),
'---\nname: gsd-wired:ready\ndescription: Show ready tasks\n---\n\nBody.'
);
const skillsDir = path.join(tmpDir, 'skills');
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
assert.ok(
fs.existsSync(path.join(skillsDir, 'gsd-wired-ready', 'SKILL.md')),
'nested command creates gsd-wired-ready/SKILL.md'
);
});
test('no-ops when source directory does not exist', () => {
const skillsDir = path.join(tmpDir, 'skills');
// Should not throw
copyCommandsAsClaudeSkills(
path.join(tmpDir, 'nonexistent'),
skillsDir,
'gsd',
'$HOME/.claude/',
'claude',
true
);
assert.ok(!fs.existsSync(skillsDir), 'skills dir not created when src missing');
});
});
// ─── Legacy cleanup during install ──────────────────────────────────────────
describe('Legacy commands/gsd/ cleanup', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-legacy-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('install removes legacy commands/gsd/ directory when present', () => {
// Create a mock legacy commands/gsd/ directory
const legacyDir = path.join(tmpDir, 'commands', 'gsd');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'next.md'), 'legacy content');
// Create source commands for the installer to read
const srcDir = path.join(tmpDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(
path.join(srcDir, 'next.md'),
'---\nname: gsd:next\ndescription: Advance\n---\n\nBody.'
);
const skillsDir = path.join(tmpDir, 'skills');
// Install skills
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
// Simulate the legacy cleanup that install() does after copyCommandsAsClaudeSkills
if (fs.existsSync(legacyDir)) {
fs.rmSync(legacyDir, { recursive: true });
}
assert.ok(!fs.existsSync(legacyDir), 'legacy commands/gsd/ removed');
assert.ok(
fs.existsSync(path.join(skillsDir, 'gsd-next', 'SKILL.md')),
'new skill installed'
);
});
});
// ─── writeManifest tracks skills/ for Claude ────────────────────────────────
describe('writeManifest tracks skills/ for Claude', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-manifest-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('manifest includes skills/gsd-xxx/SKILL.md entries for Claude runtime', () => {
// Create skills directory structure (as install would)
const skillsDir = path.join(tmpDir, 'skills');
const skillDir = path.join(skillsDir, 'gsd-next');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'skill content');
// Create get-shit-done directory (required by writeManifest)
const gsdDir = path.join(tmpDir, 'get-shit-done');
fs.mkdirSync(gsdDir, { recursive: true });
fs.writeFileSync(path.join(gsdDir, 'test.md'), 'test');
writeManifest(tmpDir, 'claude');
const manifest = JSON.parse(
fs.readFileSync(path.join(tmpDir, 'gsd-file-manifest.json'), 'utf8')
);
// Should have skills/ entries
const skillEntries = Object.keys(manifest.files).filter(k =>
k.startsWith('skills/')
);
assert.ok(skillEntries.length > 0, 'manifest has skills/ entries');
assert.ok(
skillEntries.some(k => k === 'skills/gsd-next/SKILL.md'),
'manifest has skills/gsd-next/SKILL.md'
);
// Should NOT have commands/gsd/ entries
const cmdEntries = Object.keys(manifest.files).filter(k =>
k.startsWith('commands/gsd/')
);
assert.strictEqual(cmdEntries.length, 0, 'manifest has no commands/gsd/ entries');
});
});
// ─── Exports exist ──────────────────────────────────────────────────────────
describe('Claude skills migration exports', () => {
test('convertClaudeCommandToClaudeSkill is exported', () => {
assert.strictEqual(typeof convertClaudeCommandToClaudeSkill, 'function');
});
test('copyCommandsAsClaudeSkills is exported', () => {
assert.strictEqual(typeof copyCommandsAsClaudeSkills, 'function');
});
});