diff --git a/README.md b/README.md index 095c20e4..2a13efc6 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,9 @@ Verify with: - Cline: GSD installs via `.clinerules` — verify by checking `.clinerules` exists > [!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] > 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?** - 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) -- For Codex, verify skills exist in `~/.codex/skills/gsd-*/SKILL.md` (global) or `./.codex/skills/gsd-*/SKILL.md` (local) +- Verify files exist in `~/.claude/skills/gsd-*/SKILL.md` or `~/.codex/skills/gsd-*/SKILL.md` for managed global installs +- 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?** - Run `/gsd-help` to verify installation diff --git a/docs/skills/discovery-contract.md b/docs/skills/discovery-contract.md new file mode 100644 index 00000000..6bbbb228 --- /dev/null +++ b/docs/skills/discovery-contract.md @@ -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` + diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index 8a704b81..b00b9b3d 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -1590,75 +1590,207 @@ function cmdAgentSkills(cwd, agentType, raw) { /** * Generate a skill manifest from a skills directory. * - * Scans the given skills directory for subdirectories containing SKILL.md, - * extracts frontmatter (name, description) and trigger conditions from the - * body text, and returns an array of skill descriptors. + * Scans the canonical skill discovery roots and returns a normalized + * inventory object with discovered skills, root metadata, and installation + * 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 - * @returns {Array<{name: string, description: string, triggers: string[], path: string}>} + * @param {string} cwd - Project root directory + * @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 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; - try { - entries = fs.readdirSync(skillsDir, { withFileTypes: true }); - } catch { - return []; - } + const skills = []; + const roots = []; + let legacyClaudeCommandsInstalled = false; + for (const rootInfo of canonicalRoots) { + const rootPath = rootInfo.path; + const rootSummary = { + root: rootInfo.root, + path: rootPath, + scope: rootInfo.scope, + present: fs.existsSync(rootPath), + deprecated: !!rootInfo.deprecated, + }; - const manifest = []; - for (const entry of entries) { - 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 { + if (!rootSummary.present) { + roots.push(rootSummary); 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()); - } + if (rootInfo.kind === 'commands') { + let entries = []; + try { + entries = fs.readdirSync(rootPath, { withFileTypes: true }); + } catch { + roots.push(rootSummary); + continue; } + + 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({ - name, - description, - triggers, - path: entry.name, - }); + let entries; + try { + entries = fs.readdirSync(rootPath, { withFileTypes: true }); + } catch { + 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 - manifest.sort((a, b) => a.name.localeCompare(b.name)); - return manifest; + skills.sort((a, b) => { + const rootCmp = a.root.localeCompare(b.root); + 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. * * Options: - * --skills-dir Path to skills directory (required) + * --skills-dir Optional absolute path to a single skills directory * --write Also write to .planning/skill-manifest.json */ function cmdSkillManifest(cwd, args, raw) { @@ -1667,12 +1799,7 @@ function cmdSkillManifest(cwd, args, raw) { ? args[skillsDirIdx + 1] : null; - if (!skillsDir) { - output([], raw); - return; - } - - const manifest = buildSkillManifest(skillsDir); + const manifest = buildSkillManifest(cwd, skillsDir); // Optionally write to .planning/skill-manifest.json if (args.includes('--write')) { diff --git a/get-shit-done/bin/lib/profile-output.cjs b/get-shit-done/bin/lib/profile-output.cjs index 57040079..38156bac 100644 --- a/get-shit-done/bin/lib/profile-output.cjs +++ b/get-shit-done/bin/lib/profile-output.cjs @@ -177,11 +177,11 @@ const CLAUDE_MD_FALLBACKS = { 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.', 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) -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 = [ 'Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.', diff --git a/sdk/src/query/skills.test.ts b/sdk/src/query/skills.test.ts index af2428c0..ab489fe7 100644 --- a/sdk/src/query/skills.test.ts +++ b/sdk/src/query/skills.test.ts @@ -2,29 +2,72 @@ * Tests for agent skills query handler. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; 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', () => { let tmpDir: string; + let homeDir: string; beforeEach(async () => { 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 () => { + vi.unstubAllEnvs(); 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 data = r.data as Record; - expect(data.skill_count).toBeGreaterThan(0); - expect((data.skills as string[]).length).toBeGreaterThan(0); + const skills = data.skills as string[]; + + 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; + const skills = data.skills as string[]; + + expect(skills.filter((skill) => skill === 'shared-skill')).toHaveLength(1); + expect(data.skill_count).toBe(skills.length); }); }); diff --git a/sdk/src/query/skills.ts b/sdk/src/query/skills.ts index 7e3a4667..870ea756 100644 --- a/sdk/src/query/skills.ts +++ b/sdk/src/query/skills.ts @@ -1,8 +1,9 @@ /** * Agent skills query handler — scan installed skill directories. * - * Reads from .claude/skills/, .agents/skills/, .cursor/skills/, .github/skills/, - * and the global ~/.claude/get-shit-done/skills/ directory. + * Reads from project `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, + * `.github/skills/`, `.codex/skills/`, plus managed global `~/.claude/skills/` + * and `~/.codex/skills/` roots. * * @example * ```typescript @@ -26,7 +27,9 @@ export const agentSkills: QueryHandler = async (args, projectDir) => { join(projectDir, '.agents', 'skills'), join(projectDir, '.cursor', '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[] = []; @@ -35,16 +38,19 @@ export const agentSkills: QueryHandler = async (args, projectDir) => { try { const entries = readdirSync(dir, { withFileTypes: true }); 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 */ } } + const dedupedSkills = [...new Set(skills)]; return { data: { agent_type: agentType, - skills: [...new Set(skills)], - skill_count: skills.length, + skills: dedupedSkills, + skill_count: dedupedSkills.length, }, }; }; diff --git a/tests/claude-md.test.cjs b/tests/claude-md.test.cjs index 3ac0f1c3..cd7b3a04 100644 --- a/tests/claude-md.test.cjs +++ b/tests/claude-md.test.cjs @@ -148,6 +148,38 @@ describe('generate-claude-md skills section', () => { 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', () => { const gsdSkillDir = path.join(tmpDir, '.claude', 'skills', 'gsd-plan-phase'); const userSkillDir = path.join(tmpDir, '.claude', 'skills', 'my-feature'); diff --git a/tests/profile-output.test.cjs b/tests/profile-output.test.cjs index 160ab71b..05364b1a 100644 --- a/tests/profile-output.test.cjs +++ b/tests/profile-output.test.cjs @@ -157,6 +157,19 @@ describe('generate-claude-md command', () => { const content = fs.readFileSync(outputPath, 'utf-8'); 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 ───────────────────────────────────────────────── diff --git a/tests/skill-manifest.test.cjs b/tests/skill-manifest.test.cjs index cb18110a..77ec7e55 100644 --- a/tests/skill-manifest.test.cjs +++ b/tests/skill-manifest.test.cjs @@ -1,6 +1,5 @@ /** * Tests for skill-manifest command - * TDD: RED phase — tests written before implementation */ const { describe, test, beforeEach, afterEach } = require('node:test'); @@ -9,211 +8,123 @@ const fs = require('fs'); const path = require('path'); 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', () => { let tmpDir; + let homeDir; beforeEach(() => { 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(() => { cleanup(tmpDir); + cleanup(homeDir); }); - test('skill-manifest command exists and returns JSON', () => { - // Create a skills directory with one skill - 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); + test('returns normalized inventory across canonical roots', () => { + const result = runGsdTools(['skill-manifest'], tmpDir, { HOME: homeDir }); 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.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 skillDir = path.join(tmpDir, '.claude', 'skills', 'my-skill'); - fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [ - '---', - 'name: my-skill', - 'description: Does something useful', - '---', - '', - '# My Skill', - '', - 'TRIGGER when: user asks about widgets', - ].join('\n')); + const skillNames = manifest.skills.map((skill) => skill.name).sort(); + assert.deepStrictEqual(skillNames, [ + 'global-claude', + 'global-codex', + 'gsd-help', + 'legacy-import', + 'project-agents', + 'project-claude', + 'project-codex', + ]); - 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 codexSkill = manifest.skills.find((skill) => skill.name === 'project-codex'); + 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); - assert.strictEqual(manifest.length, 1); - assert.strictEqual(manifest[0].name, 'my-skill'); - assert.strictEqual(manifest[0].description, 'Does something useful'); - assert.strictEqual(manifest[0].path, 'my-skill'); - }); + const importedSkill = manifest.skills.find((skill) => skill.name === 'legacy-import'); + assert.deepStrictEqual( + { + root: importedSkill.root, + 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 skillsDir = path.join(tmpDir, '.claude', 'skills'); - fs.mkdirSync(skillsDir, { recursive: true }); + const gsdSkill = manifest.skills.find((skill) => skill.name === 'gsd-help'); + assert.strictEqual(gsdSkill.installed, true); - const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir); - assert.ok(result.success, `Command should succeed: ${result.error || result.output}`); + const legacyRoot = manifest.roots.find((root) => root.scope === 'legacy-commands'); + assert.ok(legacyRoot, 'legacy commands root should be reported'); + assert.strictEqual(legacyRoot.present, true); - const manifest = JSON.parse(result.output); - assert.ok(Array.isArray(manifest), 'Manifest should be an array'); - assert.strictEqual(manifest.length, 0); - }); - - 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']); + assert.strictEqual(manifest.installation.gsd_skills_installed, true); + assert.strictEqual(manifest.installation.legacy_claude_commands_installed, true); + assert.strictEqual(manifest.counts.skills, 7); }); test('writes manifest to .planning/skill-manifest.json when --write flag is used', () => { - const skillDir = path.join(tmpDir, '.claude', 'skills', 'write-test'); - 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); + const result = runGsdTools(['skill-manifest', '--write'], tmpDir, { HOME: homeDir }); assert.ok(result.success, `Command should succeed: ${result.error || result.output}`); const manifestPath = path.join(tmpDir, '.planning', 'skill-manifest.json'); assert.ok(fs.existsSync(manifestPath), 'skill-manifest.json should be written to .planning/'); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); - assert.strictEqual(manifest.length, 1); - assert.strictEqual(manifest[0].name, 'write-test'); - }); - - 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'); + assert.ok(Array.isArray(manifest.skills)); + assert.ok(manifest.installation); }); });