From 01fda70a19954a07e5e8409af56a86f17910e3dc Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 1 Apr 2026 17:04:53 -0400 Subject: [PATCH] fix: migrate Claude Code commands/ to skills/ format for 2.1.88+ compatibility Claude Code 2.1.88+ deprecated commands/ subdirectory discovery in favor of skills/*/SKILL.md format. This migrates the Claude Code installer to use the same skills pattern already used by Codex, Copilot, Cursor, Windsurf, and Antigravity. Key changes: - New convertClaudeCommandToClaudeSkill() preserving allowed-tools and argument-hint - New copyCommandsAsClaudeSkills() mirroring Copilot pattern - Install now writes skills/gsd-*/SKILL.md instead of commands/gsd/*.md - Legacy commands/gsd/ cleaned up during install - Manifest tracks skills/ for Claude Code - Uninstall handles both skills/ and legacy commands/ Fixes #1504 Supersedes #1538 Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/install.js | 155 +++++++++- tests/claude-skills-migration.test.cjs | 378 +++++++++++++++++++++++++ 2 files changed, 527 insertions(+), 6 deletions(-) create mode 100644 tests/claude-skills-migration.test.cjs diff --git a/bin/install.js b/bin/install.js index 843832c14..7862bfba7 100755 --- a/bin/install.js +++ b/bin/install.js @@ -624,6 +624,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. @@ -2972,6 +3005,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. @@ -3300,6 +3390,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'; @@ -3487,13 +3578,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 @@ -3915,6 +4032,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'; @@ -3931,7 +4049,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; @@ -3944,7 +4062,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); @@ -4177,11 +4295,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); @@ -4190,6 +4308,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 @@ -4958,6 +5099,8 @@ if (process.env.GSD_TEST_MODE) { convertClaudeCommandToAntigravitySkill, convertClaudeAgentToAntigravityAgent, copyCommandsAsAntigravitySkills, + convertClaudeCommandToClaudeSkill, + copyCommandsAsClaudeSkills, convertClaudeToWindsurfMarkdown, convertClaudeCommandToWindsurfSkill, convertClaudeAgentToWindsurfAgent, diff --git a/tests/claude-skills-migration.test.cjs b/tests/claude-skills-migration.test.cjs new file mode 100644 index 000000000..c3c64a94c --- /dev/null +++ b/tests/claude-skills-migration.test.cjs @@ -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\nDo the thing.\n\n\n\nStep 1.\nStep 2.\n\n'; + const input = [ + '---', + 'name: gsd:test', + 'description: Test command', + '---', + body, + ].join(''); + + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-test'); + assert.ok(result.includes(''), 'objective tag preserved'); + assert.ok(result.includes('Do the thing.'), 'body text preserved'); + assert.ok(result.includes(''), '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'); + }); +});