refactor(skills): add SKILL.md entrypoint to skill directories (#1868)
* refactor(skills): add SKILL.md entrypoint to skill directories Align skill source format with Open Skills standard: each skill directory now contains a SKILL.md with name/description frontmatter where name must match the directory name exactly. The installer copies skill directories verbatim instead of generating SKILL.md. - Add SKILL.md to both tracer bullet skill directories - Strip name/description from workflow.md frontmatter (SKILL.md owns it) - Installer reads metadata from SKILL.md, validates name matches dirname - Install path in manifest CSV now points to SKILL.md - Copy filter excludes OS/editor artifacts (.DS_Store, backups, dotfiles) - Debug-guard validation messages, keep name-mismatch as hard error - Add typeof guard for malformed YAML frontmatter - Add negative test cases for parseSkillMd validation (Suite 30) * fix(skills): improve quick-dev-new-preview description for LLM discovery Add trigger context so LLMs know when to invoke the skill, matching the "Use when..." pattern used by other skills. * fix(cli): validate frontmatter name/description are strings in parseSkillMd Prevents cleanForCSV() crash when YAML parses name or description as a non-string type (number, object, boolean). * fix(cli): address PR review findings (mkdtemp, regex escape, recursive filter) - Replace Date.now() temp dir with fs.mkdtemp() in Suite 30 tests (F5) - Replace unescaped RegExp with startsWith/slice for path prefix stripping (F7) - Apply artifact filter recursively via fs.copy filter option (F8) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
066bfe32e9
commit
1b3c3c5013
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
name: bmad-quick-dev-new-preview
|
||||||
|
description: 'Implements any user intent, requirement, story, bug fix or change request by producing clean working code artifacts that follow the projects existing architecture, patterns and conventions. Use when the user wants to build, fix, tweak, refactor, add or modify any code, component or feature.'
|
||||||
|
---
|
||||||
|
|
||||||
|
Follow the instructions in [workflow.md](workflow.md).
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
---
|
---
|
||||||
name: quick-dev-new-preview
|
|
||||||
description: 'Unified quick flow - clarify intent, plan, implement, review, present.'
|
|
||||||
main_config: '{project-root}/_bmad/bmm/config.yaml'
|
main_config: '{project-root}/_bmad/bmm/config.yaml'
|
||||||
|
|
||||||
# Related workflows
|
# Related workflows
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
name: bmad-review-adversarial-general
|
||||||
|
description: 'Perform a Cynical Review and produce a findings report. Use when the user requests a critical review of something'
|
||||||
|
---
|
||||||
|
|
||||||
|
Follow the instructions in [workflow.md](workflow.md).
|
||||||
|
|
@ -1,8 +1,3 @@
|
||||||
---
|
|
||||||
name: bmad-review-adversarial-general
|
|
||||||
description: 'Perform a Cynical Review and produce a findings report. Use when the user requests a critical review of something'
|
|
||||||
---
|
|
||||||
|
|
||||||
# Adversarial Review (General)
|
# Adversarial Review (General)
|
||||||
|
|
||||||
**Goal:** Cynically review content and produce findings.
|
**Goal:** Cynically review content and produce findings.
|
||||||
|
|
|
||||||
|
|
@ -1607,9 +1607,10 @@ async function runTests() {
|
||||||
await fs.ensureDir(skillDir29);
|
await fs.ensureDir(skillDir29);
|
||||||
await fs.writeFile(path.join(skillDir29, 'bmad-skill-manifest.yaml'), 'type: skill\n');
|
await fs.writeFile(path.join(skillDir29, 'bmad-skill-manifest.yaml'), 'type: skill\n');
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(skillDir29, 'workflow.md'),
|
path.join(skillDir29, 'SKILL.md'),
|
||||||
'---\nname: My Custom Skill\ndescription: A skill at an unusual path\n---\n\nSkill body content\n',
|
'---\nname: my-skill\ndescription: A skill at an unusual path\n---\n\nFollow the instructions in [workflow.md](workflow.md).\n',
|
||||||
);
|
);
|
||||||
|
await fs.writeFile(path.join(skillDir29, 'workflow.md'), '# My Custom Skill\n\nSkill body content\n');
|
||||||
|
|
||||||
// --- Regular workflow dir: core/workflows/regular-wf/ (type: workflow) ---
|
// --- Regular workflow dir: core/workflows/regular-wf/ (type: workflow) ---
|
||||||
const wfDir29 = path.join(tempFixture29, 'core', 'workflows', 'regular-wf');
|
const wfDir29 = path.join(tempFixture29, 'core', 'workflows', 'regular-wf');
|
||||||
|
|
@ -1625,18 +1626,20 @@ async function runTests() {
|
||||||
await fs.ensureDir(wfSkillDir29);
|
await fs.ensureDir(wfSkillDir29);
|
||||||
await fs.writeFile(path.join(wfSkillDir29, 'bmad-skill-manifest.yaml'), 'type: skill\n');
|
await fs.writeFile(path.join(wfSkillDir29, 'bmad-skill-manifest.yaml'), 'type: skill\n');
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(wfSkillDir29, 'workflow.md'),
|
path.join(wfSkillDir29, 'SKILL.md'),
|
||||||
'---\nname: Workflow Skill\ndescription: A skill inside workflows dir\n---\n\nSkill in workflows\n',
|
'---\nname: wf-skill\ndescription: A skill inside workflows dir\n---\n\nFollow the instructions in [workflow.md](workflow.md).\n',
|
||||||
);
|
);
|
||||||
|
await fs.writeFile(path.join(wfSkillDir29, 'workflow.md'), '# Workflow Skill\n\nSkill in workflows\n');
|
||||||
|
|
||||||
// --- Skill inside tasks/ dir: core/tasks/task-skill/ ---
|
// --- Skill inside tasks/ dir: core/tasks/task-skill/ ---
|
||||||
const taskSkillDir29 = path.join(tempFixture29, 'core', 'tasks', 'task-skill');
|
const taskSkillDir29 = path.join(tempFixture29, 'core', 'tasks', 'task-skill');
|
||||||
await fs.ensureDir(taskSkillDir29);
|
await fs.ensureDir(taskSkillDir29);
|
||||||
await fs.writeFile(path.join(taskSkillDir29, 'bmad-skill-manifest.yaml'), 'type: skill\n');
|
await fs.writeFile(path.join(taskSkillDir29, 'bmad-skill-manifest.yaml'), 'type: skill\n');
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(taskSkillDir29, 'workflow.md'),
|
path.join(taskSkillDir29, 'SKILL.md'),
|
||||||
'---\nname: Task Skill\ndescription: A skill inside tasks dir\n---\n\nSkill in tasks\n',
|
'---\nname: task-skill\ndescription: A skill inside tasks dir\n---\n\nFollow the instructions in [workflow.md](workflow.md).\n',
|
||||||
);
|
);
|
||||||
|
await fs.writeFile(path.join(taskSkillDir29, 'workflow.md'), '# Task Skill\n\nSkill in tasks\n');
|
||||||
|
|
||||||
// Minimal agent so core module is detected
|
// Minimal agent so core module is detected
|
||||||
await fs.ensureDir(path.join(tempFixture29, 'core', 'agents'));
|
await fs.ensureDir(path.join(tempFixture29, 'core', 'agents'));
|
||||||
|
|
@ -1649,14 +1652,14 @@ async function runTests() {
|
||||||
// Skill at unusual path should be in skills
|
// Skill at unusual path should be in skills
|
||||||
const skillEntry29 = generator29.skills.find((s) => s.canonicalId === 'my-skill');
|
const skillEntry29 = generator29.skills.find((s) => s.canonicalId === 'my-skill');
|
||||||
assert(skillEntry29 !== undefined, 'Skill at unusual path appears in skills[]');
|
assert(skillEntry29 !== undefined, 'Skill at unusual path appears in skills[]');
|
||||||
assert(skillEntry29 && skillEntry29.name === 'My Custom Skill', 'Skill has correct name from frontmatter');
|
assert(skillEntry29 && skillEntry29.name === 'my-skill', 'Skill has correct name from frontmatter');
|
||||||
assert(
|
assert(
|
||||||
skillEntry29 && skillEntry29.path.includes('custom-area/my-skill/workflow.md'),
|
skillEntry29 && skillEntry29.path.includes('custom-area/my-skill/SKILL.md'),
|
||||||
'Skill path includes relative path from module root',
|
'Skill path includes relative path from module root',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Skill should NOT be in workflows
|
// Skill should NOT be in workflows
|
||||||
const inWorkflows29 = generator29.workflows.find((w) => w.name === 'My Custom Skill');
|
const inWorkflows29 = generator29.workflows.find((w) => w.name === 'my-skill');
|
||||||
assert(inWorkflows29 === undefined, 'Skill at unusual path does NOT appear in workflows[]');
|
assert(inWorkflows29 === undefined, 'Skill at unusual path does NOT appear in workflows[]');
|
||||||
|
|
||||||
// Skill in tasks/ dir should be in skills
|
// Skill in tasks/ dir should be in skills
|
||||||
|
|
@ -1664,7 +1667,7 @@ async function runTests() {
|
||||||
assert(taskSkillEntry29 !== undefined, 'Skill in tasks/ dir appears in skills[]');
|
assert(taskSkillEntry29 !== undefined, 'Skill in tasks/ dir appears in skills[]');
|
||||||
|
|
||||||
// Skill in tasks/ should NOT appear in tasks[]
|
// Skill in tasks/ should NOT appear in tasks[]
|
||||||
const inTasks29 = generator29.tasks.find((t) => t.name === 'Task Skill');
|
const inTasks29 = generator29.tasks.find((t) => t.name === 'task-skill');
|
||||||
assert(inTasks29 === undefined, 'Skill in tasks/ dir does NOT appear in tasks[]');
|
assert(inTasks29 === undefined, 'Skill in tasks/ dir does NOT appear in tasks[]');
|
||||||
|
|
||||||
// Regular workflow should be in workflows, NOT in skills
|
// Regular workflow should be in workflows, NOT in skills
|
||||||
|
|
@ -1677,7 +1680,7 @@ async function runTests() {
|
||||||
// Skill inside workflows/ should be in skills[], NOT in workflows[] (exercises findWorkflows skip at lines 311/322)
|
// Skill inside workflows/ should be in skills[], NOT in workflows[] (exercises findWorkflows skip at lines 311/322)
|
||||||
const wfSkill29 = generator29.skills.find((s) => s.canonicalId === 'wf-skill');
|
const wfSkill29 = generator29.skills.find((s) => s.canonicalId === 'wf-skill');
|
||||||
assert(wfSkill29 !== undefined, 'Skill in workflows/ dir appears in skills[]');
|
assert(wfSkill29 !== undefined, 'Skill in workflows/ dir appears in skills[]');
|
||||||
const wfSkillInWorkflows29 = generator29.workflows.find((w) => w.name === 'Workflow Skill');
|
const wfSkillInWorkflows29 = generator29.workflows.find((w) => w.name === 'wf-skill');
|
||||||
assert(wfSkillInWorkflows29 === undefined, 'Skill in workflows/ dir does NOT appear in workflows[]');
|
assert(wfSkillInWorkflows29 === undefined, 'Skill in workflows/ dir does NOT appear in workflows[]');
|
||||||
|
|
||||||
// Test scanInstalledModules recognizes skill-only modules
|
// Test scanInstalledModules recognizes skill-only modules
|
||||||
|
|
@ -1685,9 +1688,10 @@ async function runTests() {
|
||||||
await fs.ensureDir(path.join(skillOnlyModDir29, 'deep', 'nested', 'my-skill'));
|
await fs.ensureDir(path.join(skillOnlyModDir29, 'deep', 'nested', 'my-skill'));
|
||||||
await fs.writeFile(path.join(skillOnlyModDir29, 'deep', 'nested', 'my-skill', 'bmad-skill-manifest.yaml'), 'type: skill\n');
|
await fs.writeFile(path.join(skillOnlyModDir29, 'deep', 'nested', 'my-skill', 'bmad-skill-manifest.yaml'), 'type: skill\n');
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(skillOnlyModDir29, 'deep', 'nested', 'my-skill', 'workflow.md'),
|
path.join(skillOnlyModDir29, 'deep', 'nested', 'my-skill', 'SKILL.md'),
|
||||||
'---\nname: Nested Skill\ndescription: desc\n---\nbody\n',
|
'---\nname: my-skill\ndescription: desc\n---\n\nFollow the instructions in [workflow.md](workflow.md).\n',
|
||||||
);
|
);
|
||||||
|
await fs.writeFile(path.join(skillOnlyModDir29, 'deep', 'nested', 'my-skill', 'workflow.md'), '# Nested Skill\n\nbody\n');
|
||||||
|
|
||||||
const scannedModules29 = await generator29.scanInstalledModules(tempFixture29);
|
const scannedModules29 = await generator29.scanInstalledModules(tempFixture29);
|
||||||
assert(scannedModules29.includes('skill-only-mod'), 'scanInstalledModules recognizes skill-only module');
|
assert(scannedModules29.includes('skill-only-mod'), 'scanInstalledModules recognizes skill-only module');
|
||||||
|
|
@ -1699,6 +1703,73 @@ async function runTests() {
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Suite 30: parseSkillMd validation (negative cases)
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 30: parseSkillMd Validation${colors.reset}\n`);
|
||||||
|
|
||||||
|
let tempFixture30;
|
||||||
|
try {
|
||||||
|
tempFixture30 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-test-30-'));
|
||||||
|
|
||||||
|
const generator30 = new ManifestGenerator();
|
||||||
|
generator30.bmadFolderName = '_bmad';
|
||||||
|
|
||||||
|
// Case 1: Missing SKILL.md entirely
|
||||||
|
const noSkillDir = path.join(tempFixture30, 'no-skill-md');
|
||||||
|
await fs.ensureDir(noSkillDir);
|
||||||
|
const result1 = await generator30.parseSkillMd(path.join(noSkillDir, 'SKILL.md'), noSkillDir, 'no-skill-md');
|
||||||
|
assert(result1 === null, 'parseSkillMd returns null when SKILL.md is missing');
|
||||||
|
|
||||||
|
// Case 2: SKILL.md with no frontmatter
|
||||||
|
const noFmDir = path.join(tempFixture30, 'no-frontmatter');
|
||||||
|
await fs.ensureDir(noFmDir);
|
||||||
|
await fs.writeFile(path.join(noFmDir, 'SKILL.md'), '# Just a heading\n\nNo frontmatter here.\n');
|
||||||
|
const result2 = await generator30.parseSkillMd(path.join(noFmDir, 'SKILL.md'), noFmDir, 'no-frontmatter');
|
||||||
|
assert(result2 === null, 'parseSkillMd returns null when SKILL.md has no frontmatter');
|
||||||
|
|
||||||
|
// Case 3: SKILL.md missing description
|
||||||
|
const noDescDir = path.join(tempFixture30, 'no-desc');
|
||||||
|
await fs.ensureDir(noDescDir);
|
||||||
|
await fs.writeFile(path.join(noDescDir, 'SKILL.md'), '---\nname: no-desc\n---\n\nBody.\n');
|
||||||
|
const result3 = await generator30.parseSkillMd(path.join(noDescDir, 'SKILL.md'), noDescDir, 'no-desc');
|
||||||
|
assert(result3 === null, 'parseSkillMd returns null when description is missing');
|
||||||
|
|
||||||
|
// Case 4: SKILL.md missing name
|
||||||
|
const noNameDir = path.join(tempFixture30, 'no-name');
|
||||||
|
await fs.ensureDir(noNameDir);
|
||||||
|
await fs.writeFile(path.join(noNameDir, 'SKILL.md'), '---\ndescription: has desc but no name\n---\n\nBody.\n');
|
||||||
|
const result4 = await generator30.parseSkillMd(path.join(noNameDir, 'SKILL.md'), noNameDir, 'no-name');
|
||||||
|
assert(result4 === null, 'parseSkillMd returns null when name is missing');
|
||||||
|
|
||||||
|
// Case 5: Name mismatch
|
||||||
|
const mismatchDir = path.join(tempFixture30, 'actual-dir-name');
|
||||||
|
await fs.ensureDir(mismatchDir);
|
||||||
|
await fs.writeFile(path.join(mismatchDir, 'SKILL.md'), '---\nname: wrong-name\ndescription: A skill\n---\n\nBody.\n');
|
||||||
|
const result5 = await generator30.parseSkillMd(path.join(mismatchDir, 'SKILL.md'), mismatchDir, 'actual-dir-name');
|
||||||
|
assert(result5 === null, 'parseSkillMd returns null when name does not match directory name');
|
||||||
|
|
||||||
|
// Case 6: Valid SKILL.md (positive control)
|
||||||
|
const validDir = path.join(tempFixture30, 'valid-skill');
|
||||||
|
await fs.ensureDir(validDir);
|
||||||
|
await fs.writeFile(path.join(validDir, 'SKILL.md'), '---\nname: valid-skill\ndescription: A valid skill\n---\n\nBody.\n');
|
||||||
|
const result6 = await generator30.parseSkillMd(path.join(validDir, 'SKILL.md'), validDir, 'valid-skill');
|
||||||
|
assert(result6 !== null && result6.name === 'valid-skill', 'parseSkillMd returns metadata for valid SKILL.md');
|
||||||
|
|
||||||
|
// Case 7: Malformed YAML (non-object)
|
||||||
|
const malformedDir = path.join(tempFixture30, 'malformed');
|
||||||
|
await fs.ensureDir(malformedDir);
|
||||||
|
await fs.writeFile(path.join(malformedDir, 'SKILL.md'), '---\njust a string\n---\n\nBody.\n');
|
||||||
|
const result7 = await generator30.parseSkillMd(path.join(malformedDir, 'SKILL.md'), malformedDir, 'malformed');
|
||||||
|
assert(result7 === null, 'parseSkillMd returns null for non-object YAML frontmatter');
|
||||||
|
} catch (error) {
|
||||||
|
assert(false, 'parseSkillMd validation test succeeds', error.message);
|
||||||
|
} finally {
|
||||||
|
if (tempFixture30) await fs.remove(tempFixture30).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Summary
|
// Summary
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ class ManifestGenerator {
|
||||||
/**
|
/**
|
||||||
* Recursively walk a module directory tree, collecting skill directories.
|
* Recursively walk a module directory tree, collecting skill directories.
|
||||||
* A skill directory is one that contains both a bmad-skill-manifest.yaml with
|
* A skill directory is one that contains both a bmad-skill-manifest.yaml with
|
||||||
* type: skill AND a workflow.md file.
|
* type: skill AND a SKILL.md file with name/description frontmatter.
|
||||||
* Populates this.skills[] and this.skillClaimedDirs (Set of absolute paths).
|
* Populates this.skills[] and this.skillClaimedDirs (Set of absolute paths).
|
||||||
*/
|
*/
|
||||||
async collectSkills() {
|
async collectSkills() {
|
||||||
|
|
@ -169,75 +169,62 @@ class ManifestGenerator {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check this directory for skill manifest + workflow file
|
// Check this directory for skill manifest
|
||||||
const manifest = await this.loadSkillManifest(dir);
|
const manifest = await this.loadSkillManifest(dir);
|
||||||
|
|
||||||
const workflowFile = 'workflow.md';
|
// Determine if this directory is a skill (type: skill in manifest)
|
||||||
const workflowPath = path.join(dir, workflowFile);
|
const skillFile = 'SKILL.md';
|
||||||
if (await fs.pathExists(workflowPath)) {
|
const artifactType = this.getArtifactType(manifest, skillFile);
|
||||||
const artifactType = this.getArtifactType(manifest, workflowFile);
|
|
||||||
if (artifactType === 'skill') {
|
|
||||||
// Read and parse the workflow file
|
|
||||||
try {
|
|
||||||
const rawContent = await fs.readFile(workflowPath, 'utf8');
|
|
||||||
const content = rawContent.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
|
||||||
|
|
||||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
if (artifactType === 'skill') {
|
||||||
if (frontmatterMatch) {
|
const skillMdPath = path.join(dir, 'SKILL.md');
|
||||||
const workflow = yaml.parse(frontmatterMatch[1]);
|
const dirName = path.basename(dir);
|
||||||
|
|
||||||
if (!workflow || !workflow.name || !workflow.description) {
|
// Validate and parse SKILL.md
|
||||||
if (debug) console.log(`[DEBUG] collectSkills: skipped (missing name/description): ${workflowPath}`);
|
const skillMeta = await this.parseSkillMd(skillMdPath, dir, dirName, debug);
|
||||||
} else {
|
|
||||||
// Build path relative from module root
|
|
||||||
const relativePath = path.relative(modulePath, dir).split(path.sep).join('/');
|
|
||||||
const installPath = relativePath
|
|
||||||
? `${this.bmadFolderName}/${moduleName}/${relativePath}/${workflowFile}`
|
|
||||||
: `${this.bmadFolderName}/${moduleName}/${workflowFile}`;
|
|
||||||
|
|
||||||
// Skills derive canonicalId from directory name — never from manifest
|
if (skillMeta) {
|
||||||
if (manifest && manifest.__single && manifest.__single.canonicalId) {
|
// Build path relative from module root (points to SKILL.md — the permanent entrypoint)
|
||||||
console.warn(
|
const relativePath = path.relative(modulePath, dir).split(path.sep).join('/');
|
||||||
`Warning: Skill manifest at ${dir}/bmad-skill-manifest.yaml contains canonicalId — this field is ignored for skills (directory name is the canonical ID)`,
|
const installPath = relativePath
|
||||||
);
|
? `${this.bmadFolderName}/${moduleName}/${relativePath}/${skillFile}`
|
||||||
}
|
: `${this.bmadFolderName}/${moduleName}/${skillFile}`;
|
||||||
const canonicalId = path.basename(dir);
|
|
||||||
|
|
||||||
this.skills.push({
|
// Skills derive canonicalId from directory name — never from manifest
|
||||||
name: workflow.name,
|
if (manifest && manifest.__single && manifest.__single.canonicalId) {
|
||||||
description: this.cleanForCSV(workflow.description),
|
console.warn(
|
||||||
module: moduleName,
|
`Warning: Skill manifest at ${dir}/bmad-skill-manifest.yaml contains canonicalId — this field is ignored for skills (directory name is the canonical ID)`,
|
||||||
path: installPath,
|
);
|
||||||
canonicalId,
|
}
|
||||||
install_to_bmad: this.getInstallToBmad(manifest, workflowFile),
|
const canonicalId = dirName;
|
||||||
});
|
|
||||||
|
|
||||||
// Add to files list
|
this.skills.push({
|
||||||
this.files.push({
|
name: skillMeta.name,
|
||||||
type: 'skill',
|
description: this.cleanForCSV(skillMeta.description),
|
||||||
name: workflow.name,
|
module: moduleName,
|
||||||
module: moduleName,
|
path: installPath,
|
||||||
path: installPath,
|
canonicalId,
|
||||||
});
|
install_to_bmad: this.getInstallToBmad(manifest, skillFile),
|
||||||
|
});
|
||||||
|
|
||||||
this.skillClaimedDirs.add(dir);
|
// Add to files list
|
||||||
|
this.files.push({
|
||||||
|
type: 'skill',
|
||||||
|
name: skillMeta.name,
|
||||||
|
module: moduleName,
|
||||||
|
path: installPath,
|
||||||
|
});
|
||||||
|
|
||||||
if (debug) {
|
this.skillClaimedDirs.add(dir);
|
||||||
console.log(`[DEBUG] collectSkills: claimed skill "${workflow.name}" as ${canonicalId} at ${dir}`);
|
|
||||||
}
|
if (debug) {
|
||||||
}
|
console.log(`[DEBUG] collectSkills: claimed skill "${skillMeta.name}" as ${canonicalId} at ${dir}`);
|
||||||
} else {
|
|
||||||
if (debug) console.log(`[DEBUG] collectSkills: skipped (no frontmatter): ${workflowPath}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (debug) console.log(`[DEBUG] collectSkills: failed to parse ${workflowPath}: ${error.message}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn if manifest says type:skill but no workflow file found
|
// Warn if manifest says type:skill but directory was not claimed
|
||||||
if (manifest && !this.skillClaimedDirs.has(dir)) {
|
if (manifest && !this.skillClaimedDirs.has(dir)) {
|
||||||
// Check if any entry in the manifest is type:skill
|
|
||||||
let hasSkillType = false;
|
let hasSkillType = false;
|
||||||
if (manifest.__single) {
|
if (manifest.__single) {
|
||||||
hasSkillType = manifest.__single.type === 'skill';
|
hasSkillType = manifest.__single.type === 'skill';
|
||||||
|
|
@ -250,12 +237,7 @@ class ManifestGenerator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hasSkillType && debug) {
|
if (hasSkillType && debug) {
|
||||||
const hasWorkflow = entries.some((e) => e.name === workflowFile);
|
console.log(`[DEBUG] collectSkills: dir has type:skill manifest but failed validation: ${dir}`);
|
||||||
if (hasWorkflow) {
|
|
||||||
console.log(`[DEBUG] collectSkills: dir has type:skill manifest but workflow file failed to parse: ${dir}`);
|
|
||||||
} else {
|
|
||||||
console.log(`[DEBUG] collectSkills: dir has type:skill manifest but no workflow.md: ${dir}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,6 +257,57 @@ class ManifestGenerator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate SKILL.md for a skill directory.
|
||||||
|
* Returns parsed frontmatter object with name/description, or null if invalid.
|
||||||
|
* @param {string} skillMdPath - Absolute path to SKILL.md
|
||||||
|
* @param {string} dir - Skill directory path (for error messages)
|
||||||
|
* @param {string} dirName - Expected name (must match frontmatter name)
|
||||||
|
* @param {boolean} debug - Whether to emit debug-level messages
|
||||||
|
* @returns {Promise<Object|null>} Parsed frontmatter or null
|
||||||
|
*/
|
||||||
|
async parseSkillMd(skillMdPath, dir, dirName, debug = false) {
|
||||||
|
if (!(await fs.pathExists(skillMdPath))) {
|
||||||
|
if (debug) console.log(`[DEBUG] parseSkillMd: "${dir}" is missing SKILL.md — skipping`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawContent = await fs.readFile(skillMdPath, 'utf8');
|
||||||
|
const content = rawContent.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
||||||
|
|
||||||
|
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||||
|
if (frontmatterMatch) {
|
||||||
|
const skillMeta = yaml.parse(frontmatterMatch[1]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!skillMeta ||
|
||||||
|
typeof skillMeta !== 'object' ||
|
||||||
|
typeof skillMeta.name !== 'string' ||
|
||||||
|
typeof skillMeta.description !== 'string' ||
|
||||||
|
!skillMeta.name ||
|
||||||
|
!skillMeta.description
|
||||||
|
) {
|
||||||
|
if (debug) console.log(`[DEBUG] parseSkillMd: SKILL.md in "${dir}" is missing name or description (or wrong type) — skipping`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skillMeta.name !== dirName) {
|
||||||
|
console.error(`Error: SKILL.md name "${skillMeta.name}" does not match directory name "${dirName}" — skipping`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return skillMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debug) console.log(`[DEBUG] parseSkillMd: SKILL.md in "${dir}" has no frontmatter — skipping`);
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
if (debug) console.log(`[DEBUG] parseSkillMd: failed to parse SKILL.md in "${dir}": ${error.message} — skipping`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect all workflows from core and selected modules
|
* Collect all workflows from core and selected modules
|
||||||
* Scans the INSTALLED bmad directory, not the source
|
* Scans the INSTALLED bmad directory, not the source
|
||||||
|
|
|
||||||
|
|
@ -627,7 +627,8 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install verbatim skill directories (type: skill entries from skill-manifest.csv).
|
* Install verbatim skill directories (type: skill entries from skill-manifest.csv).
|
||||||
* Copies the entire source directory into the IDE skill directory, auto-generating SKILL.md.
|
* Copies the entire source directory as-is into the IDE skill directory.
|
||||||
|
* The source SKILL.md is used directly — no frontmatter transformation or file generation.
|
||||||
* @param {string} projectDir - Project directory
|
* @param {string} projectDir - Project directory
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
* @param {string} targetPath - Target skills directory
|
* @param {string} targetPath - Target skills directory
|
||||||
|
|
@ -636,6 +637,7 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
|
||||||
*/
|
*/
|
||||||
async installVerbatimSkills(projectDir, bmadDir, targetPath, config) {
|
async installVerbatimSkills(projectDir, bmadDir, targetPath, config) {
|
||||||
const bmadFolderName = path.basename(bmadDir);
|
const bmadFolderName = path.basename(bmadDir);
|
||||||
|
const bmadPrefix = bmadFolderName + '/';
|
||||||
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
|
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
|
||||||
|
|
||||||
if (!(await fs.pathExists(csvPath))) return 0;
|
if (!(await fs.pathExists(csvPath))) return 0;
|
||||||
|
|
@ -653,9 +655,9 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
|
||||||
if (!canonicalId) continue;
|
if (!canonicalId) continue;
|
||||||
|
|
||||||
// Derive source directory from path column
|
// Derive source directory from path column
|
||||||
// path is like "_bmad/bmm/workflows/bmad-quick-flow/bmad-quick-dev-new-preview/workflow.md"
|
// path is like "_bmad/bmm/workflows/bmad-quick-flow/bmad-quick-dev-new-preview/SKILL.md"
|
||||||
// Strip bmadFolderName prefix and join with bmadDir, then get dirname
|
// Strip bmadFolderName prefix and join with bmadDir, then get dirname
|
||||||
const relativePath = record.path.replace(new RegExp(`^${bmadFolderName}/`), '');
|
const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path;
|
||||||
const sourceFile = path.join(bmadDir, relativePath);
|
const sourceFile = path.join(bmadDir, relativePath);
|
||||||
const sourceDir = path.dirname(sourceFile);
|
const sourceDir = path.dirname(sourceFile);
|
||||||
|
|
||||||
|
|
@ -666,34 +668,18 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
|
||||||
await fs.remove(skillDir);
|
await fs.remove(skillDir);
|
||||||
await fs.ensureDir(skillDir);
|
await fs.ensureDir(skillDir);
|
||||||
|
|
||||||
// Parse workflow.md frontmatter for description
|
// Copy all skill files, filtering OS/editor artifacts recursively
|
||||||
let description = `${canonicalId} skill`;
|
const skipPatterns = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']);
|
||||||
try {
|
const skipSuffixes = ['~', '.swp', '.swo', '.bak'];
|
||||||
const workflowContent = await fs.readFile(sourceFile, 'utf8');
|
const filter = (src) => {
|
||||||
const fmMatch = workflowContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
const name = path.basename(src);
|
||||||
if (fmMatch) {
|
if (src === sourceDir) return true;
|
||||||
const frontmatter = yaml.parse(fmMatch[1]);
|
if (skipPatterns.has(name)) return false;
|
||||||
if (frontmatter?.description) {
|
if (name.startsWith('.') && name !== '.gitkeep') return false;
|
||||||
description = frontmatter.description;
|
if (skipSuffixes.some((s) => name.endsWith(s))) return false;
|
||||||
}
|
return true;
|
||||||
}
|
};
|
||||||
} catch (error) {
|
await fs.copy(sourceDir, skillDir, { filter });
|
||||||
await prompts.log.warn(`Failed to parse frontmatter from ${sourceFile}: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate SKILL.md with YAML-safe frontmatter
|
|
||||||
const frontmatterYaml = yaml.stringify({ name: canonicalId, description: String(description) }, { lineWidth: 0 }).trimEnd();
|
|
||||||
const skillMd = `---\n${frontmatterYaml}\n---\n\nIT IS CRITICAL THAT YOU FOLLOW THIS COMMAND: LOAD the FULL workflow.md, READ its entire contents and follow its directions exactly!\n`;
|
|
||||||
await fs.writeFile(path.join(skillDir, 'SKILL.md'), skillMd);
|
|
||||||
|
|
||||||
// Copy all files except bmad-skill-manifest.yaml
|
|
||||||
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.name === 'bmad-skill-manifest.yaml') continue;
|
|
||||||
const srcPath = path.join(sourceDir, entry.name);
|
|
||||||
const destPath = path.join(skillDir, entry.name);
|
|
||||||
await fs.copy(srcPath, destPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
@ -701,7 +687,7 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
|
||||||
// Post-install cleanup: remove _bmad/ directories for skills with install_to_bmad === "false"
|
// Post-install cleanup: remove _bmad/ directories for skills with install_to_bmad === "false"
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
if (record.install_to_bmad === 'false') {
|
if (record.install_to_bmad === 'false') {
|
||||||
const relativePath = record.path.replace(new RegExp(`^${bmadFolderName}/`), '');
|
const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path;
|
||||||
const sourceFile = path.join(bmadDir, relativePath);
|
const sourceFile = path.join(bmadDir, relativePath);
|
||||||
const sourceDir = path.dirname(sourceFile);
|
const sourceDir = path.dirname(sourceFile);
|
||||||
if (await fs.pathExists(sourceDir)) {
|
if (await fs.pathExists(sourceDir)) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue