mirror of
https://github.com/bmadcode/BMAD-METHOD.git
synced 2025-12-29 16:14:59 +00:00
installer updates working with basic flow
This commit is contained in:
@@ -2,6 +2,8 @@ const chalk = require('chalk');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const readline = require('node:readline');
|
||||
const yaml = require('js-yaml');
|
||||
const inquirer = require('inquirer');
|
||||
const {
|
||||
findBmadConfig,
|
||||
resolvePath,
|
||||
@@ -18,6 +20,122 @@ const {
|
||||
updateManifestYaml,
|
||||
} = require('../lib/agent/installer');
|
||||
|
||||
/**
|
||||
* Initialize BMAD core infrastructure in a directory
|
||||
* @param {string} projectDir - Project directory where .bmad should be created
|
||||
* @param {string} bmadFolderName - Name of the BMAD folder (default: .bmad)
|
||||
* @returns {Promise<Object>} BMAD project info
|
||||
*/
|
||||
async function initializeBmadCore(projectDir, bmadFolderName = '.bmad') {
|
||||
const bmadDir = path.join(projectDir, bmadFolderName);
|
||||
const cfgDir = path.join(bmadDir, '_cfg');
|
||||
|
||||
console.log(chalk.cyan('\n🏗️ Initializing BMAD Core Infrastructure\n'));
|
||||
|
||||
// Use the ConfigCollector to ask proper core configuration questions
|
||||
const { ConfigCollector } = require('../installers/lib/core/config-collector');
|
||||
const configCollector = new ConfigCollector();
|
||||
|
||||
// Collect core configuration answers
|
||||
await configCollector.loadExistingConfig(projectDir);
|
||||
await configCollector.collectModuleConfig('core', projectDir, true, true);
|
||||
|
||||
// Extract core answers from allAnswers (they are prefixed with 'core_')
|
||||
const coreAnswers = {};
|
||||
if (configCollector.allAnswers) {
|
||||
for (const [key, value] of Object.entries(configCollector.allAnswers)) {
|
||||
if (key.startsWith('core_')) {
|
||||
const configKey = key.slice(5); // Remove 'core_' prefix
|
||||
coreAnswers[configKey] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ask for IDE selection
|
||||
console.log(chalk.cyan('\n💻 IDE Configuration\n'));
|
||||
console.log(chalk.dim('Select IDEs to integrate with the installed agents:'));
|
||||
|
||||
const { UI } = require('../lib/ui');
|
||||
const ui = new UI();
|
||||
const ideConfig = await ui.promptToolSelection(projectDir, ['core']);
|
||||
const selectedIdes = ideConfig.ides || [];
|
||||
|
||||
// Create directory structure
|
||||
console.log(chalk.dim('\nCreating directory structure...'));
|
||||
await fs.promises.mkdir(bmadDir, { recursive: true });
|
||||
await fs.promises.mkdir(cfgDir, { recursive: true });
|
||||
await fs.promises.mkdir(path.join(bmadDir, 'core'), { recursive: true });
|
||||
await fs.promises.mkdir(path.join(bmadDir, 'custom', 'agents'), { recursive: true });
|
||||
await fs.promises.mkdir(path.join(cfgDir, 'agents'), { recursive: true });
|
||||
await fs.promises.mkdir(path.join(cfgDir, 'custom', 'agents'), { recursive: true });
|
||||
|
||||
// Create core config.yaml file
|
||||
const coreConfigFile = {
|
||||
'# CORE Module Configuration': 'Generated by BMAD Agent Installer',
|
||||
Version: require(path.join(__dirname, '../../../package.json')).version,
|
||||
Date: new Date().toISOString(),
|
||||
bmad_folder: bmadFolderName,
|
||||
...coreAnswers,
|
||||
};
|
||||
|
||||
const coreConfigPath = path.join(bmadDir, 'core', 'config.yaml');
|
||||
await fs.promises.writeFile(coreConfigPath, yaml.dump(coreConfigFile), 'utf8');
|
||||
|
||||
// Create manifest.yaml with complete structure
|
||||
const manifest = {
|
||||
version: require(path.join(__dirname, '../../../package.json')).version,
|
||||
date: new Date().toISOString(),
|
||||
user_name: coreAnswers.user_name,
|
||||
communication_language: coreAnswers.communication_language,
|
||||
document_output_language: coreAnswers.document_output_language,
|
||||
output_folder: coreAnswers.output_folder,
|
||||
install_user_docs: coreAnswers.install_user_docs,
|
||||
bmad_folder: bmadFolderName,
|
||||
modules: ['core'],
|
||||
ides: selectedIdes,
|
||||
custom_agents: [],
|
||||
};
|
||||
|
||||
const manifestPath = path.join(cfgDir, 'manifest.yaml');
|
||||
await fs.promises.writeFile(manifestPath, yaml.dump(manifest), 'utf8');
|
||||
|
||||
// Create empty manifests
|
||||
const agentManifestPath = path.join(cfgDir, 'agent-manifest.csv');
|
||||
await fs.promises.writeFile(agentManifestPath, 'type,subtype,name,path,display_name,description,author,version,tags\n', 'utf8');
|
||||
|
||||
// Setup IDE configurations
|
||||
if (selectedIdes.length > 0) {
|
||||
console.log(chalk.dim('\nSetting up IDE configurations...'));
|
||||
const { IdeManager } = require('../installers/lib/ide/manager');
|
||||
const ideManager = new IdeManager();
|
||||
|
||||
for (const ide of selectedIdes) {
|
||||
await ideManager.setup(ide, projectDir, bmadDir, {
|
||||
selectedModules: ['core'],
|
||||
skipModuleInstall: false,
|
||||
verbose: false,
|
||||
preCollectedConfig: coreAnswers,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.green('\n✓ BMAD core infrastructure initialized'));
|
||||
console.log(chalk.dim(` BMAD folder: ${bmadDir}`));
|
||||
console.log(chalk.dim(` Core config: ${coreConfigPath}`));
|
||||
console.log(chalk.dim(` Manifest: ${manifestPath}`));
|
||||
if (selectedIdes.length > 0) {
|
||||
console.log(chalk.dim(` IDEs configured: ${selectedIdes.join(', ')}`));
|
||||
}
|
||||
|
||||
return {
|
||||
projectRoot: projectDir,
|
||||
bmadFolder: bmadDir,
|
||||
cfgFolder: cfgDir,
|
||||
manifestFile: agentManifestPath,
|
||||
ides: selectedIdes,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
command: 'agent-install',
|
||||
description: 'Install and compile BMAD agents with personalization',
|
||||
@@ -196,12 +314,55 @@ module.exports = {
|
||||
|
||||
// If no target specified, prompt for it
|
||||
if (targetDir) {
|
||||
// If target provided via --destination, check if it's a project root and adjust
|
||||
// Check if target has BMAD infrastructure
|
||||
const otherProject = detectBmadProject(targetDir);
|
||||
if (otherProject && !targetDir.includes('agents')) {
|
||||
// User specified project root, redirect to custom agents folder
|
||||
targetDir = path.join(otherProject.bmadFolder, 'custom', 'agents');
|
||||
console.log(chalk.dim(` Auto-selecting custom agents folder: ${targetDir}`));
|
||||
|
||||
if (!otherProject) {
|
||||
// No BMAD infrastructure found - offer to initialize
|
||||
console.log(chalk.yellow(`\n⚠️ No BMAD infrastructure found in: ${targetDir}`));
|
||||
|
||||
const initResponse = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'initialize',
|
||||
message: 'Initialize BMAD core infrastructure here? (Choose No for direct installation)',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
if (initResponse.initialize) {
|
||||
// Initialize BMAD core
|
||||
targetDir = path.resolve(targetDir);
|
||||
await initializeBmadCore(targetDir, '.bmad');
|
||||
// Set targetDir to the custom agents folder
|
||||
targetDir = path.join(targetDir, '.bmad', 'custom', 'agents');
|
||||
console.log(chalk.dim(` Agent will be installed to: ${targetDir}`));
|
||||
} else {
|
||||
// User declined - keep original targetDir
|
||||
console.log(chalk.yellow(` Installing agent directly to: ${targetDir}`));
|
||||
}
|
||||
} else if (otherProject && !targetDir.includes('agents')) {
|
||||
console.log(chalk.yellow(`\n⚠️ Path is inside BMAD project: ${otherProject.projectRoot}`));
|
||||
|
||||
const projectChoice = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'choice',
|
||||
message: 'Choose installation method:',
|
||||
choices: [
|
||||
{ name: `Install to BMAD's custom agents folder (${otherProject.bmadFolder}/custom/agents)`, value: 'bmad' },
|
||||
{ name: `Install directly to specified path (${targetDir})`, value: 'direct' },
|
||||
],
|
||||
default: 'bmad',
|
||||
},
|
||||
]);
|
||||
|
||||
if (projectChoice.choice === 'bmad') {
|
||||
targetDir = path.join(otherProject.bmadFolder, 'custom', 'agents');
|
||||
console.log(chalk.dim(` Installing to BMAD custom agents folder: ${targetDir}`));
|
||||
} else {
|
||||
console.log(chalk.yellow(` Installing directly to: ${targetDir}`));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const rl = readline.createInterface({
|
||||
@@ -214,38 +375,72 @@ module.exports = {
|
||||
// Option 1: Current project's custom agents folder
|
||||
const currentCustom = path.join(config.bmadFolder, 'custom', 'agents');
|
||||
console.log(` 1. Current project: ${chalk.dim(currentCustom)}`);
|
||||
|
||||
// Option 2: Specify another project path
|
||||
console.log(` 2. Another project (enter path)`);
|
||||
console.log(` 2. Enter path directly (e.g., /Users/brianmadison/dev/test)`);
|
||||
|
||||
const choice = await new Promise((resolve) => {
|
||||
rl.question('\n Select option (1 or path): ', resolve);
|
||||
rl.question('\n Select option (1 or 2): ', resolve);
|
||||
});
|
||||
|
||||
if (choice.trim() === '1' || choice.trim() === '') {
|
||||
targetDir = currentCustom;
|
||||
} else if (choice.trim() === '2') {
|
||||
const projectPath = await new Promise((resolve) => {
|
||||
rl.question(' Project path: ', resolve);
|
||||
const userPath = await new Promise((resolve) => {
|
||||
rl.question(' Enter path: ', resolve);
|
||||
});
|
||||
|
||||
// Detect if it's a BMAD project and use its custom folder
|
||||
const otherProject = detectBmadProject(path.resolve(projectPath));
|
||||
const otherProject = detectBmadProject(path.resolve(userPath));
|
||||
|
||||
if (otherProject) {
|
||||
targetDir = path.join(otherProject.bmadFolder, 'custom', 'agents');
|
||||
console.log(chalk.dim(` Found BMAD project, using: ${targetDir}`));
|
||||
console.log(chalk.yellow(`\n⚠️ Path is inside BMAD project: ${otherProject.projectRoot}`));
|
||||
|
||||
const projectChoice = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'choice',
|
||||
message: 'Choose installation method:',
|
||||
choices: [
|
||||
{ name: `Install to BMAD's custom agents folder (${otherProject.bmadFolder}/custom/agents)`, value: 'bmad' },
|
||||
{ name: `Install directly to specified path (${userPath})`, value: 'direct' },
|
||||
],
|
||||
default: 'bmad',
|
||||
},
|
||||
]);
|
||||
|
||||
if (projectChoice.choice === 'bmad') {
|
||||
targetDir = path.join(otherProject.bmadFolder, 'custom', 'agents');
|
||||
console.log(chalk.dim(` Installing to BMAD custom agents folder: ${targetDir}`));
|
||||
} else {
|
||||
targetDir = path.resolve(userPath);
|
||||
console.log(chalk.yellow(` Installing directly to: ${targetDir}`));
|
||||
}
|
||||
} else {
|
||||
targetDir = path.resolve(projectPath);
|
||||
// No BMAD found - offer to initialize
|
||||
console.log(chalk.yellow(`\n⚠️ No BMAD infrastructure found in: ${userPath}`));
|
||||
|
||||
const initResponse = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'initialize',
|
||||
message: 'Initialize BMAD core infrastructure here? (Choose No for direct installation)',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
if (initResponse.initialize) {
|
||||
await initializeBmadCore(path.resolve(userPath), '.bmad');
|
||||
targetDir = path.join(path.resolve(userPath), '.bmad', 'custom', 'agents');
|
||||
console.log(chalk.dim(` Agent will be installed to: ${targetDir}`));
|
||||
} else {
|
||||
// User declined - create the directory and install directly
|
||||
targetDir = path.resolve(userPath);
|
||||
console.log(chalk.yellow(` Installing agent directly to: ${targetDir}`));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// User entered a path directly
|
||||
const otherProject = detectBmadProject(path.resolve(choice));
|
||||
if (otherProject) {
|
||||
targetDir = path.join(otherProject.bmadFolder, 'custom', 'agents');
|
||||
console.log(chalk.dim(` Found BMAD project, using: ${targetDir}`));
|
||||
} else {
|
||||
targetDir = path.resolve(choice);
|
||||
}
|
||||
console.log(chalk.red(' Invalid selection. Please choose 1 or 2.'));
|
||||
rl.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
rl.close();
|
||||
|
||||
@@ -1,11 +1,513 @@
|
||||
const chalk = require('chalk');
|
||||
const path = require('node:path');
|
||||
const fs = require('fs-extra');
|
||||
const { Installer } = require('../installers/lib/core/installer');
|
||||
const { UI } = require('../lib/ui');
|
||||
|
||||
const installer = new Installer();
|
||||
const ui = new UI();
|
||||
|
||||
/**
|
||||
* Install custom content (agents, workflows, modules)
|
||||
* @param {Object} config - Installation configuration
|
||||
* @param {Object} result - Installation result
|
||||
* @param {string} projectDirectory - Project directory path
|
||||
*/
|
||||
async function installCustomContent(config, result, projectDirectory) {
|
||||
const { customContent } = config;
|
||||
const { selectedItems } = customContent;
|
||||
const projectDir = projectDirectory;
|
||||
const bmadDir = result.path;
|
||||
|
||||
console.log(chalk.dim(`Project: ${projectDir}`));
|
||||
console.log(chalk.dim(`BMAD: ${bmadDir}`));
|
||||
|
||||
// Install custom agents - use agent-install logic
|
||||
if (selectedItems.agents.length > 0) {
|
||||
console.log(chalk.blue(`\n👥 Installing ${selectedItems.agents.length} custom agent(s)...`));
|
||||
for (const agent of selectedItems.agents) {
|
||||
await installCustomAgentWithPrompts(agent, projectDir, bmadDir, config);
|
||||
}
|
||||
}
|
||||
|
||||
// Install custom workflows - copy and register with IDEs
|
||||
if (selectedItems.workflows.length > 0) {
|
||||
console.log(chalk.blue(`\n📋 Installing ${selectedItems.workflows.length} custom workflow(s)...`));
|
||||
for (const workflow of selectedItems.workflows) {
|
||||
await installCustomWorkflowWithIDE(workflow, projectDir, bmadDir, config);
|
||||
}
|
||||
}
|
||||
|
||||
// Install custom modules - treat like regular modules
|
||||
if (selectedItems.modules.length > 0) {
|
||||
console.log(chalk.blue(`\n🔧 Installing ${selectedItems.modules.length} custom module(s)...`));
|
||||
for (const module of selectedItems.modules) {
|
||||
await installCustomModuleAsRegular(module, projectDir, bmadDir, config);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.green('\n✓ Custom content installation complete!'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a custom agent with proper prompts (mirrors agent-install.js)
|
||||
*/
|
||||
async function installCustomAgentWithPrompts(agent, projectDir, bmadDir, config) {
|
||||
const {
|
||||
discoverAgents,
|
||||
loadAgentConfig,
|
||||
addToManifest,
|
||||
extractManifestData,
|
||||
promptInstallQuestions,
|
||||
createIdeSlashCommands,
|
||||
updateManifestYaml,
|
||||
saveAgentSource,
|
||||
} = require('../lib/agent/installer');
|
||||
const { compileAgent } = require('../lib/agent/compiler');
|
||||
const inquirer = require('inquirer');
|
||||
const readline = require('node:readline');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
console.log(chalk.cyan(` Installing agent: ${agent.name}`));
|
||||
|
||||
// Load agent config
|
||||
const agentConfig = loadAgentConfig(agent.yamlPath);
|
||||
const agentType = agent.name; // e.g., "toolsmith"
|
||||
|
||||
// Confirm/customize agent persona name (mirrors agent-install.js)
|
||||
const rl1 = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const defaultPersonaName = agentConfig.metadata.name || agentType;
|
||||
console.log(chalk.cyan(`\n 📛 Agent Persona Name`));
|
||||
console.log(chalk.dim(` Agent type: ${agentType}`));
|
||||
console.log(chalk.dim(` Default persona: ${defaultPersonaName}`));
|
||||
console.log(chalk.dim(' Leave blank to use default, or provide a custom name.'));
|
||||
console.log(chalk.dim(' Examples:'));
|
||||
console.log(chalk.dim(` - (blank) → "${defaultPersonaName}" as ${agentType}.md`));
|
||||
console.log(chalk.dim(` - "Fred" → "Fred" as fred-${agentType}.md`));
|
||||
console.log(chalk.dim(` - "Captain Code" → "Captain Code" as captain-code-${agentType}.md`));
|
||||
|
||||
const customPersonaName = await new Promise((resolve) => {
|
||||
rl1.question(`\n Custom name (or Enter for default): `, resolve);
|
||||
});
|
||||
rl1.close();
|
||||
|
||||
// Determine final agent file name based on persona name
|
||||
let finalAgentName;
|
||||
let personaName;
|
||||
if (customPersonaName.trim()) {
|
||||
personaName = customPersonaName.trim();
|
||||
const namePrefix = personaName.toLowerCase().replaceAll(/\s+/g, '-');
|
||||
finalAgentName = `${namePrefix}-${agentType}`;
|
||||
} else {
|
||||
personaName = defaultPersonaName;
|
||||
finalAgentName = agentType;
|
||||
}
|
||||
|
||||
console.log(chalk.dim(` Persona: ${personaName}`));
|
||||
console.log(chalk.dim(` File: ${finalAgentName}.md`));
|
||||
|
||||
// Get answers (prompt or use defaults)
|
||||
let presetAnswers = {};
|
||||
|
||||
// If custom persona name provided, inject it as custom_name for template processing
|
||||
if (customPersonaName.trim()) {
|
||||
presetAnswers.custom_name = personaName;
|
||||
}
|
||||
|
||||
let answers;
|
||||
if (agentConfig.installConfig) {
|
||||
answers = await promptInstallQuestions(agentConfig.installConfig, agentConfig.defaults, presetAnswers);
|
||||
} else {
|
||||
answers = { ...agentConfig.defaults, ...presetAnswers };
|
||||
}
|
||||
|
||||
// Create target directory
|
||||
const targetDir = path.join(bmadDir, 'custom', 'agents', finalAgentName);
|
||||
await fs.ensureDir(targetDir);
|
||||
|
||||
// Compile agent with answers
|
||||
const { xml, metadata } = compileAgent(
|
||||
agentConfig.yamlContent,
|
||||
answers,
|
||||
finalAgentName,
|
||||
`.bmad/custom/agents/${finalAgentName}/${finalAgentName}.md`,
|
||||
);
|
||||
|
||||
// Write compiled agent
|
||||
const compiledPath = path.join(targetDir, `${finalAgentName}.md`);
|
||||
await fs.writeFile(compiledPath, xml, 'utf8');
|
||||
|
||||
// Copy sidecar files if exists
|
||||
if (agent.hasSidecar) {
|
||||
const entries = await fs.readdir(agent.path, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && !entry.name.endsWith('.agent.yaml')) {
|
||||
await fs.copy(path.join(agent.path, entry.name), path.join(targetDir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save source YAML for reinstallation
|
||||
const cfgAgentsBackupDir = path.join(bmadDir, '_cfg', 'custom', 'agents');
|
||||
await fs.ensureDir(cfgAgentsBackupDir);
|
||||
const backupYamlPath = path.join(cfgAgentsBackupDir, `${finalAgentName}.agent.yaml`);
|
||||
await fs.copy(agent.yamlPath, backupYamlPath);
|
||||
|
||||
// Add to agent manifest
|
||||
const manifestFile = path.join(bmadDir, '_cfg', 'agent-manifest.csv');
|
||||
const relativePath = `.bmad/custom/agents/${finalAgentName}/${finalAgentName}.md`;
|
||||
const manifestData = extractManifestData(xml, { ...metadata, name: finalAgentName }, relativePath, 'custom');
|
||||
manifestData.name = finalAgentName;
|
||||
manifestData.displayName = metadata.name || finalAgentName;
|
||||
addToManifest(manifestFile, manifestData);
|
||||
|
||||
// Update manifest.yaml
|
||||
const manifestYamlPath = path.join(bmadDir, '_cfg', 'manifest.yaml');
|
||||
updateManifestYaml(manifestYamlPath, finalAgentName, finalAgentName);
|
||||
|
||||
// Create IDE slash commands using existing IDEs from config
|
||||
const ideResults = await createIdeSlashCommands(projectDir, finalAgentName, relativePath, metadata, config.ides || []);
|
||||
const ideCount = Object.keys(ideResults).length;
|
||||
|
||||
console.log(chalk.green(` ✓ ${finalAgentName} (registered with ${ideCount} IDE${ideCount === 1 ? '' : 's'})`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a custom workflow and register with all IDEs
|
||||
*/
|
||||
async function installCustomWorkflowWithIDE(workflow, projectDir, bmadDir, config) {
|
||||
const targetDir = path.join(bmadDir, 'custom', 'workflows');
|
||||
|
||||
// Check if workflow is a directory or just a file
|
||||
// workflow.path might be a file (workflow.md) or a directory
|
||||
let sourcePath = workflow.path;
|
||||
let isDirectory = false;
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(workflow.path);
|
||||
isDirectory = stats.isDirectory();
|
||||
} catch {
|
||||
console.log(chalk.red(` ERROR: Cannot access workflow path: ${workflow.path}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a file ending in workflow.md, use the parent directory
|
||||
if (!isDirectory && workflow.path.endsWith('workflow.md')) {
|
||||
sourcePath = path.dirname(workflow.path);
|
||||
isDirectory = true;
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
// Copy the entire workflow directory
|
||||
const workflowName = path.basename(sourcePath);
|
||||
const targetWorkflowDir = path.join(targetDir, workflowName);
|
||||
await fs.copy(sourcePath, targetWorkflowDir);
|
||||
|
||||
// Update manifest with the main workflow.md file
|
||||
const relativePath = `.bmad/custom/workflows/${workflowName}/workflow.md`;
|
||||
await addWorkflowToManifest(bmadDir, workflow.name, workflow.description, relativePath, 'custom');
|
||||
} else {
|
||||
// Single file workflow
|
||||
const targetFileName = path.basename(sourcePath);
|
||||
const targetPath = path.join(targetDir, targetFileName);
|
||||
await fs.copy(sourcePath, targetPath);
|
||||
|
||||
// Update manifest
|
||||
const relativePath = `.bmad/custom/workflows/${targetFileName}`;
|
||||
await addWorkflowToManifest(bmadDir, workflow.name, workflow.description, relativePath, 'custom');
|
||||
}
|
||||
|
||||
// Register workflow with all configured IDEs
|
||||
const relativePath = `.bmad/custom/workflows/${path.basename(workflow.path)}`;
|
||||
if (config.ides && config.ides.length > 0) {
|
||||
const { IdeManager } = require('../installers/lib/ide/manager');
|
||||
const ideManager = new IdeManager();
|
||||
|
||||
for (const ide of config.ides) {
|
||||
try {
|
||||
// IdeManager uses a Map, not getHandler method
|
||||
const ideHandler = ideManager.handlers.get(ide.toLowerCase());
|
||||
if (ideHandler && typeof ideHandler.registerWorkflow === 'function') {
|
||||
await ideHandler.registerWorkflow(projectDir, bmadDir, workflow.name, relativePath);
|
||||
console.log(chalk.dim(` ✓ Registered with ${ide}`));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(` ⚠️ Could not register with ${ide}: ${error.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.green(` ✓ ${workflow.name} (copied to custom workflows and registered with IDEs)`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to add workflow to manifest
|
||||
*/
|
||||
async function addWorkflowToManifest(bmadDir, name, description, relativePath, moduleType = 'custom') {
|
||||
const workflowManifestPath = path.join(bmadDir, '_cfg', 'workflow-manifest.csv');
|
||||
|
||||
console.log(chalk.dim(`[DEBUG] Adding workflow to manifest: ${name} -> ${relativePath} (module: ${moduleType})`));
|
||||
|
||||
// Read existing manifest
|
||||
let manifestContent = '';
|
||||
if (await fs.pathExists(workflowManifestPath)) {
|
||||
manifestContent = await fs.readFile(workflowManifestPath, 'utf8');
|
||||
}
|
||||
|
||||
// Ensure header exists
|
||||
if (!manifestContent.includes('name,description,module,path')) {
|
||||
manifestContent = 'name,description,module,path\n';
|
||||
}
|
||||
|
||||
// Add workflow entry
|
||||
const csvLine = `"${name}","${description}","${moduleType}","${relativePath}"\n`;
|
||||
|
||||
// Check if workflow already exists in manifest
|
||||
if (manifestContent.includes(`"${name}",`)) {
|
||||
console.log(chalk.dim(`[DEBUG] Workflow already exists in manifest: ${name}`));
|
||||
} else {
|
||||
try {
|
||||
await fs.writeFile(workflowManifestPath, manifestContent + csvLine);
|
||||
console.log(chalk.dim(`[DEBUG] Successfully added to manifest`));
|
||||
} catch (error) {
|
||||
console.log(chalk.red(`[ERROR] Failed to write to manifest: ${error.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a custom module like a regular module
|
||||
*/
|
||||
async function installCustomModuleAsRegular(module, projectDir, bmadDir, config) {
|
||||
const yaml = require('js-yaml');
|
||||
const path = require('node:path');
|
||||
|
||||
// The custom module path should be the source location
|
||||
const customSrcPath = module.path;
|
||||
|
||||
// Install the custom module by copying it to the custom modules directory
|
||||
const targetDir = path.join(bmadDir, 'custom', 'modules', module.name);
|
||||
await fs.copy(customSrcPath, targetDir);
|
||||
|
||||
// Check if module has an installer and run it from the ORIGINAL source location
|
||||
const installerPath = path.join(customSrcPath, '_module-installer', 'installer.js');
|
||||
if (await fs.pathExists(installerPath)) {
|
||||
try {
|
||||
// Clear require cache to ensure fresh import
|
||||
delete require.cache[require.resolve(installerPath)];
|
||||
|
||||
// Load and run the module installer
|
||||
const moduleInstaller = require(installerPath);
|
||||
await moduleInstaller.install({
|
||||
projectRoot: projectDir,
|
||||
config: config.coreConfig || {},
|
||||
installedIDEs: config.ides || [],
|
||||
logger: {
|
||||
log: (msg) => console.log(chalk.dim(` ${msg}`)),
|
||||
error: (msg) => console.log(chalk.red(` ERROR: ${msg}`)),
|
||||
},
|
||||
});
|
||||
console.log(chalk.green(` ✓ ${module.name} (custom installer executed)`));
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(` ⚠️ ${module.name} installer failed: ${error.message}`));
|
||||
console.log(chalk.dim(` Module copied but not configured`));
|
||||
}
|
||||
} else {
|
||||
// No installer - check if module has agents/workflows to install
|
||||
console.log(chalk.dim(` Processing module agents and workflows...`));
|
||||
|
||||
// Install agents from the module
|
||||
const agentsPath = path.join(customSrcPath, 'agents');
|
||||
if (await fs.pathExists(agentsPath)) {
|
||||
const agentFiles = await fs.readdir(agentsPath);
|
||||
for (const agentFile of agentFiles) {
|
||||
if (agentFile.endsWith('.yaml')) {
|
||||
const agentPath = path.join(agentsPath, agentFile);
|
||||
await installModuleAgent(agentPath, module.name, projectDir, bmadDir, config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Install workflows from the module
|
||||
const workflowsPath = path.join(customSrcPath, 'workflows');
|
||||
if (await fs.pathExists(workflowsPath)) {
|
||||
const workflowDirs = await fs.readdir(workflowsPath, { withFileTypes: true });
|
||||
for (const workflowDir of workflowDirs) {
|
||||
if (workflowDir.isDirectory()) {
|
||||
const workflowPath = path.join(workflowsPath, workflowDir.name);
|
||||
await installModuleWorkflow(workflowPath, module.name, projectDir, bmadDir, config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.green(` ✓ ${module.name}`));
|
||||
}
|
||||
|
||||
// Update manifest.yaml to include custom module with proper prefix
|
||||
const manifestYamlPath = path.join(bmadDir, '_cfg', 'manifest.yaml');
|
||||
|
||||
if (await fs.pathExists(manifestYamlPath)) {
|
||||
const manifest = yaml.load(await fs.readFile(manifestYamlPath, 'utf8'));
|
||||
|
||||
// Remove any old entries without custom- prefix for this module
|
||||
const oldModuleName = module.name;
|
||||
if (manifest.modules.includes(oldModuleName)) {
|
||||
manifest.modules = manifest.modules.filter((m) => m !== oldModuleName);
|
||||
console.log(chalk.dim(` Removed old entry: ${oldModuleName}`));
|
||||
}
|
||||
|
||||
// Custom modules should be stored with custom- prefix
|
||||
const moduleNameWithPrefix = `custom-${module.name}`;
|
||||
if (!manifest.modules.includes(moduleNameWithPrefix)) {
|
||||
manifest.modules.push(moduleNameWithPrefix);
|
||||
console.log(chalk.dim(` Added to manifest.yaml as ${moduleNameWithPrefix}`));
|
||||
}
|
||||
|
||||
// Write back the cleaned manifest
|
||||
await fs.writeFile(manifestYamlPath, yaml.dump(manifest), 'utf8');
|
||||
}
|
||||
|
||||
// Register module with IDEs (like regular modules do)
|
||||
if (config.ides && config.ides.length > 0) {
|
||||
const { IdeManager } = require('../installers/lib/ide/manager');
|
||||
const ideManager = new IdeManager();
|
||||
|
||||
for (const ide of config.ides) {
|
||||
try {
|
||||
// IdeManager uses a Map, not direct property access
|
||||
const handler = ideManager.handlers.get(ide.toLowerCase());
|
||||
if (handler && handler.moduleInjector) {
|
||||
// Check if module has IDE-specific customizations
|
||||
const subModulePath = path.join(customSrcPath, 'sub-modules', ide);
|
||||
if (await fs.pathExists(subModulePath)) {
|
||||
console.log(chalk.dim(` ✓ Found ${ide} customizations for ${module.name}`));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(` ⚠️ Could not configure ${ide} for ${module.name}: ${error.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install an agent from a module
|
||||
*/
|
||||
async function installModuleAgent(agentPath, moduleName, projectDir, bmadDir, config) {
|
||||
const {
|
||||
loadAgentConfig,
|
||||
addToManifest,
|
||||
extractManifestData,
|
||||
createIdeSlashCommands,
|
||||
updateManifestYaml,
|
||||
} = require('../lib/agent/installer');
|
||||
const { compileAgent } = require('../lib/agent/compiler');
|
||||
|
||||
const agentName = path.basename(agentPath, '.yaml');
|
||||
console.log(chalk.dim(` Installing agent: ${agentName} (from ${moduleName})`));
|
||||
|
||||
// Load agent config
|
||||
const agentConfig = loadAgentConfig(agentPath);
|
||||
|
||||
// Compile agent with defaults (no prompts for module agents)
|
||||
const { xml, metadata } = compileAgent(
|
||||
agentConfig.yamlContent,
|
||||
agentConfig.defaults || {},
|
||||
agentName,
|
||||
`.bmad/custom/modules/${moduleName}/agents/${agentName}.md`,
|
||||
);
|
||||
|
||||
// Create target directory
|
||||
const targetDir = path.join(bmadDir, 'custom', 'modules', moduleName, 'agents');
|
||||
await fs.ensureDir(targetDir);
|
||||
|
||||
// Write compiled agent
|
||||
const compiledPath = path.join(targetDir, `${agentName}.md`);
|
||||
await fs.writeFile(compiledPath, xml, 'utf8');
|
||||
|
||||
// Remove the raw YAML file after compilation
|
||||
const yamlPath = path.join(targetDir, `${agentName}.yaml`);
|
||||
if (await fs.pathExists(yamlPath)) {
|
||||
await fs.remove(yamlPath);
|
||||
}
|
||||
|
||||
// Add to agent manifest
|
||||
const manifestFile = path.join(bmadDir, '_cfg', 'agent-manifest.csv');
|
||||
const relativePath = `.bmad/custom/modules/${moduleName}/agents/${agentName}.md`;
|
||||
const manifestData = extractManifestData(xml, { ...metadata, name: agentName }, relativePath, 'custom');
|
||||
manifestData.name = `${moduleName}-${agentName}`;
|
||||
manifestData.displayName = metadata.name || agentName;
|
||||
addToManifest(manifestFile, manifestData);
|
||||
|
||||
// Update manifest.yaml
|
||||
const manifestYamlPath = path.join(bmadDir, '_cfg', 'manifest.yaml');
|
||||
updateManifestYaml(manifestYamlPath, `${moduleName}-${agentName}`, agentName);
|
||||
|
||||
// Create IDE slash commands
|
||||
const ideResults = await createIdeSlashCommands(projectDir, `${moduleName}-${agentName}`, relativePath, metadata, config.ides || []);
|
||||
const ideCount = Object.keys(ideResults).length;
|
||||
|
||||
console.log(chalk.dim(` ✓ ${agentName} (registered with ${ideCount} IDE${ideCount === 1 ? '' : 's'})`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a workflow from a module
|
||||
*/
|
||||
async function installModuleWorkflow(workflowPath, moduleName, projectDir, bmadDir, config) {
|
||||
const workflowName = path.basename(workflowPath);
|
||||
|
||||
// Copy the workflow directory
|
||||
const targetDir = path.join(bmadDir, 'custom', 'modules', moduleName, 'workflows', workflowName);
|
||||
await fs.copy(workflowPath, targetDir);
|
||||
|
||||
// Add to workflow manifest
|
||||
const workflowManifestPath = path.join(bmadDir, '_cfg', 'workflow-manifest.csv');
|
||||
const relativePath = `.bmad/custom/modules/${moduleName}/workflows/${workflowName}/README.md`;
|
||||
|
||||
// Read existing manifest
|
||||
let manifestContent = '';
|
||||
if (await fs.pathExists(workflowManifestPath)) {
|
||||
manifestContent = await fs.readFile(workflowManifestPath, 'utf8');
|
||||
}
|
||||
|
||||
// Ensure header exists
|
||||
if (!manifestContent.includes('name,description,module,path')) {
|
||||
manifestContent = 'name,description,module,path\n';
|
||||
}
|
||||
|
||||
// Add workflow entry
|
||||
const csvLine = `"${moduleName}-${workflowName}","Workflow from ${moduleName} module","${moduleName}","${relativePath}"\n`;
|
||||
|
||||
// Check if workflow already exists in manifest
|
||||
if (!manifestContent.includes(`"${moduleName}-${workflowName}",`)) {
|
||||
await fs.writeFile(workflowManifestPath, manifestContent + csvLine);
|
||||
}
|
||||
|
||||
// Register with IDEs
|
||||
if (config.ides && config.ides.length > 0) {
|
||||
const { IdeManager } = require('../installers/lib/ide/manager');
|
||||
const ideManager = new IdeManager();
|
||||
|
||||
for (const ide of config.ides) {
|
||||
try {
|
||||
const ideHandler = ideManager.handlers.get(ide.toLowerCase());
|
||||
if (ideHandler && typeof ideHandler.registerWorkflow === 'function') {
|
||||
await ideHandler.registerWorkflow(projectDir, bmadDir, `${moduleName}-${workflowName}`, relativePath);
|
||||
console.log(chalk.dim(` ✓ Registered with ${ide}`));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(` ⚠️ Could not register with ${ide}: ${error.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.dim(` ✓ ${workflowName} workflow added and registered`));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
command: 'install',
|
||||
description: 'Install BMAD Core agents and tools',
|
||||
@@ -18,7 +520,6 @@ module.exports = {
|
||||
if (config.actionType === 'cancel') {
|
||||
console.log(chalk.yellow('Installation cancelled.'));
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle agent compilation separately
|
||||
@@ -27,7 +528,6 @@ module.exports = {
|
||||
console.log(chalk.green('\n✨ Agent compilation complete!'));
|
||||
console.log(chalk.cyan(`Rebuilt ${result.agentCount} agents and ${result.taskCount} tasks`));
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle quick update separately
|
||||
@@ -35,8 +535,71 @@ module.exports = {
|
||||
const result = await installer.quickUpdate(config);
|
||||
console.log(chalk.green('\n✨ Quick update complete!'));
|
||||
console.log(chalk.cyan(`Updated ${result.moduleCount} modules with preserved settings`));
|
||||
|
||||
// After quick update, check for existing custom content and re-install to regenerate IDE commands
|
||||
const { UI } = require('../lib/ui');
|
||||
const ui = new UI();
|
||||
const customPath = path.join(config.directory, 'bmad-custom-src');
|
||||
|
||||
// Check if custom content exists
|
||||
if (await fs.pathExists(customPath)) {
|
||||
console.log(chalk.cyan('\n📦 Detecting custom content to update IDE commands...'));
|
||||
|
||||
// Get existing custom content selections (default to all for updates)
|
||||
const existingCustom = {
|
||||
agents: (await fs.pathExists(path.join(customPath, 'agents'))) ? true : false,
|
||||
workflows: (await fs.pathExists(path.join(customPath, 'workflows'))) ? true : false,
|
||||
modules: (await fs.pathExists(path.join(customPath, 'modules'))) ? true : false,
|
||||
};
|
||||
|
||||
// Auto-select all existing custom content for update
|
||||
if (existingCustom.agents || existingCustom.workflows || existingCustom.modules) {
|
||||
const customContent = await ui.discoverCustomContent(customPath);
|
||||
|
||||
config.customContent = {
|
||||
path: customPath,
|
||||
selectedItems: {
|
||||
agents: existingCustom.agents ? customContent.agents.map((a) => ({ ...a, selected: true })) : [],
|
||||
workflows: existingCustom.workflows ? customContent.workflows.map((w) => ({ ...w, selected: true })) : [],
|
||||
modules: existingCustom.modules ? customContent.modules.map((m) => ({ ...m, selected: true })) : [],
|
||||
},
|
||||
};
|
||||
|
||||
await installCustomContent(config, result, config.directory);
|
||||
|
||||
// Re-run IDE setup to register custom workflows with IDEs
|
||||
if (config.ides && config.ides.length > 0) {
|
||||
console.log(chalk.cyan('\n🔧 Updating IDE configurations for custom content...'));
|
||||
const { IdeManager } = require('../installers/lib/ide/manager');
|
||||
const ideManager = new IdeManager();
|
||||
|
||||
for (const ide of config.ides) {
|
||||
try {
|
||||
const ideResult = await ideManager.setup(ide, config.directory, result.path, {
|
||||
selectedModules: [...(config.modules || []), 'custom'], // Include 'custom' for custom agents/workflows
|
||||
skipModuleInstall: true, // Don't install modules again
|
||||
verbose: false,
|
||||
preCollectedConfig: {
|
||||
...config.coreConfig,
|
||||
_alreadyConfigured: true, // Skip reconfiguration that might add duplicates
|
||||
},
|
||||
});
|
||||
|
||||
if (ideResult.success) {
|
||||
console.log(chalk.dim(` ✓ Updated ${ide} with custom workflows`));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(` ⚠️ Could not update ${ide}: ${error.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.dim(' No custom content found to update'));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.green('\n✨ Update complete with custom content!'));
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle reinstall by setting force flag
|
||||
@@ -55,11 +618,43 @@ module.exports = {
|
||||
// Check if installation was cancelled
|
||||
if (result && result.cancelled) {
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if installation succeeded
|
||||
if (result && result.success) {
|
||||
// Install custom content if selected
|
||||
if (config.customContent && config.customContent.selectedItems) {
|
||||
console.log(chalk.cyan('\n📦 Installing Custom Content...'));
|
||||
await installCustomContent(config, result, config.directory);
|
||||
|
||||
// Re-run IDE setup to register custom workflows with IDEs
|
||||
if (config.ides && config.ides.length > 0) {
|
||||
console.log(chalk.cyan('\n🔧 Updating IDE configurations for custom content...'));
|
||||
const { IdeManager } = require('../installers/lib/ide/manager');
|
||||
const ideManager = new IdeManager();
|
||||
|
||||
for (const ide of config.ides) {
|
||||
try {
|
||||
const ideResult = await ideManager.setup(ide, config.directory, result.path, {
|
||||
selectedModules: [...(config.modules || []), 'custom'], // Include 'custom' for custom agents/workflows
|
||||
skipModuleInstall: true, // Don't install modules again
|
||||
verbose: false,
|
||||
preCollectedConfig: {
|
||||
...config.coreConfig,
|
||||
_alreadyConfigured: true, // Skip reconfiguration that might add duplicates
|
||||
},
|
||||
});
|
||||
|
||||
if (ideResult.success) {
|
||||
console.log(chalk.dim(` ✓ Updated ${ide} with custom workflows`));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(` ⚠️ Could not update ${ide}: ${error.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.green('\n✨ Installation complete!'));
|
||||
console.log(chalk.cyan('BMAD Core and Selected Modules have been installed to:'), chalk.bold(result.path));
|
||||
console.log(chalk.yellow('\nThank you for helping test the early release version of the new BMad Core and BMad Method!'));
|
||||
|
||||
@@ -396,9 +396,26 @@ class ConfigCollector {
|
||||
if (!this.allAnswers) {
|
||||
this.allAnswers = {};
|
||||
}
|
||||
// Load module's config.yaml (check new location first, then fallback)
|
||||
const installerConfigPath = path.join(getModulePath(moduleName), '_module-installer', 'install-config.yaml');
|
||||
const legacyConfigPath = path.join(getModulePath(moduleName), 'config.yaml');
|
||||
// Load module's config.yaml (check custom modules first, then regular modules)
|
||||
let installerConfigPath;
|
||||
let legacyConfigPath;
|
||||
|
||||
if (moduleName.startsWith('custom-')) {
|
||||
// Handle custom modules
|
||||
const actualModuleName = moduleName.replace('custom-', '');
|
||||
|
||||
// Custom modules are in the BMAD-METHOD source directory, not the installation directory
|
||||
const bmadMethodRoot = getProjectRoot(); // This gets the BMAD-METHOD root
|
||||
const customSrcPath = path.join(bmadMethodRoot, 'bmad-custom-src', 'modules', actualModuleName);
|
||||
installerConfigPath = path.join(customSrcPath, '_module-installer', 'install-config.yaml');
|
||||
legacyConfigPath = path.join(customSrcPath, 'config.yaml');
|
||||
|
||||
console.log(chalk.dim(`[DEBUG] Looking for custom module config in: ${installerConfigPath}`));
|
||||
} else {
|
||||
// Regular modules
|
||||
installerConfigPath = path.join(getModulePath(moduleName), '_module-installer', 'install-config.yaml');
|
||||
legacyConfigPath = path.join(getModulePath(moduleName), 'config.yaml');
|
||||
}
|
||||
|
||||
let configPath = null;
|
||||
if (await fs.pathExists(installerConfigPath)) {
|
||||
|
||||
@@ -418,7 +418,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
|
||||
const projectDir = path.resolve(config.directory);
|
||||
|
||||
// If core config was pre-collected (from interactive mode), use it
|
||||
if (config.coreConfig) {
|
||||
if (config.coreConfig && !this.configCollector.collectedConfig.core) {
|
||||
this.configCollector.collectedConfig.core = config.coreConfig;
|
||||
// Also store in allAnswers for cross-referencing
|
||||
this.configCollector.allAnswers = {};
|
||||
@@ -427,11 +427,16 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
|
||||
}
|
||||
}
|
||||
|
||||
// Collect configurations for modules (skip if quick update already collected them)
|
||||
// Collect configurations for modules (skip if quick update already collected them or if pre-collected)
|
||||
let moduleConfigs;
|
||||
if (config._quickUpdate) {
|
||||
// Quick update already collected all configs, use them directly
|
||||
moduleConfigs = this.configCollector.collectedConfig;
|
||||
} else if (config.moduleConfig) {
|
||||
// Use pre-collected configs from UI (includes custom modules)
|
||||
moduleConfigs = config.moduleConfig;
|
||||
// Also need to load them into configCollector for later use
|
||||
this.configCollector.collectedConfig = moduleConfigs;
|
||||
} else {
|
||||
// Regular install - collect configurations (core was already collected in UI.promptInstall if interactive)
|
||||
moduleConfigs = await this.configCollector.collectAllConfigurations(config.modules || [], path.resolve(config.directory));
|
||||
@@ -748,13 +753,14 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
|
||||
spinner.text = 'Creating directory structure...';
|
||||
await this.createDirectoryStructure(bmadDir);
|
||||
|
||||
// Resolve dependencies for selected modules
|
||||
// Resolve dependencies for selected modules (skip custom modules)
|
||||
spinner.text = 'Resolving dependencies...';
|
||||
const projectRoot = getProjectRoot();
|
||||
const modulesToInstall = config.installCore ? ['core', ...config.modules] : config.modules;
|
||||
const regularModules = (config.modules || []).filter((m) => !m.startsWith('custom-'));
|
||||
const modulesToInstall = config.installCore ? ['core', ...regularModules] : regularModules;
|
||||
|
||||
// For dependency resolution, we need to pass the project root
|
||||
const resolution = await this.dependencyResolver.resolve(projectRoot, config.modules || [], { verbose: config.verbose });
|
||||
const resolution = await this.dependencyResolver.resolve(projectRoot, regularModules, { verbose: config.verbose });
|
||||
|
||||
if (config.verbose) {
|
||||
spinner.succeed('Dependencies resolved');
|
||||
@@ -769,17 +775,17 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
|
||||
spinner.succeed('Core installed');
|
||||
}
|
||||
|
||||
// Install modules with their dependencies
|
||||
if (config.modules && config.modules.length > 0) {
|
||||
for (const moduleName of config.modules) {
|
||||
// Install modules with their dependencies (skip custom modules - they're handled by install.js)
|
||||
if (regularModules.length > 0) {
|
||||
for (const moduleName of regularModules) {
|
||||
spinner.start(`Installing module: ${moduleName}...`);
|
||||
await this.installModuleWithDependencies(moduleName, bmadDir, resolution.byModule[moduleName]);
|
||||
spinner.succeed(`Module installed: ${moduleName}`);
|
||||
}
|
||||
|
||||
// Install partial modules (only dependencies)
|
||||
// Install partial modules (only dependencies) - skip custom modules
|
||||
for (const [module, files] of Object.entries(resolution.byModule)) {
|
||||
if (!config.modules.includes(module) && module !== 'core') {
|
||||
if (!regularModules.includes(module) && module !== 'core') {
|
||||
const totalFiles =
|
||||
files.agents.length +
|
||||
files.tasks.length +
|
||||
|
||||
@@ -24,6 +24,51 @@ async function getAgentsFromBmad(bmadDir, selectedModules = []) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get custom module agents (from bmad/custom/modules/*/agents/)
|
||||
const customModulesDir = path.join(bmadDir, 'custom', 'modules');
|
||||
if (await fs.pathExists(customModulesDir)) {
|
||||
const moduleDirs = await fs.readdir(customModulesDir, { withFileTypes: true });
|
||||
|
||||
for (const moduleDir of moduleDirs) {
|
||||
if (!moduleDir.isDirectory()) continue;
|
||||
|
||||
const moduleAgentsPath = path.join(customModulesDir, moduleDir.name, 'agents');
|
||||
if (await fs.pathExists(moduleAgentsPath)) {
|
||||
const moduleAgents = await getAgentsFromDir(moduleAgentsPath, moduleDir.name);
|
||||
agents.push(...moduleAgents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get custom agents from bmad/custom/agents/ directory
|
||||
const customAgentsDir = path.join(bmadDir, 'custom', 'agents');
|
||||
if (await fs.pathExists(customAgentsDir)) {
|
||||
const agentDirs = await fs.readdir(customAgentsDir, { withFileTypes: true });
|
||||
|
||||
for (const agentDir of agentDirs) {
|
||||
if (!agentDir.isDirectory()) continue;
|
||||
|
||||
const agentDirPath = path.join(customAgentsDir, agentDir.name);
|
||||
const agentFiles = await fs.readdir(agentDirPath);
|
||||
|
||||
for (const file of agentFiles) {
|
||||
if (!file.endsWith('.md')) continue;
|
||||
if (file.includes('.customize.')) continue;
|
||||
|
||||
const filePath = path.join(agentDirPath, file);
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
if (content.includes('localskip="true"')) continue;
|
||||
|
||||
agents.push({
|
||||
path: filePath,
|
||||
name: file.replace('.md', ''),
|
||||
module: 'custom', // Mark as custom agent
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get standalone agents from bmad/agents/ directory
|
||||
const standaloneAgentsDir = path.join(bmadDir, 'agents');
|
||||
if (await fs.pathExists(standaloneAgentsDir)) {
|
||||
|
||||
@@ -23,6 +23,7 @@ const inquirer = require('inquirer');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const fs = require('fs-extra');
|
||||
const yaml = require('js-yaml');
|
||||
const { CLIUtils } = require('./cli-utils');
|
||||
|
||||
/**
|
||||
@@ -119,6 +120,27 @@ class UI {
|
||||
const moduleChoices = await this.getModuleChoices(installedModuleIds);
|
||||
const selectedModules = await this.selectModules(moduleChoices);
|
||||
|
||||
// Check if custom module was selected
|
||||
let customContent = null;
|
||||
if (selectedModules.includes('custom')) {
|
||||
// Remove 'custom' from selectedModules since it's not a real module
|
||||
const customIndex = selectedModules.indexOf('custom');
|
||||
selectedModules.splice(customIndex, 1);
|
||||
|
||||
// Handle custom content selection
|
||||
customContent = await this.handleCustomContentSelection(confirmedDirectory);
|
||||
|
||||
// Add custom modules to the selected modules list for proper installation
|
||||
if (customContent && customContent.selectedItems && customContent.selectedItems.modules) {
|
||||
for (const customModule of customContent.selectedItems.modules) {
|
||||
selectedModules.push(`custom-${customModule.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NOW collect module configurations (including custom modules that were just added)
|
||||
const moduleConfig = await this.collectModuleConfigs(confirmedDirectory, selectedModules, coreConfig);
|
||||
|
||||
// Prompt for AgentVibes TTS integration
|
||||
const agentVibesConfig = await this.promptAgentVibes(confirmedDirectory);
|
||||
|
||||
@@ -137,11 +159,488 @@ class UI {
|
||||
ides: toolSelection.ides,
|
||||
skipIde: toolSelection.skipIde,
|
||||
coreConfig: coreConfig, // Pass collected core config to installer
|
||||
moduleConfig: moduleConfig, // Pass collected module configs (including custom modules)
|
||||
enableAgentVibes: agentVibesConfig.enabled, // AgentVibes TTS integration
|
||||
agentVibesInstalled: agentVibesConfig.alreadyInstalled,
|
||||
customContent: customContent, // Custom content to install
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle custom content selection in module phase
|
||||
* @param {string} projectDir - Project directory
|
||||
* @returns {Object} Custom content info with selected items
|
||||
*/
|
||||
async handleCustomContentSelection(projectDir) {
|
||||
const defaultPath = path.join(projectDir, 'bmad-custom-src');
|
||||
const hasDefaultFolder = await fs.pathExists(defaultPath);
|
||||
|
||||
let customPath;
|
||||
|
||||
if (hasDefaultFolder) {
|
||||
console.log(chalk.cyan('\n📁 Custom Content Detected'));
|
||||
console.log(chalk.dim(`Found custom folder at: ${defaultPath}`));
|
||||
|
||||
const { useDetected } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'useDetected',
|
||||
message: 'Install from detected custom folder?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
if (useDetected) {
|
||||
customPath = defaultPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customPath) {
|
||||
console.log(chalk.cyan('\n📁 Custom Content Selection'));
|
||||
|
||||
const { specifiedPath } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'specifiedPath',
|
||||
message: 'Enter path to custom content folder:',
|
||||
default: './bmad-custom-src',
|
||||
validate: async (input) => {
|
||||
if (!input.trim()) {
|
||||
return 'Path is required';
|
||||
}
|
||||
const resolvedPath = path.resolve(input.trim());
|
||||
if (!(await fs.pathExists(resolvedPath))) {
|
||||
return `Path does not exist: ${resolvedPath}`;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
customPath = path.resolve(specifiedPath.trim());
|
||||
}
|
||||
|
||||
// Discover and categorize custom content
|
||||
const customContent = await this.discoverAndSelectCustomContent(customPath);
|
||||
|
||||
return {
|
||||
path: customPath,
|
||||
selectedItems: customContent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and allow selection of custom content
|
||||
* @param {string} customPath - Path to custom content
|
||||
* @returns {Object} Selected items by type
|
||||
*/
|
||||
async discoverAndSelectCustomContent(customPath) {
|
||||
CLIUtils.displaySection('Custom Content', 'Discovering agents, workflows, and modules');
|
||||
|
||||
// Discover each type
|
||||
const agents = await this.discoverCustomAgents(path.join(customPath, 'agents'));
|
||||
const workflows = await this.discoverCustomWorkflows(path.join(customPath, 'workflows'));
|
||||
const modules = await this.discoverCustomModules(path.join(customPath, 'modules'));
|
||||
|
||||
// Build choices for selection
|
||||
const choices = [];
|
||||
|
||||
if (agents.length > 0) {
|
||||
choices.push({ name: '--- 👥 Custom Agents ---', value: 'sep-agents', disabled: true });
|
||||
for (const agent of agents) {
|
||||
const shortDesc = agent.description.length > 50 ? agent.description.slice(0, 47) + '...' : agent.description;
|
||||
choices.push({
|
||||
name: ` ${agent.name} - ${shortDesc}`,
|
||||
value: { type: 'agent', ...agent },
|
||||
checked: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (workflows.length > 0) {
|
||||
choices.push({ name: '--- 📋 Custom Workflows ---', value: 'sep-workflows', disabled: true });
|
||||
for (const workflow of workflows) {
|
||||
const shortDesc = workflow.description.length > 50 ? workflow.description.slice(0, 47) + '...' : workflow.description;
|
||||
choices.push({
|
||||
name: ` ${workflow.name} - ${shortDesc}`,
|
||||
value: { type: 'workflow', ...workflow },
|
||||
checked: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (modules.length > 0) {
|
||||
choices.push({ name: '--- 🔧 Custom Modules ---', value: 'sep-modules', disabled: true });
|
||||
for (const module of modules) {
|
||||
const shortDesc = module.description.length > 50 ? module.description.slice(0, 47) + '...' : module.description;
|
||||
choices.push({
|
||||
name: ` ${module.name} - ${shortDesc}`,
|
||||
value: { type: 'module', ...module },
|
||||
checked: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (choices.length === 0) {
|
||||
console.log(chalk.yellow('⚠️ No custom content found'));
|
||||
return { agents: [], workflows: [], modules: [] };
|
||||
}
|
||||
|
||||
// Ask for selection
|
||||
const { selectedItems } = await inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'selectedItems',
|
||||
message: 'Select custom items to install:',
|
||||
choices: choices,
|
||||
pageSize: 15,
|
||||
},
|
||||
]);
|
||||
|
||||
// Organize by type
|
||||
const result = { agents: [], workflows: [], modules: [] };
|
||||
for (const item of selectedItems) {
|
||||
switch (item.type) {
|
||||
case 'agent': {
|
||||
result.agents.push(item);
|
||||
break;
|
||||
}
|
||||
case 'workflow': {
|
||||
result.workflows.push(item);
|
||||
break;
|
||||
}
|
||||
case 'module': {
|
||||
result.modules.push(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.green(`\n✓ Selected: ${result.agents.length} agents, ${result.workflows.length} workflows, ${result.modules.length} modules`),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover custom agents
|
||||
*/
|
||||
async discoverCustomAgents(agentsPath) {
|
||||
const agents = [];
|
||||
if (!(await fs.pathExists(agentsPath))) return agents;
|
||||
|
||||
const entries = await fs.readdir(agentsPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const agentPath = path.join(agentsPath, entry.name);
|
||||
const yamlFiles = await fs.readdir(agentPath).then((files) => files.filter((f) => f.endsWith('.agent.yaml')));
|
||||
|
||||
if (yamlFiles.length > 0) {
|
||||
const yamlPath = path.join(agentPath, yamlFiles[0]);
|
||||
const yamlData = yaml.load(await fs.readFile(yamlPath, 'utf8'));
|
||||
agents.push({
|
||||
name: entry.name,
|
||||
path: agentPath,
|
||||
yamlPath: yamlPath,
|
||||
description: yamlData.metadata?.description || yamlData.description || 'Custom agent',
|
||||
hasSidecar: true,
|
||||
});
|
||||
}
|
||||
} else if (entry.isFile() && entry.name.endsWith('.agent.yaml')) {
|
||||
const yamlData = yaml.load(await fs.readFile(path.join(agentsPath, entry.name), 'utf8'));
|
||||
agents.push({
|
||||
name: path.basename(entry.name, '.agent.yaml'),
|
||||
path: agentsPath,
|
||||
yamlPath: path.join(agentsPath, entry.name),
|
||||
description: yamlData.metadata?.description || yamlData.description || 'Custom agent',
|
||||
hasSidecar: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover custom workflows
|
||||
*/
|
||||
async discoverCustomWorkflows(workflowsPath) {
|
||||
const workflows = [];
|
||||
if (!(await fs.pathExists(workflowsPath))) return workflows;
|
||||
|
||||
const entries = await fs.readdir(workflowsPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
const filePath = path.join(workflowsPath, entry.name);
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
// Extract YAML frontmatter
|
||||
let title = path.basename(entry.name, '.md');
|
||||
let description = '';
|
||||
let yamlMetadata = {};
|
||||
|
||||
// Check for YAML frontmatter
|
||||
if (content.startsWith('---\n')) {
|
||||
const frontmatterEnd = content.indexOf('\n---\n', 4);
|
||||
if (frontmatterEnd !== -1) {
|
||||
const yamlContent = content.slice(4, frontmatterEnd);
|
||||
try {
|
||||
yamlMetadata = yaml.load(yamlContent);
|
||||
title = yamlMetadata.name || yamlMetadata.title || title;
|
||||
description = yamlMetadata.description || yamlMetadata.summary || '';
|
||||
} catch {
|
||||
// If YAML parsing fails, fall back to markdown parsing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no YAML frontmatter or no metadata, parse from markdown
|
||||
if (!title || !description) {
|
||||
const lines = content.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('# ')) {
|
||||
title = line.slice(2).trim();
|
||||
} else if (line.startsWith('## Description:')) {
|
||||
description = line.replace('## Description:', '').trim();
|
||||
}
|
||||
if (title && description) break;
|
||||
}
|
||||
}
|
||||
|
||||
workflows.push({
|
||||
name: title,
|
||||
path: filePath,
|
||||
description: description || 'Custom workflow',
|
||||
metadata: yamlMetadata,
|
||||
});
|
||||
} else if (entry.isDirectory()) {
|
||||
// Check for workflow.md in subdirectories
|
||||
const workflowMdPath = path.join(workflowsPath, entry.name, 'workflow.md');
|
||||
if (await fs.pathExists(workflowMdPath)) {
|
||||
const content = await fs.readFile(workflowMdPath, 'utf8');
|
||||
|
||||
// Extract YAML frontmatter
|
||||
let title = entry.name;
|
||||
let description = '';
|
||||
let yamlMetadata = {};
|
||||
|
||||
// Check for YAML frontmatter
|
||||
if (content.startsWith('---\n')) {
|
||||
const frontmatterEnd = content.indexOf('\n---\n', 4);
|
||||
if (frontmatterEnd !== -1) {
|
||||
const yamlContent = content.slice(4, frontmatterEnd);
|
||||
try {
|
||||
yamlMetadata = yaml.load(yamlContent);
|
||||
title = yamlMetadata.name || yamlMetadata.title || title;
|
||||
description = yamlMetadata.description || yamlMetadata.summary || '';
|
||||
} catch {
|
||||
// If YAML parsing fails, fall back to markdown parsing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no YAML frontmatter or no metadata, parse from markdown
|
||||
if (!title || !description) {
|
||||
const lines = content.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('# ')) {
|
||||
title = line.slice(2).trim();
|
||||
} else if (line.startsWith('## Description:')) {
|
||||
description = line.replace('## Description:', '').trim();
|
||||
}
|
||||
if (title && description) break;
|
||||
}
|
||||
}
|
||||
|
||||
workflows.push({
|
||||
name: title,
|
||||
path: path.join(workflowsPath, entry.name), // Store the DIRECTORY path, not the file
|
||||
description: description || 'Custom workflow',
|
||||
metadata: yamlMetadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return workflows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover custom modules
|
||||
*/
|
||||
async discoverCustomModules(modulesPath) {
|
||||
const modules = [];
|
||||
if (!(await fs.pathExists(modulesPath))) return modules;
|
||||
|
||||
const entries = await fs.readdir(modulesPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const modulePath = path.join(modulesPath, entry.name);
|
||||
const installerPath = path.join(modulePath, '_module-installer');
|
||||
|
||||
if (await fs.pathExists(installerPath)) {
|
||||
// Check for install-config.yaml
|
||||
const configPath = path.join(installerPath, 'install-config.yaml');
|
||||
let description = 'Custom module';
|
||||
|
||||
if (await fs.pathExists(configPath)) {
|
||||
const configData = yaml.load(await fs.readFile(configPath, 'utf8'));
|
||||
description = configData.header || configData.description || description;
|
||||
}
|
||||
|
||||
modules.push({
|
||||
name: entry.name,
|
||||
path: modulePath,
|
||||
description: description,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle custom content installation
|
||||
* @param {string} projectDir - Project directory
|
||||
*/
|
||||
async handleCustomContent(projectDir) {
|
||||
const defaultPath = path.join(projectDir, 'bmad-custom-src');
|
||||
const hasDefaultFolder = await fs.pathExists(defaultPath);
|
||||
|
||||
let customPath;
|
||||
|
||||
if (hasDefaultFolder) {
|
||||
console.log(chalk.cyan('\n📁 Custom Content Detected'));
|
||||
console.log(chalk.dim(`Found custom folder at: ${defaultPath}`));
|
||||
|
||||
const { useDetected } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'useDetected',
|
||||
message: 'Install from detected custom folder?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
if (useDetected) {
|
||||
customPath = defaultPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customPath) {
|
||||
console.log(chalk.cyan('\n📁 Custom Content Installation'));
|
||||
|
||||
const { specifiedPath } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'specifiedPath',
|
||||
message: 'Enter path to custom content folder:',
|
||||
default: './bmad-custom-src',
|
||||
validate: async (input) => {
|
||||
if (!input.trim()) {
|
||||
return 'Path is required';
|
||||
}
|
||||
const resolvedPath = path.resolve(input.trim());
|
||||
if (!(await fs.pathExists(resolvedPath))) {
|
||||
return `Path does not exist: ${resolvedPath}`;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
customPath = path.resolve(specifiedPath.trim());
|
||||
}
|
||||
|
||||
// Discover custom content
|
||||
const customContent = {
|
||||
agents: await this.discoverCustomAgents(path.join(customPath, 'agents')),
|
||||
modules: await this.discoverCustomModules(path.join(customPath, 'modules')),
|
||||
workflows: await this.discoverCustomWorkflows(path.join(customPath, 'workflows')),
|
||||
};
|
||||
|
||||
// Show discovery results
|
||||
console.log(chalk.cyan('\n🔍 Custom Content Discovery'));
|
||||
console.log(chalk.dim(`Scanning: ${customPath}`));
|
||||
|
||||
if (customContent.agents.length > 0) {
|
||||
console.log(chalk.green(` ✓ Found ${customContent.agents.length} custom agent(s)`));
|
||||
}
|
||||
if (customContent.modules.length > 0) {
|
||||
console.log(chalk.green(` ✓ Found ${customContent.modules.length} custom module(s)`));
|
||||
}
|
||||
if (customContent.workflows.length > 0) {
|
||||
console.log(chalk.green(` ✓ Found ${customContent.workflows.length} custom workflow(s)`));
|
||||
}
|
||||
|
||||
if (customContent.agents.length === 0 && customContent.modules.length === 0 && customContent.workflows.length === 0) {
|
||||
console.log(chalk.yellow(' ⚠️ No custom content found in the specified folder'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm installation
|
||||
const { confirmInstall } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirmInstall',
|
||||
message: 'Install discovered custom content?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
if (confirmInstall) {
|
||||
console.log(chalk.green('\n🚀 Installing Custom Content...'));
|
||||
// Store custom content for later installation
|
||||
this._customContent = {
|
||||
path: customPath,
|
||||
items: customContent,
|
||||
};
|
||||
console.log(chalk.dim(` Custom content queued for installation`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover custom content in a directory
|
||||
* @param {string} dirPath - Directory path to scan
|
||||
* @returns {Promise<Array>} List of discovered items
|
||||
*/
|
||||
async discoverCustomContent(dirPath) {
|
||||
const items = [];
|
||||
|
||||
if (!(await fs.pathExists(dirPath))) {
|
||||
return items;
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
items.push({
|
||||
name: entry.name,
|
||||
path: path.join(dirPath, entry.name),
|
||||
type: 'directory',
|
||||
});
|
||||
} else if (entry.isFile() && (entry.name.endsWith('.agent.yaml') || entry.name.endsWith('.md'))) {
|
||||
items.push({
|
||||
name: entry.name,
|
||||
path: path.join(dirPath, entry.name),
|
||||
type: 'file',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore errors during discovery
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for tool/IDE selection (called after module configuration)
|
||||
* @param {string} projectDir - Project directory to check for existing IDEs
|
||||
@@ -224,6 +723,8 @@ class UI {
|
||||
}
|
||||
}
|
||||
|
||||
// Custom option moved to module selection
|
||||
|
||||
CLIUtils.displaySection('Tool Integration', 'Select AI coding assistants and IDEs to configure');
|
||||
|
||||
let answers;
|
||||
@@ -241,6 +742,8 @@ class UI {
|
||||
},
|
||||
]);
|
||||
|
||||
// Custom selection moved to module phase
|
||||
|
||||
// If tools were selected, we're done
|
||||
if (answers.ides && answers.ides.length > 0) {
|
||||
break;
|
||||
@@ -275,6 +778,7 @@ class UI {
|
||||
return {
|
||||
ides: answers.ides || [],
|
||||
skipIde: !answers.ides || answers.ides.length === 0,
|
||||
customContent: this._customContent || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -470,6 +974,35 @@ class UI {
|
||||
return configCollector.collectedConfig.core;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect module configurations
|
||||
* @param {string} directory - Installation directory
|
||||
* @param {Array} modules - Selected modules
|
||||
* @param {Object} existingCoreConfig - Core config already collected
|
||||
* @returns {Object} Module configurations
|
||||
*/
|
||||
async collectModuleConfigs(directory, modules, existingCoreConfig = null) {
|
||||
const { ConfigCollector } = require('../installers/lib/core/config-collector');
|
||||
const configCollector = new ConfigCollector();
|
||||
|
||||
// Load existing configs first if they exist
|
||||
await configCollector.loadExistingConfig(directory);
|
||||
|
||||
// If core config was already collected, use it
|
||||
if (existingCoreConfig) {
|
||||
configCollector.collectedConfig.core = existingCoreConfig;
|
||||
}
|
||||
|
||||
// Collect configurations for all modules except core (already collected earlier)
|
||||
// ConfigCollector now handles custom modules properly
|
||||
const modulesWithoutCore = modules.filter((m) => m !== 'core');
|
||||
if (modulesWithoutCore.length > 0) {
|
||||
await configCollector.collectAllConfigurations(modulesWithoutCore, directory);
|
||||
}
|
||||
|
||||
return configCollector.collectedConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module choices for selection
|
||||
* @param {Set} installedModuleIds - Currently installed module IDs
|
||||
@@ -481,11 +1014,32 @@ class UI {
|
||||
const availableModules = await moduleManager.listAvailable();
|
||||
|
||||
const isNewInstallation = installedModuleIds.size === 0;
|
||||
return availableModules.map((mod) => ({
|
||||
const moduleChoices = availableModules.map((mod) => ({
|
||||
name: mod.name,
|
||||
value: mod.id,
|
||||
checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id),
|
||||
}));
|
||||
|
||||
// Check for custom source folder
|
||||
const customPath = path.join(process.cwd(), 'bmad-custom-src');
|
||||
const hasCustomFolder = await fs.pathExists(customPath);
|
||||
|
||||
// Add custom option at the beginning
|
||||
if (hasCustomFolder) {
|
||||
moduleChoices.unshift({
|
||||
name: '📁 Custom: Agents, Workflows, Modules',
|
||||
value: 'custom',
|
||||
checked: false,
|
||||
});
|
||||
} else {
|
||||
moduleChoices.unshift({
|
||||
name: '📁 Custom: Agents, Workflows, Modules (specify path)',
|
||||
value: 'custom',
|
||||
checked: false,
|
||||
});
|
||||
}
|
||||
|
||||
return moduleChoices;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -75,27 +75,41 @@ function agentSchema(options = {}) {
|
||||
}
|
||||
// Handle multi format with triggers array (new format)
|
||||
else if (item.triggers && Array.isArray(item.triggers)) {
|
||||
for (const triggerGroup of item.triggers) {
|
||||
for (const triggerKey of Object.keys(triggerGroup)) {
|
||||
if (!TRIGGER_PATTERN.test(triggerKey)) {
|
||||
for (const [triggerIndex, triggerItem] of item.triggers.entries()) {
|
||||
let triggerName = null;
|
||||
|
||||
// Extract trigger name from all three formats
|
||||
if (triggerItem.trigger) {
|
||||
// Format 1: Simple flat format with trigger field
|
||||
triggerName = triggerItem.trigger;
|
||||
} else {
|
||||
// Format 2a or 2b: Object-key format
|
||||
const keys = Object.keys(triggerItem);
|
||||
if (keys.length === 1 && keys[0] !== 'trigger') {
|
||||
triggerName = keys[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (triggerName) {
|
||||
if (!TRIGGER_PATTERN.test(triggerName)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['agent', 'menu', index, 'triggers'],
|
||||
message: `agent.menu[].triggers key must be kebab-case (lowercase words separated by hyphen) - got "${triggerKey}"`,
|
||||
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(triggerKey)) {
|
||||
if (seenTriggers.has(triggerName)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['agent', 'menu', index, 'triggers'],
|
||||
message: `agent.menu[].triggers key duplicates "${triggerKey}" within the same agent`,
|
||||
path: ['agent', 'menu', index, 'triggers', triggerIndex],
|
||||
message: `agent.menu[].triggers[] duplicates "${triggerName}" within the same agent`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
seenTriggers.add(triggerKey);
|
||||
seenTriggers.add(triggerName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,102 +264,148 @@ function buildMenuItemSchema() {
|
||||
.object({
|
||||
multi: createNonEmptyString('agent.menu[].multi'),
|
||||
triggers: z
|
||||
.array(z.object({}).passthrough())
|
||||
.refine(
|
||||
(triggers) => {
|
||||
// Each item in triggers array should be an object with exactly one key
|
||||
for (const trigger of triggers) {
|
||||
const keys = Object.keys(trigger);
|
||||
if (keys.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
.array(
|
||||
z.union([
|
||||
// Format 1: Simple flat format (has trigger field)
|
||||
z
|
||||
.object({
|
||||
trigger: z.string(),
|
||||
input: createNonEmptyString('agent.menu[].triggers[].input'),
|
||||
route: createNonEmptyString('agent.menu[].triggers[].route').optional(),
|
||||
action: createNonEmptyString('agent.menu[].triggers[].action').optional(),
|
||||
data: z.string().optional(),
|
||||
type: z.enum(['exec', 'action', 'workflow']).optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => data.trigger, { message: 'Must have trigger field' })
|
||||
.superRefine((value, ctx) => {
|
||||
// Must have either route or action (or both)
|
||||
if (!value.route && !value.action) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'agent.menu[].triggers[] must have either route or action (or both)',
|
||||
});
|
||||
}
|
||||
}),
|
||||
// Format 2a: Object with array format (like bmad-builder.agent.yaml)
|
||||
z
|
||||
.object({})
|
||||
.passthrough()
|
||||
.refine(
|
||||
(value) => {
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length !== 1) return false;
|
||||
const triggerItems = value[keys[0]];
|
||||
return Array.isArray(triggerItems);
|
||||
},
|
||||
{ message: 'Must be object with single key pointing to array' },
|
||||
)
|
||||
.superRefine((value, ctx) => {
|
||||
const triggerName = Object.keys(value)[0];
|
||||
const triggerItems = value[triggerName];
|
||||
|
||||
const execArray = trigger[keys[0]];
|
||||
if (!Array.isArray(execArray)) {
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(triggerItems)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `Trigger "${triggerName}" must be an array of items`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
const hasInput = execArray.some((item) => 'input' in item);
|
||||
const hasRouteOrAction = execArray.some((item) => 'route' in item || 'action' in item);
|
||||
// Check required fields in the array
|
||||
const hasInput = triggerItems.some((item) => 'input' in item);
|
||||
const hasRouteOrAction = triggerItems.some((item) => 'route' in item || 'action' in item);
|
||||
|
||||
if (!hasInput) {
|
||||
return false;
|
||||
}
|
||||
if (!hasInput) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `Trigger "${triggerName}" must have an input field`,
|
||||
});
|
||||
}
|
||||
|
||||
// If not TODO, must have route or action
|
||||
const isTodo = execArray.some((item) => item.route === 'TODO' || item.action === 'TODO');
|
||||
if (!isTodo && !hasRouteOrAction) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'agent.menu[].triggers must be an array of trigger objects with input and either route/action or TODO',
|
||||
},
|
||||
if (!hasRouteOrAction) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `Trigger "${triggerName}" must have a route or action field`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
// Format 2b: Object with direct fields (like analyst.agent.yaml)
|
||||
z
|
||||
.object({})
|
||||
.passthrough()
|
||||
.refine(
|
||||
(value) => {
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length !== 1) return false;
|
||||
const triggerFields = value[keys[0]];
|
||||
return !Array.isArray(triggerFields) && typeof triggerFields === 'object';
|
||||
},
|
||||
{ message: 'Must be object with single key pointing to object' },
|
||||
)
|
||||
.superRefine((value, ctx) => {
|
||||
const triggerName = Object.keys(value)[0];
|
||||
const triggerFields = value[triggerName];
|
||||
|
||||
// Check required fields
|
||||
if (!triggerFields.input || typeof triggerFields.input !== 'string') {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `Trigger "${triggerName}" must have an input field`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!triggerFields.route && !triggerFields.action) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `Trigger "${triggerName}" must have a route or action field`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.transform((triggers) => {
|
||||
// Validate and clean up the triggers
|
||||
for (const trigger of triggers) {
|
||||
const keys = Object.keys(trigger);
|
||||
if (keys.length !== 1) {
|
||||
throw new Error('Each trigger object must have exactly one key');
|
||||
}
|
||||
|
||||
const execArray = trigger[keys[0]];
|
||||
if (!Array.isArray(execArray)) {
|
||||
throw new TypeError(`Trigger "${keys[0]}" must be an array`);
|
||||
}
|
||||
|
||||
// Validate each item in the exec array
|
||||
for (const item of execArray) {
|
||||
if ('input' in item && typeof item.input !== 'string') {
|
||||
throw new Error('Input must be a string');
|
||||
}
|
||||
if ('route' in item && typeof item.route !== 'string' && item.route !== 'TODO') {
|
||||
throw new Error('Route must be a string or TODO');
|
||||
}
|
||||
if ('type' in item && !['exec', 'action', 'workflow', 'TODO'].includes(item.type)) {
|
||||
throw new Error('Type must be one of: exec, action, workflow, TODO');
|
||||
}
|
||||
}
|
||||
}
|
||||
return triggers;
|
||||
}),
|
||||
.min(1, { message: 'agent.menu[].triggers must have at least one trigger' }),
|
||||
discussion: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
// Extract all trigger keys for validation
|
||||
const triggerKeys = [];
|
||||
for (const triggerGroup of value.triggers) {
|
||||
for (const key of Object.keys(triggerGroup)) {
|
||||
triggerKeys.push(key);
|
||||
// Check for duplicate trigger names
|
||||
const seenTriggers = new Set();
|
||||
for (const [index, triggerItem] of value.triggers.entries()) {
|
||||
let triggerName = null;
|
||||
|
||||
// Validate trigger key format
|
||||
if (!TRIGGER_PATTERN.test(key)) {
|
||||
// Extract trigger name from either format
|
||||
if (triggerItem.trigger) {
|
||||
// Format 1
|
||||
triggerName = triggerItem.trigger;
|
||||
} else {
|
||||
// Format 2
|
||||
const keys = Object.keys(triggerItem);
|
||||
if (keys.length === 1) {
|
||||
triggerName = keys[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (triggerName) {
|
||||
if (seenTriggers.has(triggerName)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['agent', 'menu', 'triggers'],
|
||||
message: `Trigger key "${key}" must be kebab-case (lowercase words separated by hyphen)`,
|
||||
path: ['agent', 'menu', 'triggers', index],
|
||||
message: `Trigger name "${triggerName}" is duplicated`,
|
||||
});
|
||||
}
|
||||
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)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
const seenTriggers = new Set();
|
||||
for (const triggerKey of triggerKeys) {
|
||||
if (seenTriggers.has(triggerKey)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['agent', 'menu', 'triggers'],
|
||||
message: `Trigger key "${triggerKey}" is duplicated`,
|
||||
});
|
||||
}
|
||||
seenTriggers.add(triggerKey);
|
||||
}
|
||||
});
|
||||
|
||||
return z.union([legacyMenuItemSchema, multiMenuItemSchema]);
|
||||
|
||||
Reference in New Issue
Block a user