feat(skills): normalize skill discovery contract across runtimes (#2261)

This commit is contained in:
TÂCHES
2026-04-15 07:39:48 -06:00
committed by GitHub
parent c051e71851
commit 4a34745950
9 changed files with 477 additions and 250 deletions

View File

@@ -117,7 +117,9 @@ Verify with:
- Cline: GSD installs via `.clinerules` — verify by checking `.clinerules` exists - Cline: GSD installs via `.clinerules` — verify by checking `.clinerules` exists
> [!NOTE] > [!NOTE]
> Claude Code 2.1.88+, Qwen Code, and Codex install as skills (`skills/gsd-*/SKILL.md`). Older Claude Code versions use `commands/gsd/`. Cline uses `.clinerules` for configuration. The installer handles all formats automatically. > Claude Code 2.1.88+, Qwen Code, and Codex install as skills (`.claude/skills/`, `./.codex/skills/`, or the matching global `~/.claude/skills/` / `~/.codex/skills/` roots). Older Claude Code versions use `commands/gsd/`. `~/.claude/get-shit-done/skills/` is import-only for legacy migration. The installer handles all formats automatically.
The canonical discovery contract is documented in [docs/skills/discovery-contract.md](docs/skills/discovery-contract.md).
> [!TIP] > [!TIP]
> For source-based installs or environments where npm is unavailable, see **[docs/manual-update.md](docs/manual-update.md)**. > For source-based installs or environments where npm is unavailable, see **[docs/manual-update.md](docs/manual-update.md)**.
@@ -818,8 +820,9 @@ This prevents Claude from reading these files entirely, regardless of what comma
**Commands not found after install?** **Commands not found after install?**
- Restart your runtime to reload commands/skills - Restart your runtime to reload commands/skills
- Verify files exist in `~/.claude/skills/gsd-*/SKILL.md` (Claude Code 2.1.88+) or `~/.claude/commands/gsd/` (legacy) - Verify files exist in `~/.claude/skills/gsd-*/SKILL.md` or `~/.codex/skills/gsd-*/SKILL.md` for managed global installs
- For Codex, verify skills exist in `~/.codex/skills/gsd-*/SKILL.md` (global) or `./.codex/skills/gsd-*/SKILL.md` (local) - For local installs, verify `.claude/skills/gsd-*/SKILL.md` or `./.codex/skills/gsd-*/SKILL.md`
- Legacy Claude Code installs still use `~/.claude/commands/gsd/`
**Commands not working as expected?** **Commands not working as expected?**
- Run `/gsd-help` to verify installation - Run `/gsd-help` to verify installation

View File

@@ -0,0 +1,92 @@
# Skill Discovery Contract
> Canonical rules for scanning, inventorying, and rendering GSD skills.
## Root Categories
### Project Roots
Scan these roots relative to the project root:
- `.claude/skills/`
- `.agents/skills/`
- `.cursor/skills/`
- `.github/skills/`
- `./.codex/skills/`
These roots are used for project-specific skills and for the project `CLAUDE.md` skills section.
### Managed Global Roots
Scan these roots relative to the user home directory:
- `~/.claude/skills/`
- `~/.codex/skills/`
These roots are used for managed runtime installs and inventory reporting.
### Deprecated Import-Only Root
- `~/.claude/get-shit-done/skills/`
This root is kept for legacy migration only. Inventory code may report it, but new installs should not write here.
### Legacy Claude Commands
- `~/.claude/commands/gsd/`
This is not a skills root. Discovery code only checks whether it exists so inventory can report legacy Claude installs.
## Normalization Rules
- Scan only subdirectories that contain `SKILL.md`.
- Read `name` and `description` from YAML frontmatter.
- Use the directory name when `name` is missing.
- Extract trigger hints from body lines that match `TRIGGER when: ...`.
- Treat `gsd-*` directories as installed framework skills.
- Treat `~/.claude/get-shit-done/skills/` entries as deprecated/import-only.
- Treat `~/.claude/commands/gsd/` as legacy command installation metadata, not skills.
## Scanner Behavior
### `sdk/src/query/skills.ts`
- Returns a de-duplicated list of discovered skill names.
- Scans project roots plus managed global roots.
- Does not scan the deprecated import-only root.
### `get-shit-done/bin/lib/profile-output.cjs`
- Builds the project `CLAUDE.md` skills section.
- Scans project roots only.
- Skips `gsd-*` directories so the project section stays focused on user/project skills.
- Adds `.codex/skills/` to the project discovery set.
### `get-shit-done/bin/lib/init.cjs`
- Generates the skill inventory object for `skill-manifest`.
- Reports `skills`, `roots`, `installation`, and `counts`.
- Marks `gsd_skills_installed` when any discovered skill name starts with `gsd-`.
- Marks `legacy_claude_commands_installed` when `~/.claude/commands/gsd/` contains `.md` command files.
## Inventory Shape
`skill-manifest` returns a JSON object with:
- `skills`: normalized skill entries
- `roots`: the canonical roots that were checked
- `installation`: summary booleans for installed GSD skills and legacy Claude commands
- `counts`: small inventory counts for downstream consumers
Each skill entry includes:
- `name`
- `description`
- `triggers`
- `path`
- `file_path`
- `root`
- `scope`
- `installed`
- `deprecated`

View File

@@ -1590,75 +1590,207 @@ function cmdAgentSkills(cwd, agentType, raw) {
/** /**
* Generate a skill manifest from a skills directory. * Generate a skill manifest from a skills directory.
* *
* Scans the given skills directory for subdirectories containing SKILL.md, * Scans the canonical skill discovery roots and returns a normalized
* extracts frontmatter (name, description) and trigger conditions from the * inventory object with discovered skills, root metadata, and installation
* body text, and returns an array of skill descriptors. * summary flags. A legacy `skillsDir` override is still accepted for focused
* scans, but the default mode is multi-root discovery.
* *
* @param {string} skillsDir - Absolute path to the skills directory * @param {string} cwd - Project root directory
* @returns {Array<{name: string, description: string, triggers: string[], path: string}>} * @param {string|null} [skillsDir] - Optional absolute path to a specific skills directory
* @returns {{
* skills: Array<{name: string, description: string, triggers: string[], path: string, file_path: string, root: string, scope: string, installed: boolean, deprecated: boolean}>,
* roots: Array<{root: string, path: string, scope: string, present: boolean, skill_count?: number, command_count?: number, deprecated?: boolean}>,
* installation: { gsd_skills_installed: boolean, legacy_claude_commands_installed: boolean },
* counts: { skills: number, roots: number }
* }}
*/ */
function buildSkillManifest(skillsDir) { function buildSkillManifest(cwd, skillsDir = null) {
const { extractFrontmatter } = require('./frontmatter.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs');
const os = require('os');
if (!fs.existsSync(skillsDir)) return []; const canonicalRoots = skillsDir ? [{
root: path.resolve(skillsDir),
path: path.resolve(skillsDir),
scope: 'custom',
present: fs.existsSync(skillsDir),
kind: 'skills',
}] : [
{
root: '.claude/skills',
path: path.join(cwd, '.claude', 'skills'),
scope: 'project',
kind: 'skills',
},
{
root: '.agents/skills',
path: path.join(cwd, '.agents', 'skills'),
scope: 'project',
kind: 'skills',
},
{
root: '.cursor/skills',
path: path.join(cwd, '.cursor', 'skills'),
scope: 'project',
kind: 'skills',
},
{
root: '.github/skills',
path: path.join(cwd, '.github', 'skills'),
scope: 'project',
kind: 'skills',
},
{
root: '.codex/skills',
path: path.join(cwd, '.codex', 'skills'),
scope: 'project',
kind: 'skills',
},
{
root: '~/.claude/skills',
path: path.join(os.homedir(), '.claude', 'skills'),
scope: 'global',
kind: 'skills',
},
{
root: '~/.codex/skills',
path: path.join(os.homedir(), '.codex', 'skills'),
scope: 'global',
kind: 'skills',
},
{
root: '~/.claude/get-shit-done/skills',
path: path.join(os.homedir(), '.claude', 'get-shit-done', 'skills'),
scope: 'import-only',
kind: 'skills',
deprecated: true,
},
{
root: '~/.claude/commands/gsd',
path: path.join(os.homedir(), '.claude', 'commands', 'gsd'),
scope: 'legacy-commands',
kind: 'commands',
deprecated: true,
},
];
let entries; const skills = [];
try { const roots = [];
entries = fs.readdirSync(skillsDir, { withFileTypes: true }); let legacyClaudeCommandsInstalled = false;
} catch { for (const rootInfo of canonicalRoots) {
return []; const rootPath = rootInfo.path;
} const rootSummary = {
root: rootInfo.root,
path: rootPath,
scope: rootInfo.scope,
present: fs.existsSync(rootPath),
deprecated: !!rootInfo.deprecated,
};
const manifest = []; if (!rootSummary.present) {
for (const entry of entries) { roots.push(rootSummary);
if (!entry.isDirectory()) continue;
const skillMdPath = path.join(skillsDir, entry.name, 'SKILL.md');
if (!fs.existsSync(skillMdPath)) continue;
let content;
try {
content = fs.readFileSync(skillMdPath, 'utf-8');
} catch {
continue; continue;
} }
const frontmatter = extractFrontmatter(content); if (rootInfo.kind === 'commands') {
const name = frontmatter.name || entry.name; let entries = [];
const description = frontmatter.description || ''; try {
entries = fs.readdirSync(rootPath, { withFileTypes: true });
// Extract trigger lines from body text (after frontmatter) } catch {
const triggers = []; roots.push(rootSummary);
const bodyMatch = content.match(/^---[\s\S]*?---\s*\n([\s\S]*)$/); continue;
if (bodyMatch) {
const body = bodyMatch[1];
const triggerLines = body.match(/^TRIGGER\s+when:\s*(.+)$/gmi);
if (triggerLines) {
for (const line of triggerLines) {
const m = line.match(/^TRIGGER\s+when:\s*(.+)$/i);
if (m) triggers.push(m[1].trim());
}
} }
const commandFiles = entries.filter(entry => entry.isFile() && entry.name.endsWith('.md'));
rootSummary.command_count = commandFiles.length;
if (rootSummary.command_count > 0) legacyClaudeCommandsInstalled = true;
roots.push(rootSummary);
continue;
} }
manifest.push({ let entries;
name, try {
description, entries = fs.readdirSync(rootPath, { withFileTypes: true });
triggers, } catch {
path: entry.name, roots.push(rootSummary);
}); continue;
}
let skillCount = 0;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillMdPath = path.join(rootPath, entry.name, 'SKILL.md');
if (!fs.existsSync(skillMdPath)) continue;
let content;
try {
content = fs.readFileSync(skillMdPath, 'utf-8');
} catch {
continue;
}
const frontmatter = extractFrontmatter(content);
const name = frontmatter.name || entry.name;
const description = frontmatter.description || '';
// Extract trigger lines from body text (after frontmatter)
const triggers = [];
const bodyMatch = content.match(/^---[\s\S]*?---\s*\n([\s\S]*)$/);
if (bodyMatch) {
const body = bodyMatch[1];
const triggerLines = body.match(/^TRIGGER\s+when:\s*(.+)$/gmi);
if (triggerLines) {
for (const line of triggerLines) {
const m = line.match(/^TRIGGER\s+when:\s*(.+)$/i);
if (m) triggers.push(m[1].trim());
}
}
}
skills.push({
name,
description,
triggers,
path: entry.name,
file_path: `${entry.name}/SKILL.md`,
root: rootInfo.root,
scope: rootInfo.scope,
installed: rootInfo.scope !== 'import-only',
deprecated: !!rootInfo.deprecated,
});
skillCount++;
}
rootSummary.skill_count = skillCount;
roots.push(rootSummary);
} }
// Sort by name for deterministic output skills.sort((a, b) => {
manifest.sort((a, b) => a.name.localeCompare(b.name)); const rootCmp = a.root.localeCompare(b.root);
return manifest; return rootCmp !== 0 ? rootCmp : a.name.localeCompare(b.name);
});
const gsdSkillsInstalled = skills.some(skill => skill.name.startsWith('gsd-'));
return {
skills,
roots,
installation: {
gsd_skills_installed: gsdSkillsInstalled,
legacy_claude_commands_installed: legacyClaudeCommandsInstalled,
},
counts: {
skills: skills.length,
roots: roots.length,
},
};
} }
/** /**
* Command: generate skill manifest JSON. * Command: generate skill manifest JSON.
* *
* Options: * Options:
* --skills-dir <path> Path to skills directory (required) * --skills-dir <path> Optional absolute path to a single skills directory
* --write Also write to .planning/skill-manifest.json * --write Also write to .planning/skill-manifest.json
*/ */
function cmdSkillManifest(cwd, args, raw) { function cmdSkillManifest(cwd, args, raw) {
@@ -1667,12 +1799,7 @@ function cmdSkillManifest(cwd, args, raw) {
? args[skillsDirIdx + 1] ? args[skillsDirIdx + 1]
: null; : null;
if (!skillsDir) { const manifest = buildSkillManifest(cwd, skillsDir);
output([], raw);
return;
}
const manifest = buildSkillManifest(skillsDir);
// Optionally write to .planning/skill-manifest.json // Optionally write to .planning/skill-manifest.json
if (args.includes('--write')) { if (args.includes('--write')) {

View File

@@ -177,11 +177,11 @@ const CLAUDE_MD_FALLBACKS = {
stack: 'Technology stack not yet documented. Will populate after codebase mapping or first phase.', stack: 'Technology stack not yet documented. Will populate after codebase mapping or first phase.',
conventions: 'Conventions not yet established. Will populate as patterns emerge during development.', conventions: 'Conventions not yet established. Will populate as patterns emerge during development.',
architecture: 'Architecture not yet mapped. Follow existing patterns found in the codebase.', architecture: 'Architecture not yet mapped. Follow existing patterns found in the codebase.',
skills: 'No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, or `.github/skills/` with a `SKILL.md` index file.', skills: 'No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, `.github/skills/`, or `.codex/skills/` with a `SKILL.md` index file.',
}; };
// Directories where project skills may live (checked in order) // Directories where project skills may live (checked in order)
const SKILL_SEARCH_DIRS = ['.claude/skills', '.agents/skills', '.cursor/skills', '.github/skills']; const SKILL_SEARCH_DIRS = ['.claude/skills', '.agents/skills', '.cursor/skills', '.github/skills', '.codex/skills'];
const CLAUDE_MD_WORKFLOW_ENFORCEMENT = [ const CLAUDE_MD_WORKFLOW_ENFORCEMENT = [
'Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.', 'Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.',

View File

@@ -2,29 +2,72 @@
* Tests for agent skills query handler. * Tests for agent skills query handler.
*/ */
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtemp, mkdir, rm } from 'node:fs/promises'; import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { agentSkills } from './skills.js'; import { agentSkills } from './skills.js';
function writeSkill(rootDir: string, name: string, description = 'Skill under test') {
const skillDir = join(rootDir, name);
return mkdir(skillDir, { recursive: true }).then(() => writeFile(join(skillDir, 'SKILL.md'), [
'---',
`name: ${name}`,
`description: ${description}`,
'---',
'',
`# ${name}`,
].join('\n')));
}
describe('agentSkills', () => { describe('agentSkills', () => {
let tmpDir: string; let tmpDir: string;
let homeDir: string;
beforeEach(async () => { beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-skills-')); tmpDir = await mkdtemp(join(tmpdir(), 'gsd-skills-'));
await mkdir(join(tmpDir, '.cursor', 'skills', 'my-skill'), { recursive: true }); homeDir = await mkdtemp(join(tmpdir(), 'gsd-skills-home-'));
await writeSkill(join(tmpDir, '.cursor', 'skills'), 'my-skill');
await writeSkill(join(tmpDir, '.codex', 'skills'), 'project-codex');
await mkdir(join(tmpDir, '.claude', 'skills', 'orphaned-dir'), { recursive: true });
await writeSkill(join(homeDir, '.claude', 'skills'), 'global-claude');
await writeSkill(join(homeDir, '.codex', 'skills'), 'global-codex');
await writeSkill(join(homeDir, '.claude', 'get-shit-done', 'skills'), 'legacy-import');
vi.stubEnv('HOME', homeDir);
}); });
afterEach(async () => { afterEach(async () => {
vi.unstubAllEnvs();
await rm(tmpDir, { recursive: true, force: true }); await rm(tmpDir, { recursive: true, force: true });
await rm(homeDir, { recursive: true, force: true });
}); });
it('returns deduped skill names from project skill dirs', async () => { it('returns deduped skill names from project and managed global skill dirs', async () => {
const r = await agentSkills(['gsd-executor'], tmpDir); const r = await agentSkills(['gsd-executor'], tmpDir);
const data = r.data as Record<string, unknown>; const data = r.data as Record<string, unknown>;
expect(data.skill_count).toBeGreaterThan(0); const skills = data.skills as string[];
expect((data.skills as string[]).length).toBeGreaterThan(0);
expect(skills).toEqual(expect.arrayContaining([
'my-skill',
'project-codex',
'global-claude',
'global-codex',
]));
expect(skills).not.toContain('orphaned-dir');
expect(skills).not.toContain('legacy-import');
expect(data.skill_count).toBe(skills.length);
});
it('counts deduped skill names when the same skill exists in multiple roots', async () => {
await writeSkill(join(tmpDir, '.claude', 'skills'), 'shared-skill');
await writeSkill(join(tmpDir, '.agents', 'skills'), 'shared-skill');
const r = await agentSkills(['gsd-executor'], tmpDir);
const data = r.data as Record<string, unknown>;
const skills = data.skills as string[];
expect(skills.filter((skill) => skill === 'shared-skill')).toHaveLength(1);
expect(data.skill_count).toBe(skills.length);
}); });
}); });

View File

@@ -1,8 +1,9 @@
/** /**
* Agent skills query handler — scan installed skill directories. * Agent skills query handler — scan installed skill directories.
* *
* Reads from .claude/skills/, .agents/skills/, .cursor/skills/, .github/skills/, * Reads from project `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`,
* and the global ~/.claude/get-shit-done/skills/ directory. * `.github/skills/`, `.codex/skills/`, plus managed global `~/.claude/skills/`
* and `~/.codex/skills/` roots.
* *
* @example * @example
* ```typescript * ```typescript
@@ -26,7 +27,9 @@ export const agentSkills: QueryHandler = async (args, projectDir) => {
join(projectDir, '.agents', 'skills'), join(projectDir, '.agents', 'skills'),
join(projectDir, '.cursor', 'skills'), join(projectDir, '.cursor', 'skills'),
join(projectDir, '.github', 'skills'), join(projectDir, '.github', 'skills'),
join(homedir(), '.claude', 'get-shit-done', 'skills'), join(projectDir, '.codex', 'skills'),
join(homedir(), '.claude', 'skills'),
join(homedir(), '.codex', 'skills'),
]; ];
const skills: string[] = []; const skills: string[] = [];
@@ -35,16 +38,19 @@ export const agentSkills: QueryHandler = async (args, projectDir) => {
try { try {
const entries = readdirSync(dir, { withFileTypes: true }); const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
if (entry.isDirectory()) skills.push(entry.name); if (!entry.isDirectory()) continue;
if (!existsSync(join(dir, entry.name, 'SKILL.md'))) continue;
skills.push(entry.name);
} }
} catch { /* skip */ } } catch { /* skip */ }
} }
const dedupedSkills = [...new Set(skills)];
return { return {
data: { data: {
agent_type: agentType, agent_type: agentType,
skills: [...new Set(skills)], skills: dedupedSkills,
skill_count: skills.length, skill_count: dedupedSkills.length,
}, },
}; };
}; };

View File

@@ -148,6 +148,38 @@ describe('generate-claude-md skills section', () => {
assert.ok(content.includes('ERP synchronization flows')); assert.ok(content.includes('ERP synchronization flows'));
}); });
test('discovers skills from .codex/skills/ directory and ignores deprecated import-only roots', () => {
const codexSkillDir = path.join(tmpDir, '.codex', 'skills', 'automation');
fs.mkdirSync(codexSkillDir, { recursive: true });
fs.writeFileSync(
path.join(codexSkillDir, 'SKILL.md'),
'---\nname: automation\ndescription: Project Codex skill.\n---\n\n# Automation\n'
);
const homeDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gsd-claude-skills-home-'));
fs.mkdirSync(path.join(homeDir, '.claude', 'get-shit-done', 'skills', 'import-only'), { recursive: true });
fs.writeFileSync(
path.join(homeDir, '.claude', 'get-shit-done', 'skills', 'import-only', 'SKILL.md'),
'---\nname: import-only\ndescription: Deprecated import-only skill.\n---\n'
);
const originalHome = process.env.HOME;
process.env.HOME = homeDir;
try {
const result = runGsdTools('generate-claude-md', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
assert.ok(content.includes('automation'));
assert.ok(content.includes('Project Codex skill'));
assert.ok(!content.includes('import-only'));
} finally {
process.env.HOME = originalHome;
cleanup(homeDir);
}
});
test('skips gsd- prefixed skill directories', () => { test('skips gsd- prefixed skill directories', () => {
const gsdSkillDir = path.join(tmpDir, '.claude', 'skills', 'gsd-plan-phase'); const gsdSkillDir = path.join(tmpDir, '.claude', 'skills', 'gsd-plan-phase');
const userSkillDir = path.join(tmpDir, '.claude', 'skills', 'my-feature'); const userSkillDir = path.join(tmpDir, '.claude', 'skills', 'my-feature');

View File

@@ -157,6 +157,19 @@ describe('generate-claude-md command', () => {
const content = fs.readFileSync(outputPath, 'utf-8'); const content = fs.readFileSync(outputPath, 'utf-8');
assert.ok(content.length > 0, 'should still have content'); assert.ok(content.length > 0, 'should still have content');
}); });
test('skills fallback mentions the normalized project roots', () => {
const result = runGsdTools('generate-claude-md', tmpDir);
assert.ok(result.success, `Failed: ${result.error}`);
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
assert.ok(content.includes('.claude/skills/'));
assert.ok(content.includes('.agents/skills/'));
assert.ok(content.includes('.cursor/skills/'));
assert.ok(content.includes('.github/skills/'));
assert.ok(content.includes('.codex/skills/'));
assert.ok(!content.includes('get-shit-done/skills'));
});
}); });
// ─── generate-dev-preferences ───────────────────────────────────────────────── // ─── generate-dev-preferences ─────────────────────────────────────────────────

View File

@@ -1,6 +1,5 @@
/** /**
* Tests for skill-manifest command * Tests for skill-manifest command
* TDD: RED phase — tests written before implementation
*/ */
const { describe, test, beforeEach, afterEach } = require('node:test'); const { describe, test, beforeEach, afterEach } = require('node:test');
@@ -9,211 +8,123 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
function writeSkill(rootDir, name, description, body = '') {
const skillDir = path.join(rootDir, name);
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
'---',
`name: ${name}`,
`description: ${description}`,
'---',
'',
body || `# ${name}`,
].join('\n'));
}
describe('skill-manifest', () => { describe('skill-manifest', () => {
let tmpDir; let tmpDir;
let homeDir;
beforeEach(() => { beforeEach(() => {
tmpDir = createTempProject(); tmpDir = createTempProject();
homeDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gsd-skill-manifest-home-'));
writeSkill(path.join(tmpDir, '.claude', 'skills'), 'project-claude', 'Project Claude skill');
writeSkill(path.join(tmpDir, '.claude', 'skills'), 'gsd-help', 'Installed GSD skill');
writeSkill(path.join(tmpDir, '.agents', 'skills'), 'project-agents', 'Project agent skill');
writeSkill(path.join(tmpDir, '.codex', 'skills'), 'project-codex', 'Project Codex skill');
writeSkill(path.join(homeDir, '.claude', 'skills'), 'global-claude', 'Global Claude skill');
writeSkill(path.join(homeDir, '.codex', 'skills'), 'global-codex', 'Global Codex skill');
writeSkill(
path.join(homeDir, '.claude', 'get-shit-done', 'skills'),
'legacy-import',
'Deprecated import-only skill'
);
fs.mkdirSync(path.join(homeDir, '.claude', 'commands', 'gsd'), { recursive: true });
fs.writeFileSync(path.join(homeDir, '.claude', 'commands', 'gsd', 'help.md'), '# legacy');
}); });
afterEach(() => { afterEach(() => {
cleanup(tmpDir); cleanup(tmpDir);
cleanup(homeDir);
}); });
test('skill-manifest command exists and returns JSON', () => { test('returns normalized inventory across canonical roots', () => {
// Create a skills directory with one skill const result = runGsdTools(['skill-manifest'], tmpDir, { HOME: homeDir });
const skillDir = path.join(tmpDir, '.claude', 'skills', 'test-skill');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
'---',
'name: test-skill',
'description: A test skill',
'---',
'',
'# Test Skill',
].join('\n'));
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills')], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`); assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output); const manifest = JSON.parse(result.output);
assert.ok(Array.isArray(manifest), 'Manifest should be an array'); assert.ok(Array.isArray(manifest.skills), 'skills should be an array');
}); assert.ok(Array.isArray(manifest.roots), 'roots should be an array');
assert.ok(manifest.installation && typeof manifest.installation === 'object', 'installation summary present');
assert.ok(manifest.counts && typeof manifest.counts === 'object', 'counts summary present');
test('generates manifest with correct structure from SKILL.md frontmatter', () => { const skillNames = manifest.skills.map((skill) => skill.name).sort();
const skillDir = path.join(tmpDir, '.claude', 'skills', 'my-skill'); assert.deepStrictEqual(skillNames, [
fs.mkdirSync(skillDir, { recursive: true }); 'global-claude',
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [ 'global-codex',
'---', 'gsd-help',
'name: my-skill', 'legacy-import',
'description: Does something useful', 'project-agents',
'---', 'project-claude',
'', 'project-codex',
'# My Skill', ]);
'',
'TRIGGER when: user asks about widgets',
].join('\n'));
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills')], tmpDir); const codexSkill = manifest.skills.find((skill) => skill.name === 'project-codex');
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`); assert.deepStrictEqual(
{
root: codexSkill.root,
scope: codexSkill.scope,
installed: codexSkill.installed,
deprecated: codexSkill.deprecated,
},
{
root: '.codex/skills',
scope: 'project',
installed: true,
deprecated: false,
}
);
const manifest = JSON.parse(result.output); const importedSkill = manifest.skills.find((skill) => skill.name === 'legacy-import');
assert.strictEqual(manifest.length, 1); assert.deepStrictEqual(
assert.strictEqual(manifest[0].name, 'my-skill'); {
assert.strictEqual(manifest[0].description, 'Does something useful'); root: importedSkill.root,
assert.strictEqual(manifest[0].path, 'my-skill'); scope: importedSkill.scope,
}); installed: importedSkill.installed,
deprecated: importedSkill.deprecated,
},
{
root: '~/.claude/get-shit-done/skills',
scope: 'import-only',
installed: false,
deprecated: true,
}
);
test('empty skills directory produces empty manifest', () => { const gsdSkill = manifest.skills.find((skill) => skill.name === 'gsd-help');
const skillsDir = path.join(tmpDir, '.claude', 'skills'); assert.strictEqual(gsdSkill.installed, true);
fs.mkdirSync(skillsDir, { recursive: true });
const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir); const legacyRoot = manifest.roots.find((root) => root.scope === 'legacy-commands');
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`); assert.ok(legacyRoot, 'legacy commands root should be reported');
assert.strictEqual(legacyRoot.present, true);
const manifest = JSON.parse(result.output); assert.strictEqual(manifest.installation.gsd_skills_installed, true);
assert.ok(Array.isArray(manifest), 'Manifest should be an array'); assert.strictEqual(manifest.installation.legacy_claude_commands_installed, true);
assert.strictEqual(manifest.length, 0); assert.strictEqual(manifest.counts.skills, 7);
});
test('skills without SKILL.md are skipped', () => {
const skillsDir = path.join(tmpDir, '.claude', 'skills');
// Skill with SKILL.md
const goodDir = path.join(skillsDir, 'good-skill');
fs.mkdirSync(goodDir, { recursive: true });
fs.writeFileSync(path.join(goodDir, 'SKILL.md'), [
'---',
'name: good-skill',
'description: Has a SKILL.md',
'---',
'',
'# Good Skill',
].join('\n'));
// Skill without SKILL.md (just a directory)
const badDir = path.join(skillsDir, 'bad-skill');
fs.mkdirSync(badDir, { recursive: true });
fs.writeFileSync(path.join(badDir, 'README.md'), '# No SKILL.md here');
const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.strictEqual(manifest.length, 1);
assert.strictEqual(manifest[0].name, 'good-skill');
});
test('manifest includes frontmatter fields from SKILL.md', () => {
const skillDir = path.join(tmpDir, '.claude', 'skills', 'rich-skill');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
'---',
'name: rich-skill',
'description: A richly documented skill',
'---',
'',
'# Rich Skill',
'',
'TRIGGER when: user mentions databases',
'DO NOT TRIGGER when: user asks about frontend',
].join('\n'));
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills')], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.strictEqual(manifest.length, 1);
const skill = manifest[0];
assert.strictEqual(skill.name, 'rich-skill');
assert.strictEqual(skill.description, 'A richly documented skill');
assert.strictEqual(skill.path, 'rich-skill');
// triggers extracted from body text
assert.ok(Array.isArray(skill.triggers), 'triggers should be an array');
assert.ok(skill.triggers.length > 0, 'triggers should have at least one entry');
assert.ok(skill.triggers.some(t => t.includes('databases')), 'triggers should mention databases');
});
test('multiple skills are all included in manifest', () => {
const skillsDir = path.join(tmpDir, '.claude', 'skills');
for (const name of ['alpha', 'beta', 'gamma']) {
const dir = path.join(skillsDir, name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'SKILL.md'), [
'---',
`name: ${name}`,
`description: The ${name} skill`,
'---',
'',
`# ${name}`,
].join('\n'));
}
const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.strictEqual(manifest.length, 3);
const names = manifest.map(s => s.name).sort();
assert.deepStrictEqual(names, ['alpha', 'beta', 'gamma']);
}); });
test('writes manifest to .planning/skill-manifest.json when --write flag is used', () => { test('writes manifest to .planning/skill-manifest.json when --write flag is used', () => {
const skillDir = path.join(tmpDir, '.claude', 'skills', 'write-test'); const result = runGsdTools(['skill-manifest', '--write'], tmpDir, { HOME: homeDir });
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
'---',
'name: write-test',
'description: Tests write mode',
'---',
'',
'# Write Test',
].join('\n'));
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills'), '--write'], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`); assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifestPath = path.join(tmpDir, '.planning', 'skill-manifest.json'); const manifestPath = path.join(tmpDir, '.planning', 'skill-manifest.json');
assert.ok(fs.existsSync(manifestPath), 'skill-manifest.json should be written to .planning/'); assert.ok(fs.existsSync(manifestPath), 'skill-manifest.json should be written to .planning/');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
assert.strictEqual(manifest.length, 1); assert.ok(Array.isArray(manifest.skills));
assert.strictEqual(manifest[0].name, 'write-test'); assert.ok(manifest.installation);
});
test('nonexistent skills directory returns empty manifest', () => {
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, 'nonexistent')], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.ok(Array.isArray(manifest), 'Manifest should be an array');
assert.strictEqual(manifest.length, 0);
});
test('files in skills directory are ignored (only subdirectories scanned)', () => {
const skillsDir = path.join(tmpDir, '.claude', 'skills');
fs.mkdirSync(skillsDir, { recursive: true });
// A file, not a directory
fs.writeFileSync(path.join(skillsDir, 'not-a-skill.md'), '# Not a skill');
// A valid skill directory
const skillDir = path.join(skillsDir, 'real-skill');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
'---',
'name: real-skill',
'description: A real skill',
'---',
'',
'# Real Skill',
].join('\n'));
const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
const manifest = JSON.parse(result.output);
assert.strictEqual(manifest.length, 1);
assert.strictEqual(manifest[0].name, 'real-skill');
}); });
}); });