From b5992684e44121d60d88f6876218ff51be30a2f8 Mon Sep 17 00:00:00 2001 From: Jhony Miler Date: Tue, 31 Mar 2026 15:51:14 -0300 Subject: [PATCH] feat: add project skills discovery section to CLAUDE.md generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-discover project skills from .claude/skills/, .agents/skills/, .cursor/skills/, and .github/skills/ directories and surface them in CLAUDE.md as a managed section with name, description, and path. This enables Layer 1 (discovery) at session startup — agents now know which project-specific skills are available without waiting for subagent injection via agent-skills at execution time. Behavior: - Scans standard skill directories for subdirectories containing SKILL.md - Extracts name and description from YAML frontmatter - Supports multi-line descriptions (indented continuation lines) - Skips GSD's own gsd-* prefixed skill directories - Deduplicates by skill name across directories - Falls back to actionable guidance when no skills found - Section is placed between Architecture and Workflow Enforcement - sections_total bumped from 5 to 6 --- get-shit-done/bin/lib/profile-output.cjs | 98 ++++++++++++- get-shit-done/templates/claude-md.md | 31 ++++- tests/claude-md.test.cjs | 169 ++++++++++++++++++++++- 3 files changed, 292 insertions(+), 6 deletions(-) diff --git a/get-shit-done/bin/lib/profile-output.cjs b/get-shit-done/bin/lib/profile-output.cjs index 9d2add389..08f9d16a2 100644 --- a/get-shit-done/bin/lib/profile-output.cjs +++ b/get-shit-done/bin/lib/profile-output.cjs @@ -177,8 +177,12 @@ 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 `.claude/skills/` or `.agents/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 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.', '', @@ -375,6 +379,96 @@ function generateWorkflowSection() { }; } +/** + * Discover project skills from standard directories and extract frontmatter + * (name + description) for each. Returns a table summary for CLAUDE.md so + * agents know which skills are available at session startup (Layer 1 discovery). + */ +function generateSkillsSection(cwd) { + const discovered = []; + + for (const dir of SKILL_SEARCH_DIRS) { + const absDir = path.join(cwd, dir); + if (!fs.existsSync(absDir)) continue; + + let entries; + try { + entries = fs.readdirSync(absDir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + // Skip GSD's own installed skills — only surface project-specific skills + if (entry.name.startsWith('gsd-')) continue; + + const skillMdPath = path.join(absDir, entry.name, 'SKILL.md'); + if (!fs.existsSync(skillMdPath)) continue; + + const content = safeReadFile(skillMdPath); + if (!content) continue; + + const frontmatter = extractSkillFrontmatter(content); + const name = frontmatter.name || entry.name; + const description = frontmatter.description || ''; + + // Avoid duplicates when same skill dir is symlinked from multiple locations + if (discovered.some(s => s.name === name)) continue; + + discovered.push({ name, description, path: `${dir}/${entry.name}` }); + } + } + + if (discovered.length === 0) { + return { content: CLAUDE_MD_FALLBACKS.skills, source: 'skills/', hasFallback: true }; + } + + const lines = ['| Skill | Description | Path |', '|-------|-------------|------|']; + for (const skill of discovered) { + // Sanitize table cell content (escape pipes) + const desc = skill.description.replace(/\|/g, '\\|').replace(/\n/g, ' ').trim(); + const safeName = skill.name.replace(/\|/g, '\\|'); + lines.push(`| ${safeName} | ${desc} | \`${skill.path}/SKILL.md\` |`); + } + + return { content: lines.join('\n'), source: 'skills/', hasFallback: false }; +} + +/** + * Extract name and description from YAML-like frontmatter in a SKILL.md file. + * Handles multi-line description values (continuation lines indented with spaces). + */ +function extractSkillFrontmatter(content) { + const result = { name: '', description: '' }; + const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/); + if (!fmMatch) return result; + + const fmBlock = fmMatch[1]; + const lines = fmBlock.split('\n'); + + let currentKey = ''; + for (const line of lines) { + // Top-level key: value + const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)/); + if (kvMatch) { + currentKey = kvMatch[1]; + const value = kvMatch[2].trim(); + if (currentKey === 'name') result.name = value; + if (currentKey === 'description') result.description = value; + continue; + } + // Continuation line (indented) for multi-line values + if (currentKey === 'description' && /^\s+/.test(line)) { + result.description += ' ' + line.trim(); + } else { + currentKey = ''; + } + } + + return result; +} + // ─── Commands ───────────────────────────────────────────────────────────────── function cmdWriteProfile(cwd, options, raw) { @@ -815,12 +909,13 @@ function cmdGenerateClaudeProfile(cwd, options, raw) { } function cmdGenerateClaudeMd(cwd, options, raw) { - const MANAGED_SECTIONS = ['project', 'stack', 'conventions', 'architecture', 'workflow']; + const MANAGED_SECTIONS = ['project', 'stack', 'conventions', 'architecture', 'skills', 'workflow']; const generators = { project: generateProjectSection, stack: generateStackSection, conventions: generateConventionsSection, architecture: generateArchitectureSection, + skills: generateSkillsSection, workflow: generateWorkflowSection, }; const sectionHeadings = { @@ -828,6 +923,7 @@ function cmdGenerateClaudeMd(cwd, options, raw) { stack: '## Technology Stack', conventions: '## Conventions', architecture: '## Architecture', + skills: '## Project Skills', workflow: '## GSD Workflow Enforcement', }; diff --git a/get-shit-done/templates/claude-md.md b/get-shit-done/templates/claude-md.md index 240146a28..119948184 100644 --- a/get-shit-done/templates/claude-md.md +++ b/get-shit-done/templates/claude-md.md @@ -2,8 +2,8 @@ Template for project-root `CLAUDE.md` — auto-generated by `gsd-tools generate-claude-md`. -Contains 6 marker-bounded sections. Each section is independently updatable. -The `generate-claude-md` subcommand manages 5 sections (project, stack, conventions, architecture, workflow enforcement). +Contains 7 marker-bounded sections. Each section is independently updatable. +The `generate-claude-md` subcommand manages 6 sections (project, stack, conventions, architecture, skills, workflow enforcement). The profile section is managed exclusively by `generate-claude-profile`. --- @@ -66,6 +66,28 @@ Conventions not yet established. Will populate as patterns emerge during develop Architecture not yet mapped. Follow existing patterns found in the codebase. ``` +### Skills Section +``` + +## Project Skills + +| Skill | Description | Path | +| -------------- | --------------------- | ------------------------- | +| {{skill_name}} | {{skill_description}} | `{{skill_path}}/SKILL.md` | + +``` + +**Fallback text:** +``` +No project skills found. Add skills to `.claude/skills/` or `.agents/skills/` with a `SKILL.md` index file. +``` + +**Discovery behavior:** +- Scans `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, `.github/skills/` for subdirectories containing `SKILL.md` +- Extracts `name` and `description` from YAML frontmatter (supports multi-line descriptions) +- Skips GSD's own installed skills (directories starting with `gsd-`) +- Deduplicates by skill name across directories + ### Workflow Enforcement Section ``` @@ -104,8 +126,9 @@ CLAUDE.md file and no profile section exists yet. 2. **Stack** — Technology choices (what tools are used) 3. **Conventions** — Code patterns and rules (how code is written) 4. **Architecture** — System structure (how components fit together) -5. **Workflow Enforcement** — Default GSD entry points for file-changing work -6. **Profile** — Developer behavioral preferences (how to interact) +5. **Skills** — Discovered project skills with name and description (what domain knowledge is available) +6. **Workflow Enforcement** — Default GSD entry points for file-changing work +7. **Profile** — Developer behavioral preferences (how to interact) ## Marker Format diff --git a/tests/claude-md.test.cjs b/tests/claude-md.test.cjs index 3443f7783..625c1889c 100644 --- a/tests/claude-md.test.cjs +++ b/tests/claude-md.test.cjs @@ -30,7 +30,7 @@ describe('generate-claude-md', () => { const output = JSON.parse(result.output); assert.strictEqual(output.action, 'created'); - assert.strictEqual(output.sections_total, 5); + assert.strictEqual(output.sections_total, 6); assert.ok(output.sections_generated.includes('workflow')); const claudePath = path.join(tmpDir, 'CLAUDE.md'); @@ -80,3 +80,170 @@ describe('new-project workflow includes CLAUDE.md generation', () => { assert.ok(commandsContent.includes('`CLAUDE.md`')); }); }); + +describe('generate-claude-md skills section', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'PROJECT.md'), + '# Test Project\n\n## What This Is\n\nA test project.\n' + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('includes skills fallback when no skills directories exist', () => { + const result = runGsdTools('generate-claude-md', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok(output.sections_fallback.includes('skills')); + + const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8'); + assert.ok(content.includes('')); + assert.ok(content.includes('No project skills found')); + }); + + test('discovers skills from .claude/skills/ directory', () => { + const skillDir = path.join(tmpDir, '.claude', 'skills', 'api-payments'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + '---\nname: api-payments\ndescription: Payment gateway integration.\n---\n\n# API Payments\n' + ); + + const result = runGsdTools('generate-claude-md', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.ok(output.sections_generated.includes('skills')); + assert.ok(!output.sections_fallback.includes('skills')); + + const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8'); + assert.ok(content.includes('api-payments')); + assert.ok(content.includes('Payment gateway integration')); + assert.ok(content.includes('## Project Skills')); + }); + + test('discovers skills from .agents/skills/ directory', () => { + const skillDir = path.join(tmpDir, '.agents', 'skills', 'data-sync'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + '---\nname: data-sync\ndescription: ERP synchronization flows.\n---\n\n# Data Sync\n' + ); + + 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('data-sync')); + assert.ok(content.includes('ERP synchronization flows')); + }); + + 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'); + fs.mkdirSync(gsdSkillDir, { recursive: true }); + fs.mkdirSync(userSkillDir, { recursive: true }); + fs.writeFileSync( + path.join(gsdSkillDir, 'SKILL.md'), + '---\nname: gsd-plan-phase\ndescription: GSD internal skill.\n---\n' + ); + fs.writeFileSync( + path.join(userSkillDir, 'SKILL.md'), + '---\nname: my-feature\ndescription: Custom project skill.\n---\n' + ); + + 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('gsd-plan-phase')); + assert.ok(content.includes('my-feature')); + assert.ok(content.includes('Custom project skill')); + }); + + test('handles multi-line description in frontmatter', () => { + const skillDir = path.join(tmpDir, '.claude', 'skills', 'complex-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + '---\nname: complex-skill\ndescription: First line of description.\n Continued on second line.\n And a third line.\n---\n' + ); + + 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('First line of description')); + assert.ok(content.includes('Continued on second line')); + assert.ok(content.includes('And a third line')); + }); + + test('deduplicates skills found in multiple directories', () => { + // Same skill in both .claude/skills/ and .agents/skills/ + const dir1 = path.join(tmpDir, '.claude', 'skills', 'shared-skill'); + const dir2 = path.join(tmpDir, '.agents', 'skills', 'shared-skill'); + fs.mkdirSync(dir1, { recursive: true }); + fs.mkdirSync(dir2, { recursive: true }); + const skillContent = '---\nname: shared-skill\ndescription: Appears twice.\n---\n'; + fs.writeFileSync(path.join(dir1, 'SKILL.md'), skillContent); + fs.writeFileSync(path.join(dir2, 'SKILL.md'), skillContent); + + 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'); + const matches = content.match(/shared-skill/g); + // Should appear exactly twice: once in name column, once in path column (single row) + assert.strictEqual(matches.length, 2); + }); + + test('updates existing skills section on regeneration', () => { + // First generation — no skills + runGsdTools('generate-claude-md', tmpDir); + let content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8'); + assert.ok(content.includes('No project skills found')); + + // Add a skill and regenerate + const skillDir = path.join(tmpDir, '.claude', 'skills', 'new-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + '---\nname: new-skill\ndescription: Just added.\n---\n' + ); + + const result = runGsdTools('generate-claude-md', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8'); + assert.ok(!content.includes('No project skills found')); + assert.ok(content.includes('new-skill')); + assert.ok(content.includes('Just added')); + }); + + test('skills section appears between architecture and workflow', () => { + const skillDir = path.join(tmpDir, '.claude', 'skills', 'ordering-test'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + '---\nname: ordering-test\ndescription: Verify section order.\n---\n' + ); + + 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'); + const archIdx = content.indexOf('## Architecture'); + const skillsIdx = content.indexOf('## Project Skills'); + const workflowIdx = content.indexOf('## GSD Workflow Enforcement'); + assert.ok(archIdx < skillsIdx, 'Skills section should come after Architecture'); + assert.ok(skillsIdx < workflowIdx, 'Skills section should come before Workflow Enforcement'); + }); +});