feat: add documentation website with Docusaurus build pipeline (#1177)

* feat: add documentation website with Docusaurus build pipeline

* feat(docs): add AI discovery meta tags for llms.txt files

- Add global headTags with ai-terms, llms, llms-full meta tags
- Update landing page link to clarify AI context purpose

* fix(docs): restore accidentally deleted faq.md and glossary.md

Files were removed in 12dd97fe during path restructuring.

* fix(docs): update broken project-readme links to GitHub URL

* feat(schema): add compound trigger format validation
This commit is contained in:
Alex Verkhovsky
2025-12-23 07:01:36 -08:00
committed by GitHub
parent 925b715d4f
commit 19df17b261
163 changed files with 20878 additions and 1509 deletions

630
tools/build-docs.js Normal file
View File

@@ -0,0 +1,630 @@
/**
* BMAD Documentation Build Pipeline
*
* Consolidates docs from multiple sources, generates LLM-friendly files,
* creates downloadable bundles, and builds the Docusaurus site.
*
* Build outputs:
* build/consolidated/ - Merged docs from all sources
* build/artifacts/ - With llms.txt, llms-full.txt, ZIPs
* build/site/ - Final Docusaurus output (deployable)
*/
const { execSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const archiver = require('archiver');
// =============================================================================
// Configuration
// =============================================================================
const PROJECT_ROOT = path.dirname(__dirname);
const BUILD_DIR = path.join(PROJECT_ROOT, 'build');
const SITE_URL = process.env.SITE_URL || 'https://bmad-code-org.github.io/BMAD-METHOD';
const REPO_URL = 'https://github.com/bmad-code-org/BMAD-METHOD';
const LLM_MAX_CHARS = 600_000;
const LLM_WARN_CHARS = 500_000;
const MODULES = ['bmm', 'bmb', 'bmgd', 'cis'];
// No root docs copied - only docs/ folder content goes to site
// README.md, CHANGELOG.md etc. link to GitHub
const ROOT_DOCS = [];
const LLM_EXCLUDE_PATTERNS = ['changelog', 'ide-info/', 'v4-to-v6-upgrade', 'downloads/', 'faq'];
// =============================================================================
// Main Entry Point
// =============================================================================
async function main() {
console.log();
printBanner('BMAD Documentation Build Pipeline');
console.log();
console.log(`Project root: ${PROJECT_ROOT}`);
console.log(`Build directory: ${BUILD_DIR}`);
console.log();
cleanBuildDirectory();
const consolidatedDir = consolidateDocs();
const artifactsDir = await generateArtifacts(consolidatedDir);
const siteDir = buildDocusaurusSite(artifactsDir);
printBuildSummary(consolidatedDir, artifactsDir, siteDir);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
// =============================================================================
// Pipeline Stages
// =============================================================================
function consolidateDocs() {
printHeader('Consolidating documentation sources');
const outputDir = path.join(BUILD_DIR, 'consolidated');
fs.mkdirSync(outputDir, { recursive: true });
copyMainDocs(outputDir);
copyRootDocs(outputDir);
copyModuleDocs(outputDir);
const mdCount = countMarkdownFiles(outputDir);
console.log();
console.log(` \u001B[32m✓\u001B[0m Consolidation complete: ${mdCount} markdown files`);
return outputDir;
}
async function generateArtifacts(consolidatedDir) {
printHeader('Generating LLM files and download bundles');
const outputDir = path.join(BUILD_DIR, 'artifacts');
copyDirectory(consolidatedDir, outputDir);
generateLlmsTxt(outputDir);
generateLlmsFullTxt(outputDir);
await generateDownloadBundles(outputDir);
console.log();
console.log(` \u001B[32m✓\u001B[0m Artifact generation complete`);
return outputDir;
}
function buildDocusaurusSite(artifactsDir) {
printHeader('Building Docusaurus site');
const siteDir = path.join(BUILD_DIR, 'site');
const mainDocs = path.join(PROJECT_ROOT, 'docs');
const docsBackup = path.join(BUILD_DIR, 'docs-backup');
backupAndReplaceDocs(mainDocs, docsBackup, artifactsDir);
try {
runDocusaurusBuild(siteDir);
} finally {
restoreDocs(mainDocs, docsBackup);
}
copyArtifactsToSite(artifactsDir, siteDir);
console.log();
console.log(` \u001B[32m✓\u001B[0m Docusaurus build complete`);
return siteDir;
}
// =============================================================================
// Documentation Consolidation
// =============================================================================
function copyMainDocs(destDir) {
console.log(' → Copying main docs...');
const docsDir = path.join(PROJECT_ROOT, 'docs');
copyDirectory(docsDir, destDir, ['modules', 'llms.txt', 'llms-full.txt'], true);
}
function copyRootDocs(destDir) {
console.log(' → Copying root documentation files...');
for (const doc of ROOT_DOCS) {
const srcPath = path.join(PROJECT_ROOT, doc.src);
const destPath = path.join(destDir, doc.dest);
if (fs.existsSync(srcPath)) {
let content = fs.readFileSync(srcPath, 'utf-8');
if (!content.startsWith('---')) {
content = `---\ntitle: "${doc.title}"\n---\n\n${content}`;
}
content = transformMarkdownLinks(content);
fs.writeFileSync(destPath, content);
console.log(` ${doc.src}${doc.dest}`);
}
}
}
function copyModuleDocs(destDir) {
fs.mkdirSync(path.join(destDir, 'modules'), { recursive: true });
for (const moduleName of MODULES) {
const srcPath = path.join(PROJECT_ROOT, 'src', 'modules', moduleName, 'docs');
const moduleDest = path.join(destDir, 'modules', moduleName);
if (fs.existsSync(srcPath)) {
console.log(` → Copying ${moduleName} docs...`);
copyDirectory(srcPath, moduleDest, [], false, moduleName);
const count = countMarkdownFiles(moduleDest);
console.log(` ${count} markdown files`);
} else {
console.log(` ⚠ WARNING: ${moduleName} docs not found`);
}
}
}
// =============================================================================
// LLM File Generation
// =============================================================================
function generateLlmsTxt(outputDir) {
console.log(' → Generating llms.txt...');
const content = [
'# BMAD Method Documentation',
'',
'> AI-driven agile development with specialized agents and workflows that scale from bug fixes to enterprise platforms.',
'',
`Documentation: ${SITE_URL}`,
`Repository: ${REPO_URL}`,
`Full docs: ${SITE_URL}/llms-full.txt`,
'',
'## Quick Start',
'',
`- **[Quick Start](${SITE_URL}/docs/modules/bmm/quick-start)** - Get started with BMAD Method`,
`- **[Installation](${SITE_URL}/docs/getting-started/installation)** - Installation guide`,
'',
'## Core Concepts',
'',
`- **[Scale Adaptive System](${SITE_URL}/docs/modules/bmm/scale-adaptive-system)** - Understand BMAD scaling`,
`- **[Quick Flow](${SITE_URL}/docs/modules/bmm/bmad-quick-flow)** - Fast development workflow`,
`- **[Party Mode](${SITE_URL}/docs/modules/bmm/party-mode)** - Multi-agent collaboration`,
'',
'## Modules',
'',
`- **[BMM - Method](${SITE_URL}/docs/modules/bmm/quick-start)** - Core methodology module`,
`- **[BMB - Builder](${SITE_URL}/docs/modules/bmb/)** - Agent and workflow builder`,
`- **[BMGD - Game Dev](${SITE_URL}/docs/modules/bmgd/quick-start)** - Game development module`,
'',
'---',
'',
'## Quick Links',
'',
`- [Full Documentation (llms-full.txt)](${SITE_URL}/llms-full.txt) - Complete docs for AI context`,
`- [Source Bundle](${SITE_URL}/downloads/bmad-sources.zip) - Complete source code`,
`- [Prompts Bundle](${SITE_URL}/downloads/bmad-prompts.zip) - Agent prompts and workflows`,
'',
].join('\n');
const outputPath = path.join(outputDir, 'llms.txt');
fs.writeFileSync(outputPath, content, 'utf-8');
console.log(` Generated llms.txt (${content.length.toLocaleString()} chars)`);
}
function generateLlmsFullTxt(outputDir) {
console.log(' → Generating llms-full.txt...');
const date = new Date().toISOString().split('T')[0];
const files = getDocsFromSidebar();
const output = [
'# BMAD Method Documentation (Full)',
'',
'> Complete documentation for AI consumption',
`> Generated: ${date}`,
`> Repository: ${REPO_URL}`,
'',
];
let fileCount = 0;
let skippedCount = 0;
for (const mdPath of files) {
if (shouldExcludeFromLlm(mdPath)) {
skippedCount++;
continue;
}
const fullPath = path.join(outputDir, mdPath);
try {
const content = readMarkdownContent(fullPath);
output.push(`<document path="${mdPath}">`, content, '</document>', '');
fileCount++;
} catch (error) {
console.error(` Warning: Could not read ${mdPath}: ${error.message}`);
}
}
const result = output.join('\n');
validateLlmSize(result);
const outputPath = path.join(outputDir, 'llms-full.txt');
fs.writeFileSync(outputPath, result, 'utf-8');
const tokenEstimate = Math.floor(result.length / 4).toLocaleString();
console.log(
` Processed ${fileCount} files (skipped ${skippedCount}), ${result.length.toLocaleString()} chars (~${tokenEstimate} tokens)`,
);
}
function getDocsFromSidebar() {
const sidebarsPath = path.join(PROJECT_ROOT, 'website', 'sidebars.js');
try {
const sidebarContent = fs.readFileSync(sidebarsPath, 'utf-8');
const matches = sidebarContent.matchAll(/'([a-zA-Z0-9\-_/]+)'/g);
const files = [];
for (const match of matches) {
const docId = match[1];
// Skip Docusaurus keywords
if (docId.includes('Sidebar') || docId === 'doc' || docId === 'category') {
continue;
}
// Skip category labels (Title Case words without slashes like 'Workflows', 'Reference')
if (!docId.includes('/') && /^[A-Z][a-z]/.test(docId)) {
continue;
}
files.push(docId + '.md');
}
return files;
} catch {
console.log(' Warning: Could not parse sidebars');
return [];
}
}
function shouldExcludeFromLlm(filePath) {
return LLM_EXCLUDE_PATTERNS.some((pattern) => filePath.includes(pattern));
}
function readMarkdownContent(filePath) {
let content = fs.readFileSync(filePath, 'utf-8');
if (content.startsWith('---')) {
const end = content.indexOf('---', 3);
if (end !== -1) {
content = content.slice(end + 3).trim();
}
}
return content;
}
function validateLlmSize(content) {
const charCount = content.length;
if (charCount > LLM_MAX_CHARS) {
console.error(` ERROR: Exceeds ${LLM_MAX_CHARS.toLocaleString()} char limit`);
process.exit(1);
} else if (charCount > LLM_WARN_CHARS) {
console.warn(` \u001B[33mWARNING: Approaching ${LLM_WARN_CHARS.toLocaleString()} char limit\u001B[0m`);
}
}
// =============================================================================
// Download Bundle Generation
// =============================================================================
async function generateDownloadBundles(outputDir) {
console.log(' → Generating download bundles...');
const downloadsDir = path.join(outputDir, 'downloads');
fs.mkdirSync(downloadsDir, { recursive: true });
await generateSourcesBundle(downloadsDir);
await generatePromptsBundle(downloadsDir);
}
async function generateSourcesBundle(downloadsDir) {
const srcDir = path.join(PROJECT_ROOT, 'src');
if (!fs.existsSync(srcDir)) return;
const zipPath = path.join(downloadsDir, 'bmad-sources.zip');
await createZipArchive(srcDir, zipPath, ['__pycache__', '.pyc', '.DS_Store', 'node_modules']);
const size = (fs.statSync(zipPath).size / 1024 / 1024).toFixed(1);
console.log(` bmad-sources.zip (${size}M)`);
}
async function generatePromptsBundle(downloadsDir) {
const modulesDir = path.join(PROJECT_ROOT, 'src', 'modules');
if (!fs.existsSync(modulesDir)) return;
const zipPath = path.join(downloadsDir, 'bmad-prompts.zip');
await createZipArchive(modulesDir, zipPath, ['docs', '.DS_Store', '__pycache__', 'node_modules']);
const size = Math.floor(fs.statSync(zipPath).size / 1024);
console.log(` bmad-prompts.zip (${size}K)`);
}
// =============================================================================
// Docusaurus Build
// =============================================================================
function backupAndReplaceDocs(mainDocs, backupDir, artifactsDir) {
console.log(' → Preparing docs for Docusaurus...');
if (fs.existsSync(mainDocs)) {
copyDirectory(mainDocs, backupDir);
fs.rmSync(mainDocs, { recursive: true });
}
copyDirectory(artifactsDir, mainDocs, ['llms.txt', 'llms-full.txt']);
removeZipFiles(path.join(mainDocs, 'downloads'));
}
function runDocusaurusBuild(siteDir) {
console.log(' → Running docusaurus build...');
execSync('npx docusaurus build --config website/docusaurus.config.js --out-dir ' + siteDir, {
cwd: PROJECT_ROOT,
stdio: 'inherit',
});
}
function restoreDocs(mainDocs, backupDir) {
console.log(' → Restoring original docs...');
fs.rmSync(mainDocs, { recursive: true });
if (fs.existsSync(backupDir)) {
copyDirectory(backupDir, mainDocs);
fs.rmSync(backupDir, { recursive: true });
}
}
function copyArtifactsToSite(artifactsDir, siteDir) {
console.log(' → Copying artifacts to site...');
fs.copyFileSync(path.join(artifactsDir, 'llms.txt'), path.join(siteDir, 'llms.txt'));
fs.copyFileSync(path.join(artifactsDir, 'llms-full.txt'), path.join(siteDir, 'llms-full.txt'));
const downloadsDir = path.join(artifactsDir, 'downloads');
if (fs.existsSync(downloadsDir)) {
copyDirectory(downloadsDir, path.join(siteDir, 'downloads'));
}
}
function removeZipFiles(dir) {
if (!fs.existsSync(dir)) return;
for (const file of fs.readdirSync(dir)) {
if (file.endsWith('.zip')) {
fs.unlinkSync(path.join(dir, file));
}
}
}
// =============================================================================
// Build Summary
// =============================================================================
function printBuildSummary(consolidatedDir, artifactsDir, siteDir) {
console.log();
printBanner('Build Complete!');
console.log();
console.log('Build artifacts:');
console.log(` Consolidated docs: ${consolidatedDir}`);
console.log(` Generated files: ${artifactsDir}`);
console.log(` Final site: ${siteDir}`);
console.log();
console.log(`Deployable output: ${siteDir}/`);
console.log();
listDirectoryContents(siteDir);
}
function listDirectoryContents(dir) {
const entries = fs.readdirSync(dir).slice(0, 15);
for (const entry of entries) {
const fullPath = path.join(dir, entry);
const stat = fs.statSync(fullPath);
if (stat.isFile()) {
const sizeStr = formatFileSize(stat.size);
console.log(` ${entry.padEnd(40)} ${sizeStr.padStart(8)}`);
} else {
console.log(` ${entry}/`);
}
}
}
function formatFileSize(bytes) {
if (bytes > 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)}M`;
} else if (bytes > 1024) {
return `${Math.floor(bytes / 1024)}K`;
}
return `${bytes}B`;
}
// =============================================================================
// File System Utilities
// =============================================================================
function cleanBuildDirectory() {
console.log('Cleaning previous build...');
if (fs.existsSync(BUILD_DIR)) {
fs.rmSync(BUILD_DIR, { recursive: true });
}
fs.mkdirSync(BUILD_DIR, { recursive: true });
}
function copyDirectory(src, dest, exclude = [], transformMd = false, moduleName = null) {
if (!fs.existsSync(src)) return false;
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
if (exclude.includes(entry.name)) continue;
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDirectory(srcPath, destPath, exclude, transformMd, moduleName);
} else if (entry.name.endsWith('.md')) {
// Always transform markdown links, use module context if provided
let content = fs.readFileSync(srcPath, 'utf-8');
content = transformMarkdownLinks(content, moduleName);
fs.writeFileSync(destPath, content);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
return true;
}
function transformMarkdownLinks(content, moduleName = null) {
// Transform HTML img src attributes for module docs images
content = content.replaceAll(/src="\.\/src\/modules\/([^/]+)\/docs\/images\/([^"]+)"/g, (match, mod, file) => {
return `src="./modules/${mod}/images/${file}"`;
});
return content.replaceAll(/\]\(([^)]+)\)/g, (match, url) => {
// src/modules/{mod}/docs/{path}.md → ./modules/{mod}/{path}.md
// Keeps .md - Docusaurus handles .md → page conversion
const docsMatch = url.match(/^\.\.?\/src\/modules\/([^/]+)\/docs\/(.+\.md)$/);
if (docsMatch) return `](./modules/${docsMatch[1]}/${docsMatch[2]})`;
// src/modules/{mod}/docs/ → ./modules/{mod}/
const docsDirMatch = url.match(/^\.\.?\/src\/modules\/([^/]+)\/docs\/$/);
if (docsDirMatch) return `](./modules/${docsDirMatch[1]}/)`;
// src/modules/{mod}/docs/images/{file} → ./modules/{mod}/images/{file}
const docsImageMatch = url.match(/^\.\.?\/src\/modules\/([^/]+)\/docs\/images\/(.+)$/);
if (docsImageMatch) return `](./modules/${docsImageMatch[1]}/images/${docsImageMatch[2]})`;
// src/modules/{mod}/README.md → GitHub (not in docs folder)
const readmeMatch = url.match(/^\.\.?\/src\/modules\/([^/]+)\/README\.md$/i);
if (readmeMatch) return `](${REPO_URL}/blob/main/src/modules/${readmeMatch[1]}/README.md)`;
// src/modules/* (non-docs) → GitHub
const srcMatch = url.match(/^\.\.?\/src\/modules\/(.+)$/);
if (srcMatch) return `](${REPO_URL}/tree/main/src/modules/${srcMatch[1]})`;
// Relative paths escaping docs/ folder → GitHub (when module context is known)
// e.g., ../workflows/foo/bar.md from within docs/ → src/modules/{mod}/workflows/foo/bar.md
if (moduleName) {
const relativeEscapeMatch = url.match(/^\.\.\/([^.][^)]+)$/);
if (relativeEscapeMatch && !relativeEscapeMatch[1].startsWith('src/')) {
const relativePath = relativeEscapeMatch[1];
return `](${REPO_URL}/blob/main/src/modules/${moduleName}/${relativePath})`;
}
}
// ./docs/{path}.md → ./{path}.md (docs folder contents are at root in build)
// Keeps .md - Docusaurus handles .md → page conversion
const rootDocsMatch = url.match(/^\.\/docs\/(.+\.md)$/);
if (rootDocsMatch) return `](./${rootDocsMatch[1]})`;
// Root docs → GitHub (not part of docs site)
if (url === '../README.md' || url === './README.md' || url === './project-readme') {
return `](${REPO_URL}/blob/main/README.md)`;
}
if (url === '../CHANGELOG.md' || url === './CHANGELOG.md' || url === './changelog') {
return `](${REPO_URL}/blob/main/CHANGELOG.md)`;
}
// Root files → GitHub (CONTRIBUTING, LICENSE, CODE_OF_CONDUCT, etc.)
const contributingMatch = url.match(/^(\.\.\/)?CONTRIBUTING\.md(#.*)?$/);
if (contributingMatch) {
const anchor = contributingMatch[2] || '';
return `](${REPO_URL}/blob/main/CONTRIBUTING.md${anchor})`;
}
if (url === 'LICENSE' || url === '../LICENSE') {
return `](${REPO_URL}/blob/main/LICENSE)`;
}
if (url === '.github/CODE_OF_CONDUCT.md' || url === '../.github/CODE_OF_CONDUCT.md') {
return `](${REPO_URL}/blob/main/.github/CODE_OF_CONDUCT.md)`;
}
// Other root .md files → GitHub
const rootFileMatch = url.match(/^\.\.\/([A-Z][^/]+\.md)$/);
if (rootFileMatch) return `](${REPO_URL}/blob/main/${rootFileMatch[1]})`;
// Cross-module doc links: ../../{mod}/docs/{path}.md → ../{mod}/{path}.md
// Fixes path structure but keeps .md (Docusaurus handles .md → page conversion)
const crossModuleDocsMatch = url.match(/^\.\.\/\.\.\/([^/]+)\/docs\/(.+\.md)$/);
if (crossModuleDocsMatch) return `](../${crossModuleDocsMatch[1]}/${crossModuleDocsMatch[2]})`;
// Root-level folders (samples/) → GitHub
const rootFolderMatch = url.match(/^\.\.\/((samples)\/.*)/);
if (rootFolderMatch) return `](${REPO_URL}/blob/main/${rootFolderMatch[1]})`;
return match;
});
}
function countMarkdownFiles(dir) {
let count = 0;
if (!fs.existsSync(dir)) return 0;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
count += countMarkdownFiles(fullPath);
} else if (entry.name.endsWith('.md')) {
count++;
}
}
return count;
}
function createZipArchive(sourceDir, outputPath, exclude = []) {
return new Promise((resolve, reject) => {
const output = fs.createWriteStream(outputPath);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', resolve);
archive.on('error', reject);
archive.pipe(output);
const baseName = path.basename(sourceDir);
archive.directory(sourceDir, baseName, (entry) => {
for (const pattern of exclude) {
if (entry.name.includes(pattern)) return false;
}
return entry;
});
archive.finalize();
});
}
// =============================================================================
// Console Output Formatting
// =============================================================================
function printHeader(title) {
console.log();
console.log('┌' + '─'.repeat(62) + '┐');
console.log(`${title.padEnd(60)}`);
console.log('└' + '─'.repeat(62) + '┘');
}
function printBanner(title) {
console.log('╔' + '═'.repeat(62) + '╗');
console.log(`${title.padStart(31 + title.length / 2).padEnd(62)}`);
console.log('╚' + '═'.repeat(62) + '╝');
}

View File

@@ -2,7 +2,7 @@ const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const chalk = require('chalk');
const inquirer = require('inquirer').default || require('inquirer');
const inquirer = require('inquirer');
const { getProjectRoot, getModulePath } = require('../../../lib/project-root');
const { CLIUtils } = require('../../../lib/cli-utils');

View File

@@ -2,14 +2,14 @@ const path = require('node:path');
const fs = require('fs-extra');
const chalk = require('chalk');
const ora = require('ora');
const inquirer = require('inquirer').default || require('inquirer');
const inquirer = require('inquirer');
const { Detector } = require('./detector');
const { Manifest } = require('./manifest');
const { ModuleManager } = require('../modules/manager');
const { IdeManager } = require('../ide/manager');
const { FileOps } = require('../../../lib/file-ops');
const { Config } = require('../../../lib/config');
const { XmlHandler } = require('../../../lib/agent/xml-handler');
const { XmlHandler } = require('../../../lib/xml-handler');
const { DependencyResolver } = require('./dependency-resolver');
const { ConfigCollector } = require('./config-collector');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
@@ -297,41 +297,49 @@ class Installer {
console.log('\n'); // Add spacing before IDE questions
for (const ide of newlySelectedIdes) {
// Get IDE handler and check if it needs interactive configuration
try {
// Dynamically load the IDE setup module
const ideModule = require(`../ide/${ide}`);
// List of IDEs that have interactive prompts
//TODO: Why is this here, hardcoding this list here is bad, fix me!
const needsPrompts = ['claude-code', 'github-copilot', 'roo', 'cline', 'auggie', 'codex', 'qwen', 'gemini', 'rovo-dev'].includes(
ide,
);
// Get the setup class (handle different export formats)
let SetupClass;
const className =
ide
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('') + 'Setup';
if (needsPrompts) {
// Get IDE handler and collect configuration
try {
// Dynamically load the IDE setup module
const ideModule = require(`../ide/${ide}`);
if (ideModule[className]) {
SetupClass = ideModule[className];
} else if (ideModule.default) {
SetupClass = ideModule.default;
} else {
continue;
// Get the setup class (handle different export formats)
let SetupClass;
const className =
ide
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('') + 'Setup';
if (ideModule[className]) {
SetupClass = ideModule[className];
} else if (ideModule.default) {
SetupClass = ideModule.default;
} else {
continue;
}
const ideSetup = new SetupClass();
// Check if this IDE has a collectConfiguration method
if (typeof ideSetup.collectConfiguration === 'function') {
console.log(chalk.cyan(`\nConfiguring ${ide}...`));
ideConfigurations[ide] = await ideSetup.collectConfiguration({
selectedModules: selectedModules || [],
projectDir,
bmadDir,
});
}
} catch {
// IDE doesn't have a setup file or collectConfiguration method
console.warn(chalk.yellow(`Warning: Could not load configuration for ${ide}`));
}
const ideSetup = new SetupClass();
// Check if this IDE has a collectConfiguration method (no hardcoding needed!)
if (typeof ideSetup.collectConfiguration === 'function') {
console.log(chalk.cyan(`\nConfiguring ${ide}...`));
ideConfigurations[ide] = await ideSetup.collectConfiguration({
selectedModules: selectedModules || [],
projectDir,
bmadDir,
});
}
} catch {
// IDE doesn't have a setup file or collectConfiguration method
console.warn(chalk.yellow(`Warning: Could not load configuration for ${ide}`));
}
}
}
@@ -835,6 +843,8 @@ class Installer {
allModules = allModules.filter((m) => m !== 'core');
}
const modulesToInstall = allModules;
// For dependency resolution, we only need regular modules (not custom modules)
// Custom modules are already installed in _bmad and don't need dependency resolution from source
const regularModulesForResolution = allModules.filter((module) => {
@@ -1213,19 +1223,17 @@ class Installer {
console.log(chalk.dim('Remove these .bak files it no longer needed\n'));
}
// Display completion message (skip if requested, e.g., for quick updates)
if (!config._skipCompletion) {
const { UI } = require('../../../lib/ui');
const ui = new UI();
ui.showInstallSummary({
path: bmadDir,
modules: config.modules,
ides: config.ides,
customFiles: customFiles.length > 0 ? customFiles : undefined,
ttsInjectedFiles: this.enableAgentVibes && this.ttsInjectedFiles.length > 0 ? this.ttsInjectedFiles : undefined,
agentVibesEnabled: this.enableAgentVibes || false,
});
}
// Display completion message
const { UI } = require('../../../lib/ui');
const ui = new UI();
ui.showInstallSummary({
path: bmadDir,
modules: config.modules,
ides: config.ides,
customFiles: customFiles.length > 0 ? customFiles : undefined,
ttsInjectedFiles: this.enableAgentVibes && this.ttsInjectedFiles.length > 0 ? this.ttsInjectedFiles : undefined,
agentVibesEnabled: this.enableAgentVibes || false,
});
return {
success: true,
@@ -1505,7 +1513,9 @@ class Installer {
* @param {string} bmadDir - BMAD installation directory
* @param {Object} coreFiles - Core files to install
*/
async installCoreWithDependencies(bmadDir) {
async installCoreWithDependencies(bmadDir, coreFiles) {
const sourcePath = getModulePath('core');
const targetPath = path.join(bmadDir, 'core');
await this.installCore(bmadDir);
}
@@ -1515,7 +1525,7 @@ class Installer {
* @param {string} bmadDir - BMAD installation directory
* @param {Object} moduleFiles - Module files to install
*/
async installModuleWithDependencies(moduleName, bmadDir) {
async installModuleWithDependencies(moduleName, bmadDir, moduleFiles) {
// Get module configuration for conditional installation
const moduleConfig = this.configCollector.collectedConfig[moduleName] || {};
@@ -1787,6 +1797,7 @@ class Installer {
}
const agentName = agentFile.replace('.md', '');
const mdPath = path.join(agentsPath, agentFile);
const customizePath = path.join(cfgAgentsDir, `${moduleName}-${agentName}.customize.yaml`);
// For .md files that are already compiled, we don't need to do much
@@ -2000,9 +2011,8 @@ class Installer {
_existingModules: installedModules, // Pass all installed modules for manifest generation
};
// Call the standard install method, but skip UI completion for quick updates
installConfig._skipCompletion = true;
await this.install(installConfig);
// Call the standard install method
const result = await this.install(installConfig);
// Only succeed the spinner if it's still spinning
// (install method might have stopped it if folder name changed)
@@ -2130,7 +2140,7 @@ class Installer {
* Private: Prompt for update action
*/
async promptUpdateAction() {
const inquirer = require('inquirer').default || require('inquirer');
const inquirer = require('inquirer');
return await inquirer.prompt([
{
type: 'list',
@@ -2160,7 +2170,7 @@ class Installer {
return !name.startsWith('_bmad'); // Everything else is manual cleanup
});
const inquirer = require('inquirer').default || require('inquirer');
const inquirer = require('inquirer');
// Show warning for other offending paths FIRST
if (otherOffenders.length > 0) {
@@ -2459,7 +2469,7 @@ class Installer {
console.log(chalk.yellow(`\n⚠️ Found ${customModulesWithMissingSources.length} custom module(s) with missing sources:`));
const inquirer = require('inquirer').default || require('inquirer');
const inquirer = require('inquirer');
let keptCount = 0;
let updatedCount = 0;
let removedCount = 0;

View File

@@ -3,7 +3,7 @@ const fs = require('fs-extra');
const chalk = require('chalk');
const yaml = require('yaml');
const { FileOps } = require('../../../lib/file-ops');
const { XmlHandler } = require('../../../lib/agent/xml-handler');
const { XmlHandler } = require('../../../lib/xml-handler');
/**
* Handler for custom content (custom.yaml)
@@ -311,7 +311,7 @@ class CustomHandler {
// Read and compile the YAML
try {
const yamlContent = await fs.readFile(agentFile, 'utf8');
const { compileAgent } = require('../../../lib/agent/yaml-xml-builder');
const { compileAgent } = require('../../../lib/agent/compiler');
// Create customize template if it doesn't exist
if (!(await fs.pathExists(customizePath))) {

View File

@@ -1,7 +1,7 @@
const path = require('node:path');
const fs = require('fs-extra');
const chalk = require('chalk');
const { XmlHandler } = require('../../../lib/agent/xml-handler');
const { XmlHandler } = require('../../../lib/xml-handler');
const { getSourcePath } = require('../../../lib/project-root');
/**
@@ -493,6 +493,11 @@ class BaseIdeSetup {
// Replace placeholders
let processed = content;
// Inject activation block for agent files FIRST (before replacements)
if (metadata.name && content.includes('<agent')) {
processed = this.xmlHandler.injectActivationSimple(processed, metadata);
}
// Only replace {project-root} if a specific projectDir is provided
// Otherwise leave the placeholder intact
// Note: Don't add trailing slash - paths in source include leading slash

View File

@@ -2,7 +2,6 @@ const path = require('node:path');
const fs = require('fs-extra');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const { FileOps, PathUtils } = require('../../../lib/file-ops');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
const { WorkflowCommandGenerator } = require('./shared/workflow-command-generator');
const { TaskToolCommandGenerator } = require('./shared/task-tool-command-generator');
@@ -59,7 +58,7 @@ class AntigravitySetup extends BaseIdeSetup {
if (config.subagentChoices.install !== 'none') {
// Ask for installation location
const inquirer = require('inquirer').default || require('inquirer');
const inquirer = require('inquirer');
const locationAnswer = await inquirer.prompt([
{
type: 'list',
@@ -89,9 +88,9 @@ class AntigravitySetup extends BaseIdeSetup {
* @param {string} projectDir - Project directory
*/
async cleanup(projectDir) {
const bmadWorkflowsDir = PathUtils.getIdeSubDir(projectDir, this.configDir, this.workflowsDir, 'bmad');
const bmadWorkflowsDir = path.join(projectDir, this.configDir, this.workflowsDir, 'bmad');
if (await this.exists(bmadWorkflowsDir)) {
if (await fs.pathExists(bmadWorkflowsDir)) {
await fs.remove(bmadWorkflowsDir);
console.log(chalk.dim(` Removed old BMAD workflows from ${this.name}`));
}
@@ -113,9 +112,9 @@ class AntigravitySetup extends BaseIdeSetup {
await this.cleanup(projectDir);
// Create .agent/workflows directory structure
const agentDir = PathUtils.getConfigDir(projectDir, this.configDir);
const workflowsDir = PathUtils.getIdeSubDir(projectDir, this.configDir, this.workflowsDir);
const bmadWorkflowsDir = PathUtils.getIdeSubDir(projectDir, this.configDir, this.workflowsDir, 'bmad');
const agentDir = path.join(projectDir, this.configDir);
const workflowsDir = path.join(agentDir, this.workflowsDir);
const bmadWorkflowsDir = path.join(workflowsDir, 'bmad');
await this.ensureDir(bmadWorkflowsDir);
@@ -191,7 +190,7 @@ class AntigravitySetup extends BaseIdeSetup {
* Read and process file content
*/
async readAndProcess(filePath, metadata) {
const content = await this.readFile(filePath);
const content = await fs.readFile(filePath, 'utf8');
return this.processContent(content, metadata);
}
@@ -211,7 +210,7 @@ class AntigravitySetup extends BaseIdeSetup {
// Add core agents
const corePath = getModulePath('core');
if (await this.exists(path.join(corePath, 'agents'))) {
if (await fs.pathExists(path.join(corePath, 'agents'))) {
const coreAgents = await getAgentsFromDir(path.join(corePath, 'agents'), 'core');
agents.push(...coreAgents);
}
@@ -221,7 +220,7 @@ class AntigravitySetup extends BaseIdeSetup {
const modulePath = path.join(sourceDir, moduleName);
const agentsPath = path.join(modulePath, 'agents');
if (await this.exists(agentsPath)) {
if (await fs.pathExists(agentsPath)) {
const moduleAgents = await getAgentsFromDir(agentsPath, moduleName);
agents.push(...moduleAgents);
}
@@ -298,7 +297,7 @@ class AntigravitySetup extends BaseIdeSetup {
choices = await this.promptSubagentInstallation(config.subagents);
if (choices.install !== 'none') {
const inquirer = require('inquirer').default || require('inquirer');
const inquirer = require('inquirer');
const locationAnswer = await inquirer.prompt([
{
type: 'list',
@@ -335,7 +334,7 @@ class AntigravitySetup extends BaseIdeSetup {
* Prompt user for subagent installation preferences
*/
async promptSubagentInstallation(subagentConfig) {
const inquirer = require('inquirer').default || require('inquirer');
const inquirer = require('inquirer');
// First ask if they want to install subagents
const { install } = await inquirer.prompt([
@@ -388,7 +387,7 @@ class AntigravitySetup extends BaseIdeSetup {
const targetPath = path.join(projectDir, injection.file);
if (await this.exists(targetPath)) {
let content = await this.readFile(targetPath);
let content = await fs.readFile(targetPath, 'utf8');
const marker = `<!-- IDE-INJECT-POINT: ${injection.point} -->`;
if (content.includes(marker)) {
@@ -400,7 +399,7 @@ class AntigravitySetup extends BaseIdeSetup {
}
content = content.replace(marker, injectionContent);
await this.writeFile(targetPath, content);
await fs.writeFile(targetPath, content);
console.log(chalk.dim(` Injected: ${injection.point}${injection.file}`));
}
}
@@ -418,7 +417,7 @@ class AntigravitySetup extends BaseIdeSetup {
targetDir = path.join(os.homedir(), '.agent', 'agents');
console.log(chalk.dim(` Installing subagents globally to: ~/.agent/agents/`));
} else {
targetDir = PathUtils.getIdeSubDir(projectDir, '.agent', 'agents');
targetDir = path.join(projectDir, '.agent', 'agents');
console.log(chalk.dim(` Installing subagents to project: .agent/agents/`));
}
@@ -465,11 +464,11 @@ class AntigravitySetup extends BaseIdeSetup {
*/
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
// Create .agent/workflows/bmad directory structure (same as regular agents)
const agentDir = PathUtils.getConfigDir(projectDir, this.configDir);
const workflowsDir = PathUtils.getIdeSubDir(projectDir, this.configDir, this.workflowsDir);
const bmadWorkflowsDir = PathUtils.getIdeSubDir(projectDir, this.configDir, this.workflowsDir, 'bmad');
const agentDir = path.join(projectDir, this.configDir);
const workflowsDir = path.join(agentDir, this.workflowsDir);
const bmadWorkflowsDir = path.join(workflowsDir, 'bmad');
await this.ensureDir(bmadWorkflowsDir);
await fs.ensureDir(bmadWorkflowsDir);
// Create custom agent launcher with same pattern as regular agents
const launcherContent = `name: '${agentName}'
@@ -494,7 +493,7 @@ usage: |
const launcherPath = path.join(bmadWorkflowsDir, fileName);
// Write the launcher file
await this.writeFile(launcherPath, launcherContent);
await fs.writeFile(launcherPath, launcherContent, 'utf8');
return {
ide: 'antigravity',

View File

@@ -2,7 +2,6 @@ const path = require('node:path');
const fs = require('fs-extra');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const { FileOps, PathUtils } = require('../../../lib/file-ops');
const { AgentCommandGenerator } = require('./shared/agent-command-generator');
const { WorkflowCommandGenerator } = require('./shared/workflow-command-generator');
@@ -26,7 +25,7 @@ class AuggieSetup extends BaseIdeSetup {
console.log(chalk.cyan(`Setting up ${this.name}...`));
// Always use project directory
const location = PathUtils.getIdeSubDir(projectDir, '.augment', 'commands');
const location = path.join(projectDir, '.augment', 'commands');
// Clean up old BMAD installation first
await this.cleanup(projectDir);
@@ -53,11 +52,11 @@ class AuggieSetup extends BaseIdeSetup {
content: artifact.content,
}));
const bmadCommandsDir = PathUtils.getIdeSubDir(location, 'bmad');
const agentsDir = PathUtils.getIdeSubDir(bmadCommandsDir, 'agents');
const tasksDir = PathUtils.getIdeSubDir(bmadCommandsDir, 'tasks');
const toolsDir = PathUtils.getIdeSubDir(bmadCommandsDir, 'tools');
const workflowsDir = PathUtils.getIdeSubDir(bmadCommandsDir, 'workflows');
const bmadCommandsDir = path.join(location, 'bmad');
const agentsDir = path.join(bmadCommandsDir, 'agents');
const tasksDir = path.join(bmadCommandsDir, 'tasks');
const toolsDir = path.join(bmadCommandsDir, 'tools');
const workflowsDir = path.join(bmadCommandsDir, 'workflows');
await this.ensureDir(agentsDir);
await this.ensureDir(tasksDir);
@@ -180,10 +179,10 @@ BMAD ${workflow.module.toUpperCase()} module
const fs = require('fs-extra');
// Only clean up project directory
const location = PathUtils.getIdeSubDir(projectDir, '.augment', 'commands');
const location = path.join(projectDir, '.augment', 'commands');
const bmadDir = path.join(location, 'bmad');
if (await this.exists(bmadDir)) {
if (await fs.pathExists(bmadDir)) {
await fs.remove(bmadDir);
console.log(chalk.dim(` Removed old BMAD commands`));
}
@@ -199,12 +198,12 @@ BMAD ${workflow.module.toUpperCase()} module
*/
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
// Auggie uses .augment/commands directory
const location = PathUtils.getIdeSubDir(projectDir, '.augment', 'commands');
const bmadCommandsDir = PathUtils.getIdeSubDir(location, 'bmad');
const agentsDir = PathUtils.getIdeSubDir(bmadCommandsDir, 'agents');
const location = path.join(projectDir, '.augment', 'commands');
const bmadCommandsDir = path.join(location, 'bmad');
const agentsDir = path.join(bmadCommandsDir, 'agents');
// Create .augment/commands/bmad/agents directory if it doesn't exist
await this.ensureDir(agentsDir);
await fs.ensureDir(agentsDir);
// Create custom agent launcher
const launcherContent = `---
@@ -231,7 +230,7 @@ BMAD Custom agent
const launcherPath = path.join(agentsDir, fileName);
// Write the launcher file
await this.writeFile(launcherPath, launcherContent);
await fs.writeFile(launcherPath, launcherContent, 'utf8');
return {
ide: 'auggie',

View File

@@ -2,7 +2,6 @@ const path = require('node:path');
const fs = require('fs-extra');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const { FileOps, PathUtils } = require('../../../lib/file-ops');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
const { WorkflowCommandGenerator } = require('./shared/workflow-command-generator');
const { TaskToolCommandGenerator } = require('./shared/task-tool-command-generator');
@@ -49,7 +48,7 @@ class ClaudeCodeSetup extends BaseIdeSetup {
try {
// Load injection configuration
const configContent = await this.readFile(injectionConfigPath);
const configContent = await fs.readFile(injectionConfigPath, 'utf8');
const injectionConfig = yaml.parse(configContent);
// Ask about subagents if they exist and we haven't asked yet
@@ -58,7 +57,7 @@ class ClaudeCodeSetup extends BaseIdeSetup {
if (config.subagentChoices.install !== 'none') {
// Ask for installation location
const inquirer = require('inquirer').default || require('inquirer');
const inquirer = require('inquirer');
const locationAnswer = await inquirer.prompt([
{
type: 'list',
@@ -88,9 +87,9 @@ class ClaudeCodeSetup extends BaseIdeSetup {
* @param {string} projectDir - Project directory
*/
async cleanup(projectDir) {
const bmadCommandsDir = PathUtils.getIdeSubDir(projectDir, this.configDir, this.commandsDir, 'bmad');
const bmadCommandsDir = path.join(projectDir, this.configDir, this.commandsDir, 'bmad');
if (await this.exists(bmadCommandsDir)) {
if (await fs.pathExists(bmadCommandsDir)) {
await fs.remove(bmadCommandsDir);
console.log(chalk.dim(` Removed old BMAD commands from ${this.name}`));
}
@@ -112,9 +111,9 @@ class ClaudeCodeSetup extends BaseIdeSetup {
await this.cleanup(projectDir);
// Create .claude/commands directory structure
const claudeDir = PathUtils.getConfigDir(projectDir, this.configDir);
const commandsDir = PathUtils.getIdeSubDir(projectDir, this.configDir, this.commandsDir);
const bmadCommandsDir = PathUtils.getIdeSubDir(projectDir, this.configDir, this.commandsDir, 'bmad');
const claudeDir = path.join(projectDir, this.configDir);
const commandsDir = path.join(claudeDir, this.commandsDir);
const bmadCommandsDir = path.join(commandsDir, 'bmad');
await this.ensureDir(bmadCommandsDir);
@@ -160,7 +159,7 @@ class ClaudeCodeSetup extends BaseIdeSetup {
let workflowCommandCount = 0;
for (const artifact of workflowArtifacts) {
if (artifact.type === 'workflow-command') {
const moduleWorkflowsDir = PathUtils.getIdeSubDir(bmadCommandsDir, artifact.module, 'workflows');
const moduleWorkflowsDir = path.join(bmadCommandsDir, artifact.module, 'workflows');
await this.ensureDir(moduleWorkflowsDir);
const commandPath = path.join(moduleWorkflowsDir, path.basename(artifact.relativePath));
await this.writeFile(commandPath, artifact.content);
@@ -199,7 +198,7 @@ class ClaudeCodeSetup extends BaseIdeSetup {
* Read and process file content
*/
async readAndProcess(filePath, metadata) {
const content = await this.readFile(filePath);
const content = await fs.readFile(filePath, 'utf8');
return this.processContent(content, metadata);
}
@@ -219,7 +218,7 @@ class ClaudeCodeSetup extends BaseIdeSetup {
// Add core agents
const corePath = getModulePath('core');
if (await this.exists(path.join(corePath, 'agents'))) {
if (await fs.pathExists(path.join(corePath, 'agents'))) {
const coreAgents = await getAgentsFromDir(path.join(corePath, 'agents'), 'core');
agents.push(...coreAgents);
}
@@ -229,7 +228,7 @@ class ClaudeCodeSetup extends BaseIdeSetup {
const modulePath = path.join(sourceDir, moduleName);
const agentsPath = path.join(modulePath, 'agents');
if (await this.exists(agentsPath)) {
if (await fs.pathExists(agentsPath)) {
const moduleAgents = await getAgentsFromDir(agentsPath, moduleName);
agents.push(...moduleAgents);
}
@@ -306,7 +305,7 @@ class ClaudeCodeSetup extends BaseIdeSetup {
choices = await this.promptSubagentInstallation(config.subagents);
if (choices.install !== 'none') {
const inquirer = require('inquirer').default || require('inquirer');
const inquirer = require('inquirer');
const locationAnswer = await inquirer.prompt([
{
type: 'list',
@@ -343,7 +342,7 @@ class ClaudeCodeSetup extends BaseIdeSetup {
* Prompt user for subagent installation preferences
*/
async promptSubagentInstallation(subagentConfig) {
const inquirer = require('inquirer').default || require('inquirer');
const inquirer = require('inquirer');
// First ask if they want to install subagents
const { install } = await inquirer.prompt([
@@ -396,7 +395,7 @@ class ClaudeCodeSetup extends BaseIdeSetup {
const targetPath = path.join(projectDir, injection.file);
if (await this.exists(targetPath)) {
let content = await this.readFile(targetPath);
let content = await fs.readFile(targetPath, 'utf8');
const marker = `<!-- IDE-INJECT-POINT: ${injection.point} -->`;
if (content.includes(marker)) {
@@ -408,7 +407,7 @@ class ClaudeCodeSetup extends BaseIdeSetup {
}
content = content.replace(marker, injectionContent);
await this.writeFile(targetPath, content);
await fs.writeFile(targetPath, content);
console.log(chalk.dim(` Injected: ${injection.point}${injection.file}`));
}
}
@@ -426,7 +425,7 @@ class ClaudeCodeSetup extends BaseIdeSetup {
targetDir = path.join(os.homedir(), '.claude', 'agents');
console.log(chalk.dim(` Installing subagents globally to: ~/.claude/agents/`));
} else {
targetDir = PathUtils.getIdeSubDir(projectDir, '.claude', 'agents');
targetDir = path.join(projectDir, '.claude', 'agents');
console.log(chalk.dim(` Installing subagents to project: .claude/agents/`));
}
@@ -472,7 +471,7 @@ class ClaudeCodeSetup extends BaseIdeSetup {
* @returns {Object|null} Info about created command
*/
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
const customAgentsDir = PathUtils.getIdeSubDir(projectDir, this.configDir, this.commandsDir, 'bmad', 'custom', 'agents');
const customAgentsDir = path.join(projectDir, this.configDir, this.commandsDir, 'bmad', 'custom', 'agents');
if (!(await this.exists(path.join(projectDir, this.configDir)))) {
return null; // IDE not configured for this project

View File

@@ -2,7 +2,6 @@ const path = require('node:path');
const fs = require('fs-extra');
const chalk = require('chalk');
const { BaseIdeSetup } = require('./_base-ide');
const { FileOps, PathUtils } = require('../../../lib/file-ops');
const { WorkflowCommandGenerator } = require('./shared/workflow-command-generator');
const { AgentCommandGenerator } = require('./shared/agent-command-generator');
const { getAgentsFromBmad, getTasksFromBmad } = require('./shared/bmad-artifacts');
@@ -27,8 +26,9 @@ class ClineSetup extends BaseIdeSetup {
async setup(projectDir, bmadDir, options = {}) {
console.log(chalk.cyan(`Setting up ${this.name}...`));
// Create .clinerules/workflows directory using shared utilities
const workflowsDir = PathUtils.getIdeSubDir(projectDir, this.configDir, this.workflowsDir);
// Create .clinerules/workflows directory
const clineDir = path.join(projectDir, this.configDir);
const workflowsDir = path.join(clineDir, this.workflowsDir);
await this.ensureDir(workflowsDir);
@@ -72,9 +72,9 @@ class ClineSetup extends BaseIdeSetup {
* Detect Cline installation by checking for .clinerules/workflows directory
*/
async detect(projectDir) {
const workflowsDir = PathUtils.getIdeSubDir(projectDir, this.configDir, this.workflowsDir);
const workflowsDir = path.join(projectDir, this.configDir, this.workflowsDir);
if (!(await this.exists(workflowsDir))) {
if (!(await fs.pathExists(workflowsDir))) {
return false;
}
@@ -159,8 +159,8 @@ class ClineSetup extends BaseIdeSetup {
for (const artifact of artifacts) {
const flattenedName = this.flattenFilename(artifact.relativePath);
const targetPath = PathUtils.joinSafe(destDir, flattenedName);
await this.writeFile(targetPath, artifact.content);
const targetPath = path.join(destDir, flattenedName);
await fs.writeFile(targetPath, artifact.content);
written++;
}
@@ -204,7 +204,7 @@ class ClineSetup extends BaseIdeSetup {
* Cleanup Cline configuration
*/
async cleanup(projectDir) {
const workflowsDir = PathUtils.getIdeSubDir(projectDir, this.configDir, this.workflowsDir);
const workflowsDir = path.join(projectDir, this.configDir, this.workflowsDir);
await this.clearOldBmadFiles(workflowsDir);
console.log(chalk.dim(`Removed ${this.name} BMAD configuration`));
}
@@ -218,10 +218,11 @@ class ClineSetup extends BaseIdeSetup {
* @returns {Object} Installation result
*/
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
const workflowsDir = PathUtils.getIdeSubDir(projectDir, this.configDir, this.workflowsDir);
const clineDir = path.join(projectDir, this.configDir);
const workflowsDir = path.join(clineDir, this.workflowsDir);
// Create .clinerules/workflows directory if it doesn't exist
await this.ensureDir(workflowsDir);
await fs.ensureDir(workflowsDir);
// Create custom agent launcher workflow
const launcherContent = `name: ${agentName}

View File

@@ -3,7 +3,6 @@ const fs = require('fs-extra');
const os = require('node:os');
const chalk = require('chalk');
const { BaseIdeSetup } = require('./_base-ide');
const { FileOps, PathUtils } = require('../../../lib/file-ops');
const { WorkflowCommandGenerator } = require('./shared/workflow-command-generator');
const { AgentCommandGenerator } = require('./shared/agent-command-generator');
const { getTasksFromBmad } = require('./shared/bmad-artifacts');
@@ -22,7 +21,7 @@ class CodexSetup extends BaseIdeSetup {
* @returns {Object} Collected configuration
*/
async collectConfiguration(options = {}) {
const inquirer = require('inquirer').default || require('inquirer');
const inquirer = require('inquirer');
let confirmed = false;
let installLocation = 'global';
@@ -131,7 +130,7 @@ class CodexSetup extends BaseIdeSetup {
const projectSpecificDir = this.getCodexPromptDir(projectDir_local, 'project');
// Check global location
if (await this.exists(globalDir)) {
if (await fs.pathExists(globalDir)) {
const entries = await fs.readdir(globalDir);
if (entries.some((entry) => entry.startsWith('bmad-'))) {
return true;
@@ -139,7 +138,7 @@ class CodexSetup extends BaseIdeSetup {
}
// Check project-specific location
if (await this.exists(projectSpecificDir)) {
if (await fs.pathExists(projectSpecificDir)) {
const entries = await fs.readdir(projectSpecificDir);
if (entries.some((entry) => entry.startsWith('bmad-'))) {
return true;
@@ -208,7 +207,7 @@ class CodexSetup extends BaseIdeSetup {
getCodexPromptDir(projectDir = null, location = 'global') {
if (location === 'project' && projectDir) {
return PathUtils.getIdeSubDir(projectDir, '.codex', 'prompts');
return path.join(projectDir, '.codex', 'prompts');
}
return path.join(os.homedir(), '.codex', 'prompts');
}
@@ -219,7 +218,7 @@ class CodexSetup extends BaseIdeSetup {
for (const artifact of artifacts) {
const flattenedName = this.flattenFilename(artifact.relativePath);
const targetPath = path.join(destDir, flattenedName);
await this.writeFile(targetPath, artifact.content);
await fs.writeFile(targetPath, artifact.content);
written++;
}
@@ -227,7 +226,7 @@ class CodexSetup extends BaseIdeSetup {
}
async clearOldBmadFiles(destDir) {
if (!(await this.exists(destDir))) {
if (!(await fs.pathExists(destDir))) {
return;
}
@@ -249,7 +248,7 @@ class CodexSetup extends BaseIdeSetup {
}
async readAndProcessWithProject(filePath, metadata, projectDir) {
const content = await this.readFile(filePath);
const content = await fs.readFile(filePath, 'utf8');
return super.processContent(content, metadata, projectDir);
}
@@ -377,7 +376,7 @@ You must fully embody this agent's persona and follow all activation instruction
const fileName = `bmad-custom-agents-${agentName}.md`;
const launcherPath = path.join(destDir, fileName);
await this.writeFile(launcherPath, launcherContent);
await fs.writeFile(launcherPath, launcherContent, 'utf8');
return {
path: path.relative(projectDir, launcherPath),

View File

@@ -2,7 +2,6 @@ const path = require('node:path');
const fs = require('fs-extra');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const { FileOps, PathUtils } = require('../../../lib/file-ops');
const { AgentCommandGenerator } = require('./shared/agent-command-generator');
const { WorkflowCommandGenerator } = require('./shared/workflow-command-generator');
@@ -27,8 +26,8 @@ class CrushSetup extends BaseIdeSetup {
console.log(chalk.cyan(`Setting up ${this.name}...`));
// Create .crush/commands/bmad directory structure
const crushDir = PathUtils.getConfigDir(projectDir, this.configDir);
const commandsDir = PathUtils.getIdeSubDir(projectDir, this.configDir, this.commandsDir, 'bmad');
const crushDir = path.join(projectDir, this.configDir);
const commandsDir = path.join(crushDir, this.commandsDir, 'bmad');
await this.ensureDir(commandsDir);
@@ -243,9 +242,9 @@ Part of the BMAD ${workflow.module.toUpperCase()} module.
*/
async cleanup(projectDir) {
const fs = require('fs-extra');
const bmadCommandsDir = PathUtils.getIdeSubDir(projectDir, this.configDir, this.commandsDir, 'bmad');
const bmadCommandsDir = path.join(projectDir, this.configDir, this.commandsDir, 'bmad');
if (await this.exists(bmadCommandsDir)) {
if (await fs.pathExists(bmadCommandsDir)) {
await fs.remove(bmadCommandsDir);
console.log(chalk.dim(`Removed BMAD commands from Crush`));
}
@@ -260,8 +259,8 @@ Part of the BMAD ${workflow.module.toUpperCase()} module.
* @returns {Object} Installation result
*/
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
const crushDir = PathUtils.getConfigDir(projectDir, this.configDir);
const bmadCommandsDir = PathUtils.getIdeSubDir(projectDir, this.configDir, this.commandsDir, 'bmad');
const crushDir = path.join(projectDir, this.configDir);
const bmadCommandsDir = path.join(crushDir, this.commandsDir, 'bmad');
// Create .crush/commands/bmad directory if it doesn't exist
await fs.ensureDir(bmadCommandsDir);
@@ -287,7 +286,7 @@ The agent will follow the persona and instructions from the main agent file.
const launcherPath = path.join(bmadCommandsDir, fileName);
// Write the launcher file
await this.writeFile(launcherPath, launcherContent);
await fs.writeFile(launcherPath, launcherContent, 'utf8');
return {
ide: 'crush',

View File

@@ -1,7 +1,6 @@
const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const { FileOps, PathUtils } = require('../../../lib/file-ops');
const { AgentCommandGenerator } = require('./shared/agent-command-generator');
const { WorkflowCommandGenerator } = require('./shared/workflow-command-generator');

View File

@@ -3,7 +3,6 @@ const fs = require('fs-extra');
const yaml = require('yaml');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const { FileOps, PathUtils } = require('../../../lib/file-ops');
const { AgentCommandGenerator } = require('./shared/agent-command-generator');
const { WorkflowCommandGenerator } = require('./shared/workflow-command-generator');

View File

@@ -1,8 +1,7 @@
const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const { FileOps, PathUtils } = require('../../../lib/file-ops');
const inquirer = require('inquirer').default || require('inquirer');
const inquirer = require('inquirer');
const { AgentCommandGenerator } = require('./shared/agent-command-generator');
/**

View File

@@ -2,7 +2,6 @@ const path = require('node:path');
const fs = require('fs-extra');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const { FileOps, PathUtils } = require('../../../lib/file-ops');
const { AgentCommandGenerator } = require('./shared/agent-command-generator');
const { WorkflowCommandGenerator } = require('./shared/workflow-command-generator');

View File

@@ -1,7 +1,6 @@
const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const { FileOps, PathUtils } = require('../../../lib/file-ops');
const { AgentCommandGenerator } = require('./shared/agent-command-generator');
/**

View File

@@ -3,7 +3,6 @@ const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const fs = require('fs-extra');
const yaml = require('yaml');
const { FileOps, PathUtils } = require('../../../lib/file-ops');
/**
* Kiro CLI setup handler for BMad Method

View File

@@ -4,7 +4,6 @@ const os = require('node:os');
const chalk = require('chalk');
const yaml = require('yaml');
const { BaseIdeSetup } = require('./_base-ide');
const { FileOps, PathUtils } = require('../../../lib/file-ops');
const { WorkflowCommandGenerator } = require('./shared/workflow-command-generator');
const { TaskToolCommandGenerator } = require('./shared/task-tool-command-generator');
const { AgentCommandGenerator } = require('./shared/agent-command-generator');

View File

@@ -2,7 +2,6 @@ const path = require('node:path');
const fs = require('fs-extra');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const { FileOps, PathUtils } = require('../../../lib/file-ops');
const { getAgentsFromBmad, getTasksFromBmad } = require('./shared/bmad-artifacts');
const { AgentCommandGenerator } = require('./shared/agent-command-generator');

View File

@@ -1,7 +1,6 @@
const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const { FileOps, PathUtils } = require('../../../lib/file-ops');
const { AgentCommandGenerator } = require('./shared/agent-command-generator');
/**

View File

@@ -2,7 +2,6 @@ const path = require('node:path');
const fs = require('fs-extra');
const chalk = require('chalk');
const { BaseIdeSetup } = require('./_base-ide');
const { FileOps, PathUtils } = require('../../../lib/file-ops');
const { AgentCommandGenerator } = require('./shared/agent-command-generator');
const { WorkflowCommandGenerator } = require('./shared/workflow-command-generator');
const { TaskToolCommandGenerator } = require('./shared/task-tool-command-generator');

View File

@@ -2,7 +2,6 @@ const path = require('node:path');
const fs = require('fs-extra');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const { FileOps, PathUtils } = require('../../../lib/file-ops');
const { AgentCommandGenerator } = require('./shared/agent-command-generator');
/**

View File

@@ -1,7 +1,6 @@
const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const { FileOps, PathUtils } = require('../../../lib/file-ops');
const { AgentCommandGenerator } = require('./shared/agent-command-generator');
/**

View File

@@ -2,9 +2,9 @@ const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const chalk = require('chalk');
const { XmlHandler } = require('../../../lib/agent/xml-handler');
const { XmlHandler } = require('../../../lib/xml-handler');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
const { filterCustomizationData } = require('../../../lib/agent/yaml-xml-builder');
const { filterCustomizationData } = require('../../../lib/agent/compiler');
/**
* Manages the installation, updating, and removal of BMAD modules.
@@ -757,7 +757,7 @@ class ModuleManager {
// Read and compile the YAML
try {
const yamlContent = await fs.readFile(sourceYamlPath, 'utf8');
const { compileAgent } = require('../../../lib/agent/yaml-xml-builder');
const { compileAgent } = require('../../../lib/agent/compiler');
// Create customize template if it doesn't exist
if (!(await fs.pathExists(customizePath))) {
@@ -952,7 +952,7 @@ class ModuleManager {
// // Check if content has agent XML and no activation block
// if (content.includes('<agent') && !content.includes('<activation')) {
// // Inject the activation block using XML handler
// // TODO: Reimplement activation injection if needed
// content = this.xmlHandler.injectActivationSimple(content);
// await fs.writeFile(agentFile, content, 'utf8');
// }
// }

View File

@@ -1,6 +1,6 @@
const fs = require('fs-extra');
const path = require('node:path');
const { getSourcePath } = require('../project-root');
const { getSourcePath } = require('./project-root');
/**
* Builds activation blocks from fragments based on agent profile

View File

@@ -1,6 +1,6 @@
const path = require('node:path');
const fs = require('fs-extra');
const { escapeXml } = require('./xml-utils');
const { escapeXml } = require('../../lib/xml-utils');
const AgentPartyGenerator = {
/**

View File

@@ -0,0 +1,564 @@
/**
* BMAD Agent Compiler
* Transforms agent YAML to compiled XML (.md) format
* Uses the existing BMAD builder infrastructure for proper formatting
*/
const yaml = require('yaml');
const fs = require('node:fs');
const path = require('node:path');
const { processAgentYaml, extractInstallConfig, stripInstallConfig, getDefaultValues } = require('./template-engine');
const { escapeXml } = require('../../../lib/xml-utils');
const { ActivationBuilder } = require('../activation-builder');
const { AgentAnalyzer } = require('../agent-analyzer');
/**
* Build frontmatter for agent
* @param {Object} metadata - Agent metadata
* @param {string} agentName - Final agent name
* @returns {string} YAML frontmatter
*/
function buildFrontmatter(metadata, agentName) {
const nameFromFile = agentName.replaceAll('-', ' ');
const description = metadata.title || 'BMAD Agent';
return `---
name: "${nameFromFile}"
description: "${description}"
---
You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.
`;
}
// buildSimpleActivation function removed - replaced by ActivationBuilder for proper fragment loading from src/utility/agent-components/
/**
* Build persona XML section
* @param {Object} persona - Persona object
* @returns {string} Persona XML
*/
function buildPersonaXml(persona) {
if (!persona) return '';
let xml = ' <persona>\n';
if (persona.role) {
const roleText = persona.role.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ');
xml += ` <role>${escapeXml(roleText)}</role>\n`;
}
if (persona.identity) {
const identityText = persona.identity.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ');
xml += ` <identity>${escapeXml(identityText)}</identity>\n`;
}
if (persona.communication_style) {
const styleText = persona.communication_style.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ');
xml += ` <communication_style>${escapeXml(styleText)}</communication_style>\n`;
}
if (persona.principles) {
let principlesText;
if (Array.isArray(persona.principles)) {
principlesText = persona.principles.join(' ');
} else {
principlesText = persona.principles.trim().replaceAll(/\n+/g, ' ');
}
xml += ` <principles>${escapeXml(principlesText)}</principles>\n`;
}
xml += ' </persona>\n';
return xml;
}
/**
* Build prompts XML section
* @param {Array} prompts - Prompts array
* @returns {string} Prompts XML
*/
function buildPromptsXml(prompts) {
if (!prompts || prompts.length === 0) return '';
let xml = ' <prompts>\n';
for (const prompt of prompts) {
xml += ` <prompt id="${prompt.id || ''}">\n`;
xml += ` <content>\n`;
// Don't escape prompt content - it's meant to be read as-is
xml += `${prompt.content || ''}\n`;
xml += ` </content>\n`;
xml += ` </prompt>\n`;
}
xml += ' </prompts>\n';
return xml;
}
/**
* Build memories XML section
* @param {Array} memories - Memories array
* @returns {string} Memories XML
*/
function buildMemoriesXml(memories) {
if (!memories || memories.length === 0) return '';
let xml = ' <memories>\n';
for (const memory of memories) {
xml += ` <memory>${escapeXml(String(memory))}</memory>\n`;
}
xml += ' </memories>\n';
return xml;
}
/**
* Build menu XML section
* Supports both legacy and multi format menu items
* Multi items display as a single menu item with nested handlers
* @param {Array} menuItems - Menu items
* @returns {string} Menu XML
*/
function buildMenuXml(menuItems) {
let xml = ' <menu>\n';
// Always inject menu display option first
xml += ` <item cmd="*menu">[M] Redisplay Menu Options</item>\n`;
// Add user-defined menu items
if (menuItems && menuItems.length > 0) {
for (const item of menuItems) {
// Handle multi format menu items with nested handlers
if (item.multi && item.triggers && Array.isArray(item.triggers)) {
xml += ` <item type="multi">${escapeXml(item.multi)}\n`;
xml += buildNestedHandlers(item.triggers);
xml += ` </item>\n`;
}
// Handle legacy format menu items
else if (item.trigger) {
// For legacy items, keep using cmd with *<trigger> format
let trigger = item.trigger || '';
if (!trigger.startsWith('*')) {
trigger = '*' + trigger;
}
const attrs = [`cmd="${trigger}"`];
// Add handler attributes
if (item.workflow) attrs.push(`workflow="${item.workflow}"`);
if (item.exec) attrs.push(`exec="${item.exec}"`);
if (item.tmpl) attrs.push(`tmpl="${item.tmpl}"`);
if (item.data) attrs.push(`data="${item.data}"`);
if (item.action) attrs.push(`action="${item.action}"`);
xml += ` <item ${attrs.join(' ')}>${escapeXml(item.description || '')}</item>\n`;
}
}
}
// Always inject dismiss last
xml += ` <item cmd="*dismiss">[D] Dismiss Agent</item>\n`;
xml += ' </menu>\n';
return xml;
}
/**
* Build nested handlers for multi format menu items
* @param {Array} triggers - Triggers array from multi format
* @returns {string} Handler XML
*/
function buildNestedHandlers(triggers) {
let xml = '';
for (const triggerGroup of triggers) {
for (const [triggerName, execArray] of Object.entries(triggerGroup)) {
// Build trigger with * prefix
let trigger = triggerName.startsWith('*') ? triggerName : '*' + triggerName;
// Extract the relevant execution data
const execData = processExecArray(execArray);
// For nested handlers in multi items, we use match attribute for fuzzy matching
const attrs = [`match="${escapeXml(execData.description || '')}"`];
// Add handler attributes based on exec data
if (execData.route) attrs.push(`exec="${execData.route}"`);
if (execData.workflow) attrs.push(`workflow="${execData.workflow}"`);
if (execData['validate-workflow']) attrs.push(`validate-workflow="${execData['validate-workflow']}"`);
if (execData.action) attrs.push(`action="${execData.action}"`);
if (execData.data) attrs.push(`data="${execData.data}"`);
if (execData.tmpl) attrs.push(`tmpl="${execData.tmpl}"`);
// Only add type if it's not 'exec' (exec is already implied by the exec attribute)
if (execData.type && execData.type !== 'exec') attrs.push(`type="${execData.type}"`);
xml += ` <handler ${attrs.join(' ')}></handler>\n`;
}
}
return xml;
}
/**
* Process the execution array from multi format triggers
* Extracts relevant data for XML attributes
* @param {Array} execArray - Array of execution objects
* @returns {Object} Processed execution data
*/
function processExecArray(execArray) {
const result = {
description: '',
route: null,
workflow: null,
data: null,
action: null,
type: null,
};
if (!Array.isArray(execArray)) {
return result;
}
for (const exec of execArray) {
if (exec.input) {
// Use input as description if no explicit description is provided
result.description = exec.input;
}
if (exec.route) {
// Determine if it's a workflow or exec based on file extension or context
if (exec.route.endsWith('.yaml') || exec.route.endsWith('.yml')) {
result.workflow = exec.route;
} else {
result.route = exec.route;
}
}
if (exec.data !== null && exec.data !== undefined) {
result.data = exec.data;
}
if (exec.action) {
result.action = exec.action;
}
if (exec.type) {
result.type = exec.type;
}
}
return result;
}
/**
* Compile agent YAML to proper XML format
* @param {Object} agentYaml - Parsed and processed agent YAML
* @param {string} agentName - Final agent name (for ID and frontmatter)
* @param {string} targetPath - Target path for agent ID
* @returns {Promise<string>} Compiled XML string with frontmatter
*/
async function compileToXml(agentYaml, agentName = '', targetPath = '') {
const agent = agentYaml.agent;
const meta = agent.metadata;
let xml = '';
// Build frontmatter
xml += buildFrontmatter(meta, agentName || meta.name || 'agent');
// Start code fence
xml += '```xml\n';
// Agent opening tag
const agentAttrs = [
`id="${targetPath || meta.id || ''}"`,
`name="${meta.name || ''}"`,
`title="${meta.title || ''}"`,
`icon="${meta.icon || '🤖'}"`,
];
xml += `<agent ${agentAttrs.join(' ')}>\n`;
// Activation block - use ActivationBuilder for proper fragment loading
const activationBuilder = new ActivationBuilder();
const analyzer = new AgentAnalyzer();
const profile = analyzer.analyzeAgentObject(agentYaml);
xml += await activationBuilder.buildActivation(
profile,
meta,
agent.critical_actions || [],
false, // forWebBundle - set to false for IDE deployment
);
// Persona section
xml += buildPersonaXml(agent.persona);
// Prompts section (if present)
if (agent.prompts && agent.prompts.length > 0) {
xml += buildPromptsXml(agent.prompts);
}
// Memories section (if present)
if (agent.memories && agent.memories.length > 0) {
xml += buildMemoriesXml(agent.memories);
}
// Menu section
xml += buildMenuXml(agent.menu || []);
// Closing agent tag
xml += '</agent>\n';
// Close code fence
xml += '```\n';
return xml;
}
/**
* Full compilation pipeline
* @param {string} yamlContent - Raw YAML string
* @param {Object} answers - Answers from install_config questions (or defaults)
* @param {string} agentName - Optional final agent name (user's custom persona name)
* @param {string} targetPath - Optional target path for agent ID
* @param {Object} options - Additional options including config
* @returns {Promise<Object>} { xml: string, metadata: Object }
*/
async function compileAgent(yamlContent, answers = {}, agentName = '', targetPath = '', options = {}) {
// Parse YAML
let agentYaml = yaml.parse(yamlContent);
// Apply customization merges before template processing
// Handle metadata overrides (like name)
if (answers.metadata) {
// Filter out empty values from metadata
const filteredMetadata = filterCustomizationData(answers.metadata);
if (Object.keys(filteredMetadata).length > 0) {
agentYaml.agent.metadata = { ...agentYaml.agent.metadata, ...filteredMetadata };
}
// Remove from answers so it doesn't get processed as template variables
const { metadata, ...templateAnswers } = answers;
answers = templateAnswers;
}
// Handle other customization properties
// These should be merged into the agent structure, not processed as template variables
const customizationKeys = ['persona', 'critical_actions', 'memories', 'menu', 'prompts'];
const customizations = {};
const remainingAnswers = { ...answers };
for (const key of customizationKeys) {
if (answers[key]) {
let filtered;
// Handle different data types
if (Array.isArray(answers[key])) {
// For arrays, filter out empty/null/undefined values
filtered = answers[key].filter((item) => item !== null && item !== undefined && item !== '');
} else {
// For objects, use filterCustomizationData
filtered = filterCustomizationData(answers[key]);
}
// Check if we have valid content
const hasContent = Array.isArray(filtered) ? filtered.length > 0 : Object.keys(filtered).length > 0;
if (hasContent) {
customizations[key] = filtered;
}
delete remainingAnswers[key];
}
}
// Merge customizations into agentYaml
if (Object.keys(customizations).length > 0) {
// For persona: replace entire section
if (customizations.persona) {
agentYaml.agent.persona = customizations.persona;
}
// For critical_actions: append to existing or create new
if (customizations.critical_actions) {
const existing = agentYaml.agent.critical_actions || [];
agentYaml.agent.critical_actions = [...existing, ...customizations.critical_actions];
}
// For memories: append to existing or create new
if (customizations.memories) {
const existing = agentYaml.agent.memories || [];
agentYaml.agent.memories = [...existing, ...customizations.memories];
}
// For menu: append to existing or create new
if (customizations.menu) {
const existing = agentYaml.agent.menu || [];
agentYaml.agent.menu = [...existing, ...customizations.menu];
}
// For prompts: append to existing or create new (by id)
if (customizations.prompts) {
const existing = agentYaml.agent.prompts || [];
// Merge by id, with customizations taking precedence
const mergedPrompts = [...existing];
for (const customPrompt of customizations.prompts) {
const existingIndex = mergedPrompts.findIndex((p) => p.id === customPrompt.id);
if (existingIndex === -1) {
mergedPrompts.push(customPrompt);
} else {
mergedPrompts[existingIndex] = customPrompt;
}
}
agentYaml.agent.prompts = mergedPrompts;
}
}
// Use remaining answers for template processing
answers = remainingAnswers;
// Extract install_config
const installConfig = extractInstallConfig(agentYaml);
// Merge defaults with provided answers
let finalAnswers = answers;
if (installConfig) {
const defaults = getDefaultValues(installConfig);
finalAnswers = { ...defaults, ...answers };
}
// Process templates with answers
const processedYaml = processAgentYaml(agentYaml, finalAnswers);
// Strip install_config from output
const cleanYaml = stripInstallConfig(processedYaml);
let xml = await compileToXml(cleanYaml, agentName, targetPath);
// Ensure xml is a string before attempting replaceAll
if (typeof xml !== 'string') {
throw new TypeError('compileToXml did not return a string');
}
return {
xml,
metadata: cleanYaml.agent.metadata,
processedYaml: cleanYaml,
};
}
/**
* Filter customization data to remove empty/null values
* @param {Object} data - Raw customization data
* @returns {Object} Filtered customization data
*/
function filterCustomizationData(data) {
const filtered = {};
for (const [key, value] of Object.entries(data)) {
if (value === null || value === undefined || value === '') {
continue; // Skip null/undefined/empty values
}
if (Array.isArray(value)) {
if (value.length > 0) {
filtered[key] = value;
}
} else if (typeof value === 'object') {
const nested = filterCustomizationData(value);
if (Object.keys(nested).length > 0) {
filtered[key] = nested;
}
} else {
filtered[key] = value;
}
}
return filtered;
}
/**
* Process TTS injection markers in content
* @param {string} content - Content to process
* @param {boolean} enableAgentVibes - Whether AgentVibes is enabled
* @returns {Object} { content: string, hadInjection: boolean }
*/
function processTTSInjectionPoints(content, enableAgentVibes) {
const hasAgentTTS = content.includes('<!-- TTS_INJECTION:agent-tts -->');
if (enableAgentVibes && hasAgentTTS) {
// Replace agent-tts injection marker with TTS rule
content = content.replaceAll(
'<!-- TTS_INJECTION:agent-tts -->',
`- When responding to user messages, speak your responses using TTS:
Call: \`.claude/hooks/bmad-speak.sh '{agent-id}' '{response-text}'\` after each response
Replace {agent-id} with YOUR agent ID from <agent id="..."> tag at top of this file
Replace {response-text} with the text you just output to the user
IMPORTANT: Use single quotes as shown - do NOT escape special characters like ! or $ inside single quotes
Run in background (&) to avoid blocking`,
);
return { content, hadInjection: true };
} else if (!enableAgentVibes && hasAgentTTS) {
// Strip injection markers when disabled
content = content.replaceAll(/<!-- TTS_INJECTION:agent-tts -->\n?/g, '');
}
return { content, hadInjection: false };
}
/**
* Compile agent file to .md
* @param {string} yamlPath - Path to agent YAML file
* @param {Object} options - { answers: {}, outputPath: string, enableAgentVibes: boolean }
* @returns {Object} Compilation result
*/
function compileAgentFile(yamlPath, options = {}) {
const yamlContent = fs.readFileSync(yamlPath, 'utf8');
const result = compileAgent(yamlContent, options.answers || {});
// Determine output path
let outputPath = options.outputPath;
if (!outputPath) {
// Default: same directory, same name, .md extension
const dir = path.dirname(yamlPath);
const basename = path.basename(yamlPath, '.agent.yaml');
outputPath = path.join(dir, `${basename}.md`);
}
// Process TTS injection points if enableAgentVibes option is provided
let xml = result.xml;
let ttsInjected = false;
if (options.enableAgentVibes !== undefined) {
const ttsResult = processTTSInjectionPoints(xml, options.enableAgentVibes);
xml = ttsResult.content;
ttsInjected = ttsResult.hadInjection;
}
// Write compiled XML
fs.writeFileSync(outputPath, xml, 'utf8');
return {
...result,
xml,
outputPath,
sourcePath: yamlPath,
ttsInjected,
};
}
module.exports = {
compileToXml,
compileAgent,
compileAgentFile,
escapeXml,
buildFrontmatter,
buildPersonaXml,
buildPromptsXml,
buildMemoriesXml,
buildMenuXml,
filterCustomizationData,
};

View File

@@ -0,0 +1,716 @@
/**
* BMAD Agent Installer
* Discovers, prompts, compiles, and installs agents
*/
const fs = require('node:fs');
const path = require('node:path');
const yaml = require('yaml');
const readline = require('node:readline');
const { compileAgent, compileAgentFile } = require('./compiler');
const { extractInstallConfig, getDefaultValues } = require('./template-engine');
/**
* Find BMAD config file in project
* @param {string} startPath - Starting directory to search from
* @returns {Object|null} Config data or null
*/
function findBmadConfig(startPath = process.cwd()) {
// Look for common BMAD folder names
const possibleNames = ['_bmad'];
for (const name of possibleNames) {
const configPath = path.join(startPath, name, 'bmb', 'config.yaml');
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, 'utf8');
const config = yaml.parse(content);
return {
...config,
bmadFolder: path.join(startPath, name),
projectRoot: startPath,
};
}
}
return null;
}
/**
* Resolve path variables like {project-root} and {bmad-folder}
* @param {string} pathStr - Path with variables
* @param {Object} context - Contains projectRoot, bmadFolder
* @returns {string} Resolved path
*/
function resolvePath(pathStr, context) {
return pathStr.replaceAll('{project-root}', context.projectRoot).replaceAll('{bmad-folder}', context_bmadFolder);
}
/**
* Discover available agents in the custom agent location recursively
* @param {string} searchPath - Path to search for agents
* @returns {Array} List of agent info objects
*/
function discoverAgents(searchPath) {
if (!fs.existsSync(searchPath)) {
return [];
}
const agents = [];
// Helper function to recursively search
function searchDirectory(dir, relativePath = '') {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const agentRelativePath = relativePath ? path.join(relativePath, entry.name) : entry.name;
if (entry.isFile() && entry.name.endsWith('.agent.yaml')) {
// Simple agent (single file)
// The agent name is based on the filename
const agentName = entry.name.replace('.agent.yaml', '');
agents.push({
type: 'simple',
name: agentName,
path: fullPath,
yamlFile: fullPath,
relativePath: agentRelativePath.replace('.agent.yaml', ''),
});
} else if (entry.isDirectory()) {
// Check if this directory contains an .agent.yaml file
try {
const dirContents = fs.readdirSync(fullPath);
const yamlFiles = dirContents.filter((f) => f.endsWith('.agent.yaml'));
if (yamlFiles.length > 0) {
// Found .agent.yaml files in this directory
for (const yamlFile of yamlFiles) {
const agentYamlPath = path.join(fullPath, yamlFile);
const agentName = path.basename(yamlFile, '.agent.yaml');
agents.push({
type: 'expert',
name: agentName,
path: fullPath,
yamlFile: agentYamlPath,
relativePath: agentRelativePath,
});
}
} else {
// No .agent.yaml in this directory, recurse deeper
searchDirectory(fullPath, agentRelativePath);
}
} catch {
// Skip directories we can't read
}
}
}
}
searchDirectory(searchPath);
return agents;
}
/**
* Load agent YAML and extract install_config
* @param {string} yamlPath - Path to agent YAML file
* @returns {Object} Agent YAML and install config
*/
function loadAgentConfig(yamlPath) {
const content = fs.readFileSync(yamlPath, 'utf8');
const agentYaml = yaml.parse(content);
const installConfig = extractInstallConfig(agentYaml);
const defaults = installConfig ? getDefaultValues(installConfig) : {};
// Check for saved_answers (from previously installed custom agents)
// These take precedence over defaults
const savedAnswers = agentYaml?.saved_answers || {};
const metadata = agentYaml?.agent?.metadata || {};
return {
yamlContent: content,
agentYaml,
installConfig,
defaults: { ...defaults, ...savedAnswers }, // saved_answers override defaults
metadata,
hasSidecar: metadata.hasSidecar === true,
};
}
/**
* Interactive prompt for install_config questions
* @param {Object} installConfig - Install configuration with questions
* @param {Object} defaults - Default values
* @returns {Promise<Object>} User answers
*/
async function promptInstallQuestions(installConfig, defaults, presetAnswers = {}) {
if (!installConfig || !installConfig.questions || installConfig.questions.length === 0) {
return { ...defaults, ...presetAnswers };
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const question = (prompt) =>
new Promise((resolve) => {
rl.question(prompt, resolve);
});
const answers = { ...defaults, ...presetAnswers };
console.log('\n📝 Agent Configuration\n');
if (installConfig.description) {
console.log(` ${installConfig.description}\n`);
}
for (const q of installConfig.questions) {
// Skip questions for variables that are already set (e.g., custom_name set upfront)
if (answers[q.var] !== undefined && answers[q.var] !== defaults[q.var]) {
console.log(chalk.dim(` ${q.var}: ${answers[q.var]} (already set)`));
continue;
}
let response;
switch (q.type) {
case 'text': {
const defaultHint = q.default ? ` (default: ${q.default})` : '';
response = await question(` ${q.prompt}${defaultHint}: `);
answers[q.var] = response || q.default || '';
break;
}
case 'boolean': {
const defaultHint = q.default ? ' [Y/n]' : ' [y/N]';
response = await question(` ${q.prompt}${defaultHint}: `);
if (response === '') {
answers[q.var] = q.default;
} else {
answers[q.var] = response.toLowerCase().startsWith('y');
}
break;
}
case 'choice': {
console.log(` ${q.prompt}`);
for (const [idx, opt] of q.options.entries()) {
const marker = opt.value === q.default ? '* ' : ' ';
console.log(` ${marker}${idx + 1}. ${opt.label}`);
}
const defaultIdx = q.options.findIndex((o) => o.value === q.default) + 1;
let validChoice = false;
let choiceIdx;
while (!validChoice) {
response = await question(` Choice (default: ${defaultIdx}): `);
if (response) {
choiceIdx = parseInt(response, 10) - 1;
if (isNaN(choiceIdx) || choiceIdx < 0 || choiceIdx >= q.options.length) {
console.log(` Invalid choice. Please enter 1-${q.options.length}`);
} else {
validChoice = true;
}
} else {
choiceIdx = defaultIdx - 1;
validChoice = true;
}
}
answers[q.var] = q.options[choiceIdx].value;
break;
}
// No default
}
}
rl.close();
return answers;
}
/**
* Install a compiled agent to target location
* @param {Object} agentInfo - Agent discovery info
* @param {Object} answers - User answers for install_config
* @param {string} targetPath - Target installation directory
* @param {Object} options - Additional options including config
* @returns {Object} Installation result
*/
function installAgent(agentInfo, answers, targetPath, options = {}) {
// Compile the agent
const { xml, metadata, processedYaml } = compileAgent(fs.readFileSync(agentInfo.yamlFile, 'utf8'), answers);
// Determine target agent folder name
// Use the folder name from agentInfo, NOT the persona name from metadata
const agentFolderName = agentInfo.name;
const agentTargetDir = path.join(targetPath, agentFolderName);
// Create target directory
if (!fs.existsSync(agentTargetDir)) {
fs.mkdirSync(agentTargetDir, { recursive: true });
}
// Write compiled XML (.md)
const compiledFileName = `${agentFolderName}.md`;
const compiledPath = path.join(agentTargetDir, compiledFileName);
fs.writeFileSync(compiledPath, xml, 'utf8');
const result = {
success: true,
agentName: metadata.name || agentInfo.name,
targetDir: agentTargetDir,
compiledFile: compiledPath,
};
return result;
}
/**
* Update agent metadata ID to reflect installed location
* @param {string} compiledContent - Compiled XML content
* @param {string} targetPath - Target installation path relative to project
* @returns {string} Updated content
*/
function updateAgentId(compiledContent, targetPath) {
// Update the id attribute in the opening agent tag
return compiledContent.replace(/(<agent\s+id=")[^"]*(")/, `$1${targetPath}$2`);
}
/**
* Detect if a path is within a BMAD project
* @param {string} targetPath - Path to check
* @returns {Object|null} Project info with bmadFolder and cfgFolder
*/
function detectBmadProject(targetPath) {
let checkPath = path.resolve(targetPath);
const root = path.parse(checkPath).root;
// Walk up directory tree looking for BMAD installation
while (checkPath !== root) {
const possibleNames = ['_bmad'];
for (const name of possibleNames) {
const bmadFolder = path.join(checkPath, name);
const cfgFolder = path.join(bmadFolder, '_config');
const manifestFile = path.join(cfgFolder, 'agent-manifest.csv');
if (fs.existsSync(manifestFile)) {
return {
projectRoot: checkPath,
bmadFolder,
cfgFolder,
manifestFile,
};
}
}
checkPath = path.dirname(checkPath);
}
return null;
}
/**
* Escape CSV field value
* @param {string} value - Value to escape
* @returns {string} Escaped value
*/
function escapeCsvField(value) {
if (typeof value !== 'string') value = String(value);
// If contains comma, quote, or newline, wrap in quotes and escape internal quotes
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
return '"' + value.replaceAll('"', '""') + '"';
}
return value;
}
/**
* Parse CSV line respecting quoted fields
* @param {string} line - CSV line
* @returns {Array} Parsed fields
*/
function parseCsvLine(line) {
const fields = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
const nextChar = line[i + 1];
if (char === '"' && !inQuotes) {
inQuotes = true;
} else if (char === '"' && inQuotes) {
if (nextChar === '"') {
current += '"';
i++; // Skip escaped quote
} else {
inQuotes = false;
}
} else if (char === ',' && !inQuotes) {
fields.push(current);
current = '';
} else {
current += char;
}
}
fields.push(current);
return fields;
}
/**
* Check if agent name exists in manifest
* @param {string} manifestFile - Path to agent-manifest.csv
* @param {string} agentName - Agent name to check
* @returns {Object|null} Existing entry or null
*/
function checkManifestForAgent(manifestFile, agentName) {
const content = fs.readFileSync(manifestFile, 'utf8');
const lines = content.trim().split('\n');
if (lines.length < 2) return null;
const header = parseCsvLine(lines[0]);
const nameIndex = header.indexOf('name');
if (nameIndex === -1) return null;
for (let i = 1; i < lines.length; i++) {
const fields = parseCsvLine(lines[i]);
if (fields[nameIndex] === agentName) {
const entry = {};
for (const [idx, col] of header.entries()) {
entry[col] = fields[idx] || '';
}
entry._lineNumber = i;
return entry;
}
}
return null;
}
/**
* Check if agent path exists in manifest
* @param {string} manifestFile - Path to agent-manifest.csv
* @param {string} agentPath - Agent path to check
* @returns {Object|null} Existing entry or null
*/
function checkManifestForPath(manifestFile, agentPath) {
const content = fs.readFileSync(manifestFile, 'utf8');
const lines = content.trim().split('\n');
if (lines.length < 2) return null;
const header = parseCsvLine(lines[0]);
const pathIndex = header.indexOf('path');
if (pathIndex === -1) return null;
for (let i = 1; i < lines.length; i++) {
const fields = parseCsvLine(lines[i]);
if (fields[pathIndex] === agentPath) {
const entry = {};
for (const [idx, col] of header.entries()) {
entry[col] = fields[idx] || '';
}
entry._lineNumber = i;
return entry;
}
}
return null;
}
/**
* Update existing entry in manifest
* @param {string} manifestFile - Path to agent-manifest.csv
* @param {Object} agentData - New agent data
* @param {number} lineNumber - Line number to replace (1-indexed, excluding header)
* @returns {boolean} Success
*/
function updateManifestEntry(manifestFile, agentData, lineNumber) {
const content = fs.readFileSync(manifestFile, 'utf8');
const lines = content.trim().split('\n');
const header = lines[0];
const columns = header.split(',');
// Build the new row
const row = columns.map((col) => {
const value = agentData[col] || '';
return escapeCsvField(value);
});
// Replace the line
lines[lineNumber] = row.join(',');
fs.writeFileSync(manifestFile, lines.join('\n') + '\n', 'utf8');
return true;
}
/**
* Add agent to manifest CSV
* @param {string} manifestFile - Path to agent-manifest.csv
* @param {Object} agentData - Agent metadata and path info
* @returns {boolean} Success
*/
function addToManifest(manifestFile, agentData) {
const content = fs.readFileSync(manifestFile, 'utf8');
const lines = content.trim().split('\n');
// Parse header to understand column order
const header = lines[0];
const columns = header.split(',');
// Build the new row based on header columns
const row = columns.map((col) => {
const value = agentData[col] || '';
return escapeCsvField(value);
});
// Append new row
const newLine = row.join(',');
const updatedContent = content.trim() + '\n' + newLine + '\n';
fs.writeFileSync(manifestFile, updatedContent, 'utf8');
return true;
}
/**
* Save agent source YAML to _config/custom/agents/ for reinstallation
* Stores user answers in a top-level saved_answers section (cleaner than overwriting defaults)
* @param {Object} agentInfo - Agent info (path, type, etc.)
* @param {string} cfgFolder - Path to _config folder
* @param {string} agentName - Final agent name (e.g., "fred-commit-poet")
* @param {Object} answers - User answers to save for reinstallation
* @returns {Object} Info about saved source
*/
function saveAgentSource(agentInfo, cfgFolder, agentName, answers = {}) {
// Save to _config/custom/agents/ instead of _config/agents/
const customAgentsCfgDir = path.join(cfgFolder, 'custom', 'agents');
if (!fs.existsSync(customAgentsCfgDir)) {
fs.mkdirSync(customAgentsCfgDir, { recursive: true });
}
const yamlLib = require('yaml');
/**
* Add saved_answers section to store user's actual answers
*/
function addSavedAnswers(agentYaml, answers) {
// Store answers in a clear, separate section
agentYaml.saved_answers = answers;
return agentYaml;
}
if (agentInfo.type === 'simple') {
// Simple agent: copy YAML with saved_answers section
const targetYaml = path.join(customAgentsCfgDir, `${agentName}.agent.yaml`);
const originalContent = fs.readFileSync(agentInfo.yamlFile, 'utf8');
const agentYaml = yamlLib.parse(originalContent);
// Add saved_answers section with user's choices
addSavedAnswers(agentYaml, answers);
fs.writeFileSync(targetYaml, yamlLib.stringify(agentYaml), 'utf8');
return { type: 'simple', path: targetYaml };
} else {
// Expert agent with sidecar: copy entire folder with saved_answers
const targetFolder = path.join(customAgentsCfgDir, agentName);
if (!fs.existsSync(targetFolder)) {
fs.mkdirSync(targetFolder, { recursive: true });
}
// Copy YAML and entire sidecar structure
const sourceDir = agentInfo.path;
const copied = [];
function copyDir(src, dest) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDir(srcPath, destPath);
} else if (entry.name.endsWith('.agent.yaml')) {
// For the agent YAML, add saved_answers section
const originalContent = fs.readFileSync(srcPath, 'utf8');
const agentYaml = yamlLib.parse(originalContent);
addSavedAnswers(agentYaml, answers);
// Rename YAML to match final agent name
const newYamlPath = path.join(dest, `${agentName}.agent.yaml`);
fs.writeFileSync(newYamlPath, yamlLib.stringify(agentYaml), 'utf8');
copied.push(newYamlPath);
} else {
fs.copyFileSync(srcPath, destPath);
copied.push(destPath);
}
}
}
copyDir(sourceDir, targetFolder);
return { type: 'expert', path: targetFolder, files: copied };
}
}
/**
* Create IDE slash command wrapper for agent
* Leverages IdeManager to dispatch to IDE-specific handlers
* @param {string} projectRoot - Project root path
* @param {string} agentName - Agent name (e.g., "commit-poet")
* @param {string} agentPath - Path to compiled agent (relative to project root)
* @param {Object} metadata - Agent metadata
* @returns {Promise<Object>} Info about created slash commands
*/
async function createIdeSlashCommands(projectRoot, agentName, agentPath, metadata) {
// Read manifest.yaml to get installed IDEs
const manifestPath = path.join(projectRoot, '_bmad', '_config', 'manifest.yaml');
let installedIdes = ['claude-code']; // Default to Claude Code if no manifest
if (fs.existsSync(manifestPath)) {
const yamlLib = require('yaml');
const manifestContent = fs.readFileSync(manifestPath, 'utf8');
const manifest = yamlLib.parse(manifestContent);
if (manifest.ides && Array.isArray(manifest.ides)) {
installedIdes = manifest.ides;
}
}
// Use IdeManager to install custom agent launchers for all configured IDEs
const { IdeManager } = require('../../installers/lib/ide/manager');
const ideManager = new IdeManager();
const results = await ideManager.installCustomAgentLaunchers(installedIdes, projectRoot, agentName, agentPath, metadata);
return results;
}
/**
* Update manifest.yaml to track custom agent
* @param {string} manifestPath - Path to manifest.yaml
* @param {string} agentName - Agent name
* @param {string} agentType - Agent type (source name)
* @returns {boolean} Success
*/
function updateManifestYaml(manifestPath, agentName, agentType) {
if (!fs.existsSync(manifestPath)) {
return false;
}
const yamlLib = require('yaml');
const content = fs.readFileSync(manifestPath, 'utf8');
const manifest = yamlLib.parse(content);
// Initialize custom_agents array if not exists
if (!manifest.custom_agents) {
manifest.custom_agents = [];
}
// Check if this agent is already registered
const existingIndex = manifest.custom_agents.findIndex((a) => a.name === agentName || (typeof a === 'string' && a === agentName));
const agentEntry = {
name: agentName,
type: agentType,
installed: new Date().toISOString(),
};
if (existingIndex === -1) {
// Add new entry
manifest.custom_agents.push(agentEntry);
} else {
// Update existing entry
manifest.custom_agents[existingIndex] = agentEntry;
}
// Update lastUpdated timestamp
if (manifest.installation) {
manifest.installation.lastUpdated = new Date().toISOString();
}
// Write back
const newContent = yamlLib.stringify(manifest);
fs.writeFileSync(manifestPath, newContent, 'utf8');
return true;
}
/**
* Extract manifest data from compiled agent XML
* @param {string} xmlContent - Compiled agent XML
* @param {Object} metadata - Agent metadata from YAML
* @param {string} agentPath - Relative path to agent file
* @param {string} moduleName - Module name (default: 'custom')
* @returns {Object} Manifest row data
*/
function extractManifestData(xmlContent, metadata, agentPath, moduleName = 'custom') {
// Extract data from XML using regex (simple parsing)
const extractTag = (tag) => {
const match = xmlContent.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`));
if (!match) return '';
// Collapse multiple lines into single line, normalize whitespace
return match[1].trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ').trim();
};
// Extract attributes from agent tag
const extractAgentAttribute = (attr) => {
const match = xmlContent.match(new RegExp(`<agent[^>]*\\s${attr}=["']([^"']+)["']`));
return match ? match[1] : '';
};
const extractPrinciples = () => {
const match = xmlContent.match(/<principles>([\s\S]*?)<\/principles>/);
if (!match) return '';
// Extract individual principle lines
const principles = match[1]
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0)
.join(' ');
return principles;
};
// Prioritize XML extraction over metadata for agent persona info
const xmlTitle = extractAgentAttribute('title') || extractTag('name');
const xmlIcon = extractAgentAttribute('icon');
return {
name: metadata.id ? path.basename(metadata.id, '.md') : metadata.name.toLowerCase().replaceAll(/\s+/g, '-'),
displayName: xmlTitle || metadata.name || '',
title: xmlTitle || metadata.title || '',
icon: xmlIcon || metadata.icon || '',
role: extractTag('role'),
identity: extractTag('identity'),
communicationStyle: extractTag('communication_style'),
principles: extractPrinciples(),
module: moduleName,
path: agentPath,
};
}
module.exports = {
findBmadConfig,
resolvePath,
discoverAgents,
loadAgentConfig,
promptInstallQuestions,
installAgent,
updateAgentId,
detectBmadProject,
addToManifest,
extractManifestData,
escapeCsvField,
checkManifestForAgent,
checkManifestForPath,
updateManifestEntry,
saveAgentSource,
createIdeSlashCommands,
updateManifestYaml,
};

View File

@@ -140,36 +140,6 @@ function getDefaultValues(installConfig) {
return defaults;
}
/**
* Filter out empty/null/undefined values from customization data
* @param {Object} data - Data to filter
* @returns {Object} Filtered data
*/
function filterCustomizationData(data) {
const filtered = {};
for (const [key, value] of Object.entries(data)) {
if (value === null || value === undefined || value === '') {
continue; // Skip null/undefined/empty values
}
if (Array.isArray(value)) {
if (value.length > 0) {
filtered[key] = value;
}
} else if (typeof value === 'object') {
const nested = filterCustomizationData(value);
if (Object.keys(nested).length > 0) {
filtered[key] = nested;
}
} else {
filtered[key] = value;
}
}
return filtered;
}
module.exports = {
processTemplate,
processConditionals,
@@ -179,5 +149,4 @@ module.exports = {
processAgentYaml,
getDefaultValues,
cleanupEmptyLines,
filterCustomizationData,
};

View File

@@ -201,64 +201,4 @@ class FileOps {
}
}
/**
* Path utilities for common IDE path patterns
*/
const PathUtils = {
/**
* Get IDE configuration directory path
* @param {string} projectDir - Project directory
* @param {string} configDir - IDE-specific config directory
* @returns {string} Full path to IDE config directory
*/
getConfigDir(projectDir, configDir) {
return path.join(projectDir, configDir);
},
/**
* Get IDE subdirectory path (e.g., workflows, agents, commands)
* @param {string} projectDir - Project directory
* @param {string} configDir - IDE-specific config directory
* @param {string} subDir - Subdirectory name
* @param {...string} additionalDirs - Additional directory levels
* @returns {string} Full path to IDE subdirectory
*/
getIdeSubDir(projectDir, configDir, subDir, ...additionalDirs) {
const parts = [projectDir, configDir, subDir, ...additionalDirs].filter(Boolean);
return path.join(...parts);
},
/**
* Get BMAD subdirectory path
* @param {string} projectDir - Project directory
* @param {string} bmadDir - BMAD installation directory
* @param {string} subDir - Subdirectory name
* @param {...string} additionalDirs - Additional directory levels
* @returns {string} Full path to BMAD subdirectory
*/
getBmadSubDir(projectDir, bmadDir, subDir, ...additionalDirs) {
const parts = [projectDir, bmadDir, subDir, ...additionalDirs].filter(Boolean);
return path.join(...parts);
},
/**
* Get path relative to a base directory
* @param {string} baseDir - Base directory
* @param {...string} pathParts - Path parts
* @returns {string} Full path
*/
relativeTo(baseDir, ...pathParts) {
return path.join(baseDir, ...pathParts.filter(Boolean));
},
/**
* Join multiple path parts safely (filters out null/undefined/empty)
* @param {...string} pathParts - Path parts
* @returns {string} Joined path
*/
joinSafe(...pathParts) {
return path.join(...pathParts.filter(Boolean));
},
};
module.exports = { FileOps, PathUtils };
module.exports = { FileOps };

View File

@@ -1,5 +1,5 @@
const chalk = require('chalk');
const inquirer = require('inquirer').default || require('inquirer');
const inquirer = require('inquirer');
const path = require('node:path');
const os = require('node:os');
const fs = require('fs-extra');

View File

@@ -1,7 +1,7 @@
const xml2js = require('xml2js');
const fs = require('fs-extra');
const path = require('node:path');
const { getProjectRoot, getSourcePath } = require('../project-root');
const { getProjectRoot, getSourcePath } = require('./project-root');
const { YamlXmlBuilder } = require('./yaml-xml-builder');
/**
@@ -124,6 +124,13 @@ class XmlHandler {
}
}
/**
* TODO: DELETE THIS METHOD
*/
injectActivationSimple(agentContent, metadata = {}) {
console.error('Error in simple injection:', error);
}
/**
* Build agent from YAML source
* @param {string} yamlPath - Path to .agent.yaml file

View File

@@ -4,14 +4,7 @@ const path = require('node:path');
const crypto = require('node:crypto');
const { AgentAnalyzer } = require('./agent-analyzer');
const { ActivationBuilder } = require('./activation-builder');
const { escapeXml } = require('../xml-utils');
const {
processAgentYaml,
extractInstallConfig,
stripInstallConfig,
getDefaultValues,
filterCustomizationData,
} = require('./template-engine');
const { escapeXml } = require('../../lib/xml-utils');
/**
* Converts agent YAML files to XML format with smart activation injection
@@ -232,7 +225,7 @@ class YamlXmlBuilder {
// Menu section (support both 'menu' and legacy 'commands')
const menuItems = agent.menu || agent.commands || [];
xml += this.buildMenuXml(menuItems, buildMetadata.forWebBundle);
xml += this.buildCommandsXml(menuItems, buildMetadata.forWebBundle);
xml += '</agent>\n';
xml += '```\n';
@@ -322,7 +315,7 @@ class YamlXmlBuilder {
for (const prompt of promptsArray) {
xml += ` <prompt id="${prompt.id || ''}">\n`;
xml += ` <content>\n`;
xml += `${prompt.content || ''}\n`; // Don't escape prompt content - it's meant to be read as-is
xml += `${escapeXml(prompt.content || '')}\n`;
xml += ` </content>\n`;
xml += ` </prompt>\n`;
}
@@ -339,11 +332,11 @@ class YamlXmlBuilder {
* @param {Array} menuItems - Menu items from YAML
* @param {boolean} forWebBundle - Whether building for web bundle
*/
buildMenuXml(menuItems, forWebBundle = false) {
buildCommandsXml(menuItems, forWebBundle = false) {
let xml = ' <menu>\n';
// Always inject menu display option first
xml += ` <item cmd="HM or fuzzy match on help">[HM] Redisplay Help Menu Options</item>\n`;
xml += ` <item cmd="*menu">[M] Redisplay Menu Options</item>\n`;
// Add user-defined menu items with * prefix
if (menuItems && menuItems.length > 0) {
@@ -365,7 +358,11 @@ class YamlXmlBuilder {
}
// Handle legacy format menu items
else if (item.trigger) {
// For legacy items, keep using cmd with *<trigger> format
let trigger = item.trigger || '';
if (!trigger.startsWith('*')) {
trigger = '*' + trigger;
}
const attrs = [`cmd="${trigger}"`];
@@ -391,7 +388,7 @@ class YamlXmlBuilder {
}
// Always inject dismiss last
xml += ` <item cmd="DA or fuzzy match on dismiss">[DA] Dismiss Agent</item>\n`;
xml += ` <item cmd="*dismiss">[D] Dismiss Agent</item>\n`;
xml += ' </menu>\n';
@@ -408,6 +405,9 @@ class YamlXmlBuilder {
for (const triggerGroup of triggers) {
for (const [triggerName, execArray] of Object.entries(triggerGroup)) {
// Build trigger with * prefix
let trigger = triggerName.startsWith('*') ? triggerName : '*' + triggerName;
// Extract the relevant execution data
const execData = this.processExecArray(execArray);
@@ -422,6 +422,7 @@ class YamlXmlBuilder {
if (execData.action) attrs.push(`action="${execData.action}"`);
if (execData.data) attrs.push(`data="${execData.data}"`);
if (execData.tmpl) attrs.push(`tmpl="${execData.tmpl}"`);
// Only add type if it's not 'exec' (exec is already implied by the exec attribute)
if (execData.type && execData.type !== 'exec') attrs.push(`type="${execData.type}"`);
xml += ` <handler ${attrs.join(' ')}></handler>\n`;
@@ -573,143 +574,6 @@ class YamlXmlBuilder {
customizeHash,
};
}
/**
* Process TTS injection points in XML content
* @param {string} content - XML content with TTS markers
* @param {boolean} enableAgentVibes - Whether to process AgentVibes markers
* @returns {string} Processed content
*/
processTTSInjectionPoints(content, enableAgentVibes = false) {
let result = content;
if (enableAgentVibes) {
// Process AgentVibes markers
result = result.replaceAll(/<AgentVibes\s+id="([^"]+)"\s*>/g, (match, id) => {
// Look for AgentVibes function in agent-analyzer data
if (this.analyzer.agentData && this.analyzer.agentData[id]) {
const functionText = this.analyzer.agentData[id];
return `<AgentVibes id="${id}">\n${functionText}\n</AgentVibes>`;
}
return match; // Keep original if not found
});
}
return result;
}
/**
* Legacy compatibility: compileAgent function for backward compatibility
* @param {string} yamlContent - YAML content
* @param {Object} answers - Template answers
* @param {string} agentName - Agent name
* @param {string} targetPath - Target path
* @param {Object} options - Additional options
* @returns {Object} Compilation result
*/
async compileAgent(yamlContent, answers = {}, agentName = '', targetPath = '', options = {}) {
// Parse YAML
let agentYaml = yaml.parse(yamlContent);
// Apply customization merges before template processing
// Handle metadata overrides (like name)
if (answers.metadata) {
// Filter out empty values from metadata
const filteredMetadata = filterCustomizationData(answers.metadata);
if (Object.keys(filteredMetadata).length > 0) {
agentYaml.agent.metadata = { ...agentYaml.agent.metadata, ...filteredMetadata };
}
// Remove from answers so it doesn't get processed as template variables
const { metadata, ...templateAnswers } = answers;
answers = templateAnswers;
}
// Handle other customization properties
// These should be merged into the agent structure, not processed as template variables
if (
answers.critical_actions && // Handle critical_actions merging
Array.isArray(answers.critical_actions)
) {
agentYaml.agent.critical_actions = [...(agentYaml.agent.critical_actions || []), ...answers.critical_actions];
}
// Extract install_config and process templates
const installConfig = extractInstallConfig(agentYaml);
const defaults = installConfig ? getDefaultValues(installConfig) : {};
// Process template variables
const processedYaml = processAgentYaml(agentYaml, { ...defaults, ...answers });
// Remove install_config after processing
const cleanYaml = stripInstallConfig(processedYaml);
// Convert to XML using our enhanced builder
const buildMetadata = {
sourceFile: targetPath,
module: cleanYaml.agent?.metadata?.module || 'core',
forWebBundle: options.forWebBundle || false,
skipActivation: options.skipActivation || false,
};
const xml = await this.convertToXml(cleanYaml, buildMetadata);
return {
xml,
metadata: cleanYaml.agent.metadata,
processedYaml: cleanYaml,
};
}
/**
* Legacy compatibility: compileAgentFile function
* @param {string} yamlPath - Path to YAML file
* @param {Object} options - Options
* @returns {Object} Compilation result
*/
async compileAgentFile(yamlPath, options = {}) {
const yamlContent = fs.readFileSync(yamlPath, 'utf8');
const result = await this.compileAgent(yamlContent, options.answers || {});
// Determine output path
let outputPath = options.outputPath;
if (!outputPath) {
// Default: same directory, same name, .md extension
const parsedPath = path.parse(yamlPath);
outputPath = path.join(parsedPath.dir, `${parsedPath.name}.md`);
}
// Process TTS injection if enabled
let finalXml = result.xml;
if (options.enableTTS) {
finalXml = this.processTTSInjectionPoints(finalXml, true);
}
// Write output file
fs.writeFileSync(outputPath, finalXml, 'utf8');
return {
outputPath,
xml: finalXml,
metadata: result.metadata,
};
}
}
// Export both the class and legacy functions for backward compatibility
module.exports = {
YamlXmlBuilder,
// Legacy exports for backward compatibility
compileAgent: (yamlContent, answers, agentName, targetPath, options) => {
const builder = new YamlXmlBuilder();
return builder.compileAgent(yamlContent, answers, agentName, targetPath, options);
},
compileAgentFile: (yamlPath, options) => {
const builder = new YamlXmlBuilder();
return builder.compileAgentFile(yamlPath, options);
},
filterCustomizationData,
processAgentYaml,
extractInstallConfig,
stripInstallConfig,
getDefaultValues,
};
module.exports = { YamlXmlBuilder };

View File

@@ -1,5 +1,5 @@
const fs = require('fs-extra');
const { escapeXml } = require('../cli/lib/xml-utils');
const { escapeXml } = require('../lib/xml-utils');
function indentFileContent(content) {
if (typeof content !== 'string') {

View File

@@ -55,6 +55,12 @@ platforms:
category: ide
description: "Enhanced Cline fork"
rovo:
name: "Rovo Dev"
preferred: false
category: ide
description: "Atlassian's AI coding assistant"
github-copilot:
name: "GitHub Copilot"
preferred: false
@@ -109,18 +115,6 @@ platforms:
category: ide
description: "AI coding tool"
kiro-cli:
name: "Kiro CLI"
preferred: false
category: cli
description: "Kiro command line interface"
rovo-dev:
name: "Rovo Dev"
preferred: false
category: ide
description: "Atlassian's AI coding assistant"
# Platform categories
categories:
ide:

View File

@@ -4,6 +4,56 @@ const { z } = require('zod');
const COMMAND_TARGET_KEYS = ['workflow', 'validate-workflow', 'exec', 'action', 'tmpl', 'data'];
const TRIGGER_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const COMPOUND_TRIGGER_PATTERN = /^([A-Z]{1,2}) or ([a-z0-9]+(?:-[a-z0-9]+)*) or fuzzy match on ([a-z0-9]+(?:-[a-z0-9]+)*)$/;
/**
* Derive the expected shortcut from a kebab-case trigger.
* - Single word: first letter (e.g., "help" → "H")
* - Multi-word: first letter of first two words (e.g., "tech-spec" → "TS")
* @param {string} kebabTrigger The kebab-case trigger name.
* @returns {string} The expected uppercase shortcut.
*/
function deriveShortcutFromKebab(kebabTrigger) {
const words = kebabTrigger.split('-');
if (words.length === 1) {
return words[0][0].toUpperCase();
}
return (words[0][0] + words[1][0]).toUpperCase();
}
/**
* Parse and validate a compound trigger string.
* Format: "<SHORTCUT> or <kebab-case> or fuzzy match on <kebab-case>"
* @param {string} triggerValue The trigger string to parse.
* @returns {{ valid: boolean, kebabTrigger?: string, error?: string }}
*/
function parseCompoundTrigger(triggerValue) {
const match = COMPOUND_TRIGGER_PATTERN.exec(triggerValue);
if (!match) {
return { valid: false, error: 'invalid compound trigger format' };
}
const [, shortcut, kebabTrigger, fuzzyKebab] = match;
// Validate both kebab instances are identical
if (kebabTrigger !== fuzzyKebab) {
return {
valid: false,
error: `kebab-case trigger mismatch: "${kebabTrigger}" vs "${fuzzyKebab}"`,
};
}
// Validate shortcut matches derived value
const expectedShortcut = deriveShortcutFromKebab(kebabTrigger);
if (shortcut !== expectedShortcut) {
return {
valid: false,
error: `shortcut "${shortcut}" does not match expected "${expectedShortcut}" for "${kebabTrigger}"`,
};
}
return { valid: true, kebabTrigger };
}
// Public API ---------------------------------------------------------------
@@ -52,17 +102,39 @@ function agentSchema(options = {}) {
// Handle legacy format with trigger field
if (item.trigger) {
const triggerValue = item.trigger;
let canonicalTrigger = triggerValue;
if (seenTriggers.has(triggerValue)) {
// Check if it's a compound trigger (contains " or ")
if (triggerValue.includes(' or ')) {
const result = parseCompoundTrigger(triggerValue);
if (!result.valid) {
ctx.addIssue({
code: 'custom',
path: ['agent', 'menu', index, 'trigger'],
message: `agent.menu[].trigger compound format error: ${result.error}`,
});
return;
}
canonicalTrigger = result.kebabTrigger;
} else if (!TRIGGER_PATTERN.test(triggerValue)) {
ctx.addIssue({
code: 'custom',
path: ['agent', 'menu', index, 'trigger'],
message: `agent.menu[].trigger duplicates "${triggerValue}" within the same agent`,
message: 'agent.menu[].trigger must be kebab-case (lowercase words separated by hyphen)',
});
return;
}
seenTriggers.add(triggerValue);
if (seenTriggers.has(canonicalTrigger)) {
ctx.addIssue({
code: 'custom',
path: ['agent', 'menu', index, 'trigger'],
message: `agent.menu[].trigger duplicates "${canonicalTrigger}" within the same agent`,
});
return;
}
seenTriggers.add(canonicalTrigger);
}
// Handle multi format with triggers array (new format)
else if (item.triggers && Array.isArray(item.triggers)) {
@@ -82,6 +154,15 @@ function agentSchema(options = {}) {
}
if (triggerName) {
if (!TRIGGER_PATTERN.test(triggerName)) {
ctx.addIssue({
code: 'custom',
path: ['agent', 'menu', index, 'triggers', triggerIndex],
message: `agent.menu[].triggers[] must be kebab-case (lowercase words separated by hyphen) - got "${triggerName}"`,
});
return;
}
if (seenTriggers.has(triggerName)) {
ctx.addIssue({
code: 'custom',
@@ -377,6 +458,15 @@ function buildMenuItemSchema() {
});
}
seenTriggers.add(triggerName);
// Validate trigger name format
if (!TRIGGER_PATTERN.test(triggerName)) {
ctx.addIssue({
code: 'custom',
path: ['agent', 'menu', 'triggers', index],
message: `Trigger name "${triggerName}" must be kebab-case (lowercase words separated by hyphen)`,
});
}
}
}
});