Merge 18277c0ba1 into 434e7efab6
This commit is contained in:
commit
92e90c4777
|
|
@ -0,0 +1,12 @@
|
||||||
|
---
|
||||||
|
name: bmad-shard-doc-skill-prototype
|
||||||
|
description: Prototype native skill wrapper for shard-doc during transition.
|
||||||
|
---
|
||||||
|
|
||||||
|
# bmad-shard-doc-skill-prototype
|
||||||
|
|
||||||
|
Prototype marker: source-authored-skill
|
||||||
|
|
||||||
|
Read and execute from: {project-root}/_bmad/core/tasks/shard-doc.xml
|
||||||
|
|
||||||
|
Follow all shard-doc task instructions exactly as written.
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
canonicalId: bmad-shard-doc-skill-prototype
|
||||||
|
type: task
|
||||||
|
description: "Prototype native skill wrapper for shard-doc during installer transition"
|
||||||
|
|
@ -30,6 +30,8 @@ review-edge-case-hunter.xml:
|
||||||
|
|
||||||
shard-doc.xml:
|
shard-doc.xml:
|
||||||
canonicalId: bmad-shard-doc
|
canonicalId: bmad-shard-doc
|
||||||
|
prototypeIds:
|
||||||
|
- bmad-shard-doc-skill-prototype
|
||||||
type: task
|
type: task
|
||||||
description: "Splits large markdown documents into smaller, organized files based on sections"
|
description: "Splits large markdown documents into smaller, organized files based on sections"
|
||||||
|
|
||||||
|
|
@ -72,7 +72,7 @@ async function createTestBmadFixture() {
|
||||||
await fs.ensureDir(path.join(fixtureDir, 'core', 'agents'));
|
await fs.ensureDir(path.join(fixtureDir, 'core', 'agents'));
|
||||||
await fs.writeFile(path.join(fixtureDir, 'core', 'agents', 'bmad-master.md'), minimalAgent);
|
await fs.writeFile(path.join(fixtureDir, 'core', 'agents', 'bmad-master.md'), minimalAgent);
|
||||||
// Skill manifest so the installer uses 'bmad-master' as the canonical skill name
|
// Skill manifest so the installer uses 'bmad-master' as the canonical skill name
|
||||||
await fs.writeFile(path.join(fixtureDir, 'core', 'agents', 'bmad-skill-manifest.yaml'), 'bmad-master.md:\n canonicalId: bmad-master\n');
|
await fs.writeFile(path.join(fixtureDir, 'core', 'agents', 'skill-manifest.yaml'), 'bmad-master.md:\n canonicalId: bmad-master\n');
|
||||||
|
|
||||||
// Minimal compiled agent for bmm module (tests use selectedModules: ['bmm'])
|
// Minimal compiled agent for bmm module (tests use selectedModules: ['bmm'])
|
||||||
await fs.ensureDir(path.join(fixtureDir, 'bmm', 'agents'));
|
await fs.ensureDir(path.join(fixtureDir, 'bmm', 'agents'));
|
||||||
|
|
@ -81,6 +81,63 @@ async function createTestBmadFixture() {
|
||||||
return fixtureDir;
|
return fixtureDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createShardDocPrototypeFixture() {
|
||||||
|
const fixtureDir = await createTestBmadFixture();
|
||||||
|
|
||||||
|
await fs.ensureDir(path.join(fixtureDir, 'core', 'tasks'));
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(fixtureDir, 'core', 'tasks', 'shard-doc.xml'),
|
||||||
|
'<task id="_bmad/core/tasks/shard-doc" name="Shard Document" description="Test shard-doc task"><objective>Test objective</objective></task>\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(fixtureDir, 'core', 'tasks', 'skill-manifest.yaml'),
|
||||||
|
[
|
||||||
|
'shard-doc.xml:',
|
||||||
|
' canonicalId: bmad-shard-doc',
|
||||||
|
' prototypeIds:',
|
||||||
|
' - bmad-shard-doc-skill-prototype',
|
||||||
|
' type: task',
|
||||||
|
' description: "Splits large markdown documents into smaller, organized files based on sections"',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.ensureDir(path.join(fixtureDir, 'core', 'tasks', 'bmad-shard-doc-skill-prototype'));
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(fixtureDir, 'core', 'tasks', 'bmad-shard-doc-skill-prototype', 'SKILL.md'),
|
||||||
|
[
|
||||||
|
'---',
|
||||||
|
'name: bmad-shard-doc-skill-prototype',
|
||||||
|
'description: Source-authored prototype skill',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'# bmad-shard-doc-skill-prototype',
|
||||||
|
'',
|
||||||
|
'Prototype marker: source-authored-skill',
|
||||||
|
'',
|
||||||
|
'Read and execute from: {project-root}/_bmad/core/tasks/shard-doc.xml',
|
||||||
|
'',
|
||||||
|
'Follow all shard-doc task instructions exactly as written.',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(fixtureDir, '_config', 'task-manifest.csv'),
|
||||||
|
[
|
||||||
|
'name,displayName,description,module,path,standalone,canonicalId',
|
||||||
|
'shard-doc,Shard Document,Test shard-doc task,core,_bmad/core/tasks/shard-doc.xml,true,bmad-shard-doc',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure tool manifest exists to avoid parser edge-cases in some environments.
|
||||||
|
await fs.writeFile(path.join(fixtureDir, '_config', 'tool-manifest.csv'), '');
|
||||||
|
|
||||||
|
return fixtureDir;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test Suite
|
* Test Suite
|
||||||
*/
|
*/
|
||||||
|
|
@ -524,6 +581,72 @@ async function runTests() {
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test 11: Shard-doc Prototype Duplication (Skill-Format Only)
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 11: Shard-doc Prototype Duplication${colors.reset}\n`);
|
||||||
|
|
||||||
|
let tempCodexProjectDir;
|
||||||
|
let tempGeminiProjectDir;
|
||||||
|
let installedBmadDir;
|
||||||
|
try {
|
||||||
|
clearCache();
|
||||||
|
const platformCodes = await loadPlatformCodes();
|
||||||
|
const codexInstaller = platformCodes.platforms.codex?.installer;
|
||||||
|
const geminiInstaller = platformCodes.platforms.gemini?.installer;
|
||||||
|
|
||||||
|
assert(codexInstaller?.skill_format === true, 'Codex installer uses skill_format output');
|
||||||
|
assert(geminiInstaller?.skill_format !== true, 'Gemini installer remains non-skill_format');
|
||||||
|
|
||||||
|
tempCodexProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-codex-prototype-test-'));
|
||||||
|
tempGeminiProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-gemini-prototype-test-'));
|
||||||
|
installedBmadDir = await createShardDocPrototypeFixture();
|
||||||
|
|
||||||
|
const ideManager = new IdeManager();
|
||||||
|
await ideManager.ensureInitialized();
|
||||||
|
|
||||||
|
const codexResult = await ideManager.setup('codex', tempCodexProjectDir, installedBmadDir, {
|
||||||
|
silent: true,
|
||||||
|
selectedModules: ['bmm'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(codexResult.success === true, 'Codex setup succeeds for shard-doc prototype fixture');
|
||||||
|
|
||||||
|
const codexCanonicalSkill = path.join(tempCodexProjectDir, '.agents', 'skills', 'bmad-shard-doc', 'SKILL.md');
|
||||||
|
const codexPrototypeSkill = path.join(tempCodexProjectDir, '.agents', 'skills', 'bmad-shard-doc-skill-prototype', 'SKILL.md');
|
||||||
|
assert(await fs.pathExists(codexCanonicalSkill), 'Codex install writes canonical shard-doc skill');
|
||||||
|
assert(await fs.pathExists(codexPrototypeSkill), 'Codex install writes duplicated shard-doc prototype skill');
|
||||||
|
|
||||||
|
const codexCanonicalContent = await fs.readFile(codexCanonicalSkill, 'utf8');
|
||||||
|
const codexPrototypeContent = await fs.readFile(codexPrototypeSkill, 'utf8');
|
||||||
|
assert(codexCanonicalContent.includes('name: bmad-shard-doc'), 'Canonical shard-doc skill keeps canonical frontmatter name');
|
||||||
|
assert(codexPrototypeContent.includes('name: bmad-shard-doc-skill-prototype'), 'Prototype shard-doc skill uses prototype frontmatter name');
|
||||||
|
assert(codexPrototypeContent.includes('Prototype marker: source-authored-skill'), 'Prototype shard-doc skill is copied from source SKILL.md');
|
||||||
|
|
||||||
|
const geminiResult = await ideManager.setup('gemini', tempGeminiProjectDir, installedBmadDir, {
|
||||||
|
silent: true,
|
||||||
|
selectedModules: ['bmm'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(geminiResult.success === true, 'Gemini setup succeeds for shard-doc prototype fixture');
|
||||||
|
|
||||||
|
const geminiCanonicalTask = path.join(tempGeminiProjectDir, '.gemini', 'commands', 'bmad-shard-doc.toml');
|
||||||
|
const geminiPrototypeTask = path.join(tempGeminiProjectDir, '.gemini', 'commands', 'bmad-shard-doc-skill-prototype.toml');
|
||||||
|
assert(await fs.pathExists(geminiCanonicalTask), 'Gemini install writes canonical shard-doc command artifact');
|
||||||
|
assert(!(await fs.pathExists(geminiPrototypeTask)), 'Gemini install does not write duplicated shard-doc prototype artifact');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
assert(false, 'Shard-doc prototype duplication test succeeds', error.message);
|
||||||
|
} finally {
|
||||||
|
await Promise.allSettled(
|
||||||
|
[tempCodexProjectDir, tempGeminiProjectDir, installedBmadDir]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((dir) => fs.remove(dir)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Summary
|
// Summary
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const prompts = require('../../../lib/prompts');
|
||||||
const { AgentCommandGenerator } = require('./shared/agent-command-generator');
|
const { AgentCommandGenerator } = require('./shared/agent-command-generator');
|
||||||
const { WorkflowCommandGenerator } = require('./shared/workflow-command-generator');
|
const { WorkflowCommandGenerator } = require('./shared/workflow-command-generator');
|
||||||
const { TaskToolCommandGenerator } = require('./shared/task-tool-command-generator');
|
const { TaskToolCommandGenerator } = require('./shared/task-tool-command-generator');
|
||||||
|
const { loadSkillManifest, getPrototypeIds } = require('./shared/skill-manifest');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Config-driven IDE setup handler
|
* Config-driven IDE setup handler
|
||||||
|
|
@ -26,6 +27,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
|
||||||
super(platformCode, platformConfig.name, platformConfig.preferred);
|
super(platformCode, platformConfig.name, platformConfig.preferred);
|
||||||
this.platformConfig = platformConfig;
|
this.platformConfig = platformConfig;
|
||||||
this.installerConfig = platformConfig.installer || null;
|
this.installerConfig = platformConfig.installer || null;
|
||||||
|
this._manifestCache = new Map();
|
||||||
|
|
||||||
// Set configDir from target_dir so base-class detect() works
|
// Set configDir from target_dir so base-class detect() works
|
||||||
if (this.installerConfig?.target_dir) {
|
if (this.installerConfig?.target_dir) {
|
||||||
|
|
@ -115,6 +117,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
|
||||||
*/
|
*/
|
||||||
async installToTarget(projectDir, bmadDir, config, options) {
|
async installToTarget(projectDir, bmadDir, config, options) {
|
||||||
const { target_dir, template_type, artifact_types } = config;
|
const { target_dir, template_type, artifact_types } = config;
|
||||||
|
this._manifestCache = new Map();
|
||||||
|
|
||||||
// Skip targets with explicitly empty artifact_types array
|
// Skip targets with explicitly empty artifact_types array
|
||||||
// This prevents creating empty directories when no artifacts will be written
|
// This prevents creating empty directories when no artifacts will be written
|
||||||
|
|
@ -132,21 +135,21 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
|
||||||
if (!artifact_types || artifact_types.includes('agents')) {
|
if (!artifact_types || artifact_types.includes('agents')) {
|
||||||
const agentGen = new AgentCommandGenerator(this.bmadFolderName);
|
const agentGen = new AgentCommandGenerator(this.bmadFolderName);
|
||||||
const { artifacts } = await agentGen.collectAgentArtifacts(bmadDir, selectedModules);
|
const { artifacts } = await agentGen.collectAgentArtifacts(bmadDir, selectedModules);
|
||||||
results.agents = await this.writeAgentArtifacts(targetPath, artifacts, template_type, config);
|
results.agents = await this.writeAgentArtifacts(targetPath, artifacts, template_type, config, bmadDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install workflows
|
// Install workflows
|
||||||
if (!artifact_types || artifact_types.includes('workflows')) {
|
if (!artifact_types || artifact_types.includes('workflows')) {
|
||||||
const workflowGen = new WorkflowCommandGenerator(this.bmadFolderName);
|
const workflowGen = new WorkflowCommandGenerator(this.bmadFolderName);
|
||||||
const { artifacts } = await workflowGen.collectWorkflowArtifacts(bmadDir);
|
const { artifacts } = await workflowGen.collectWorkflowArtifacts(bmadDir);
|
||||||
results.workflows = await this.writeWorkflowArtifacts(targetPath, artifacts, template_type, config);
|
results.workflows = await this.writeWorkflowArtifacts(targetPath, artifacts, template_type, config, bmadDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install tasks and tools using template system (supports TOML for Gemini, MD for others)
|
// Install tasks and tools using template system (supports TOML for Gemini, MD for others)
|
||||||
if (!artifact_types || artifact_types.includes('tasks') || artifact_types.includes('tools')) {
|
if (!artifact_types || artifact_types.includes('tasks') || artifact_types.includes('tools')) {
|
||||||
const taskToolGen = new TaskToolCommandGenerator(this.bmadFolderName);
|
const taskToolGen = new TaskToolCommandGenerator(this.bmadFolderName);
|
||||||
const { artifacts } = await taskToolGen.collectTaskToolArtifacts(bmadDir);
|
const { artifacts } = await taskToolGen.collectTaskToolArtifacts(bmadDir);
|
||||||
const taskToolResult = await this.writeTaskToolArtifacts(targetPath, artifacts, template_type, config);
|
const taskToolResult = await this.writeTaskToolArtifacts(targetPath, artifacts, template_type, config, bmadDir);
|
||||||
results.tasks = taskToolResult.tasks || 0;
|
results.tasks = taskToolResult.tasks || 0;
|
||||||
results.tools = taskToolResult.tools || 0;
|
results.tools = taskToolResult.tools || 0;
|
||||||
}
|
}
|
||||||
|
|
@ -187,7 +190,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
|
||||||
* @param {Object} config - Installation configuration
|
* @param {Object} config - Installation configuration
|
||||||
* @returns {Promise<number>} Count of artifacts written
|
* @returns {Promise<number>} Count of artifacts written
|
||||||
*/
|
*/
|
||||||
async writeAgentArtifacts(targetPath, artifacts, templateType, config = {}) {
|
async writeAgentArtifacts(targetPath, artifacts, templateType, config = {}, bmadDir = null) {
|
||||||
// Try to load platform-specific template, fall back to default-agent
|
// Try to load platform-specific template, fall back to default-agent
|
||||||
const { content: template, extension } = await this.loadTemplate(templateType, 'agent', config, 'default-agent');
|
const { content: template, extension } = await this.loadTemplate(templateType, 'agent', config, 'default-agent');
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
@ -197,7 +200,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
|
||||||
const filename = this.generateFilename(artifact, 'agent', extension);
|
const filename = this.generateFilename(artifact, 'agent', extension);
|
||||||
|
|
||||||
if (config.skill_format) {
|
if (config.skill_format) {
|
||||||
await this.writeSkillFile(targetPath, artifact, content);
|
await this.writeSkillFile(targetPath, artifact, content, bmadDir);
|
||||||
} else {
|
} else {
|
||||||
const filePath = path.join(targetPath, filename);
|
const filePath = path.join(targetPath, filename);
|
||||||
await this.writeFile(filePath, content);
|
await this.writeFile(filePath, content);
|
||||||
|
|
@ -216,7 +219,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
|
||||||
* @param {Object} config - Installation configuration
|
* @param {Object} config - Installation configuration
|
||||||
* @returns {Promise<number>} Count of artifacts written
|
* @returns {Promise<number>} Count of artifacts written
|
||||||
*/
|
*/
|
||||||
async writeWorkflowArtifacts(targetPath, artifacts, templateType, config = {}) {
|
async writeWorkflowArtifacts(targetPath, artifacts, templateType, config = {}, bmadDir = null) {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
for (const artifact of artifacts) {
|
for (const artifact of artifacts) {
|
||||||
|
|
@ -235,7 +238,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
|
||||||
const filename = this.generateFilename(artifact, 'workflow', extension);
|
const filename = this.generateFilename(artifact, 'workflow', extension);
|
||||||
|
|
||||||
if (config.skill_format) {
|
if (config.skill_format) {
|
||||||
await this.writeSkillFile(targetPath, artifact, content);
|
await this.writeSkillFile(targetPath, artifact, content, bmadDir);
|
||||||
} else {
|
} else {
|
||||||
const filePath = path.join(targetPath, filename);
|
const filePath = path.join(targetPath, filename);
|
||||||
await this.writeFile(filePath, content);
|
await this.writeFile(filePath, content);
|
||||||
|
|
@ -255,7 +258,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
|
||||||
* @param {Object} config - Installation configuration
|
* @param {Object} config - Installation configuration
|
||||||
* @returns {Promise<Object>} Counts of tasks and tools written
|
* @returns {Promise<Object>} Counts of tasks and tools written
|
||||||
*/
|
*/
|
||||||
async writeTaskToolArtifacts(targetPath, artifacts, templateType, config = {}) {
|
async writeTaskToolArtifacts(targetPath, artifacts, templateType, config = {}, bmadDir = null) {
|
||||||
let taskCount = 0;
|
let taskCount = 0;
|
||||||
let toolCount = 0;
|
let toolCount = 0;
|
||||||
|
|
||||||
|
|
@ -283,7 +286,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
|
||||||
const filename = this.generateFilename(artifact, artifact.type, extension);
|
const filename = this.generateFilename(artifact, artifact.type, extension);
|
||||||
|
|
||||||
if (config.skill_format) {
|
if (config.skill_format) {
|
||||||
await this.writeSkillFile(targetPath, artifact, content);
|
await this.writeSkillFile(targetPath, artifact, content, bmadDir);
|
||||||
} else {
|
} else {
|
||||||
const filePath = path.join(targetPath, filename);
|
const filePath = path.join(targetPath, filename);
|
||||||
await this.writeFile(filePath, content);
|
await this.writeFile(filePath, content);
|
||||||
|
|
@ -478,7 +481,7 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
|
||||||
* @param {Object} artifact - Artifact data
|
* @param {Object} artifact - Artifact data
|
||||||
* @param {string} content - Rendered template content
|
* @param {string} content - Rendered template content
|
||||||
*/
|
*/
|
||||||
async writeSkillFile(targetPath, artifact, content) {
|
async writeSkillFile(targetPath, artifact, content, bmadDir = null) {
|
||||||
const { resolveSkillName } = require('./shared/path-utils');
|
const { resolveSkillName } = require('./shared/path-utils');
|
||||||
|
|
||||||
// Get the skill name (prefers canonicalId, falls back to path-derived) and remove .md
|
// Get the skill name (prefers canonicalId, falls back to path-derived) and remove .md
|
||||||
|
|
@ -497,6 +500,131 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
|
||||||
const skillContent = this.transformToSkillFormat(content, skillName);
|
const skillContent = this.transformToSkillFormat(content, skillName);
|
||||||
|
|
||||||
await this.writeFile(path.join(skillDir, 'SKILL.md'), skillContent);
|
await this.writeFile(path.join(skillDir, 'SKILL.md'), skillContent);
|
||||||
|
|
||||||
|
if (!bmadDir) return;
|
||||||
|
|
||||||
|
const sourceRef = this.resolveArtifactSourceRef(artifact, bmadDir);
|
||||||
|
const duplicatePrototypeIds = await this.getPrototypeSkillIdsForArtifact(artifact, bmadDir, sourceRef);
|
||||||
|
for (const prototypeId of duplicatePrototypeIds) {
|
||||||
|
if (!this.isSafeSkillFolderName(prototypeId)) continue;
|
||||||
|
if (prototypeId === skillName) continue;
|
||||||
|
const prototypeDir = path.join(targetPath, prototypeId);
|
||||||
|
await this.ensureDir(prototypeDir);
|
||||||
|
const sourceSkillContent = await this.readPrototypeSourceSkillContent(sourceRef, prototypeId);
|
||||||
|
const prototypeContent = sourceSkillContent ?? this.transformToSkillFormat(content, prototypeId);
|
||||||
|
await this.writeFile(path.join(prototypeDir, 'SKILL.md'), prototypeContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve duplicate prototype IDs for an artifact from the installed bmad source tree.
|
||||||
|
* @param {Object} artifact - Artifact metadata
|
||||||
|
* @param {string} bmadDir - Installed bmad directory
|
||||||
|
* @returns {Promise<string[]>} Prototype skill IDs
|
||||||
|
*/
|
||||||
|
async getPrototypeSkillIdsForArtifact(artifact, bmadDir, sourceRefOverride = null) {
|
||||||
|
const sourceRef = sourceRefOverride ?? this.resolveArtifactSourceRef(artifact, bmadDir);
|
||||||
|
if (!sourceRef) return [];
|
||||||
|
|
||||||
|
let manifest = this._manifestCache.get(sourceRef.dirPath);
|
||||||
|
if (manifest === undefined) {
|
||||||
|
manifest = await loadSkillManifest(sourceRef.dirPath);
|
||||||
|
this._manifestCache.set(sourceRef.dirPath, manifest);
|
||||||
|
}
|
||||||
|
return getPrototypeIds(manifest, sourceRef.filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read prototype SKILL.md content directly from source when present.
|
||||||
|
* This enables copy-as-is installation for native skill prototypes.
|
||||||
|
* @param {{dirPath: string, filename: string}|null} sourceRef - Resolved source reference
|
||||||
|
* @param {string} prototypeId - Prototype skill ID
|
||||||
|
* @returns {Promise<string|null>} Source SKILL.md content or null
|
||||||
|
*/
|
||||||
|
async readPrototypeSourceSkillContent(sourceRef, prototypeId) {
|
||||||
|
if (!sourceRef || !this.isSafeSkillFolderName(prototypeId)) return null;
|
||||||
|
|
||||||
|
const resolvedSourceDir = path.resolve(sourceRef.dirPath);
|
||||||
|
const sourceSkillPath = path.resolve(resolvedSourceDir, prototypeId, 'SKILL.md');
|
||||||
|
const relativeToSourceDir = path.relative(resolvedSourceDir, sourceSkillPath);
|
||||||
|
|
||||||
|
if (
|
||||||
|
relativeToSourceDir === '..' ||
|
||||||
|
relativeToSourceDir.startsWith(`..${path.sep}`) ||
|
||||||
|
path.isAbsolute(relativeToSourceDir)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(sourceSkillPath))) return null;
|
||||||
|
return fs.readFile(sourceSkillPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate skill folder names used for source lookup.
|
||||||
|
* @param {string} skillId - Candidate skill ID
|
||||||
|
* @returns {boolean} True if safe to use as a folder name segment
|
||||||
|
*/
|
||||||
|
isSafeSkillFolderName(skillId) {
|
||||||
|
return (
|
||||||
|
typeof skillId === 'string' &&
|
||||||
|
skillId.length > 0 &&
|
||||||
|
skillId !== '.' &&
|
||||||
|
skillId !== '..' &&
|
||||||
|
!skillId.includes('/') &&
|
||||||
|
!skillId.includes('\\')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the artifact source directory + filename within installed bmad tree.
|
||||||
|
* @param {Object} artifact - Artifact metadata
|
||||||
|
* @param {string} bmadDir - Installed bmad directory
|
||||||
|
* @returns {{dirPath: string, filename: string}|null}
|
||||||
|
*/
|
||||||
|
resolveArtifactSourceRef(artifact, bmadDir) {
|
||||||
|
let sourcePath = '';
|
||||||
|
|
||||||
|
if ((artifact.type === 'task' || artifact.type === 'tool') && artifact.path) {
|
||||||
|
sourcePath = artifact.path;
|
||||||
|
} else if (artifact.type === 'workflow-command' && artifact.workflowPath) {
|
||||||
|
sourcePath = artifact.workflowPath;
|
||||||
|
} else if (artifact.type === 'agent-launcher' && artifact.agentPath) {
|
||||||
|
sourcePath = artifact.agentPath;
|
||||||
|
} else if (typeof artifact.sourcePath === 'string') {
|
||||||
|
sourcePath = artifact.sourcePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourcePath) return null;
|
||||||
|
|
||||||
|
let normalized = sourcePath.replaceAll('\\', '/');
|
||||||
|
if (path.isAbsolute(normalized)) {
|
||||||
|
normalized = path.relative(bmadDir, normalized).replaceAll('\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prefix of [`${this.bmadFolderName}/`, '_bmad/', 'bmad/']) {
|
||||||
|
if (normalized.startsWith(prefix)) {
|
||||||
|
normalized = normalized.slice(prefix.length);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized = normalized.replace(/^\/+/, '');
|
||||||
|
if (!normalized || normalized.startsWith('..')) return null;
|
||||||
|
|
||||||
|
const filename = path.basename(normalized);
|
||||||
|
if (!filename || filename === '.' || filename === '..') return null;
|
||||||
|
|
||||||
|
const resolvedBmadDir = path.resolve(bmadDir);
|
||||||
|
const relativeDir = path.dirname(normalized);
|
||||||
|
const dirPath = relativeDir === '.' ? resolvedBmadDir : path.resolve(resolvedBmadDir, relativeDir);
|
||||||
|
const pathFromBmadRoot = path.relative(resolvedBmadDir, dirPath);
|
||||||
|
|
||||||
|
if (pathFromBmadRoot === '..' || pathFromBmadRoot.startsWith(`..${path.sep}`) || path.isAbsolute(pathFromBmadRoot)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dirPath, filename };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -266,7 +266,7 @@ function parseUnderscoreName(filename) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the skill name for an artifact.
|
* Resolve the skill name for an artifact.
|
||||||
* Prefers canonicalId from a bmad-skill-manifest.yaml sidecar when available,
|
* Prefers canonicalId from a skill manifest sidecar when available,
|
||||||
* falling back to the path-derived name from toDashPath().
|
* falling back to the path-derived name from toDashPath().
|
||||||
*
|
*
|
||||||
* @param {Object} artifact - Artifact object (must have relativePath; may have canonicalId)
|
* @param {Object} artifact - Artifact object (must have relativePath; may have canonicalId)
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,32 @@ const path = require('node:path');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
|
|
||||||
|
const SKILL_MANIFEST_FILENAMES = ['skill-manifest.yaml', 'bmad-skill-manifest.yaml', 'manifest.yaml'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load bmad-skill-manifest.yaml from a directory.
|
* Load skill manifest from a directory.
|
||||||
* Single-entry manifests (canonicalId at top level) apply to all files in the directory.
|
* Single-entry manifests (canonicalId at top level) apply to all files in the directory.
|
||||||
* Multi-entry manifests are keyed by source filename.
|
* Multi-entry manifests are keyed by source filename.
|
||||||
* @param {string} dirPath - Directory to check for bmad-skill-manifest.yaml
|
* @param {string} dirPath - Directory to check for supported manifest filenames
|
||||||
* @returns {Object|null} Parsed manifest or null
|
* @returns {Object|null} Parsed manifest or null
|
||||||
*/
|
*/
|
||||||
async function loadSkillManifest(dirPath) {
|
async function loadSkillManifest(dirPath) {
|
||||||
const manifestPath = path.join(dirPath, 'bmad-skill-manifest.yaml');
|
for (const manifestFilename of SKILL_MANIFEST_FILENAMES) {
|
||||||
|
const manifestPath = path.join(dirPath, manifestFilename);
|
||||||
try {
|
try {
|
||||||
if (!(await fs.pathExists(manifestPath))) return null;
|
if (!(await fs.pathExists(manifestPath))) continue;
|
||||||
const content = await fs.readFile(manifestPath, 'utf8');
|
const content = await fs.readFile(manifestPath, 'utf8');
|
||||||
const parsed = yaml.parse(content);
|
const parsed = yaml.parse(content);
|
||||||
if (!parsed || typeof parsed !== 'object') return null;
|
if (!parsed || typeof parsed !== 'object') return null;
|
||||||
if (parsed.canonicalId) return { __single: parsed };
|
if (parsed.canonicalId) return { __single: parsed };
|
||||||
return parsed;
|
return parsed;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Warning: Failed to parse bmad-skill-manifest.yaml in ${dirPath}: ${error.message}`);
|
console.warn(`Warning: Failed to parse ${manifestFilename} in ${dirPath}: ${error.message}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -31,18 +37,65 @@ async function loadSkillManifest(dirPath) {
|
||||||
* @returns {string} canonicalId or empty string
|
* @returns {string} canonicalId or empty string
|
||||||
*/
|
*/
|
||||||
function getCanonicalId(manifest, filename) {
|
function getCanonicalId(manifest, filename) {
|
||||||
if (!manifest) return '';
|
const manifestEntry = resolveManifestEntry(manifest, filename);
|
||||||
|
return manifestEntry?.canonicalId || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get duplicate prototype skill IDs for a specific file from a loaded skill manifest.
|
||||||
|
* Prototype IDs are optional and only used by skill-format installers.
|
||||||
|
* @param {Object|null} manifest - Loaded manifest (from loadSkillManifest)
|
||||||
|
* @param {string} filename - Source filename to look up
|
||||||
|
* @returns {string[]} Duplicate prototype IDs
|
||||||
|
*/
|
||||||
|
function getPrototypeIds(manifest, filename) {
|
||||||
|
const manifestEntry = resolveManifestEntry(manifest, filename);
|
||||||
|
if (!manifestEntry) return [];
|
||||||
|
|
||||||
|
// Support one canonical field name plus temporary/fallback aliases during transition.
|
||||||
|
const rawIds = manifestEntry.prototypeIds ?? manifestEntry.skillPrototypeIds ?? manifestEntry.duplicateSkillIds ?? [];
|
||||||
|
return normalizeIdList(rawIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a manifest entry for a source filename.
|
||||||
|
* Handles single-entry manifests and extension fallbacks.
|
||||||
|
* @param {Object|null} manifest - Loaded manifest
|
||||||
|
* @param {string} filename - Source filename
|
||||||
|
* @returns {Object|null} Manifest entry object
|
||||||
|
*/
|
||||||
|
function resolveManifestEntry(manifest, filename) {
|
||||||
|
if (!manifest) return null;
|
||||||
// Single-entry manifest applies to all files in the directory
|
// Single-entry manifest applies to all files in the directory
|
||||||
if (manifest.__single) return manifest.__single.canonicalId || '';
|
if (manifest.__single) return manifest.__single;
|
||||||
// Multi-entry: look up by filename directly
|
// Multi-entry: look up by filename directly
|
||||||
if (manifest[filename]) return manifest[filename].canonicalId || '';
|
if (manifest[filename]) return manifest[filename];
|
||||||
// Fallback: try alternate extensions for compiled files
|
// Fallback: try alternate extensions for compiled files
|
||||||
const baseName = filename.replace(/\.(md|xml)$/i, '');
|
const baseName = filename.replace(/\.(md|xml)$/i, '');
|
||||||
const agentKey = `${baseName}.agent.yaml`;
|
const agentKey = `${baseName}.agent.yaml`;
|
||||||
if (manifest[agentKey]) return manifest[agentKey].canonicalId || '';
|
if (manifest[agentKey]) return manifest[agentKey];
|
||||||
const xmlKey = `${baseName}.xml`;
|
const xmlKey = `${baseName}.xml`;
|
||||||
if (manifest[xmlKey]) return manifest[xmlKey].canonicalId || '';
|
if (manifest[xmlKey]) return manifest[xmlKey];
|
||||||
return '';
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { loadSkillManifest, getCanonicalId };
|
/**
|
||||||
|
* Normalize possible scalar/array ID list formats to a unique string array.
|
||||||
|
* @param {string|string[]|unknown} ids - Candidate IDs
|
||||||
|
* @returns {string[]} Normalized IDs
|
||||||
|
*/
|
||||||
|
function normalizeIdList(ids) {
|
||||||
|
const asArray = Array.isArray(ids) ? ids : typeof ids === 'string' ? [ids] : [];
|
||||||
|
const unique = new Set();
|
||||||
|
|
||||||
|
for (const id of asArray) {
|
||||||
|
if (typeof id !== 'string') continue;
|
||||||
|
const trimmed = id.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
unique.add(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...unique];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { loadSkillManifest, getCanonicalId, getPrototypeIds };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue