Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions lib/sdk-skill-discovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 "---") ---
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -316,6 +322,7 @@ function attachSkillDiscovery(ctx) {
module.exports = {
splitShellSegments: splitShellSegments,
attachSkillDiscovery: attachSkillDiscovery,
extractDescriptionFromContent: extractDescriptionFromContent,
extractSkillDescription: extractSkillDescription,
discoverSkillsWithMeta: discoverSkillsWithMeta,
mergeSkillsWithMeta: mergeSkillsWithMeta,
Expand Down
56 changes: 53 additions & 3 deletions lib/sdk-workflow-discovery.js
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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. <cwd>/.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.
//
Expand All @@ -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]; });
}
Expand Down
183 changes: 179 additions & 4 deletions test/sdk-workflow-discovery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 });
Expand All @@ -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 });
});
Loading