From 3755e72f61ca085cc1d9383c0fac9eccf06177d2 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Sun, 8 Mar 2026 16:44:10 +0000 Subject: [PATCH 1/5] feat(installer): add lean shard-doc skill prototype install PoC --- .../bmad-shard-doc-skill-prototype/SKILL.md | 12 ++ .../bmad-skill-manifest.yaml | 3 + test/test-installation-components.js | 119 ++++++++++++++++++ .../cli/installers/lib/ide/_config-driven.js | 92 ++++++++++++-- .../lib/ide/shared/skill-manifest.js | 26 ++-- 5 files changed, 235 insertions(+), 17 deletions(-) create mode 100644 src/core/tasks/bmad-shard-doc-skill-prototype/SKILL.md create mode 100644 src/core/tasks/bmad-shard-doc-skill-prototype/bmad-skill-manifest.yaml diff --git a/src/core/tasks/bmad-shard-doc-skill-prototype/SKILL.md b/src/core/tasks/bmad-shard-doc-skill-prototype/SKILL.md new file mode 100644 index 000000000..afa37c831 --- /dev/null +++ b/src/core/tasks/bmad-shard-doc-skill-prototype/SKILL.md @@ -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. diff --git a/src/core/tasks/bmad-shard-doc-skill-prototype/bmad-skill-manifest.yaml b/src/core/tasks/bmad-shard-doc-skill-prototype/bmad-skill-manifest.yaml new file mode 100644 index 000000000..29ce5aeb6 --- /dev/null +++ b/src/core/tasks/bmad-shard-doc-skill-prototype/bmad-skill-manifest.yaml @@ -0,0 +1,3 @@ +canonicalId: bmad-shard-doc-skill-prototype +type: task +description: "Prototype native skill wrapper for shard-doc during installer transition" diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 56f37b365..a2e930aee 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -81,6 +81,61 @@ async function createTestBmadFixture() { 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'), + 'Test objective\n', + ); + + await fs.writeFile( + path.join(fixtureDir, 'core', 'tasks', 'bmad-skill-manifest.yaml'), + [ + 'shard-doc.xml:', + ' canonicalId: bmad-shard-doc', + ' 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 */ @@ -827,6 +882,70 @@ async function runTests() { 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 uses skill_format output'); + + 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 geminiCanonicalSkill = path.join(tempGeminiProjectDir, '.gemini', 'skills', 'bmad-shard-doc', 'SKILL.md'); + const geminiPrototypeSkill = path.join(tempGeminiProjectDir, '.gemini', 'skills', 'bmad-shard-doc-skill-prototype', 'SKILL.md'); + assert(await fs.pathExists(geminiCanonicalSkill), 'Gemini install writes canonical shard-doc skill'); + assert(await fs.pathExists(geminiPrototypeSkill), 'Gemini install writes duplicated shard-doc prototype skill'); + } 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))); + } + // Test 17: GitHub Copilot Native Skills Install // ============================================================ console.log(`${colors.yellow}Test Suite 17: GitHub Copilot Native Skills${colors.reset}\n`); diff --git a/tools/cli/installers/lib/ide/_config-driven.js b/tools/cli/installers/lib/ide/_config-driven.js index 0a311a68d..8595033a0 100644 --- a/tools/cli/installers/lib/ide/_config-driven.js +++ b/tools/cli/installers/lib/ide/_config-driven.js @@ -132,21 +132,21 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { if (!artifact_types || artifact_types.includes('agents')) { const agentGen = new AgentCommandGenerator(this.bmadFolderName); 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 if (!artifact_types || artifact_types.includes('workflows')) { const workflowGen = new WorkflowCommandGenerator(this.bmadFolderName); 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) if (!artifact_types || artifact_types.includes('tasks') || artifact_types.includes('tools')) { const taskToolGen = new TaskToolCommandGenerator(this.bmadFolderName); 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.tools = taskToolResult.tools || 0; } @@ -187,7 +187,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { * @param {Object} config - Installation configuration * @returns {Promise} 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 const { content: template, extension } = await this.loadTemplate(templateType, 'agent', config, 'default-agent'); let count = 0; @@ -197,7 +197,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { const filename = this.generateFilename(artifact, 'agent', extension); if (config.skill_format) { - await this.writeSkillFile(targetPath, artifact, content); + await this.writeSkillFile(targetPath, artifact, content, bmadDir); } else { const filePath = path.join(targetPath, filename); await this.writeFile(filePath, content); @@ -216,7 +216,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { * @param {Object} config - Installation configuration * @returns {Promise} Count of artifacts written */ - async writeWorkflowArtifacts(targetPath, artifacts, templateType, config = {}) { + async writeWorkflowArtifacts(targetPath, artifacts, templateType, config = {}, bmadDir = null) { let count = 0; for (const artifact of artifacts) { @@ -235,7 +235,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { const filename = this.generateFilename(artifact, 'workflow', extension); if (config.skill_format) { - await this.writeSkillFile(targetPath, artifact, content); + await this.writeSkillFile(targetPath, artifact, content, bmadDir); } else { const filePath = path.join(targetPath, filename); await this.writeFile(filePath, content); @@ -255,7 +255,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { * @param {Object} config - Installation configuration * @returns {Promise} Counts of tasks and tools written */ - async writeTaskToolArtifacts(targetPath, artifacts, templateType, config = {}) { + async writeTaskToolArtifacts(targetPath, artifacts, templateType, config = {}, bmadDir = null) { let taskCount = 0; let toolCount = 0; @@ -283,7 +283,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { const filename = this.generateFilename(artifact, artifact.type, extension); if (config.skill_format) { - await this.writeSkillFile(targetPath, artifact, content); + await this.writeSkillFile(targetPath, artifact, content, bmadDir); } else { const filePath = path.join(targetPath, filename); await this.writeFile(filePath, content); @@ -478,7 +478,7 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} * @param {Object} artifact - Artifact data * @param {string} content - Rendered template content */ - async writeSkillFile(targetPath, artifact, content) { + async writeSkillFile(targetPath, artifact, content, bmadDir = null) { const { resolveSkillName } = require('./shared/path-utils'); // Get the skill name (prefers canonicalId, falls back to path-derived) and remove .md @@ -497,6 +497,78 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} const skillContent = this.transformToSkillFormat(content, skillName); await this.writeFile(path.join(skillDir, 'SKILL.md'), skillContent); + + await this.writeShardDocPrototypeSkill(targetPath, artifact, bmadDir, skillName); + } + + /** + * Copy shard-doc prototype skill during transition when installing skill-format targets. + * Keeps scope literal for the first PoC without introducing generalized prototype linkage. + * @param {string} targetPath - Base skills directory + * @param {Object} artifact - Artifact metadata + * @param {string|null} bmadDir - Installed bmad directory + * @param {string} skillName - Canonical skill name being written + */ + async writeShardDocPrototypeSkill(targetPath, artifact, bmadDir, skillName) { + if (!bmadDir || skillName !== 'bmad-shard-doc') return; + + const sourceRef = this.resolveArtifactSourceRef(artifact, bmadDir); + if (!sourceRef) return; + + const prototypeId = 'bmad-shard-doc-skill-prototype'; + const sourceSkillContent = await this.readPrototypeSourceSkillContent(sourceRef, prototypeId); + if (!sourceSkillContent) return; + + const prototypeDir = path.join(targetPath, prototypeId); + await this.ensureDir(prototypeDir); + await this.writeFile(path.join(prototypeDir, 'SKILL.md'), sourceSkillContent); + } + + /** + * 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} Source SKILL.md content or null + */ + async readPrototypeSourceSkillContent(sourceRef, prototypeId) { + if (!sourceRef || typeof prototypeId !== 'string' || !prototypeId.trim()) return null; + const sourceSkillPath = path.join(sourceRef.dirPath, prototypeId, 'SKILL.md'); + if (!(await fs.pathExists(sourceSkillPath))) return null; + return fs.readFile(sourceSkillPath, 'utf8'); + } + + /** + * 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) { + if (artifact.type !== 'task' || !artifact.path) return null; + const sourcePath = artifact.path; + + 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 relativeDir = path.dirname(normalized); + const dirPath = relativeDir === '.' ? bmadDir : path.join(bmadDir, relativeDir); + return { dirPath, filename }; } /** diff --git a/tools/cli/installers/lib/ide/shared/skill-manifest.js b/tools/cli/installers/lib/ide/shared/skill-manifest.js index ff940242f..48a949368 100644 --- a/tools/cli/installers/lib/ide/shared/skill-manifest.js +++ b/tools/cli/installers/lib/ide/shared/skill-manifest.js @@ -3,7 +3,7 @@ const fs = require('fs-extra'); const yaml = require('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. * Multi-entry manifests are keyed by source filename. * @param {string} dirPath - Directory to check for bmad-skill-manifest.yaml @@ -31,18 +31,30 @@ async function loadSkillManifest(dirPath) { * @returns {string} canonicalId or empty string */ function getCanonicalId(manifest, filename) { - if (!manifest) return ''; + const manifestEntry = resolveManifestEntry(manifest, filename); + return manifestEntry?.canonicalId || ''; +} + +/** + * 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 - if (manifest.__single) return manifest.__single.canonicalId || ''; + if (manifest.__single) return manifest.__single; // 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 const baseName = filename.replace(/\.(md|xml)$/i, ''); const agentKey = `${baseName}.agent.yaml`; - if (manifest[agentKey]) return manifest[agentKey].canonicalId || ''; + if (manifest[agentKey]) return manifest[agentKey]; const xmlKey = `${baseName}.xml`; - if (manifest[xmlKey]) return manifest[xmlKey].canonicalId || ''; - return ''; + if (manifest[xmlKey]) return manifest[xmlKey]; + return null; } module.exports = { loadSkillManifest, getCanonicalId }; From c56af3296e5f427dd7e23f31a11e368d4ea6355e Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Sun, 8 Mar 2026 17:17:58 +0000 Subject: [PATCH 2/5] fix(installer): tighten lean shard-doc review follow-ups --- test/test-installation-components.js | 93 +++++++++++++------ .../cli/installers/lib/ide/_config-driven.js | 6 +- .../lib/ide/shared/skill-manifest.js | 37 ++------ 3 files changed, 77 insertions(+), 59 deletions(-) diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 27d7cf5a1..2ffbcd3cb 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -17,6 +17,7 @@ const fs = require('fs-extra'); const { YamlXmlBuilder } = require('../tools/cli/lib/yaml-xml-builder'); const { ManifestGenerator } = require('../tools/cli/installers/lib/core/manifest-generator'); const { IdeManager } = require('../tools/cli/installers/lib/ide/manager'); +const { ConfigDrivenIdeSetup } = require('../tools/cli/installers/lib/ide/_config-driven'); const { clearCache, loadPlatformCodes } = require('../tools/cli/installers/lib/ide/platform-codes'); // ANSI colors @@ -120,6 +121,15 @@ async function createShardDocPrototypeFixture() { '', ].join('\n'), ); + await fs.writeFile( + path.join(fixtureDir, 'core', 'tasks', 'bmad-shard-doc-skill-prototype', 'bmad-skill-manifest.yaml'), + [ + 'canonicalId: bmad-shard-doc-skill-prototype', + 'type: task', + 'description: "Prototype native skill wrapper for shard-doc during installer transition"', + '', + ].join('\n'), + ); await fs.writeFile( path.join(fixtureDir, '_config', 'task-manifest.csv'), @@ -882,68 +892,97 @@ async function runTests() { console.log(''); // ============================================================ - // Test 11: Shard-doc Prototype Duplication (Skill-Format Only) + // Test 11: Shard-doc Prototype Duplication (Skill/Non-Skill Scope) // ============================================================ console.log(`${colors.yellow}Test Suite 11: Shard-doc Prototype Duplication${colors.reset}\n`); - let tempCodexProjectDir; - let tempGeminiProjectDir; + let tempSkillProjectDir; + let tempNonSkillProjectDir; let installedBmadDir; try { clearCache(); const platformCodes = await loadPlatformCodes(); - const codexInstaller = platformCodes.platforms.codex?.installer; - const geminiInstaller = platformCodes.platforms.gemini?.installer; + const skillFormatEntry = Object.entries(platformCodes.platforms || {}).find(([_, platform]) => { + const installer = platform?.installer; + if (!installer || installer.skill_format !== true || typeof installer.target_dir !== 'string') return false; + if (Array.isArray(installer.artifact_types) && !installer.artifact_types.includes('tasks')) return false; + return true; + }); - assert(codexInstaller?.skill_format === true, 'Codex installer uses skill_format output'); - assert(geminiInstaller?.skill_format === true, 'Gemini installer uses skill_format output'); + assert(Boolean(skillFormatEntry), 'Found a skill_format platform that installs task artifacts'); + if (!skillFormatEntry) throw new Error('No suitable skill_format platform found for shard-doc prototype test'); - tempCodexProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-codex-prototype-test-')); - tempGeminiProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-gemini-prototype-test-')); + const [skillFormatPlatformCode, skillFormatPlatform] = skillFormatEntry; + const skillInstaller = skillFormatPlatform.installer; + + tempSkillProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-skill-prototype-test-')); + tempNonSkillProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-nonskill-prototype-test-')); installedBmadDir = await createShardDocPrototypeFixture(); const ideManager = new IdeManager(); await ideManager.ensureInitialized(); - const codexResult = await ideManager.setup('codex', tempCodexProjectDir, installedBmadDir, { + const skillResult = await ideManager.setup(skillFormatPlatformCode, tempSkillProjectDir, installedBmadDir, { silent: true, selectedModules: ['bmm'], }); - assert(codexResult.success === true, 'Codex setup succeeds for shard-doc prototype fixture'); + assert(skillResult.success === true, `${skillFormatPlatformCode} 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 canonicalSkillPath = path.join(tempSkillProjectDir, skillInstaller.target_dir, 'bmad-shard-doc', 'SKILL.md'); + const prototypeSkillPath = path.join(tempSkillProjectDir, skillInstaller.target_dir, 'bmad-shard-doc-skill-prototype', 'SKILL.md'); + assert(await fs.pathExists(canonicalSkillPath), `${skillFormatPlatformCode} install writes canonical shard-doc skill`); + assert(await fs.pathExists(prototypeSkillPath), `${skillFormatPlatformCode} 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'); + const canonicalSkillContent = await fs.readFile(canonicalSkillPath, 'utf8'); + const prototypeSkillContent = await fs.readFile(prototypeSkillPath, 'utf8'); + + assert(canonicalSkillContent.includes('name: bmad-shard-doc'), 'Canonical shard-doc skill keeps canonical frontmatter name'); assert( - codexPrototypeContent.includes('name: bmad-shard-doc-skill-prototype'), + canonicalSkillContent.includes('Read the entire task file at: {project-root}/_bmad/core/tasks/shard-doc.xml'), + 'Canonical shard-doc skill points to shard-doc.xml', + ); + assert( + prototypeSkillContent.includes('name: bmad-shard-doc-skill-prototype'), 'Prototype shard-doc skill uses prototype frontmatter name', ); assert( - codexPrototypeContent.includes('Prototype marker: source-authored-skill'), + prototypeSkillContent.includes('Prototype marker: source-authored-skill'), 'Prototype shard-doc skill is copied from source SKILL.md', ); + assert( + prototypeSkillContent.includes('Read and execute from: {project-root}/_bmad/core/tasks/shard-doc.xml'), + 'Prototype shard-doc skill preserves source-authored shard-doc.xml reference', + ); - const geminiResult = await ideManager.setup('gemini', tempGeminiProjectDir, installedBmadDir, { + const nonSkillInstaller = { + ...skillInstaller, + target_dir: '.legacy/prototype-commands', + skill_format: false, + artifact_types: ['tasks'], + }; + const nonSkillHandler = new ConfigDrivenIdeSetup('prototype-nonskill-test', { + name: 'Prototype Non-Skill Test', + preferred: false, + installer: nonSkillInstaller, + }); + const nonSkillResult = await nonSkillHandler.setup(tempNonSkillProjectDir, installedBmadDir, { silent: true, selectedModules: ['bmm'], }); - assert(geminiResult.success === true, 'Gemini setup succeeds for shard-doc prototype fixture'); + assert(nonSkillResult.success === true, 'Synthetic non-skill-format setup succeeds for shard-doc prototype fixture'); - const geminiCanonicalSkill = path.join(tempGeminiProjectDir, '.gemini', 'skills', 'bmad-shard-doc', 'SKILL.md'); - const geminiPrototypeSkill = path.join(tempGeminiProjectDir, '.gemini', 'skills', 'bmad-shard-doc-skill-prototype', 'SKILL.md'); - assert(await fs.pathExists(geminiCanonicalSkill), 'Gemini install writes canonical shard-doc skill'); - assert(await fs.pathExists(geminiPrototypeSkill), 'Gemini install writes duplicated shard-doc prototype skill'); + const nonSkillTargetDir = path.join(tempNonSkillProjectDir, nonSkillInstaller.target_dir); + const nonSkillEntries = await fs.readdir(nonSkillTargetDir); + const hasCanonical = nonSkillEntries.some((entry) => /^bmad-shard-doc(\.|$)/.test(entry)); + const hasPrototype = nonSkillEntries.some((entry) => /^bmad-shard-doc-skill-prototype(\.|$)/.test(entry)); + assert(hasCanonical, 'Non-skill-format install writes canonical shard-doc artifact'); + assert(!hasPrototype, 'Non-skill-format 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))); + await Promise.allSettled([tempSkillProjectDir, tempNonSkillProjectDir, installedBmadDir].filter(Boolean).map((dir) => fs.remove(dir))); } // Test 17: GitHub Copilot Native Skills Install diff --git a/tools/cli/installers/lib/ide/_config-driven.js b/tools/cli/installers/lib/ide/_config-driven.js index c21c0525d..980d65e51 100644 --- a/tools/cli/installers/lib/ide/_config-driven.js +++ b/tools/cli/installers/lib/ide/_config-driven.js @@ -558,6 +558,7 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} resolveArtifactSourceRef(artifact, bmadDir) { if (artifact.type !== 'task' || !artifact.path) return null; const sourcePath = artifact.path; + const resolvedBmadDir = path.resolve(bmadDir); let normalized = sourcePath.replaceAll('\\', '/'); if (path.isAbsolute(normalized)) { @@ -578,7 +579,10 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} if (!filename || filename === '.' || filename === '..') return null; const relativeDir = path.dirname(normalized); - const dirPath = relativeDir === '.' ? bmadDir : path.join(bmadDir, relativeDir); + const dirPath = relativeDir === '.' ? resolvedBmadDir : path.resolve(resolvedBmadDir, relativeDir); + const relativeToRoot = path.relative(resolvedBmadDir, dirPath); + if (relativeToRoot === '..' || relativeToRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeToRoot)) return null; + return { dirPath, filename }; } diff --git a/tools/cli/installers/lib/ide/shared/skill-manifest.js b/tools/cli/installers/lib/ide/shared/skill-manifest.js index f80235ab0..c487891c6 100644 --- a/tools/cli/installers/lib/ide/shared/skill-manifest.js +++ b/tools/cli/installers/lib/ide/shared/skill-manifest.js @@ -37,7 +37,7 @@ function getCanonicalId(manifest, filename) { /** * Resolve a manifest entry for a source filename. - * Handles single-entry manifests and extension fallbacks. + * Strict by default: supports single-entry manifests and exact filename keys only. * @param {Object|null} manifest - Loaded manifest * @param {string} filename - Source filename * @returns {Object|null} Manifest entry object @@ -48,12 +48,6 @@ function resolveManifestEntry(manifest, filename) { if (manifest.__single) return manifest.__single; // Multi-entry: look up by filename directly if (manifest[filename]) return manifest[filename]; - // Fallback: try alternate extensions for compiled files - const baseName = filename.replace(/\.(md|xml)$/i, ''); - const agentKey = `${baseName}.agent.yaml`; - if (manifest[agentKey]) return manifest[agentKey]; - const xmlKey = `${baseName}.xml`; - if (manifest[xmlKey]) return manifest[xmlKey]; return null; } @@ -64,18 +58,8 @@ function resolveManifestEntry(manifest, filename) { * @returns {string|null} type or null */ function getArtifactType(manifest, filename) { - if (!manifest) return null; - // Single-entry manifest applies to all files in the directory - if (manifest.__single) return manifest.__single.type || null; - // Multi-entry: look up by filename directly - if (manifest[filename]) return manifest[filename].type || null; - // Fallback: try alternate extensions for compiled files - const baseName = filename.replace(/\.(md|xml)$/i, ''); - const agentKey = `${baseName}.agent.yaml`; - if (manifest[agentKey]) return manifest[agentKey].type || null; - const xmlKey = `${baseName}.xml`; - if (manifest[xmlKey]) return manifest[xmlKey].type || null; - return null; + const manifestEntry = resolveManifestEntry(manifest, filename); + return manifestEntry?.type || null; } /** @@ -85,18 +69,9 @@ function getArtifactType(manifest, filename) { * @returns {boolean} install_to_bmad value (defaults to true) */ function getInstallToBmad(manifest, filename) { - if (!manifest) return true; - // Single-entry manifest applies to all files in the directory - if (manifest.__single) return manifest.__single.install_to_bmad !== false; - // Multi-entry: look up by filename directly - if (manifest[filename]) return manifest[filename].install_to_bmad !== false; - // Fallback: try alternate extensions for compiled files - const baseName = filename.replace(/\.(md|xml)$/i, ''); - const agentKey = `${baseName}.agent.yaml`; - if (manifest[agentKey]) return manifest[agentKey].install_to_bmad !== false; - const xmlKey = `${baseName}.xml`; - if (manifest[xmlKey]) return manifest[xmlKey].install_to_bmad !== false; - return true; + const manifestEntry = resolveManifestEntry(manifest, filename); + if (!manifestEntry) return true; + return manifestEntry.install_to_bmad !== false; } module.exports = { loadSkillManifest, getCanonicalId, getArtifactType, getInstallToBmad }; From 671425319739b9b9ba39e2982828fd1ff83e210d Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Sun, 8 Mar 2026 17:38:50 +0000 Subject: [PATCH 3/5] chore(lean): defer skill-manifest resolver cleanup to follow-up --- .../lib/ide/shared/skill-manifest.js | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/tools/cli/installers/lib/ide/shared/skill-manifest.js b/tools/cli/installers/lib/ide/shared/skill-manifest.js index c487891c6..f80235ab0 100644 --- a/tools/cli/installers/lib/ide/shared/skill-manifest.js +++ b/tools/cli/installers/lib/ide/shared/skill-manifest.js @@ -37,7 +37,7 @@ function getCanonicalId(manifest, filename) { /** * Resolve a manifest entry for a source filename. - * Strict by default: supports single-entry manifests and exact filename keys only. + * Handles single-entry manifests and extension fallbacks. * @param {Object|null} manifest - Loaded manifest * @param {string} filename - Source filename * @returns {Object|null} Manifest entry object @@ -48,6 +48,12 @@ function resolveManifestEntry(manifest, filename) { if (manifest.__single) return manifest.__single; // Multi-entry: look up by filename directly if (manifest[filename]) return manifest[filename]; + // Fallback: try alternate extensions for compiled files + const baseName = filename.replace(/\.(md|xml)$/i, ''); + const agentKey = `${baseName}.agent.yaml`; + if (manifest[agentKey]) return manifest[agentKey]; + const xmlKey = `${baseName}.xml`; + if (manifest[xmlKey]) return manifest[xmlKey]; return null; } @@ -58,8 +64,18 @@ function resolveManifestEntry(manifest, filename) { * @returns {string|null} type or null */ function getArtifactType(manifest, filename) { - const manifestEntry = resolveManifestEntry(manifest, filename); - return manifestEntry?.type || null; + if (!manifest) return null; + // Single-entry manifest applies to all files in the directory + if (manifest.__single) return manifest.__single.type || null; + // Multi-entry: look up by filename directly + if (manifest[filename]) return manifest[filename].type || null; + // Fallback: try alternate extensions for compiled files + const baseName = filename.replace(/\.(md|xml)$/i, ''); + const agentKey = `${baseName}.agent.yaml`; + if (manifest[agentKey]) return manifest[agentKey].type || null; + const xmlKey = `${baseName}.xml`; + if (manifest[xmlKey]) return manifest[xmlKey].type || null; + return null; } /** @@ -69,9 +85,18 @@ function getArtifactType(manifest, filename) { * @returns {boolean} install_to_bmad value (defaults to true) */ function getInstallToBmad(manifest, filename) { - const manifestEntry = resolveManifestEntry(manifest, filename); - if (!manifestEntry) return true; - return manifestEntry.install_to_bmad !== false; + if (!manifest) return true; + // Single-entry manifest applies to all files in the directory + if (manifest.__single) return manifest.__single.install_to_bmad !== false; + // Multi-entry: look up by filename directly + if (manifest[filename]) return manifest[filename].install_to_bmad !== false; + // Fallback: try alternate extensions for compiled files + const baseName = filename.replace(/\.(md|xml)$/i, ''); + const agentKey = `${baseName}.agent.yaml`; + if (manifest[agentKey]) return manifest[agentKey].install_to_bmad !== false; + const xmlKey = `${baseName}.xml`; + if (manifest[xmlKey]) return manifest[xmlKey].install_to_bmad !== false; + return true; } module.exports = { loadSkillManifest, getCanonicalId, getArtifactType, getInstallToBmad }; From 08a9d1d3e33a4eb326b1041bb45decff6b4cb83e Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Sun, 8 Mar 2026 17:48:01 +0000 Subject: [PATCH 4/5] chore(lean): drop skill-manifest refactors from PoC scope --- .../lib/ide/shared/skill-manifest.js | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/tools/cli/installers/lib/ide/shared/skill-manifest.js b/tools/cli/installers/lib/ide/shared/skill-manifest.js index f80235ab0..22a7cceef 100644 --- a/tools/cli/installers/lib/ide/shared/skill-manifest.js +++ b/tools/cli/installers/lib/ide/shared/skill-manifest.js @@ -3,7 +3,7 @@ const fs = require('fs-extra'); const yaml = require('yaml'); /** - * Load skill manifest from a directory. + * Load bmad-skill-manifest.yaml from a directory. * Single-entry manifests (canonicalId at top level) apply to all files in the directory. * Multi-entry manifests are keyed by source filename. * @param {string} dirPath - Directory to check for bmad-skill-manifest.yaml @@ -31,30 +31,18 @@ async function loadSkillManifest(dirPath) { * @returns {string} canonicalId or empty string */ function getCanonicalId(manifest, filename) { - const manifestEntry = resolveManifestEntry(manifest, filename); - return manifestEntry?.canonicalId || ''; -} - -/** - * 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; + if (!manifest) return ''; // Single-entry manifest applies to all files in the directory - if (manifest.__single) return manifest.__single; + if (manifest.__single) return manifest.__single.canonicalId || ''; // Multi-entry: look up by filename directly - if (manifest[filename]) return manifest[filename]; + if (manifest[filename]) return manifest[filename].canonicalId || ''; // Fallback: try alternate extensions for compiled files const baseName = filename.replace(/\.(md|xml)$/i, ''); const agentKey = `${baseName}.agent.yaml`; - if (manifest[agentKey]) return manifest[agentKey]; + if (manifest[agentKey]) return manifest[agentKey].canonicalId || ''; const xmlKey = `${baseName}.xml`; - if (manifest[xmlKey]) return manifest[xmlKey]; - return null; + if (manifest[xmlKey]) return manifest[xmlKey].canonicalId || ''; + return ''; } /** From 1eee9cd36c1d0ff7c25cb22442377e7177208b83 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Sun, 8 Mar 2026 17:50:12 +0000 Subject: [PATCH 5/5] refactor(lean): simplify shard-doc prototype copy path --- .../cli/installers/lib/ide/_config-driven.js | 76 +++---------------- 1 file changed, 12 insertions(+), 64 deletions(-) diff --git a/tools/cli/installers/lib/ide/_config-driven.js b/tools/cli/installers/lib/ide/_config-driven.js index 980d65e51..9ca8db30d 100644 --- a/tools/cli/installers/lib/ide/_config-driven.js +++ b/tools/cli/installers/lib/ide/_config-driven.js @@ -136,14 +136,14 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { if (!artifact_types || artifact_types.includes('agents')) { const agentGen = new AgentCommandGenerator(this.bmadFolderName); const { artifacts } = await agentGen.collectAgentArtifacts(bmadDir, selectedModules); - results.agents = await this.writeAgentArtifacts(targetPath, artifacts, template_type, config, bmadDir); + results.agents = await this.writeAgentArtifacts(targetPath, artifacts, template_type, config); } // Install workflows if (!artifact_types || artifact_types.includes('workflows')) { const workflowGen = new WorkflowCommandGenerator(this.bmadFolderName); const { artifacts } = await workflowGen.collectWorkflowArtifacts(bmadDir); - results.workflows = await this.writeWorkflowArtifacts(targetPath, artifacts, template_type, config, bmadDir); + results.workflows = await this.writeWorkflowArtifacts(targetPath, artifacts, template_type, config); } // Install tasks and tools using template system (supports TOML for Gemini, MD for others) @@ -198,7 +198,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { * @param {Object} config - Installation configuration * @returns {Promise} Count of artifacts written */ - async writeAgentArtifacts(targetPath, artifacts, templateType, config = {}, bmadDir = null) { + async writeAgentArtifacts(targetPath, artifacts, templateType, config = {}) { // Try to load platform-specific template, fall back to default-agent const { content: template, extension } = await this.loadTemplate(templateType, 'agent', config, 'default-agent'); let count = 0; @@ -208,7 +208,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { const filename = this.generateFilename(artifact, 'agent', extension); if (config.skill_format) { - await this.writeSkillFile(targetPath, artifact, content, bmadDir); + await this.writeSkillFile(targetPath, artifact, content); } else { const filePath = path.join(targetPath, filename); await this.writeFile(filePath, content); @@ -227,7 +227,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { * @param {Object} config - Installation configuration * @returns {Promise} Count of artifacts written */ - async writeWorkflowArtifacts(targetPath, artifacts, templateType, config = {}, bmadDir = null) { + async writeWorkflowArtifacts(targetPath, artifacts, templateType, config = {}) { let count = 0; for (const artifact of artifacts) { @@ -246,7 +246,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { const filename = this.generateFilename(artifact, 'workflow', extension); if (config.skill_format) { - await this.writeSkillFile(targetPath, artifact, content, bmadDir); + await this.writeSkillFile(targetPath, artifact, content); } else { const filePath = path.join(targetPath, filename); await this.writeFile(filePath, content); @@ -509,25 +509,24 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} await this.writeFile(path.join(skillDir, 'SKILL.md'), skillContent); - await this.writeShardDocPrototypeSkill(targetPath, artifact, bmadDir, skillName); + await this.writeShardDocPrototypeSkill(targetPath, bmadDir, skillName); } /** * Copy shard-doc prototype skill during transition when installing skill-format targets. * Keeps scope literal for the first PoC without introducing generalized prototype linkage. * @param {string} targetPath - Base skills directory - * @param {Object} artifact - Artifact metadata * @param {string|null} bmadDir - Installed bmad directory * @param {string} skillName - Canonical skill name being written */ - async writeShardDocPrototypeSkill(targetPath, artifact, bmadDir, skillName) { + async writeShardDocPrototypeSkill(targetPath, bmadDir, skillName) { if (!bmadDir || skillName !== 'bmad-shard-doc') return; - const sourceRef = this.resolveArtifactSourceRef(artifact, bmadDir); - if (!sourceRef) return; - const prototypeId = 'bmad-shard-doc-skill-prototype'; - const sourceSkillContent = await this.readPrototypeSourceSkillContent(sourceRef, prototypeId); + const sourceSkillPath = path.join(bmadDir, 'core', 'tasks', prototypeId, 'SKILL.md'); + if (!(await fs.pathExists(sourceSkillPath))) return; + + const sourceSkillContent = await fs.readFile(sourceSkillPath, 'utf8'); if (!sourceSkillContent) return; const prototypeDir = path.join(targetPath, prototypeId); @@ -535,57 +534,6 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} await this.writeFile(path.join(prototypeDir, 'SKILL.md'), sourceSkillContent); } - /** - * 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} Source SKILL.md content or null - */ - async readPrototypeSourceSkillContent(sourceRef, prototypeId) { - if (!sourceRef || typeof prototypeId !== 'string' || !prototypeId.trim()) return null; - const sourceSkillPath = path.join(sourceRef.dirPath, prototypeId, 'SKILL.md'); - if (!(await fs.pathExists(sourceSkillPath))) return null; - return fs.readFile(sourceSkillPath, 'utf8'); - } - - /** - * 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) { - if (artifact.type !== 'task' || !artifact.path) return null; - const sourcePath = artifact.path; - const resolvedBmadDir = path.resolve(bmadDir); - - 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 relativeDir = path.dirname(normalized); - const dirPath = relativeDir === '.' ? resolvedBmadDir : path.resolve(resolvedBmadDir, relativeDir); - const relativeToRoot = path.relative(resolvedBmadDir, dirPath); - if (relativeToRoot === '..' || relativeToRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeToRoot)) return null; - - return { dirPath, filename }; - } - /** * Transform artifact content to Agent Skills format. * Rewrites frontmatter to contain only unquoted name and description.