feat(installer): add lean shard-doc skill prototype install PoC

This commit is contained in:
Dicky Moore 2026-03-08 16:44:10 +00:00
parent 4974cd847f
commit 3755e72f61
5 changed files with 235 additions and 17 deletions

View File

@ -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.

View File

@ -0,0 +1,3 @@
canonicalId: bmad-shard-doc-skill-prototype
type: task
description: "Prototype native skill wrapper for shard-doc during installer transition"

View File

@ -81,6 +81,61 @@ 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', '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 * Test Suite
*/ */
@ -827,6 +882,70 @@ 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 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 // Test 17: GitHub Copilot Native Skills Install
// ============================================================ // ============================================================
console.log(`${colors.yellow}Test Suite 17: GitHub Copilot Native Skills${colors.reset}\n`); console.log(`${colors.yellow}Test Suite 17: GitHub Copilot Native Skills${colors.reset}\n`);

View File

@ -132,21 +132,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 +187,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 +197,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 +216,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 +235,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 +255,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 +283,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 +478,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 +497,78 @@ 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);
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<string|null>} 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 };
} }
/** /**

View File

@ -3,7 +3,7 @@ const fs = require('fs-extra');
const yaml = require('yaml'); 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. * 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 bmad-skill-manifest.yaml
@ -31,18 +31,30 @@ 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 || '';
}
/**
* 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 }; module.exports = { loadSkillManifest, getCanonicalId };