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:
155
bin/install.js
155
bin/install.js
@@ -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,
|
||||
|
||||
378
tests/claude-skills-migration.test.cjs
Normal file
378
tests/claude-skills-migration.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user