diff --git a/lib/sdk-skill-discovery.js b/lib/sdk-skill-discovery.js index bc9de52..159b5d5 100644 --- a/lib/sdk-skill-discovery.js +++ b/lib/sdk-skill-discovery.js @@ -113,7 +113,7 @@ function stripYamlQuotes(value) { return value; } -// Read SKILL.md for a skill directory and extract a short description string. +// Extract a short description from the raw text content of a markdown file. // Priority: // 1. YAML frontmatter (--- delimited, line 0 === "---"): scan for // description: key within the block (before closing ---), handling both @@ -122,14 +122,7 @@ function stripYamlQuotes(value) { // scalar support (lr-2634). // 3. First non-empty, non-heading prose line after any heading. // 4. Empty string if none found. -function extractSkillDescription(skillDir) { - var skillMd = path.join(skillDir, "SKILL.md"); - var content; - try { - content = fs.readFileSync(skillMd, "utf8"); - } catch (e) { - return ""; - } +function extractDescriptionFromContent(content) { var lines = content.split(/\r?\n/); // --- Priority 1: YAML frontmatter (line 0 must be exactly "---") --- @@ -187,6 +180,19 @@ function extractSkillDescription(skillDir) { return ""; } +// Read SKILL.md for a skill directory and extract a short description string. +// Delegates to extractDescriptionFromContent for the actual parsing logic. +function extractSkillDescription(skillDir) { + var skillMd = path.join(skillDir, "SKILL.md"); + var content; + try { + content = fs.readFileSync(skillMd, "utf8"); + } catch (e) { + return ""; + } + return extractDescriptionFromContent(content); +} + // Return an array of {name, description, type:'skill'} for all discovered // skill directories. Reads every SKILL.md to extract a description. // Project skills override global skills with the same name (global → project @@ -316,6 +322,7 @@ function attachSkillDiscovery(ctx) { module.exports = { splitShellSegments: splitShellSegments, attachSkillDiscovery: attachSkillDiscovery, + extractDescriptionFromContent: extractDescriptionFromContent, extractSkillDescription: extractSkillDescription, discoverSkillsWithMeta: discoverSkillsWithMeta, mergeSkillsWithMeta: mergeSkillsWithMeta, diff --git a/lib/sdk-workflow-discovery.js b/lib/sdk-workflow-discovery.js index fdd5430..b1ae714 100644 --- a/lib/sdk-workflow-discovery.js +++ b/lib/sdk-workflow-discovery.js @@ -1,5 +1,6 @@ var fs = require('fs'); var path = require('path'); +var extractDescriptionFromContent = require('./sdk-skill-discovery').extractDescriptionFromContent; // Regex to locate the meta block and extract string fields from it. // The meta block always appears as a static literal near the top of the file: @@ -67,11 +68,54 @@ function scanWorkflowDir(dir, resultMap) { } } +// Scan a single workflow directory for .md files and extract descriptions. +// Files named exactly "SKILL.md" are skipped — those are skill definitions, +// not user-invocable slash commands. +// Adds results to the provided map (name -> entry), so later calls override earlier ones. +// .md entries always override .js entries on name collision when scanned after .js. +function scanWorkflowMdDir(dir, resultMap) { + var entries; + try { + entries = fs.readdirSync(dir); + } catch (e) { + // Directory does not exist or is unreadable — skip silently. + return; + } + + for (var i = 0; i < entries.length; i++) { + var fname = entries[i]; + if (path.extname(fname) !== '.md') continue; + // SKILL.md is a skill definition file, not a workflow slash command. + if (fname === 'SKILL.md') continue; + + var fpath = path.join(dir, fname); + var content; + try { + content = fs.readFileSync(fpath, 'utf8'); + } catch (e) { + continue; + } + + var name = path.basename(fname, '.md'); + var description = extractDescriptionFromContent(content); + + resultMap[name] = { + name: name, + description: description, + type: 'workflow', + }; + } +} + // Discover workflow files and return their metadata. // Scans: // 1. ~/.claude/workflows/ (global, from REAL_HOME) // 2. /.claude/workflows/ (project-local, wins on name collision) // +// Within each directory, .js files are scanned first, then .md files. +// .md entries override .js entries on name collision (user-facing slash +// commands take priority over internal orchestration scripts). +// // Returns an array of { name, description, type: 'workflow' }. // Never throws — missing directories are silently skipped. // @@ -90,10 +134,16 @@ function discoverWorkflows(cwd, _homeDir) { } var resultMap = {}; + var globalWfDir = path.join(homeDir, '.claude', 'workflows'); + var localWfDir = path.join(cwd, '.claude', 'workflows'); + + // Global .js first, then global .md (so .md overrides .js on same name). + scanWorkflowDir(globalWfDir, resultMap); + scanWorkflowMdDir(globalWfDir, resultMap); - // Global workflows first so project-local entries can override. - scanWorkflowDir(path.join(homeDir, '.claude', 'workflows'), resultMap); - scanWorkflowDir(path.join(cwd, '.claude', 'workflows'), resultMap); + // Project-local .js, then project-local .md — local entries override global. + scanWorkflowDir(localWfDir, resultMap); + scanWorkflowMdDir(localWfDir, resultMap); return Object.keys(resultMap).map(function (k) { return resultMap[k]; }); } diff --git a/test/sdk-workflow-discovery.test.js b/test/sdk-workflow-discovery.test.js index f91fd05..37dc223 100644 --- a/test/sdk-workflow-discovery.test.js +++ b/test/sdk-workflow-discovery.test.js @@ -9,6 +9,13 @@ * (5) Project-local entry wins on name collision over global entry * (6) Non-.js files are ignored * (7) Merges global and local workflows without collision + * (8) .md files are discovered with name derived from filename and description from frontmatter + * (9) .md files with bare description: key are parsed correctly + * (10) .md files with prose description (no frontmatter) are parsed correctly + * (11) SKILL.md is skipped when scanning .md workflow files + * (12) .md entry overrides .js entry on same name collision + * (13) .js files are still discovered alongside .md files + * (14) Missing workflow dir is silently skipped for .md scan */ var test = require('node:test'); @@ -142,19 +149,23 @@ test('project-local entry wins on name collision over global entry', function () fs.rmSync(fakeCwd, { recursive: true }); }); -test('non-.js files are ignored', function () { +test('.ts and other non-.js/.md files are ignored', function () { var fakeHome = makeTmpDir(); var fakeCwd = makeTmpDir(); var wfDir = path.join(fakeHome, '.claude', 'workflows'); writeWorkflow(wfDir, 'valid.js', makeWorkflowContent('valid', 'A valid workflow')); - writeWorkflow(wfDir, 'README.md', 'Docs'); + // .md files ARE now discovered (user-facing slash commands) + var mdContent = '---\ndescription: A markdown workflow\n---\n'; + writeWorkflow(wfDir, 'md-workflow.md', mdContent); + // .ts files are NOT discovered — only .js and .md are handled writeWorkflow(wfDir, 'helper.ts', makeWorkflowContent('ts-wf', 'TypeScript file')); var result = discoverWorkflows(fakeCwd, fakeHome); - assert.equal(result.length, 1); - assert.equal(result[0].name, 'valid'); + assert.equal(result.length, 2); + var names = result.map(function (r) { return r.name; }).sort(); + assert.deepEqual(names, ['md-workflow', 'valid']); fs.rmSync(fakeHome, { recursive: true }); fs.rmSync(fakeCwd, { recursive: true }); @@ -178,3 +189,167 @@ test('merges global and local workflows without collision', function () { fs.rmSync(fakeHome, { recursive: true }); fs.rmSync(fakeCwd, { recursive: true }); }); + +test('.md file is discovered with name from filename and description from YAML frontmatter', function () { + var fakeHome = makeTmpDir(); + var fakeCwd = makeTmpDir(); + var wfDir = path.join(fakeHome, '.claude', 'workflows'); + + var mdContent = [ + '---', + 'description: Run the release process', + '---', + '', + '# Release Workflow', + '', + 'Steps for releasing...', + ].join('\n'); + writeWorkflow(wfDir, 'release.md', mdContent); + + var result = discoverWorkflows(fakeCwd, fakeHome); + + assert.equal(result.length, 1); + assert.equal(result[0].name, 'release'); + assert.equal(result[0].description, 'Run the release process'); + assert.equal(result[0].type, 'workflow'); + + fs.rmSync(fakeHome, { recursive: true }); + fs.rmSync(fakeCwd, { recursive: true }); +}); + +test('.md file with bare description key is parsed correctly', function () { + var fakeHome = makeTmpDir(); + var fakeCwd = makeTmpDir(); + var wfDir = path.join(fakeHome, '.claude', 'workflows'); + + var mdContent = [ + 'description: Quick deploy helper', + '', + '# Quick Deploy', + '', + 'Content here.', + ].join('\n'); + writeWorkflow(wfDir, 'quick-deploy.md', mdContent); + + var result = discoverWorkflows(fakeCwd, fakeHome); + + assert.equal(result.length, 1); + assert.equal(result[0].name, 'quick-deploy'); + assert.equal(result[0].description, 'Quick deploy helper'); + + fs.rmSync(fakeHome, { recursive: true }); + fs.rmSync(fakeCwd, { recursive: true }); +}); + +test('.md file with prose description (no frontmatter) is parsed correctly', function () { + var fakeHome = makeTmpDir(); + var fakeCwd = makeTmpDir(); + var wfDir = path.join(fakeHome, '.claude', 'workflows'); + + var mdContent = [ + '# Lint Workflow', + '', + 'Runs the project linter and reports failures.', + ].join('\n'); + writeWorkflow(wfDir, 'lint-workflow.md', mdContent); + + var result = discoverWorkflows(fakeCwd, fakeHome); + + assert.equal(result.length, 1); + assert.equal(result[0].name, 'lint-workflow'); + assert.equal(result[0].description, 'Runs the project linter and reports failures.'); + + fs.rmSync(fakeHome, { recursive: true }); + fs.rmSync(fakeCwd, { recursive: true }); +}); + +test('SKILL.md is skipped when scanning .md workflow files', function () { + var fakeHome = makeTmpDir(); + var fakeCwd = makeTmpDir(); + var wfDir = path.join(fakeHome, '.claude', 'workflows'); + + var skillMdContent = [ + '---', + 'description: This is a skill, not a workflow command', + '---', + ].join('\n'); + writeWorkflow(wfDir, 'SKILL.md', skillMdContent); + + var validMdContent = [ + '---', + 'description: A real workflow command', + '---', + ].join('\n'); + writeWorkflow(wfDir, 'real-workflow.md', validMdContent); + + var result = discoverWorkflows(fakeCwd, fakeHome); + + assert.equal(result.length, 1); + assert.equal(result[0].name, 'real-workflow'); + + fs.rmSync(fakeHome, { recursive: true }); + fs.rmSync(fakeCwd, { recursive: true }); +}); + +test('.md entry overrides .js entry on same name collision', function () { + var fakeHome = makeTmpDir(); + var fakeCwd = makeTmpDir(); + var wfDir = path.join(fakeHome, '.claude', 'workflows'); + + // .js file for the same name (internal orchestration script) + writeWorkflow(wfDir, 'deploy.js', makeWorkflowContent('deploy', 'Internal deploy script')); + // .md file (user-facing slash command) — should win + var mdContent = [ + '---', + 'description: User-facing deploy slash command', + '---', + ].join('\n'); + writeWorkflow(wfDir, 'deploy.md', mdContent); + + var result = discoverWorkflows(fakeCwd, fakeHome); + + assert.equal(result.length, 1); + assert.equal(result[0].name, 'deploy'); + assert.equal(result[0].description, 'User-facing deploy slash command'); + + fs.rmSync(fakeHome, { recursive: true }); + fs.rmSync(fakeCwd, { recursive: true }); +}); + +test('.js workflow files are still discovered alongside .md files', function () { + var fakeHome = makeTmpDir(); + var fakeCwd = makeTmpDir(); + var wfDir = path.join(fakeHome, '.claude', 'workflows'); + + writeWorkflow(wfDir, 'orchestrate.js', makeWorkflowContent('orchestrate', 'JS orchestration workflow')); + var mdContent = [ + '---', + 'description: MD slash command workflow', + '---', + ].join('\n'); + writeWorkflow(wfDir, 'slash-cmd.md', mdContent); + + var result = discoverWorkflows(fakeCwd, fakeHome); + + assert.equal(result.length, 2); + var byName = {}; + result.forEach(function (r) { byName[r.name] = r; }); + assert.equal(byName['orchestrate'].description, 'JS orchestration workflow'); + assert.equal(byName['slash-cmd'].description, 'MD slash command workflow'); + + fs.rmSync(fakeHome, { recursive: true }); + fs.rmSync(fakeCwd, { recursive: true }); +}); + +test('missing workflow dir is silently skipped for .md scan', function () { + var fakeHome = makeTmpDir(); + var fakeCwd = makeTmpDir(); + // Neither home nor cwd has a .claude/workflows directory — should return empty. + + var result = discoverWorkflows(fakeCwd, fakeHome); + + assert.deepEqual(result, []); + + fs.rmSync(fakeHome, { recursive: true }); + fs.rmSync(fakeCwd, { recursive: true }); +});