2025-12-06 22:45:02 -06:00
|
|
|
const path = require('node:path');
|
|
|
|
|
const fs = require('fs-extra');
|
|
|
|
|
const chalk = require('chalk');
|
|
|
|
|
const yaml = require('js-yaml');
|
|
|
|
|
const { FileOps } = require('../../../lib/file-ops');
|
2025-12-07 17:17:50 -06:00
|
|
|
const { XmlHandler } = require('../../../lib/xml-handler');
|
2025-12-06 22:45:02 -06:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handler for custom content (custom.yaml)
|
|
|
|
|
* Installs custom agents and workflows without requiring a full module structure
|
|
|
|
|
*/
|
|
|
|
|
class CustomHandler {
|
|
|
|
|
constructor() {
|
|
|
|
|
this.fileOps = new FileOps();
|
2025-12-07 17:17:50 -06:00
|
|
|
this.xmlHandler = new XmlHandler();
|
2025-12-06 22:45:02 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Find all custom.yaml files in the project
|
|
|
|
|
* @param {string} projectRoot - Project root directory
|
|
|
|
|
* @returns {Array} List of custom content paths
|
|
|
|
|
*/
|
|
|
|
|
async findCustomContent(projectRoot) {
|
|
|
|
|
const customPaths = [];
|
|
|
|
|
|
|
|
|
|
// Helper function to recursively scan directories
|
|
|
|
|
async function scanDirectory(dir, excludePaths = []) {
|
|
|
|
|
try {
|
|
|
|
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
|
|
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
const fullPath = path.join(dir, entry.name);
|
|
|
|
|
|
|
|
|
|
// Skip hidden directories and common exclusions
|
|
|
|
|
if (
|
|
|
|
|
entry.name.startsWith('.') ||
|
|
|
|
|
entry.name === 'node_modules' ||
|
|
|
|
|
entry.name === 'dist' ||
|
|
|
|
|
entry.name === 'build' ||
|
|
|
|
|
entry.name === '.git' ||
|
|
|
|
|
entry.name === 'bmad'
|
|
|
|
|
) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skip excluded paths
|
|
|
|
|
if (excludePaths.some((exclude) => fullPath.startsWith(exclude))) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entry.isDirectory()) {
|
|
|
|
|
// Recursively scan subdirectories
|
|
|
|
|
await scanDirectory(fullPath, excludePaths);
|
|
|
|
|
} else if (entry.name === 'custom.yaml') {
|
|
|
|
|
// Found a custom.yaml file
|
|
|
|
|
customPaths.push(fullPath);
|
2025-12-07 17:17:50 -06:00
|
|
|
} else if (
|
|
|
|
|
entry.name === 'module.yaml' && // Check if this is a custom module (either in _module-installer or in root directory)
|
|
|
|
|
// Skip if it's in src/modules (those are standard modules)
|
|
|
|
|
!fullPath.includes(path.join('src', 'modules'))
|
|
|
|
|
) {
|
|
|
|
|
customPaths.push(fullPath);
|
2025-12-06 22:45:02 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore errors (e.g., permission denied)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Scan the entire project, but exclude source directories
|
|
|
|
|
await scanDirectory(projectRoot, [path.join(projectRoot, 'src'), path.join(projectRoot, 'tools'), path.join(projectRoot, 'test')]);
|
|
|
|
|
|
|
|
|
|
return customPaths;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-12-07 17:17:50 -06:00
|
|
|
* Get custom content info from a custom.yaml or module.yaml file
|
|
|
|
|
* @param {string} configPath - Path to config file
|
2025-12-07 13:39:03 -06:00
|
|
|
* @param {string} projectRoot - Project root directory for calculating relative paths
|
2025-12-06 22:45:02 -06:00
|
|
|
* @returns {Object|null} Custom content info
|
|
|
|
|
*/
|
2025-12-07 17:17:50 -06:00
|
|
|
async getCustomInfo(configPath, projectRoot = null) {
|
2025-12-06 22:45:02 -06:00
|
|
|
try {
|
2025-12-07 17:17:50 -06:00
|
|
|
const configContent = await fs.readFile(configPath, 'utf8');
|
2025-12-06 22:45:02 -06:00
|
|
|
|
|
|
|
|
// Try to parse YAML with error handling
|
|
|
|
|
let config;
|
|
|
|
|
try {
|
2025-12-13 17:50:33 +08:00
|
|
|
config = yaml.parse(configContent);
|
2025-12-06 22:45:02 -06:00
|
|
|
} catch (parseError) {
|
2025-12-07 17:17:50 -06:00
|
|
|
console.warn(chalk.yellow(`Warning: YAML parse error in ${configPath}:`, parseError.message));
|
2025-12-06 22:45:02 -06:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-07 17:17:50 -06:00
|
|
|
// Check if this is an module.yaml (module) or custom.yaml (custom content)
|
|
|
|
|
const isInstallConfig = configPath.endsWith('module.yaml');
|
|
|
|
|
const configDir = path.dirname(configPath);
|
|
|
|
|
|
2025-12-07 13:39:03 -06:00
|
|
|
// Use provided projectRoot or fall back to process.cwd()
|
|
|
|
|
const basePath = projectRoot || process.cwd();
|
2025-12-07 17:17:50 -06:00
|
|
|
const relativePath = path.relative(basePath, configDir);
|
2025-12-06 22:45:02 -06:00
|
|
|
|
|
|
|
|
return {
|
2025-12-07 17:17:50 -06:00
|
|
|
id: config.code || 'unknown-code',
|
|
|
|
|
name: config.name,
|
|
|
|
|
description: config.description || '',
|
|
|
|
|
path: configDir,
|
2025-12-06 22:45:02 -06:00
|
|
|
relativePath: relativePath,
|
|
|
|
|
defaultSelected: config.default_selected === true,
|
|
|
|
|
config: config,
|
2025-12-07 17:17:50 -06:00
|
|
|
isInstallConfig: isInstallConfig, // Track which type this is
|
2025-12-06 22:45:02 -06:00
|
|
|
};
|
|
|
|
|
} catch (error) {
|
2025-12-07 17:17:50 -06:00
|
|
|
console.warn(chalk.yellow(`Warning: Failed to read ${configPath}:`, error.message));
|
2025-12-06 22:45:02 -06:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Install custom content
|
|
|
|
|
* @param {string} customPath - Path to custom content directory
|
|
|
|
|
* @param {string} bmadDir - Target bmad directory
|
|
|
|
|
* @param {Object} config - Configuration from custom.yaml
|
|
|
|
|
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
|
|
|
|
* @returns {Object} Installation result
|
|
|
|
|
*/
|
|
|
|
|
async install(customPath, bmadDir, config, fileTrackingCallback = null) {
|
|
|
|
|
const results = {
|
|
|
|
|
agentsInstalled: 0,
|
|
|
|
|
workflowsInstalled: 0,
|
|
|
|
|
filesCopied: 0,
|
|
|
|
|
preserved: 0,
|
|
|
|
|
errors: [],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Create custom directories in bmad
|
|
|
|
|
const bmadCustomDir = path.join(bmadDir, 'custom');
|
|
|
|
|
const bmadAgentsDir = path.join(bmadCustomDir, 'agents');
|
|
|
|
|
const bmadWorkflowsDir = path.join(bmadCustomDir, 'workflows');
|
|
|
|
|
|
|
|
|
|
await fs.ensureDir(bmadCustomDir);
|
|
|
|
|
await fs.ensureDir(bmadAgentsDir);
|
|
|
|
|
await fs.ensureDir(bmadWorkflowsDir);
|
|
|
|
|
|
2025-12-07 17:17:50 -06:00
|
|
|
// Process agents - compile and copy agents
|
2025-12-06 22:45:02 -06:00
|
|
|
const agentsDir = path.join(customPath, 'agents');
|
|
|
|
|
if (await fs.pathExists(agentsDir)) {
|
2025-12-07 17:17:50 -06:00
|
|
|
await this.compileAndCopyAgents(agentsDir, bmadAgentsDir, bmadDir, config, fileTrackingCallback, results);
|
2025-12-06 22:45:02 -06:00
|
|
|
|
|
|
|
|
// Count agent files
|
|
|
|
|
const agentFiles = await this.findFilesRecursively(agentsDir, ['.agent.yaml', '.md']);
|
|
|
|
|
results.agentsInstalled = agentFiles.length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Process workflows - copy entire workflows directory structure
|
|
|
|
|
const workflowsDir = path.join(customPath, 'workflows');
|
|
|
|
|
if (await fs.pathExists(workflowsDir)) {
|
|
|
|
|
await this.copyDirectory(workflowsDir, bmadWorkflowsDir, results, fileTrackingCallback, config);
|
|
|
|
|
|
|
|
|
|
// Count workflow files
|
|
|
|
|
const workflowFiles = await this.findFilesRecursively(workflowsDir, ['.md']);
|
|
|
|
|
results.workflowsInstalled = workflowFiles.length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Process any additional files at root
|
|
|
|
|
const entries = await fs.readdir(customPath, { withFileTypes: true });
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
if (entry.isFile() && entry.name !== 'custom.yaml' && !entry.name.startsWith('.') && !entry.name.endsWith('.md')) {
|
|
|
|
|
// Skip .md files at root as they're likely docs
|
|
|
|
|
const sourcePath = path.join(customPath, entry.name);
|
|
|
|
|
const targetPath = path.join(bmadCustomDir, entry.name);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Check if file already exists
|
|
|
|
|
if (await fs.pathExists(targetPath)) {
|
|
|
|
|
// File already exists, preserve it
|
|
|
|
|
results.preserved = (results.preserved || 0) + 1;
|
|
|
|
|
} else {
|
|
|
|
|
await fs.copy(sourcePath, targetPath);
|
|
|
|
|
results.filesCopied++;
|
|
|
|
|
|
|
|
|
|
if (fileTrackingCallback) {
|
|
|
|
|
fileTrackingCallback(targetPath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
results.errors.push(`Failed to copy file ${entry.name}: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
results.errors.push(`Installation failed: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return results;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Find all files with specific extensions recursively
|
|
|
|
|
* @param {string} dir - Directory to search
|
|
|
|
|
* @param {Array} extensions - File extensions to match
|
|
|
|
|
* @returns {Array} List of matching files
|
|
|
|
|
*/
|
|
|
|
|
async findFilesRecursively(dir, extensions) {
|
|
|
|
|
const files = [];
|
|
|
|
|
|
|
|
|
|
async function search(currentDir) {
|
|
|
|
|
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
|
|
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
const fullPath = path.join(currentDir, entry.name);
|
|
|
|
|
|
|
|
|
|
if (entry.isDirectory()) {
|
|
|
|
|
await search(fullPath);
|
|
|
|
|
} else if (extensions.some((ext) => entry.name.endsWith(ext))) {
|
|
|
|
|
files.push(fullPath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await search(dir);
|
|
|
|
|
return files;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Recursively copy a directory
|
|
|
|
|
* @param {string} sourceDir - Source directory
|
|
|
|
|
* @param {string} targetDir - Target directory
|
|
|
|
|
* @param {Object} results - Results object to update
|
|
|
|
|
* @param {Function} fileTrackingCallback - Optional callback
|
|
|
|
|
* @param {Object} config - Configuration for placeholder replacement
|
|
|
|
|
*/
|
|
|
|
|
async copyDirectory(sourceDir, targetDir, results, fileTrackingCallback, config) {
|
|
|
|
|
await fs.ensureDir(targetDir);
|
|
|
|
|
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
|
|
|
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
const sourcePath = path.join(sourceDir, entry.name);
|
|
|
|
|
const targetPath = path.join(targetDir, entry.name);
|
|
|
|
|
|
|
|
|
|
if (entry.isDirectory()) {
|
|
|
|
|
await this.copyDirectory(sourcePath, targetPath, results, fileTrackingCallback, config);
|
|
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
// Check if file already exists
|
|
|
|
|
if (await fs.pathExists(targetPath)) {
|
|
|
|
|
// File already exists, preserve it
|
|
|
|
|
results.preserved = (results.preserved || 0) + 1;
|
|
|
|
|
} else {
|
|
|
|
|
// Copy with placeholder replacement for text files
|
|
|
|
|
const textExtensions = ['.md', '.yaml', '.yml', '.txt', '.json'];
|
|
|
|
|
if (textExtensions.some((ext) => entry.name.endsWith(ext))) {
|
2025-12-07 13:39:03 -06:00
|
|
|
// Read source content
|
|
|
|
|
let content = await fs.readFile(sourcePath, 'utf8');
|
|
|
|
|
|
|
|
|
|
// Replace placeholders
|
|
|
|
|
content = content.replaceAll('{user_name}', config.user_name || 'User');
|
|
|
|
|
content = content.replaceAll('{communication_language}', config.communication_language || 'English');
|
|
|
|
|
content = content.replaceAll('{output_folder}', config.output_folder || 'docs');
|
|
|
|
|
|
|
|
|
|
// Write to target
|
|
|
|
|
await fs.ensureDir(path.dirname(targetPath));
|
|
|
|
|
await fs.writeFile(targetPath, content, 'utf8');
|
2025-12-06 22:45:02 -06:00
|
|
|
} else {
|
2025-12-07 13:39:03 -06:00
|
|
|
// Copy binary files as-is
|
2025-12-06 22:45:02 -06:00
|
|
|
await fs.copy(sourcePath, targetPath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
results.filesCopied++;
|
|
|
|
|
if (fileTrackingCallback) {
|
|
|
|
|
fileTrackingCallback(targetPath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entry.name.endsWith('.md')) {
|
|
|
|
|
results.workflowsInstalled++;
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
results.errors.push(`Failed to copy ${entry.name}: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-07 17:17:50 -06:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Compile .agent.yaml files to .md format and handle sidecars
|
|
|
|
|
* @param {string} sourceAgentsPath - Source agents directory
|
|
|
|
|
* @param {string} targetAgentsPath - Target agents directory
|
|
|
|
|
* @param {string} bmadDir - BMAD installation directory
|
|
|
|
|
* @param {Object} config - Configuration for placeholder replacement
|
|
|
|
|
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
|
|
|
|
* @param {Object} results - Results object to update
|
|
|
|
|
*/
|
|
|
|
|
async compileAndCopyAgents(sourceAgentsPath, targetAgentsPath, bmadDir, config, fileTrackingCallback, results) {
|
|
|
|
|
// Get all .agent.yaml files recursively
|
|
|
|
|
const agentFiles = await this.findFilesRecursively(sourceAgentsPath, ['.agent.yaml']);
|
|
|
|
|
|
|
|
|
|
for (const agentFile of agentFiles) {
|
|
|
|
|
const relativePath = path.relative(sourceAgentsPath, agentFile);
|
|
|
|
|
const targetDir = path.join(targetAgentsPath, path.dirname(relativePath));
|
|
|
|
|
|
|
|
|
|
await fs.ensureDir(targetDir);
|
|
|
|
|
|
|
|
|
|
const agentName = path.basename(agentFile, '.agent.yaml');
|
|
|
|
|
const targetMdPath = path.join(targetDir, `${agentName}.md`);
|
|
|
|
|
// Use the actual bmadDir if available (for when installing to temp dir)
|
|
|
|
|
const actualBmadDir = config._bmadDir || bmadDir;
|
|
|
|
|
const customizePath = path.join(actualBmadDir, '_cfg', 'agents', `custom-${agentName}.customize.yaml`);
|
|
|
|
|
|
|
|
|
|
// Read and compile the YAML
|
|
|
|
|
try {
|
|
|
|
|
const yamlContent = await fs.readFile(agentFile, 'utf8');
|
|
|
|
|
const { compileAgent } = require('../../../lib/agent/compiler');
|
|
|
|
|
|
|
|
|
|
// Create customize template if it doesn't exist
|
|
|
|
|
if (!(await fs.pathExists(customizePath))) {
|
|
|
|
|
const { getSourcePath } = require('../../../lib/project-root');
|
2025-12-13 16:22:34 +08:00
|
|
|
const genericTemplatePath = getSourcePath('utility', 'agent-components', 'agent.customize.template.yaml');
|
2025-12-07 17:17:50 -06:00
|
|
|
if (await fs.pathExists(genericTemplatePath)) {
|
|
|
|
|
// Copy with placeholder replacement
|
|
|
|
|
let templateContent = await fs.readFile(genericTemplatePath, 'utf8');
|
|
|
|
|
await fs.writeFile(customizePath, templateContent, 'utf8');
|
|
|
|
|
console.log(chalk.dim(` Created customize: custom-${agentName}.customize.yaml`));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Compile the agent
|
|
|
|
|
const { xml } = compileAgent(yamlContent, {}, agentName, relativePath, { config });
|
|
|
|
|
|
|
|
|
|
// Replace placeholders in the compiled content
|
|
|
|
|
let processedXml = xml;
|
|
|
|
|
processedXml = processedXml.replaceAll('{user_name}', config.user_name || 'User');
|
|
|
|
|
processedXml = processedXml.replaceAll('{communication_language}', config.communication_language || 'English');
|
|
|
|
|
processedXml = processedXml.replaceAll('{output_folder}', config.output_folder || 'docs');
|
|
|
|
|
|
|
|
|
|
// Write the compiled MD file
|
|
|
|
|
await fs.writeFile(targetMdPath, processedXml, 'utf8');
|
|
|
|
|
|
|
|
|
|
// Check if agent has sidecar
|
|
|
|
|
let hasSidecar = false;
|
|
|
|
|
try {
|
|
|
|
|
const yamlLib = require('yaml');
|
|
|
|
|
const agentYaml = yamlLib.parse(yamlContent);
|
|
|
|
|
hasSidecar = agentYaml?.agent?.metadata?.hasSidecar === true;
|
|
|
|
|
} catch {
|
|
|
|
|
// Continue without sidecar processing
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Copy sidecar files if agent has hasSidecar flag
|
|
|
|
|
if (hasSidecar && config.agent_sidecar_folder) {
|
|
|
|
|
const { copyAgentSidecarFiles } = require('../../../lib/agent/installer');
|
|
|
|
|
|
|
|
|
|
// Resolve agent sidecar folder path
|
|
|
|
|
const projectDir = path.dirname(bmadDir);
|
|
|
|
|
const resolvedSidecarFolder = config.agent_sidecar_folder
|
|
|
|
|
.replaceAll('{project-root}', projectDir)
|
2025-12-13 16:22:34 +08:00
|
|
|
.replaceAll('_bmad', path.basename(bmadDir));
|
2025-12-07 17:17:50 -06:00
|
|
|
|
|
|
|
|
// Create sidecar directory for this agent
|
|
|
|
|
const agentSidecarDir = path.join(resolvedSidecarFolder, agentName);
|
|
|
|
|
await fs.ensureDir(agentSidecarDir);
|
|
|
|
|
|
|
|
|
|
// Copy sidecar files
|
|
|
|
|
const sidecarResult = copyAgentSidecarFiles(path.dirname(agentFile), agentSidecarDir, agentFile);
|
|
|
|
|
|
|
|
|
|
if (sidecarResult.copied.length > 0) {
|
|
|
|
|
console.log(chalk.dim(` Copied ${sidecarResult.copied.length} sidecar file(s) to: ${agentSidecarDir}`));
|
|
|
|
|
}
|
|
|
|
|
if (sidecarResult.preserved.length > 0) {
|
|
|
|
|
console.log(chalk.dim(` Preserved ${sidecarResult.preserved.length} existing sidecar file(s)`));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Track the file
|
|
|
|
|
if (fileTrackingCallback) {
|
|
|
|
|
fileTrackingCallback(targetMdPath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(
|
|
|
|
|
chalk.dim(
|
|
|
|
|
` Compiled agent: ${agentName} -> ${path.relative(targetAgentsPath, targetMdPath)}${hasSidecar ? ' (with sidecar)' : ''}`,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn(chalk.yellow(` Failed to compile agent ${agentName}:`, error.message));
|
|
|
|
|
results.errors.push(`Failed to compile agent ${agentName}: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-06 22:45:02 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = { CustomHandler };
|