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/skill-manifest.yaml b/src/core/tasks/bmad-shard-doc-skill-prototype/skill-manifest.yaml new file mode 100644 index 000000000..29ce5aeb6 --- /dev/null +++ b/src/core/tasks/bmad-shard-doc-skill-prototype/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/src/core/tasks/bmad-skill-manifest.yaml b/src/core/tasks/skill-manifest.yaml similarity index 100% rename from src/core/tasks/bmad-skill-manifest.yaml rename to src/core/tasks/skill-manifest.yaml diff --git a/test/test-installation-components.js b/test/test-installation-components.js index ee99c0136..90ec733b9 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -72,7 +72,7 @@ async function createTestBmadFixture() { await fs.ensureDir(path.join(fixtureDir, 'core', 'agents')); 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 - 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']) await fs.ensureDir(path.join(fixtureDir, 'bmm', 'agents')); @@ -91,7 +91,7 @@ async function createShardDocPrototypeFixture() { ); await fs.writeFile( - path.join(fixtureDir, 'core', 'tasks', 'bmad-skill-manifest.yaml'), + path.join(fixtureDir, 'core', 'tasks', 'skill-manifest.yaml'), [ 'shard-doc.xml:', ' canonicalId: bmad-shard-doc', @@ -103,6 +103,26 @@ async function createShardDocPrototypeFixture() { ].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'), [ @@ -601,6 +621,7 @@ async function runTests() { 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, diff --git a/tools/cli/installers/lib/ide/_config-driven.js b/tools/cli/installers/lib/ide/_config-driven.js index 285246548..de72e5ab4 100644 --- a/tools/cli/installers/lib/ide/_config-driven.js +++ b/tools/cli/installers/lib/ide/_config-driven.js @@ -503,12 +503,15 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} if (!bmadDir) return; - const duplicatePrototypeIds = await this.getPrototypeSkillIdsForArtifact(artifact, bmadDir); + 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 prototypeContent = this.transformToSkillFormat(content, prototypeId); + const sourceSkillContent = await this.readPrototypeSourceSkillContent(sourceRef, prototypeId); + const prototypeContent = sourceSkillContent ?? this.transformToSkillFormat(content, prototypeId); await this.writeFile(path.join(prototypeDir, 'SKILL.md'), prototypeContent); } } @@ -519,8 +522,8 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} * @param {string} bmadDir - Installed bmad directory * @returns {Promise} Prototype skill IDs */ - async getPrototypeSkillIdsForArtifact(artifact, bmadDir) { - const sourceRef = this.resolveArtifactSourceRef(artifact, bmadDir); + async getPrototypeSkillIdsForArtifact(artifact, bmadDir, sourceRefOverride = null) { + const sourceRef = sourceRefOverride ?? this.resolveArtifactSourceRef(artifact, bmadDir); if (!sourceRef) return []; let manifest = this._manifestCache.get(sourceRef.dirPath); @@ -531,6 +534,48 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} 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} 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 diff --git a/tools/cli/installers/lib/ide/shared/path-utils.js b/tools/cli/installers/lib/ide/shared/path-utils.js index 45efd2ec1..ebec2b6cb 100644 --- a/tools/cli/installers/lib/ide/shared/path-utils.js +++ b/tools/cli/installers/lib/ide/shared/path-utils.js @@ -266,7 +266,7 @@ function parseUnderscoreName(filename) { /** * 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(). * * @param {Object} artifact - Artifact object (must have relativePath; may have canonicalId) diff --git a/tools/cli/installers/lib/ide/shared/skill-manifest.js b/tools/cli/installers/lib/ide/shared/skill-manifest.js index 71a380cf0..dce4aaca6 100644 --- a/tools/cli/installers/lib/ide/shared/skill-manifest.js +++ b/tools/cli/installers/lib/ide/shared/skill-manifest.js @@ -2,26 +2,32 @@ const path = require('node:path'); const fs = require('fs-extra'); 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. * 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 */ async function loadSkillManifest(dirPath) { - const manifestPath = path.join(dirPath, 'bmad-skill-manifest.yaml'); - try { - if (!(await fs.pathExists(manifestPath))) return null; - const content = await fs.readFile(manifestPath, 'utf8'); - const parsed = yaml.parse(content); - if (!parsed || typeof parsed !== 'object') return null; - if (parsed.canonicalId) return { __single: parsed }; - return parsed; - } catch (error) { - console.warn(`Warning: Failed to parse bmad-skill-manifest.yaml in ${dirPath}: ${error.message}`); - return null; + for (const manifestFilename of SKILL_MANIFEST_FILENAMES) { + const manifestPath = path.join(dirPath, manifestFilename); + try { + if (!(await fs.pathExists(manifestPath))) continue; + const content = await fs.readFile(manifestPath, 'utf8'); + const parsed = yaml.parse(content); + if (!parsed || typeof parsed !== 'object') return null; + if (parsed.canonicalId) return { __single: parsed }; + return parsed; + } catch (error) { + console.warn(`Warning: Failed to parse ${manifestFilename} in ${dirPath}: ${error.message}`); + return null; + } } + + return null; } /**