feat: v6.0.0-alpha.0 - the future is now
This commit is contained in:
383
tools/cli/installers/lib/core/config-collector.js
Normal file
383
tools/cli/installers/lib/core/config-collector.js
Normal file
@@ -0,0 +1,383 @@
|
||||
const path = require('node:path');
|
||||
const fs = require('fs-extra');
|
||||
const yaml = require('js-yaml');
|
||||
const chalk = require('chalk');
|
||||
const inquirer = require('inquirer');
|
||||
const { getProjectRoot, getModulePath } = require('../../../lib/project-root');
|
||||
const { CLIUtils } = require('../../../lib/cli-utils');
|
||||
|
||||
class ConfigCollector {
|
||||
constructor() {
|
||||
this.collectedConfig = {};
|
||||
this.existingConfig = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing config if it exists from module config files
|
||||
* @param {string} projectDir - Target project directory
|
||||
*/
|
||||
async loadExistingConfig(projectDir) {
|
||||
const bmadDir = path.join(projectDir, 'bmad');
|
||||
this.existingConfig = {};
|
||||
|
||||
// Check if bmad directory exists
|
||||
if (!(await fs.pathExists(bmadDir))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to load existing module configs
|
||||
const modules = ['core', 'bmm', 'cis'];
|
||||
let foundAny = false;
|
||||
|
||||
for (const moduleName of modules) {
|
||||
const moduleConfigPath = path.join(bmadDir, moduleName, 'config.yaml');
|
||||
if (await fs.pathExists(moduleConfigPath)) {
|
||||
try {
|
||||
const content = await fs.readFile(moduleConfigPath, 'utf8');
|
||||
const moduleConfig = yaml.load(content);
|
||||
if (moduleConfig) {
|
||||
this.existingConfig[moduleName] = moduleConfig;
|
||||
foundAny = true;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors for individual modules
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundAny) {
|
||||
console.log(chalk.cyan('\n📋 Found existing BMAD module configurations'));
|
||||
}
|
||||
|
||||
return foundAny;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect configuration for all modules
|
||||
* @param {Array} modules - List of modules to configure (including 'core')
|
||||
* @param {string} projectDir - Target project directory
|
||||
*/
|
||||
async collectAllConfigurations(modules, projectDir) {
|
||||
await this.loadExistingConfig(projectDir);
|
||||
|
||||
// Check if core was already collected (e.g., in early collection phase)
|
||||
const coreAlreadyCollected = this.collectedConfig.core && Object.keys(this.collectedConfig.core).length > 0;
|
||||
|
||||
// If core wasn't already collected, include it
|
||||
const allModules = coreAlreadyCollected ? modules.filter((m) => m !== 'core') : ['core', ...modules.filter((m) => m !== 'core')];
|
||||
|
||||
// Store all answers across modules for cross-referencing
|
||||
if (!this.allAnswers) {
|
||||
this.allAnswers = {};
|
||||
}
|
||||
|
||||
for (const moduleName of allModules) {
|
||||
await this.collectModuleConfig(moduleName, projectDir);
|
||||
}
|
||||
|
||||
// Add metadata
|
||||
this.collectedConfig._meta = {
|
||||
version: require(path.join(getProjectRoot(), 'package.json')).version,
|
||||
installDate: new Date().toISOString(),
|
||||
lastModified: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return this.collectedConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect configuration for a single module
|
||||
* @param {string} moduleName - Module name
|
||||
* @param {string} projectDir - Target project directory
|
||||
* @param {boolean} skipLoadExisting - Skip loading existing config (for early core collection)
|
||||
* @param {boolean} skipCompletion - Skip showing completion message (for early core collection)
|
||||
*/
|
||||
async collectModuleConfig(moduleName, projectDir, skipLoadExisting = false, skipCompletion = false) {
|
||||
// Load existing config if needed and not already loaded
|
||||
if (!skipLoadExisting && !this.existingConfig) {
|
||||
await this.loadExistingConfig(projectDir);
|
||||
}
|
||||
|
||||
// Initialize allAnswers if not already initialized
|
||||
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-menu-config.yaml');
|
||||
const legacyConfigPath = path.join(getModulePath(moduleName), 'config.yaml');
|
||||
|
||||
let configPath = null;
|
||||
if (await fs.pathExists(installerConfigPath)) {
|
||||
configPath = installerConfigPath;
|
||||
} else if (await fs.pathExists(legacyConfigPath)) {
|
||||
configPath = legacyConfigPath;
|
||||
} else {
|
||||
// No config for this module
|
||||
return;
|
||||
}
|
||||
|
||||
const configContent = await fs.readFile(configPath, 'utf8');
|
||||
const moduleConfig = yaml.load(configContent);
|
||||
|
||||
if (!moduleConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Display module prompts using better formatting
|
||||
if (moduleConfig.prompt) {
|
||||
const prompts = Array.isArray(moduleConfig.prompt) ? moduleConfig.prompt : [moduleConfig.prompt];
|
||||
CLIUtils.displayPromptSection(prompts);
|
||||
}
|
||||
|
||||
// Process each config item
|
||||
const questions = [];
|
||||
const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt');
|
||||
|
||||
for (const key of configKeys) {
|
||||
const item = moduleConfig[key];
|
||||
|
||||
// Skip if not a config object
|
||||
if (!item || typeof item !== 'object' || !item.prompt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const question = await this.buildQuestion(moduleName, key, item);
|
||||
if (question) {
|
||||
questions.push(question);
|
||||
}
|
||||
}
|
||||
|
||||
if (questions.length > 0) {
|
||||
console.log(); // Line break before questions
|
||||
const answers = await inquirer.prompt(questions);
|
||||
|
||||
// Store answers for cross-referencing
|
||||
Object.assign(this.allAnswers, answers);
|
||||
|
||||
// Process answers and build result values
|
||||
for (const key of Object.keys(answers)) {
|
||||
const originalKey = key.replace(`${moduleName}_`, '');
|
||||
const item = moduleConfig[originalKey];
|
||||
const value = answers[key];
|
||||
|
||||
// Build the result using the template
|
||||
let result;
|
||||
|
||||
// For arrays (multi-select), handle differently
|
||||
if (Array.isArray(value)) {
|
||||
// If there's a result template and it's a string, don't use it for arrays
|
||||
// Just use the array value directly
|
||||
result = value;
|
||||
} else if (item.result) {
|
||||
result = item.result;
|
||||
|
||||
// Replace placeholders only for strings
|
||||
if (typeof result === 'string' && value !== undefined) {
|
||||
// Replace {value} with the actual value
|
||||
if (typeof value === 'string') {
|
||||
result = result.replace('{value}', value);
|
||||
} else if (typeof value === 'boolean' || typeof value === 'number') {
|
||||
// For boolean and number values, if result is just "{value}", use the raw value
|
||||
if (result === '{value}') {
|
||||
result = value;
|
||||
} else {
|
||||
// Otherwise replace in the string
|
||||
result = result.replace('{value}', value);
|
||||
}
|
||||
} else {
|
||||
// For non-string values, use directly
|
||||
result = value;
|
||||
}
|
||||
|
||||
// Only do further replacements if result is still a string
|
||||
if (typeof result === 'string') {
|
||||
// Replace references to other config values
|
||||
result = result.replaceAll(/{([^}]+)}/g, (match, configKey) => {
|
||||
// Check if it's a special placeholder
|
||||
if (configKey === 'project-root') {
|
||||
return '{project-root}';
|
||||
}
|
||||
|
||||
// Skip if it's the 'value' placeholder we already handled
|
||||
if (configKey === 'value') {
|
||||
return match;
|
||||
}
|
||||
|
||||
// Look for the config value across all modules
|
||||
// First check if it's in the current module's answers
|
||||
let configValue = answers[`${moduleName}_${configKey}`];
|
||||
|
||||
// Then check all answers (for cross-module references like outputFolder)
|
||||
if (!configValue) {
|
||||
// Try with various module prefixes
|
||||
for (const [answerKey, answerValue] of Object.entries(this.allAnswers)) {
|
||||
if (answerKey.endsWith(`_${configKey}`)) {
|
||||
configValue = answerValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check in already collected config
|
||||
if (!configValue) {
|
||||
for (const mod of Object.keys(this.collectedConfig)) {
|
||||
if (mod !== '_meta' && this.collectedConfig[mod] && this.collectedConfig[mod][configKey]) {
|
||||
configValue = this.collectedConfig[mod][configKey];
|
||||
// Extract just the value part if it's a result template
|
||||
if (typeof configValue === 'string' && configValue.includes('{project-root}/')) {
|
||||
configValue = configValue.replace('{project-root}/', '');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return configValue || match;
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No result template, use value directly
|
||||
result = value;
|
||||
}
|
||||
|
||||
// Store only the result value (no prompts, defaults, examples, etc.)
|
||||
if (!this.collectedConfig[moduleName]) {
|
||||
this.collectedConfig[moduleName] = {};
|
||||
}
|
||||
this.collectedConfig[moduleName][originalKey] = result;
|
||||
}
|
||||
|
||||
// Display module completion message after collecting all answers (unless skipped)
|
||||
if (!skipCompletion) {
|
||||
CLIUtils.displayModuleComplete(moduleName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an inquirer question from a config item
|
||||
* @param {string} moduleName - Module name
|
||||
* @param {string} key - Config key
|
||||
* @param {Object} item - Config item definition
|
||||
*/
|
||||
async buildQuestion(moduleName, key, item) {
|
||||
const questionName = `${moduleName}_${key}`;
|
||||
|
||||
// Check for existing value
|
||||
let existingValue = null;
|
||||
if (this.existingConfig && this.existingConfig[moduleName]) {
|
||||
existingValue = this.existingConfig[moduleName][key];
|
||||
|
||||
// Clean up existing value - remove {project-root}/ prefix if present
|
||||
// This prevents duplication when the result template adds it back
|
||||
if (typeof existingValue === 'string' && existingValue.startsWith('{project-root}/')) {
|
||||
existingValue = existingValue.replace('{project-root}/', '');
|
||||
}
|
||||
}
|
||||
|
||||
// Determine question type and default value
|
||||
let questionType = 'input';
|
||||
let defaultValue = item.default;
|
||||
let choices = null;
|
||||
|
||||
// Handle different question types
|
||||
if (item['single-select']) {
|
||||
questionType = 'list';
|
||||
choices = item['single-select'];
|
||||
if (existingValue && choices.includes(existingValue)) {
|
||||
defaultValue = existingValue;
|
||||
}
|
||||
} else if (item['multi-select']) {
|
||||
questionType = 'checkbox';
|
||||
choices = item['multi-select'].map((choice) => ({
|
||||
name: choice,
|
||||
value: choice,
|
||||
checked: existingValue
|
||||
? existingValue.includes(choice)
|
||||
: item.default && Array.isArray(item.default)
|
||||
? item.default.includes(choice)
|
||||
: false,
|
||||
}));
|
||||
} else if (typeof defaultValue === 'boolean') {
|
||||
questionType = 'confirm';
|
||||
}
|
||||
|
||||
// Build the prompt message
|
||||
let message = '';
|
||||
|
||||
// Handle array prompts for multi-line messages
|
||||
if (Array.isArray(item.prompt)) {
|
||||
message = item.prompt.join('\n');
|
||||
} else {
|
||||
message = item.prompt;
|
||||
}
|
||||
|
||||
// Add current value indicator for existing configs
|
||||
if (existingValue !== null && existingValue !== undefined) {
|
||||
if (typeof existingValue === 'boolean') {
|
||||
message += chalk.dim(` (current: ${existingValue ? 'true' : 'false'})`);
|
||||
defaultValue = existingValue;
|
||||
} else if (Array.isArray(existingValue)) {
|
||||
message += chalk.dim(` (current: ${existingValue.join(', ')})`);
|
||||
} else if (questionType !== 'list') {
|
||||
// Show the cleaned value (without {project-root}/) for display
|
||||
message += chalk.dim(` (current: ${existingValue})`);
|
||||
defaultValue = existingValue;
|
||||
}
|
||||
} else if (item.example && questionType === 'input') {
|
||||
// Show example for input fields
|
||||
const exampleText = typeof item.example === 'string' ? item.example.replace('{project-root}/', '') : JSON.stringify(item.example);
|
||||
message += chalk.dim(` (e.g., ${exampleText})`);
|
||||
}
|
||||
|
||||
const question = {
|
||||
type: questionType,
|
||||
name: questionName,
|
||||
message: message,
|
||||
default: defaultValue,
|
||||
};
|
||||
|
||||
// Add choices for select types
|
||||
if (choices) {
|
||||
question.choices = choices;
|
||||
}
|
||||
|
||||
// Add validation for input fields
|
||||
if (questionType === 'input') {
|
||||
question.validate = (input) => {
|
||||
if (!input && item.required) {
|
||||
return 'This field is required';
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge two objects
|
||||
* @param {Object} target - Target object
|
||||
* @param {Object} source - Source object
|
||||
*/
|
||||
deepMerge(target, source) {
|
||||
const result = { ...target };
|
||||
|
||||
for (const key in source) {
|
||||
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||
if (result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])) {
|
||||
result[key] = this.deepMerge(result[key], source[key]);
|
||||
} else {
|
||||
result[key] = source[key];
|
||||
}
|
||||
} else {
|
||||
result[key] = source[key];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ConfigCollector };
|
||||
721
tools/cli/installers/lib/core/dependency-resolver.js
Normal file
721
tools/cli/installers/lib/core/dependency-resolver.js
Normal file
@@ -0,0 +1,721 @@
|
||||
const fs = require('fs-extra');
|
||||
const path = require('node:path');
|
||||
const glob = require('glob');
|
||||
const chalk = require('chalk');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
/**
|
||||
* Dependency Resolver for BMAD modules
|
||||
* Handles cross-module dependencies and ensures all required files are included
|
||||
*/
|
||||
class DependencyResolver {
|
||||
constructor() {
|
||||
this.dependencies = new Map();
|
||||
this.resolvedFiles = new Set();
|
||||
this.missingDependencies = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all dependencies for selected modules
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Array} selectedModules - Modules explicitly selected by user
|
||||
* @param {Object} options - Resolution options
|
||||
* @returns {Object} Resolution results with all required files
|
||||
*/
|
||||
async resolve(bmadDir, selectedModules = [], options = {}) {
|
||||
if (options.verbose) {
|
||||
console.log(chalk.cyan('Resolving module dependencies...'));
|
||||
}
|
||||
|
||||
// Always include core as base
|
||||
const modulesToProcess = new Set(['core', ...selectedModules]);
|
||||
|
||||
// First pass: collect all explicitly selected files
|
||||
const primaryFiles = await this.collectPrimaryFiles(bmadDir, modulesToProcess);
|
||||
|
||||
// Second pass: parse and resolve dependencies
|
||||
const allDependencies = await this.parseDependencies(primaryFiles);
|
||||
|
||||
// Third pass: resolve dependency paths and collect files
|
||||
const resolvedDeps = await this.resolveDependencyPaths(bmadDir, allDependencies);
|
||||
|
||||
// Fourth pass: check for transitive dependencies
|
||||
const transitiveDeps = await this.resolveTransitiveDependencies(bmadDir, resolvedDeps);
|
||||
|
||||
// Combine all files
|
||||
const allFiles = new Set([...primaryFiles.map((f) => f.path), ...resolvedDeps, ...transitiveDeps]);
|
||||
|
||||
// Organize by module
|
||||
const organizedFiles = this.organizeByModule(bmadDir, allFiles);
|
||||
|
||||
// Report results (only in verbose mode)
|
||||
if (options.verbose) {
|
||||
this.reportResults(organizedFiles, selectedModules);
|
||||
}
|
||||
|
||||
return {
|
||||
primaryFiles,
|
||||
dependencies: resolvedDeps,
|
||||
transitiveDependencies: transitiveDeps,
|
||||
allFiles: [...allFiles],
|
||||
byModule: organizedFiles,
|
||||
missing: [...this.missingDependencies],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect primary files from selected modules
|
||||
*/
|
||||
async collectPrimaryFiles(bmadDir, modules) {
|
||||
const files = [];
|
||||
|
||||
for (const module of modules) {
|
||||
// Handle both source (src/) and installed (bmad/) directory structures
|
||||
let moduleDir;
|
||||
|
||||
// Check if this is a source directory (has 'src' subdirectory)
|
||||
const srcDir = path.join(bmadDir, 'src');
|
||||
if (await fs.pathExists(srcDir)) {
|
||||
// Source directory structure: src/core or src/modules/xxx
|
||||
moduleDir = module === 'core' ? path.join(srcDir, 'core') : path.join(srcDir, 'modules', module);
|
||||
} else {
|
||||
// Installed directory structure: bmad/core or bmad/modules/xxx
|
||||
moduleDir = module === 'core' ? path.join(bmadDir, 'core') : path.join(bmadDir, 'modules', module);
|
||||
}
|
||||
|
||||
if (!(await fs.pathExists(moduleDir))) {
|
||||
console.warn(chalk.yellow(`Module directory not found: ${moduleDir}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect agents
|
||||
const agentsDir = path.join(moduleDir, 'agents');
|
||||
if (await fs.pathExists(agentsDir)) {
|
||||
const agentFiles = await glob.glob('*.md', { cwd: agentsDir });
|
||||
for (const file of agentFiles) {
|
||||
const agentPath = path.join(agentsDir, file);
|
||||
|
||||
// Check for localskip attribute
|
||||
const content = await fs.readFile(agentPath, 'utf8');
|
||||
const hasLocalSkip = content.match(/<agent[^>]*\slocalskip="true"[^>]*>/);
|
||||
if (hasLocalSkip) {
|
||||
continue; // Skip agents marked for web-only
|
||||
}
|
||||
|
||||
files.push({
|
||||
path: agentPath,
|
||||
type: 'agent',
|
||||
module,
|
||||
name: path.basename(file, '.md'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Collect tasks
|
||||
const tasksDir = path.join(moduleDir, 'tasks');
|
||||
if (await fs.pathExists(tasksDir)) {
|
||||
const taskFiles = await glob.glob('*.md', { cwd: tasksDir });
|
||||
for (const file of taskFiles) {
|
||||
files.push({
|
||||
path: path.join(tasksDir, file),
|
||||
type: 'task',
|
||||
module,
|
||||
name: path.basename(file, '.md'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse dependencies from file content
|
||||
*/
|
||||
async parseDependencies(files) {
|
||||
const allDeps = new Set();
|
||||
|
||||
for (const file of files) {
|
||||
const content = await fs.readFile(file.path, 'utf8');
|
||||
|
||||
// Parse YAML frontmatter for explicit dependencies
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (frontmatterMatch) {
|
||||
try {
|
||||
// Pre-process to handle backticks in YAML values
|
||||
let yamlContent = frontmatterMatch[1];
|
||||
// Quote values with backticks to make them valid YAML
|
||||
yamlContent = yamlContent.replaceAll(/: `([^`]+)`/g, ': "$1"');
|
||||
|
||||
const frontmatter = yaml.load(yamlContent);
|
||||
if (frontmatter.dependencies) {
|
||||
const deps = Array.isArray(frontmatter.dependencies) ? frontmatter.dependencies : [frontmatter.dependencies];
|
||||
|
||||
for (const dep of deps) {
|
||||
allDeps.add({
|
||||
from: file.path,
|
||||
dependency: dep,
|
||||
type: 'explicit',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for template dependencies
|
||||
if (frontmatter.template) {
|
||||
const templates = Array.isArray(frontmatter.template) ? frontmatter.template : [frontmatter.template];
|
||||
for (const template of templates) {
|
||||
allDeps.add({
|
||||
from: file.path,
|
||||
dependency: template,
|
||||
type: 'template',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(chalk.yellow(`Failed to parse frontmatter in ${file.name}: ${error.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Parse content for command references (cross-module dependencies)
|
||||
const commandRefs = this.parseCommandReferences(content);
|
||||
for (const ref of commandRefs) {
|
||||
allDeps.add({
|
||||
from: file.path,
|
||||
dependency: ref,
|
||||
type: 'command',
|
||||
});
|
||||
}
|
||||
|
||||
// Parse for file path references
|
||||
const fileRefs = this.parseFileReferences(content);
|
||||
for (const ref of fileRefs) {
|
||||
// Determine type based on path format
|
||||
// Paths starting with bmad/ are absolute references to the bmad installation
|
||||
const depType = ref.startsWith('bmad/') ? 'bmad-path' : 'file';
|
||||
allDeps.add({
|
||||
from: file.path,
|
||||
dependency: ref,
|
||||
type: depType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allDeps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse command references from content
|
||||
*/
|
||||
parseCommandReferences(content) {
|
||||
const refs = new Set();
|
||||
|
||||
// Match @task-{name} or @agent-{name} or @{module}-{type}-{name}
|
||||
const commandPattern = /@(task-|agent-|bmad-)([a-z0-9-]+)/g;
|
||||
let match;
|
||||
|
||||
while ((match = commandPattern.exec(content)) !== null) {
|
||||
refs.add(match[0]);
|
||||
}
|
||||
|
||||
// Match file paths like bmad/core/agents/analyst
|
||||
const pathPattern = /bmad\/(core|bmm|cis)\/(agents|tasks)\/([a-z0-9-]+)/g;
|
||||
|
||||
while ((match = pathPattern.exec(content)) !== null) {
|
||||
refs.add(match[0]);
|
||||
}
|
||||
|
||||
return [...refs];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse file path references from content
|
||||
*/
|
||||
parseFileReferences(content) {
|
||||
const refs = new Set();
|
||||
|
||||
// Match relative paths like ../templates/file.yaml or ./data/file.md
|
||||
const relativePattern = /['"](\.\.?\/[^'"]+\.(md|yaml|yml|xml|json|txt|csv))['"]/g;
|
||||
let match;
|
||||
|
||||
while ((match = relativePattern.exec(content)) !== null) {
|
||||
refs.add(match[1]);
|
||||
}
|
||||
|
||||
// Parse exec attributes in command tags
|
||||
const execPattern = /exec="([^"]+)"/g;
|
||||
while ((match = execPattern.exec(content)) !== null) {
|
||||
let execPath = match[1];
|
||||
if (execPath && execPath !== '*') {
|
||||
// Remove {project-root} prefix to get the actual path
|
||||
// Usage is like {project-root}/bmad/core/tasks/foo.md
|
||||
if (execPath.includes('{project-root}')) {
|
||||
execPath = execPath.replace('{project-root}', '');
|
||||
}
|
||||
refs.add(execPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse tmpl attributes in command tags
|
||||
const tmplPattern = /tmpl="([^"]+)"/g;
|
||||
while ((match = tmplPattern.exec(content)) !== null) {
|
||||
let tmplPath = match[1];
|
||||
if (tmplPath && tmplPath !== '*') {
|
||||
// Remove {project-root} prefix to get the actual path
|
||||
// Usage is like {project-root}/bmad/core/tasks/foo.md
|
||||
if (tmplPath.includes('{project-root}')) {
|
||||
tmplPath = tmplPath.replace('{project-root}', '');
|
||||
}
|
||||
refs.add(tmplPath);
|
||||
}
|
||||
}
|
||||
|
||||
return [...refs];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve dependency paths to actual files
|
||||
*/
|
||||
async resolveDependencyPaths(bmadDir, dependencies) {
|
||||
const resolved = new Set();
|
||||
|
||||
for (const dep of dependencies) {
|
||||
const resolvedPaths = await this.resolveSingleDependency(bmadDir, dep);
|
||||
for (const path of resolvedPaths) {
|
||||
resolved.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single dependency to file paths
|
||||
*/
|
||||
async resolveSingleDependency(bmadDir, dep) {
|
||||
const paths = [];
|
||||
|
||||
switch (dep.type) {
|
||||
case 'explicit':
|
||||
case 'file': {
|
||||
let depPath = dep.dependency;
|
||||
|
||||
// Handle {project-root} prefix if present
|
||||
if (depPath.includes('{project-root}')) {
|
||||
// Remove {project-root} and resolve as bmad path
|
||||
depPath = depPath.replace('{project-root}', '');
|
||||
|
||||
if (depPath.startsWith('bmad/')) {
|
||||
const bmadPath = depPath.replace(/^bmad\//, '');
|
||||
|
||||
// Handle glob patterns
|
||||
if (depPath.includes('*')) {
|
||||
// Extract the base path and pattern
|
||||
const pathParts = bmadPath.split('/');
|
||||
const module = pathParts[0];
|
||||
const filePattern = pathParts.at(-1);
|
||||
const middlePath = pathParts.slice(1, -1).join('/');
|
||||
|
||||
let basePath;
|
||||
if (module === 'core') {
|
||||
basePath = path.join(bmadDir, 'core', middlePath);
|
||||
} else {
|
||||
basePath = path.join(bmadDir, 'modules', module, middlePath);
|
||||
}
|
||||
|
||||
if (await fs.pathExists(basePath)) {
|
||||
const files = await glob.glob(filePattern, { cwd: basePath });
|
||||
for (const file of files) {
|
||||
paths.push(path.join(basePath, file));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct path
|
||||
if (bmadPath.startsWith('core/')) {
|
||||
const corePath = path.join(bmadDir, bmadPath);
|
||||
if (await fs.pathExists(corePath)) {
|
||||
paths.push(corePath);
|
||||
}
|
||||
} else {
|
||||
const parts = bmadPath.split('/');
|
||||
const module = parts[0];
|
||||
const rest = parts.slice(1).join('/');
|
||||
const modulePath = path.join(bmadDir, 'modules', module, rest);
|
||||
|
||||
if (await fs.pathExists(modulePath)) {
|
||||
paths.push(modulePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular relative path handling
|
||||
const sourceDir = path.dirname(dep.from);
|
||||
|
||||
// Handle glob patterns
|
||||
if (depPath.includes('*')) {
|
||||
const basePath = path.resolve(sourceDir, path.dirname(depPath));
|
||||
const pattern = path.basename(depPath);
|
||||
|
||||
if (await fs.pathExists(basePath)) {
|
||||
const files = await glob.glob(pattern, { cwd: basePath });
|
||||
for (const file of files) {
|
||||
paths.push(path.join(basePath, file));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct file reference
|
||||
const fullPath = path.resolve(sourceDir, depPath);
|
||||
if (await fs.pathExists(fullPath)) {
|
||||
paths.push(fullPath);
|
||||
} else {
|
||||
this.missingDependencies.add(`${depPath} (referenced by ${path.basename(dep.from)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'command': {
|
||||
// Resolve command references to actual files
|
||||
const commandPath = await this.resolveCommandToPath(bmadDir, dep.dependency);
|
||||
if (commandPath) {
|
||||
paths.push(commandPath);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'bmad-path': {
|
||||
// Resolve bmad/ paths (from {project-root}/bmad/... references)
|
||||
// These are paths relative to the src directory structure
|
||||
const bmadPath = dep.dependency.replace(/^bmad\//, '');
|
||||
|
||||
// Try to resolve as if it's in src structure
|
||||
// bmad/core/tasks/foo.md -> src/core/tasks/foo.md
|
||||
// bmad/bmm/tasks/bar.md -> src/modules/bmm/tasks/bar.md
|
||||
|
||||
if (bmadPath.startsWith('core/')) {
|
||||
const corePath = path.join(bmadDir, bmadPath);
|
||||
if (await fs.pathExists(corePath)) {
|
||||
paths.push(corePath);
|
||||
} else {
|
||||
// Not found, but don't report as missing since it might be installed later
|
||||
}
|
||||
} else {
|
||||
// It's a module path like bmm/tasks/foo.md or cis/agents/bar.md
|
||||
const parts = bmadPath.split('/');
|
||||
const module = parts[0];
|
||||
const rest = parts.slice(1).join('/');
|
||||
const modulePath = path.join(bmadDir, 'modules', module, rest);
|
||||
|
||||
if (await fs.pathExists(modulePath)) {
|
||||
paths.push(modulePath);
|
||||
} else {
|
||||
// Not found, but don't report as missing since it might be installed later
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'template': {
|
||||
// Resolve template references
|
||||
let templateDep = dep.dependency;
|
||||
|
||||
// Handle {project-root} prefix if present
|
||||
if (templateDep.includes('{project-root}')) {
|
||||
// Remove {project-root} and treat as bmad-path
|
||||
templateDep = templateDep.replace('{project-root}', '');
|
||||
|
||||
// Now resolve as a bmad path
|
||||
if (templateDep.startsWith('bmad/')) {
|
||||
const bmadPath = templateDep.replace(/^bmad\//, '');
|
||||
|
||||
if (bmadPath.startsWith('core/')) {
|
||||
const corePath = path.join(bmadDir, bmadPath);
|
||||
if (await fs.pathExists(corePath)) {
|
||||
paths.push(corePath);
|
||||
}
|
||||
} else {
|
||||
// Module path like cis/templates/brainstorm.md
|
||||
const parts = bmadPath.split('/');
|
||||
const module = parts[0];
|
||||
const rest = parts.slice(1).join('/');
|
||||
const modulePath = path.join(bmadDir, 'modules', module, rest);
|
||||
|
||||
if (await fs.pathExists(modulePath)) {
|
||||
paths.push(modulePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular relative template path
|
||||
const sourceDir = path.dirname(dep.from);
|
||||
const templatePath = path.resolve(sourceDir, templateDep);
|
||||
|
||||
if (await fs.pathExists(templatePath)) {
|
||||
paths.push(templatePath);
|
||||
} else {
|
||||
this.missingDependencies.add(`Template: ${dep.dependency}`);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
// No default
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve command reference to file path
|
||||
*/
|
||||
async resolveCommandToPath(bmadDir, command) {
|
||||
// Parse command format: @task-name or @agent-name or bmad/module/type/name
|
||||
|
||||
if (command.startsWith('@task-')) {
|
||||
const taskName = command.slice(6);
|
||||
// Search all modules for this task
|
||||
for (const module of ['core', 'bmm', 'cis']) {
|
||||
const taskPath =
|
||||
module === 'core'
|
||||
? path.join(bmadDir, 'core', 'tasks', `${taskName}.md`)
|
||||
: path.join(bmadDir, 'modules', module, 'tasks', `${taskName}.md`);
|
||||
if (await fs.pathExists(taskPath)) {
|
||||
return taskPath;
|
||||
}
|
||||
}
|
||||
} else if (command.startsWith('@agent-')) {
|
||||
const agentName = command.slice(7);
|
||||
// Search all modules for this agent
|
||||
for (const module of ['core', 'bmm', 'cis']) {
|
||||
const agentPath =
|
||||
module === 'core'
|
||||
? path.join(bmadDir, 'core', 'agents', `${agentName}.md`)
|
||||
: path.join(bmadDir, 'modules', module, 'agents', `${agentName}.md`);
|
||||
if (await fs.pathExists(agentPath)) {
|
||||
return agentPath;
|
||||
}
|
||||
}
|
||||
} else if (command.startsWith('bmad/')) {
|
||||
// Direct path reference
|
||||
const parts = command.split('/');
|
||||
if (parts.length >= 4) {
|
||||
const [, module, type, ...nameParts] = parts;
|
||||
const name = nameParts.join('/'); // Handle nested paths
|
||||
|
||||
// Check if name already has extension
|
||||
const fileName = name.endsWith('.md') ? name : `${name}.md`;
|
||||
|
||||
const filePath =
|
||||
module === 'core' ? path.join(bmadDir, 'core', type, fileName) : path.join(bmadDir, 'modules', module, type, fileName);
|
||||
if (await fs.pathExists(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't report as missing if it's a self-reference within the module being installed
|
||||
if (!command.includes('cis') || command.includes('brain')) {
|
||||
// Only report missing if it's a true external dependency
|
||||
// this.missingDependencies.add(`Command: ${command}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve transitive dependencies (dependencies of dependencies)
|
||||
*/
|
||||
async resolveTransitiveDependencies(bmadDir, directDeps) {
|
||||
const transitive = new Set();
|
||||
const processed = new Set();
|
||||
|
||||
// Process each direct dependency
|
||||
for (const depPath of directDeps) {
|
||||
if (processed.has(depPath)) continue;
|
||||
processed.add(depPath);
|
||||
|
||||
// Only process markdown and YAML files for transitive deps
|
||||
if ((depPath.endsWith('.md') || depPath.endsWith('.yaml') || depPath.endsWith('.yml')) && (await fs.pathExists(depPath))) {
|
||||
const content = await fs.readFile(depPath, 'utf8');
|
||||
const subDeps = await this.parseDependencies([
|
||||
{
|
||||
path: depPath,
|
||||
type: 'dependency',
|
||||
module: this.getModuleFromPath(bmadDir, depPath),
|
||||
name: path.basename(depPath),
|
||||
},
|
||||
]);
|
||||
|
||||
const resolvedSubDeps = await this.resolveDependencyPaths(bmadDir, subDeps);
|
||||
for (const subDep of resolvedSubDeps) {
|
||||
if (!directDeps.has(subDep)) {
|
||||
transitive.add(subDep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return transitive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module name from file path
|
||||
*/
|
||||
getModuleFromPath(bmadDir, filePath) {
|
||||
const relative = path.relative(bmadDir, filePath);
|
||||
const parts = relative.split(path.sep);
|
||||
|
||||
// Handle source directory structure (src/core or src/modules/xxx)
|
||||
if (parts[0] === 'src') {
|
||||
if (parts[1] === 'core') {
|
||||
return 'core';
|
||||
} else if (parts[1] === 'modules' && parts.length > 2) {
|
||||
return parts[2];
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's in modules directory (installed structure)
|
||||
if (parts[0] === 'modules' && parts.length > 1) {
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
// Otherwise return the first part (core, etc.)
|
||||
// But don't return 'src' as a module name
|
||||
if (parts[0] === 'src') {
|
||||
return 'unknown';
|
||||
}
|
||||
return parts[0] || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Organize files by module
|
||||
*/
|
||||
organizeByModule(bmadDir, files) {
|
||||
const organized = {};
|
||||
|
||||
for (const file of files) {
|
||||
const module = this.getModuleFromPath(bmadDir, file);
|
||||
if (!organized[module]) {
|
||||
organized[module] = {
|
||||
agents: [],
|
||||
tasks: [],
|
||||
templates: [],
|
||||
data: [],
|
||||
other: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Get relative path correctly based on module structure
|
||||
let moduleBase;
|
||||
|
||||
// Check if file is in source directory structure
|
||||
if (file.includes('/src/core/') || file.includes('/src/modules/')) {
|
||||
moduleBase = module === 'core' ? path.join(bmadDir, 'src', 'core') : path.join(bmadDir, 'src', 'modules', module);
|
||||
} else {
|
||||
// Installed structure
|
||||
moduleBase = module === 'core' ? path.join(bmadDir, 'core') : path.join(bmadDir, 'modules', module);
|
||||
}
|
||||
|
||||
const relative = path.relative(moduleBase, file);
|
||||
|
||||
// Check file path for categorization
|
||||
// Brain-tech files are data, not tasks (even though they're in tasks/brain-tech/)
|
||||
if (file.includes('/brain-tech/')) {
|
||||
organized[module].data.push(file);
|
||||
} else if (relative.startsWith('agents/') || file.includes('/agents/')) {
|
||||
organized[module].agents.push(file);
|
||||
} else if (relative.startsWith('tasks/') || file.includes('/tasks/')) {
|
||||
organized[module].tasks.push(file);
|
||||
} else if (relative.includes('template') || file.includes('/templates/')) {
|
||||
organized[module].templates.push(file);
|
||||
} else if (relative.includes('data/')) {
|
||||
organized[module].data.push(file);
|
||||
} else {
|
||||
organized[module].other.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
return organized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report resolution results
|
||||
*/
|
||||
reportResults(organized, selectedModules) {
|
||||
console.log(chalk.green('\n✓ Dependency resolution complete'));
|
||||
|
||||
for (const [module, files] of Object.entries(organized)) {
|
||||
const isSelected = selectedModules.includes(module) || module === 'core';
|
||||
const totalFiles = files.agents.length + files.tasks.length + files.templates.length + files.data.length + files.other.length;
|
||||
|
||||
if (totalFiles > 0) {
|
||||
console.log(chalk.cyan(`\n ${module.toUpperCase()} module:`));
|
||||
console.log(chalk.dim(` Status: ${isSelected ? 'Selected' : 'Dependencies only'}`));
|
||||
|
||||
if (files.agents.length > 0) {
|
||||
console.log(chalk.dim(` Agents: ${files.agents.length}`));
|
||||
}
|
||||
if (files.tasks.length > 0) {
|
||||
console.log(chalk.dim(` Tasks: ${files.tasks.length}`));
|
||||
}
|
||||
if (files.templates.length > 0) {
|
||||
console.log(chalk.dim(` Templates: ${files.templates.length}`));
|
||||
}
|
||||
if (files.data.length > 0) {
|
||||
console.log(chalk.dim(` Data files: ${files.data.length}`));
|
||||
}
|
||||
if (files.other.length > 0) {
|
||||
console.log(chalk.dim(` Other files: ${files.other.length}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.missingDependencies.size > 0) {
|
||||
console.log(chalk.yellow('\n ⚠ Missing dependencies:'));
|
||||
for (const missing of this.missingDependencies) {
|
||||
console.log(chalk.yellow(` - ${missing}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a bundle for web deployment
|
||||
* @param {Object} resolution - Resolution results from resolve()
|
||||
* @returns {Object} Bundle data ready for web
|
||||
*/
|
||||
async createWebBundle(resolution) {
|
||||
const bundle = {
|
||||
metadata: {
|
||||
created: new Date().toISOString(),
|
||||
modules: Object.keys(resolution.byModule),
|
||||
totalFiles: resolution.allFiles.length,
|
||||
},
|
||||
agents: {},
|
||||
tasks: {},
|
||||
templates: {},
|
||||
data: {},
|
||||
};
|
||||
|
||||
// Bundle all files by type
|
||||
for (const filePath of resolution.allFiles) {
|
||||
if (!(await fs.pathExists(filePath))) continue;
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
const relative = path.relative(path.dirname(resolution.primaryFiles[0]?.path || '.'), filePath);
|
||||
|
||||
if (filePath.includes('/agents/')) {
|
||||
bundle.agents[relative] = content;
|
||||
} else if (filePath.includes('/tasks/')) {
|
||||
bundle.tasks[relative] = content;
|
||||
} else if (filePath.includes('template')) {
|
||||
bundle.templates[relative] = content;
|
||||
} else {
|
||||
bundle.data[relative] = content;
|
||||
}
|
||||
}
|
||||
|
||||
return bundle;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DependencyResolver };
|
||||
208
tools/cli/installers/lib/core/detector.js
Normal file
208
tools/cli/installers/lib/core/detector.js
Normal file
@@ -0,0 +1,208 @@
|
||||
const path = require('node:path');
|
||||
const fs = require('fs-extra');
|
||||
const yaml = require('js-yaml');
|
||||
const { Manifest } = require('./manifest');
|
||||
|
||||
class Detector {
|
||||
/**
|
||||
* Detect existing BMAD installation
|
||||
* @param {string} bmadDir - Path to bmad directory
|
||||
* @returns {Object} Installation status and details
|
||||
*/
|
||||
async detect(bmadDir) {
|
||||
const result = {
|
||||
installed: false,
|
||||
path: bmadDir,
|
||||
version: null,
|
||||
hasCore: false,
|
||||
modules: [],
|
||||
ides: [],
|
||||
manifest: null,
|
||||
};
|
||||
|
||||
// Check if bmad directory exists
|
||||
if (!(await fs.pathExists(bmadDir))) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check for manifest using the Manifest class
|
||||
const manifest = new Manifest();
|
||||
const manifestData = await manifest.read(bmadDir);
|
||||
if (manifestData) {
|
||||
result.manifest = manifestData;
|
||||
result.version = manifestData.version;
|
||||
result.installed = true;
|
||||
}
|
||||
|
||||
// Check for core
|
||||
const corePath = path.join(bmadDir, 'core');
|
||||
if (await fs.pathExists(corePath)) {
|
||||
result.hasCore = true;
|
||||
|
||||
// Try to get core version from config
|
||||
const coreConfigPath = path.join(corePath, 'config.yaml');
|
||||
if (await fs.pathExists(coreConfigPath)) {
|
||||
try {
|
||||
const configContent = await fs.readFile(coreConfigPath, 'utf8');
|
||||
const config = yaml.load(configContent);
|
||||
if (!result.version && config.version) {
|
||||
result.version = config.version;
|
||||
}
|
||||
} catch {
|
||||
// Ignore config read errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for modules
|
||||
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_cfg') {
|
||||
const modulePath = path.join(bmadDir, entry.name);
|
||||
const moduleConfigPath = path.join(modulePath, 'config.yaml');
|
||||
|
||||
const moduleInfo = {
|
||||
id: entry.name,
|
||||
path: modulePath,
|
||||
version: 'unknown',
|
||||
};
|
||||
|
||||
if (await fs.pathExists(moduleConfigPath)) {
|
||||
try {
|
||||
const configContent = await fs.readFile(moduleConfigPath, 'utf8');
|
||||
const config = yaml.load(configContent);
|
||||
moduleInfo.version = config.version || 'unknown';
|
||||
moduleInfo.name = config.name || entry.name;
|
||||
moduleInfo.description = config.description;
|
||||
} catch {
|
||||
// Ignore config read errors
|
||||
}
|
||||
}
|
||||
|
||||
result.modules.push(moduleInfo);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for IDE configurations from manifest
|
||||
if (result.manifest && result.manifest.ides) {
|
||||
result.ides = result.manifest.ides;
|
||||
}
|
||||
|
||||
// Mark as installed if we found core or modules
|
||||
if (result.hasCore || result.modules.length > 0) {
|
||||
result.installed = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect legacy installation (.bmad-method, .bmm, .cis)
|
||||
* @param {string} projectDir - Project directory to check
|
||||
* @returns {Object} Legacy installation details
|
||||
*/
|
||||
async detectLegacy(projectDir) {
|
||||
const result = {
|
||||
hasLegacy: false,
|
||||
legacyCore: false,
|
||||
legacyModules: [],
|
||||
paths: [],
|
||||
};
|
||||
|
||||
// Check for legacy core (.bmad-method)
|
||||
const legacyCorePath = path.join(projectDir, '.bmad-method');
|
||||
if (await fs.pathExists(legacyCorePath)) {
|
||||
result.hasLegacy = true;
|
||||
result.legacyCore = true;
|
||||
result.paths.push(legacyCorePath);
|
||||
}
|
||||
|
||||
// Check for legacy modules (directories starting with .)
|
||||
const entries = await fs.readdir(projectDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
entry.isDirectory() &&
|
||||
entry.name.startsWith('.') &&
|
||||
entry.name !== '.bmad-method' &&
|
||||
!entry.name.startsWith('.git') &&
|
||||
!entry.name.startsWith('.vscode') &&
|
||||
!entry.name.startsWith('.idea')
|
||||
) {
|
||||
const modulePath = path.join(projectDir, entry.name);
|
||||
const moduleManifestPath = path.join(modulePath, 'install-manifest.yaml');
|
||||
|
||||
// Check if it's likely a BMAD module
|
||||
if ((await fs.pathExists(moduleManifestPath)) || (await fs.pathExists(path.join(modulePath, 'config.yaml')))) {
|
||||
result.hasLegacy = true;
|
||||
result.legacyModules.push({
|
||||
name: entry.name.slice(1), // Remove leading dot
|
||||
path: modulePath,
|
||||
});
|
||||
result.paths.push(modulePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if migration from legacy is needed
|
||||
* @param {string} projectDir - Project directory
|
||||
* @returns {Object} Migration requirements
|
||||
*/
|
||||
async checkMigrationNeeded(projectDir) {
|
||||
const bmadDir = path.join(projectDir, 'bmad');
|
||||
const current = await this.detect(bmadDir);
|
||||
const legacy = await this.detectLegacy(projectDir);
|
||||
|
||||
return {
|
||||
needed: legacy.hasLegacy && !current.installed,
|
||||
canMigrate: legacy.hasLegacy,
|
||||
legacy: legacy,
|
||||
current: current,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect legacy BMAD v4 footprints (case-sensitive path checks)
|
||||
* @param {string} projectDir - Project directory to check
|
||||
* @returns {{ hasLegacyV4: boolean, offenders: string[] }}
|
||||
*/
|
||||
async detectLegacyV4(projectDir) {
|
||||
// Helper: check existence of a nested path with case-sensitive segment matching
|
||||
const existsCaseSensitive = async (baseDir, segments) => {
|
||||
let dir = baseDir;
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const hit = entries.find((e) => e.name === seg);
|
||||
if (!hit) return false;
|
||||
// Parents must be directories; the last segment may be a file or directory
|
||||
if (i < segments.length - 1 && !hit.isDirectory()) return false;
|
||||
dir = path.join(dir, hit.name);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const offenders = [];
|
||||
if (await existsCaseSensitive(projectDir, ['.bmad-core'])) {
|
||||
offenders.push(path.join(projectDir, '.bmad-core'));
|
||||
}
|
||||
if (await existsCaseSensitive(projectDir, ['.claude', 'commands', 'BMad'])) {
|
||||
offenders.push(path.join(projectDir, '.claude', 'commands', 'BMad'));
|
||||
}
|
||||
if (await existsCaseSensitive(projectDir, ['.crush', 'commands', 'BMad'])) {
|
||||
offenders.push(path.join(projectDir, '.crush', 'commands', 'BMad'));
|
||||
}
|
||||
|
||||
return { hasLegacyV4: offenders.length > 0, offenders };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Detector };
|
||||
1070
tools/cli/installers/lib/core/installer.js
Normal file
1070
tools/cli/installers/lib/core/installer.js
Normal file
File diff suppressed because it is too large
Load Diff
385
tools/cli/installers/lib/core/manifest-generator.js
Normal file
385
tools/cli/installers/lib/core/manifest-generator.js
Normal file
@@ -0,0 +1,385 @@
|
||||
const path = require('node:path');
|
||||
const fs = require('fs-extra');
|
||||
const yaml = require('js-yaml');
|
||||
const { getSourcePath, getModulePath } = require('../../../lib/project-root');
|
||||
|
||||
/**
|
||||
* Generates manifest files for installed workflows, agents, and tasks
|
||||
*/
|
||||
class ManifestGenerator {
|
||||
constructor() {
|
||||
this.workflows = [];
|
||||
this.agents = [];
|
||||
this.tasks = [];
|
||||
this.modules = [];
|
||||
this.files = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all manifests for the installation
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Array} selectedModules - Selected modules for installation
|
||||
*/
|
||||
async generateManifests(bmadDir, selectedModules) {
|
||||
// Create _cfg directory if it doesn't exist
|
||||
const cfgDir = path.join(bmadDir, '_cfg');
|
||||
await fs.ensureDir(cfgDir);
|
||||
|
||||
// Store modules list
|
||||
this.modules = ['core', ...selectedModules];
|
||||
|
||||
// Collect workflow data
|
||||
await this.collectWorkflows(selectedModules);
|
||||
|
||||
// Collect agent data
|
||||
await this.collectAgents(selectedModules);
|
||||
|
||||
// Collect task data
|
||||
await this.collectTasks(selectedModules);
|
||||
|
||||
// Write manifest files
|
||||
await this.writeMainManifest(cfgDir);
|
||||
await this.writeWorkflowManifest(cfgDir);
|
||||
await this.writeAgentManifest(cfgDir);
|
||||
await this.writeTaskManifest(cfgDir);
|
||||
await this.writeFilesManifest(cfgDir);
|
||||
|
||||
return {
|
||||
workflows: this.workflows.length,
|
||||
agents: this.agents.length,
|
||||
tasks: this.tasks.length,
|
||||
files: this.files.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all workflows from core and selected modules
|
||||
*/
|
||||
async collectWorkflows(selectedModules) {
|
||||
this.workflows = [];
|
||||
|
||||
// Get core workflows
|
||||
const corePath = getModulePath('core');
|
||||
const coreWorkflows = await this.getWorkflowsFromPath(corePath, 'core');
|
||||
this.workflows.push(...coreWorkflows);
|
||||
|
||||
// Get module workflows
|
||||
for (const moduleName of selectedModules) {
|
||||
const modulePath = getSourcePath(`modules/${moduleName}`);
|
||||
const moduleWorkflows = await this.getWorkflowsFromPath(modulePath, moduleName);
|
||||
this.workflows.push(...moduleWorkflows);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find and parse workflow.yaml files
|
||||
*/
|
||||
async getWorkflowsFromPath(basePath, moduleName) {
|
||||
const workflows = [];
|
||||
const workflowsPath = path.join(basePath, 'workflows');
|
||||
|
||||
if (!(await fs.pathExists(workflowsPath))) {
|
||||
return workflows;
|
||||
}
|
||||
|
||||
// Recursively find workflow.yaml files
|
||||
const findWorkflows = async (dir, relativePath = '') => {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Recurse into subdirectories
|
||||
const newRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
||||
await findWorkflows(fullPath, newRelativePath);
|
||||
} else if (entry.name === 'workflow.yaml') {
|
||||
// Parse workflow file
|
||||
try {
|
||||
const content = await fs.readFile(fullPath, 'utf8');
|
||||
const workflow = yaml.load(content);
|
||||
|
||||
// Skip template workflows (those with placeholder values)
|
||||
if (workflow.name && workflow.name.includes('{') && workflow.name.includes('}')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (workflow.name && workflow.description) {
|
||||
// Build relative path for installation
|
||||
const installPath =
|
||||
moduleName === 'core'
|
||||
? `bmad/core/workflows/${relativePath}/workflow.yaml`
|
||||
: `bmad/${moduleName}/workflows/${relativePath}/workflow.yaml`;
|
||||
|
||||
workflows.push({
|
||||
name: workflow.name,
|
||||
description: workflow.description.replaceAll('"', '""'), // Escape quotes for CSV
|
||||
module: moduleName,
|
||||
path: installPath,
|
||||
});
|
||||
|
||||
// Add to files list
|
||||
this.files.push({
|
||||
type: 'workflow',
|
||||
name: workflow.name,
|
||||
module: moduleName,
|
||||
path: installPath,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Failed to parse workflow at ${fullPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await findWorkflows(workflowsPath);
|
||||
return workflows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all agents from core and selected modules
|
||||
*/
|
||||
async collectAgents(selectedModules) {
|
||||
this.agents = [];
|
||||
|
||||
// Get core agents
|
||||
const corePath = getModulePath('core');
|
||||
const coreAgentsPath = path.join(corePath, 'agents');
|
||||
if (await fs.pathExists(coreAgentsPath)) {
|
||||
const coreAgents = await this.getAgentsFromDir(coreAgentsPath, 'core');
|
||||
this.agents.push(...coreAgents);
|
||||
}
|
||||
|
||||
// Get module agents
|
||||
for (const moduleName of selectedModules) {
|
||||
const modulePath = getSourcePath(`modules/${moduleName}`);
|
||||
const agentsPath = path.join(modulePath, 'agents');
|
||||
|
||||
if (await fs.pathExists(agentsPath)) {
|
||||
const moduleAgents = await this.getAgentsFromDir(agentsPath, moduleName);
|
||||
this.agents.push(...moduleAgents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agents from a directory
|
||||
*/
|
||||
async getAgentsFromDir(dirPath, moduleName) {
|
||||
const agents = [];
|
||||
const files = await fs.readdir(dirPath);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
const filePath = path.join(dirPath, file);
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
// Skip web-only agents
|
||||
if (content.includes('localskip="true"')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract agent metadata from content if possible
|
||||
const nameMatch = content.match(/name="([^"]+)"/);
|
||||
const descMatch = content.match(/<objective>([^<]+)<\/objective>/);
|
||||
|
||||
// Build relative path for installation
|
||||
const installPath = moduleName === 'core' ? `bmad/core/agents/${file}` : `bmad/${moduleName}/agents/${file}`;
|
||||
|
||||
const agentName = file.replace('.md', '');
|
||||
agents.push({
|
||||
name: agentName,
|
||||
displayName: nameMatch ? nameMatch[1] : agentName,
|
||||
description: descMatch ? descMatch[1].trim().replaceAll('"', '""') : '',
|
||||
module: moduleName,
|
||||
path: installPath,
|
||||
});
|
||||
|
||||
// Add to files list
|
||||
this.files.push({
|
||||
type: 'agent',
|
||||
name: agentName,
|
||||
module: moduleName,
|
||||
path: installPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all tasks from core and selected modules
|
||||
*/
|
||||
async collectTasks(selectedModules) {
|
||||
this.tasks = [];
|
||||
|
||||
// Get core tasks
|
||||
const corePath = getModulePath('core');
|
||||
const coreTasksPath = path.join(corePath, 'tasks');
|
||||
if (await fs.pathExists(coreTasksPath)) {
|
||||
const coreTasks = await this.getTasksFromDir(coreTasksPath, 'core');
|
||||
this.tasks.push(...coreTasks);
|
||||
}
|
||||
|
||||
// Get module tasks
|
||||
for (const moduleName of selectedModules) {
|
||||
const modulePath = getSourcePath(`modules/${moduleName}`);
|
||||
const tasksPath = path.join(modulePath, 'tasks');
|
||||
|
||||
if (await fs.pathExists(tasksPath)) {
|
||||
const moduleTasks = await this.getTasksFromDir(tasksPath, moduleName);
|
||||
this.tasks.push(...moduleTasks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tasks from a directory
|
||||
*/
|
||||
async getTasksFromDir(dirPath, moduleName) {
|
||||
const tasks = [];
|
||||
const files = await fs.readdir(dirPath);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
const filePath = path.join(dirPath, file);
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
// Extract task metadata from content if possible
|
||||
const nameMatch = content.match(/name="([^"]+)"/);
|
||||
const objMatch = content.match(/<objective>([^<]+)<\/objective>/);
|
||||
|
||||
// Build relative path for installation
|
||||
const installPath = moduleName === 'core' ? `bmad/core/tasks/${file}` : `bmad/${moduleName}/tasks/${file}`;
|
||||
|
||||
const taskName = file.replace('.md', '');
|
||||
tasks.push({
|
||||
name: taskName,
|
||||
displayName: nameMatch ? nameMatch[1] : taskName,
|
||||
description: objMatch ? objMatch[1].trim().replaceAll('"', '""') : '',
|
||||
module: moduleName,
|
||||
path: installPath,
|
||||
});
|
||||
|
||||
// Add to files list
|
||||
this.files.push({
|
||||
type: 'task',
|
||||
name: taskName,
|
||||
module: moduleName,
|
||||
path: installPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write main manifest as YAML with installation info only
|
||||
*/
|
||||
async writeMainManifest(cfgDir) {
|
||||
const manifestPath = path.join(cfgDir, 'manifest.yaml');
|
||||
|
||||
const manifest = {
|
||||
installation: {
|
||||
version: '6.0.0-alpha.0',
|
||||
installDate: new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
modules: this.modules.map((name) => ({
|
||||
name,
|
||||
version: '',
|
||||
shortTitle: '',
|
||||
})),
|
||||
ides: ['claude-code'],
|
||||
};
|
||||
|
||||
const yamlStr = yaml.dump(manifest, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
sortKeys: false,
|
||||
});
|
||||
|
||||
await fs.writeFile(manifestPath, yamlStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write workflow manifest CSV
|
||||
*/
|
||||
async writeWorkflowManifest(cfgDir) {
|
||||
const csvPath = path.join(cfgDir, 'workflow-manifest.csv');
|
||||
|
||||
// Create CSV header
|
||||
let csv = 'name,description,module,path\n';
|
||||
|
||||
// Add rows
|
||||
for (const workflow of this.workflows) {
|
||||
csv += `"${workflow.name}","${workflow.description}","${workflow.module}","${workflow.path}"\n`;
|
||||
}
|
||||
|
||||
await fs.writeFile(csvPath, csv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write agent manifest CSV
|
||||
*/
|
||||
async writeAgentManifest(cfgDir) {
|
||||
const csvPath = path.join(cfgDir, 'agent-manifest.csv');
|
||||
|
||||
// Create CSV header
|
||||
let csv = 'name,displayName,description,module,path\n';
|
||||
|
||||
// Add rows
|
||||
for (const agent of this.agents) {
|
||||
csv += `"${agent.name}","${agent.displayName}","${agent.description}","${agent.module}","${agent.path}"\n`;
|
||||
}
|
||||
|
||||
await fs.writeFile(csvPath, csv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write task manifest CSV
|
||||
*/
|
||||
async writeTaskManifest(cfgDir) {
|
||||
const csvPath = path.join(cfgDir, 'task-manifest.csv');
|
||||
|
||||
// Create CSV header
|
||||
let csv = 'name,displayName,description,module,path\n';
|
||||
|
||||
// Add rows
|
||||
for (const task of this.tasks) {
|
||||
csv += `"${task.name}","${task.displayName}","${task.description}","${task.module}","${task.path}"\n`;
|
||||
}
|
||||
|
||||
await fs.writeFile(csvPath, csv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write files manifest CSV
|
||||
*/
|
||||
async writeFilesManifest(cfgDir) {
|
||||
const csvPath = path.join(cfgDir, 'files-manifest.csv');
|
||||
|
||||
// Create CSV header
|
||||
let csv = 'type,name,module,path\n';
|
||||
|
||||
// Sort files by type, then module, then name
|
||||
this.files.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type.localeCompare(b.type);
|
||||
if (a.module !== b.module) return a.module.localeCompare(b.module);
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Add rows
|
||||
for (const file of this.files) {
|
||||
csv += `"${file.type}","${file.name}","${file.module}","${file.path}"\n`;
|
||||
}
|
||||
|
||||
await fs.writeFile(csvPath, csv);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ManifestGenerator };
|
||||
484
tools/cli/installers/lib/core/manifest.js
Normal file
484
tools/cli/installers/lib/core/manifest.js
Normal file
@@ -0,0 +1,484 @@
|
||||
const path = require('node:path');
|
||||
const fs = require('fs-extra');
|
||||
|
||||
class Manifest {
|
||||
/**
|
||||
* Create a new manifest
|
||||
* @param {string} bmadDir - Path to bmad directory
|
||||
* @param {Object} data - Manifest data
|
||||
* @param {Array} installedFiles - List of installed files to track
|
||||
*/
|
||||
async create(bmadDir, data, installedFiles = []) {
|
||||
const manifestPath = path.join(bmadDir, '_cfg', 'manifest.csv');
|
||||
|
||||
// Ensure _cfg directory exists
|
||||
await fs.ensureDir(path.dirname(manifestPath));
|
||||
|
||||
// Load module configs to get module metadata
|
||||
// If core is installed, add it to modules list
|
||||
const allModules = [...(data.modules || [])];
|
||||
if (data.core) {
|
||||
allModules.unshift('core'); // Add core at the beginning
|
||||
}
|
||||
const moduleConfigs = await this.loadModuleConfigs(allModules);
|
||||
|
||||
// Parse installed files to extract metadata - pass bmadDir for relative paths
|
||||
const fileMetadata = await this.parseInstalledFiles(installedFiles, bmadDir);
|
||||
|
||||
// Don't store installation path in manifest
|
||||
|
||||
// Generate CSV content
|
||||
const csvContent = this.generateManifestCsv({ ...data, modules: allModules }, fileMetadata, moduleConfigs);
|
||||
|
||||
await fs.writeFile(manifestPath, csvContent, 'utf8');
|
||||
return { success: true, path: manifestPath, filesTracked: fileMetadata.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read existing manifest
|
||||
* @param {string} bmadDir - Path to bmad directory
|
||||
* @returns {Object|null} Manifest data or null if not found
|
||||
*/
|
||||
async read(bmadDir) {
|
||||
const csvPath = path.join(bmadDir, '_cfg', 'manifest.csv');
|
||||
|
||||
if (await fs.pathExists(csvPath)) {
|
||||
try {
|
||||
const content = await fs.readFile(csvPath, 'utf8');
|
||||
return this.parseManifestCsv(content);
|
||||
} catch (error) {
|
||||
console.error('Failed to read CSV manifest:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing manifest
|
||||
* @param {string} bmadDir - Path to bmad directory
|
||||
* @param {Object} updates - Fields to update
|
||||
* @param {Array} installedFiles - Updated list of installed files
|
||||
*/
|
||||
async update(bmadDir, updates, installedFiles = null) {
|
||||
const manifest = (await this.read(bmadDir)) || {};
|
||||
|
||||
// Merge updates
|
||||
Object.assign(manifest, updates);
|
||||
manifest.lastUpdated = new Date().toISOString();
|
||||
|
||||
// If new file list provided, reparse metadata
|
||||
let fileMetadata = manifest.files || [];
|
||||
if (installedFiles) {
|
||||
fileMetadata = await this.parseInstalledFiles(installedFiles);
|
||||
}
|
||||
|
||||
const manifestPath = path.join(bmadDir, '_cfg', 'manifest.csv');
|
||||
await fs.ensureDir(path.dirname(manifestPath));
|
||||
|
||||
const csvContent = this.generateManifestCsv({ ...manifest, ...updates }, fileMetadata);
|
||||
await fs.writeFile(manifestPath, csvContent, 'utf8');
|
||||
|
||||
return { ...manifest, ...updates, files: fileMetadata };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a module to the manifest
|
||||
* @param {string} bmadDir - Path to bmad directory
|
||||
* @param {string} moduleName - Module name to add
|
||||
*/
|
||||
async addModule(bmadDir, moduleName) {
|
||||
const manifest = await this.read(bmadDir);
|
||||
if (!manifest) {
|
||||
throw new Error('No manifest found');
|
||||
}
|
||||
|
||||
if (!manifest.modules) {
|
||||
manifest.modules = [];
|
||||
}
|
||||
|
||||
if (!manifest.modules.includes(moduleName)) {
|
||||
manifest.modules.push(moduleName);
|
||||
await this.update(bmadDir, { modules: manifest.modules });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a module from the manifest
|
||||
* @param {string} bmadDir - Path to bmad directory
|
||||
* @param {string} moduleName - Module name to remove
|
||||
*/
|
||||
async removeModule(bmadDir, moduleName) {
|
||||
const manifest = await this.read(bmadDir);
|
||||
if (!manifest || !manifest.modules) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = manifest.modules.indexOf(moduleName);
|
||||
if (index !== -1) {
|
||||
manifest.modules.splice(index, 1);
|
||||
await this.update(bmadDir, { modules: manifest.modules });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an IDE configuration to the manifest
|
||||
* @param {string} bmadDir - Path to bmad directory
|
||||
* @param {string} ideName - IDE name to add
|
||||
*/
|
||||
async addIde(bmadDir, ideName) {
|
||||
const manifest = await this.read(bmadDir);
|
||||
if (!manifest) {
|
||||
throw new Error('No manifest found');
|
||||
}
|
||||
|
||||
if (!manifest.ides) {
|
||||
manifest.ides = [];
|
||||
}
|
||||
|
||||
if (!manifest.ides.includes(ideName)) {
|
||||
manifest.ides.push(ideName);
|
||||
await this.update(bmadDir, { ides: manifest.ides });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse installed files to extract metadata
|
||||
* @param {Array} installedFiles - List of installed file paths
|
||||
* @param {string} bmadDir - Path to bmad directory for relative paths
|
||||
* @returns {Array} Array of file metadata objects
|
||||
*/
|
||||
async parseInstalledFiles(installedFiles, bmadDir) {
|
||||
const fileMetadata = [];
|
||||
|
||||
for (const filePath of installedFiles) {
|
||||
const fileExt = path.extname(filePath).toLowerCase();
|
||||
// Make path relative to parent of bmad directory, starting with 'bmad/'
|
||||
const relativePath = 'bmad' + filePath.replace(bmadDir, '').replaceAll('\\', '/');
|
||||
|
||||
// Handle markdown files - extract XML metadata
|
||||
if (fileExt === '.md') {
|
||||
try {
|
||||
if (await fs.pathExists(filePath)) {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
const metadata = this.extractXmlNodeAttributes(content, filePath, relativePath);
|
||||
|
||||
if (metadata) {
|
||||
fileMetadata.push(metadata);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Could not parse ${filePath}:`, error.message);
|
||||
}
|
||||
}
|
||||
// Handle other file types (CSV, JSON, etc.)
|
||||
else {
|
||||
fileMetadata.push({
|
||||
file: relativePath,
|
||||
type: fileExt.slice(1), // Remove the dot
|
||||
name: path.basename(filePath, fileExt),
|
||||
title: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return fileMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract XML node attributes from MD file content
|
||||
* @param {string} content - File content
|
||||
* @param {string} filePath - File path for context
|
||||
* @param {string} relativePath - Relative path starting with 'bmad/'
|
||||
* @returns {Object|null} Extracted metadata or null
|
||||
*/
|
||||
extractXmlNodeAttributes(content, filePath, relativePath) {
|
||||
// Look for XML blocks in code fences
|
||||
const xmlBlockMatch = content.match(/```xml\s*([\s\S]*?)```/);
|
||||
if (!xmlBlockMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const xmlContent = xmlBlockMatch[1];
|
||||
|
||||
// Extract root XML node (agent, task, template, etc.)
|
||||
const rootNodeMatch = xmlContent.match(/<(\w+)([^>]*)>/);
|
||||
if (!rootNodeMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodeType = rootNodeMatch[1];
|
||||
const attributes = rootNodeMatch[2];
|
||||
|
||||
// Extract name and title attributes (id not needed since we have path)
|
||||
const nameMatch = attributes.match(/name="([^"]*)"/);
|
||||
const titleMatch = attributes.match(/title="([^"]*)"/);
|
||||
|
||||
return {
|
||||
file: relativePath,
|
||||
type: nodeType,
|
||||
name: nameMatch ? nameMatch[1] : null,
|
||||
title: titleMatch ? titleMatch[1] : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CSV manifest content
|
||||
* @param {Object} data - Manifest data
|
||||
* @param {Array} fileMetadata - File metadata array
|
||||
* @param {Object} moduleConfigs - Module configuration data
|
||||
* @returns {string} CSV content
|
||||
*/
|
||||
generateManifestCsv(data, fileMetadata, moduleConfigs = {}) {
|
||||
const timestamp = new Date().toISOString();
|
||||
let csv = [];
|
||||
|
||||
// Header section
|
||||
csv.push(
|
||||
'# BMAD Manifest',
|
||||
`# Generated: ${timestamp}`,
|
||||
'',
|
||||
'## Installation Info',
|
||||
'Property,Value',
|
||||
`Version,${data.version}`,
|
||||
`InstallDate,${data.installDate || timestamp}`,
|
||||
`LastUpdated,${data.lastUpdated || timestamp}`,
|
||||
);
|
||||
if (data.language) {
|
||||
csv.push(`Language,${data.language}`);
|
||||
}
|
||||
csv.push('');
|
||||
|
||||
// Modules section
|
||||
if (data.modules && data.modules.length > 0) {
|
||||
csv.push('## Modules', 'Name,Version,ShortTitle');
|
||||
for (const moduleName of data.modules) {
|
||||
const config = moduleConfigs[moduleName] || {};
|
||||
csv.push([moduleName, config.version || '', config['short-title'] || ''].map((v) => this.escapeCsv(v)).join(','));
|
||||
}
|
||||
csv.push('');
|
||||
}
|
||||
|
||||
// IDEs section
|
||||
if (data.ides && data.ides.length > 0) {
|
||||
csv.push('## IDEs', 'IDE');
|
||||
for (const ide of data.ides) {
|
||||
csv.push(this.escapeCsv(ide));
|
||||
}
|
||||
csv.push('');
|
||||
}
|
||||
|
||||
// Files section
|
||||
if (fileMetadata.length > 0) {
|
||||
csv.push('## Files', 'Type,Path,Name,Title');
|
||||
for (const file of fileMetadata) {
|
||||
csv.push([file.type || '', file.file || '', file.name || '', file.title || ''].map((v) => this.escapeCsv(v)).join(','));
|
||||
}
|
||||
}
|
||||
|
||||
return csv.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CSV manifest content back to object
|
||||
* @param {string} csvContent - CSV content to parse
|
||||
* @returns {Object} Parsed manifest data
|
||||
*/
|
||||
parseManifestCsv(csvContent) {
|
||||
const result = {
|
||||
modules: [],
|
||||
ides: [],
|
||||
files: [],
|
||||
};
|
||||
|
||||
const lines = csvContent.split('\n');
|
||||
let section = '';
|
||||
|
||||
for (const line_ of lines) {
|
||||
const line = line_.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if (!line || line.startsWith('#')) {
|
||||
// Check for section headers
|
||||
if (line.startsWith('## ')) {
|
||||
section = line.slice(3).toLowerCase();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse based on current section
|
||||
switch (section) {
|
||||
case 'installation info': {
|
||||
// Skip header row
|
||||
if (line === 'Property,Value') continue;
|
||||
|
||||
const [property, ...valueParts] = line.split(',');
|
||||
const value = this.unescapeCsv(valueParts.join(','));
|
||||
|
||||
switch (property) {
|
||||
// Path no longer stored in manifest
|
||||
case 'Version': {
|
||||
result.version = value;
|
||||
break;
|
||||
}
|
||||
case 'InstallDate': {
|
||||
result.installDate = value;
|
||||
break;
|
||||
}
|
||||
case 'LastUpdated': {
|
||||
result.lastUpdated = value;
|
||||
break;
|
||||
}
|
||||
case 'Language': {
|
||||
result.language = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'modules': {
|
||||
// Skip header row
|
||||
if (line === 'Name,Version,ShortTitle') continue;
|
||||
|
||||
const parts = this.parseCsvLine(line);
|
||||
if (parts[0]) {
|
||||
result.modules.push(parts[0]);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'ides': {
|
||||
// Skip header row
|
||||
if (line === 'IDE') continue;
|
||||
|
||||
result.ides.push(this.unescapeCsv(line));
|
||||
|
||||
break;
|
||||
}
|
||||
case 'files': {
|
||||
// Skip header row
|
||||
if (line === 'Type,Path,Name,Title') continue;
|
||||
|
||||
const parts = this.parseCsvLine(line);
|
||||
if (parts.length >= 2) {
|
||||
result.files.push({
|
||||
type: parts[0] || '',
|
||||
file: parts[1] || '',
|
||||
name: parts[2] || null,
|
||||
title: parts[3] || null,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
// No default
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a CSV line handling quotes and commas
|
||||
* @param {string} line - CSV line to parse
|
||||
* @returns {Array} Array of values
|
||||
*/
|
||||
parseCsvLine(line) {
|
||||
const result = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') {
|
||||
// Escaped quote
|
||||
current += '"';
|
||||
i++;
|
||||
} else {
|
||||
// Toggle quote state
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
// Field separator
|
||||
result.push(this.unescapeCsv(current));
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last field
|
||||
result.push(this.unescapeCsv(current));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape CSV special characters
|
||||
* @param {string} text - Text to escape
|
||||
* @returns {string} Escaped text
|
||||
*/
|
||||
escapeCsv(text) {
|
||||
if (!text) return '';
|
||||
const str = String(text);
|
||||
|
||||
// If contains comma, newline, or quote, wrap in quotes and escape quotes
|
||||
if (str.includes(',') || str.includes('\n') || str.includes('"')) {
|
||||
return '"' + str.replaceAll('"', '""') + '"';
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unescape CSV field
|
||||
* @param {string} text - Text to unescape
|
||||
* @returns {string} Unescaped text
|
||||
*/
|
||||
unescapeCsv(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// Remove surrounding quotes if present
|
||||
if (text.startsWith('"') && text.endsWith('"')) {
|
||||
text = text.slice(1, -1);
|
||||
// Unescape doubled quotes
|
||||
text = text.replaceAll('""', '"');
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load module configuration files
|
||||
* @param {Array} modules - List of module names
|
||||
* @returns {Object} Module configurations indexed by name
|
||||
*/
|
||||
async loadModuleConfigs(modules) {
|
||||
const configs = {};
|
||||
|
||||
for (const moduleName of modules) {
|
||||
// Handle core module differently - it's in src/core not src/modules/core
|
||||
const configPath =
|
||||
moduleName === 'core'
|
||||
? path.join(process.cwd(), 'src', 'core', 'config.yaml')
|
||||
: path.join(process.cwd(), 'src', 'modules', moduleName, 'config.yaml');
|
||||
|
||||
try {
|
||||
if (await fs.pathExists(configPath)) {
|
||||
const yaml = require('js-yaml');
|
||||
const content = await fs.readFile(configPath, 'utf8');
|
||||
configs[moduleName] = yaml.load(content);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Could not load config for module ${moduleName}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Manifest };
|
||||
281
tools/cli/installers/lib/ide/_base-ide.js
Normal file
281
tools/cli/installers/lib/ide/_base-ide.js
Normal file
@@ -0,0 +1,281 @@
|
||||
const path = require('node:path');
|
||||
const fs = require('fs-extra');
|
||||
const chalk = require('chalk');
|
||||
const { XmlHandler } = require('../../../lib/xml-handler');
|
||||
const { getSourcePath } = require('../../../lib/project-root');
|
||||
|
||||
/**
|
||||
* Base class for IDE-specific setup
|
||||
* All IDE handlers should extend this class
|
||||
*/
|
||||
class BaseIdeSetup {
|
||||
constructor(name, displayName = null, preferred = false) {
|
||||
this.name = name;
|
||||
this.displayName = displayName || name; // Human-readable name for UI
|
||||
this.preferred = preferred; // Whether this IDE should be shown in preferred list
|
||||
this.configDir = null; // Override in subclasses
|
||||
this.rulesDir = null; // Override in subclasses
|
||||
this.xmlHandler = new XmlHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Main setup method - must be implemented by subclasses
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} options - Setup options
|
||||
*/
|
||||
async setup(projectDir, bmadDir, options = {}) {
|
||||
throw new Error(`setup() must be implemented by ${this.name} handler`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup IDE configuration
|
||||
* @param {string} projectDir - Project directory
|
||||
*/
|
||||
async cleanup(projectDir) {
|
||||
// Default implementation - can be overridden
|
||||
if (this.configDir) {
|
||||
const configPath = path.join(projectDir, this.configDir);
|
||||
if (await fs.pathExists(configPath)) {
|
||||
const bmadRulesPath = path.join(configPath, 'bmad');
|
||||
if (await fs.pathExists(bmadRulesPath)) {
|
||||
await fs.remove(bmadRulesPath);
|
||||
console.log(chalk.dim(`Removed ${this.name} BMAD configuration`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of agents from BMAD installation
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @returns {Array} List of agent files
|
||||
*/
|
||||
async getAgents(bmadDir) {
|
||||
const agents = [];
|
||||
|
||||
// Get core agents
|
||||
const coreAgentsPath = path.join(bmadDir, 'core', 'agents');
|
||||
if (await fs.pathExists(coreAgentsPath)) {
|
||||
const coreAgents = await this.scanDirectory(coreAgentsPath, '.md');
|
||||
agents.push(
|
||||
...coreAgents.map((a) => ({
|
||||
...a,
|
||||
module: 'core',
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// Get module agents
|
||||
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_cfg') {
|
||||
const moduleAgentsPath = path.join(bmadDir, entry.name, 'agents');
|
||||
if (await fs.pathExists(moduleAgentsPath)) {
|
||||
const moduleAgents = await this.scanDirectory(moduleAgentsPath, '.md');
|
||||
agents.push(
|
||||
...moduleAgents.map((a) => ({
|
||||
...a,
|
||||
module: entry.name,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of tasks from BMAD installation
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @returns {Array} List of task files
|
||||
*/
|
||||
async getTasks(bmadDir) {
|
||||
const tasks = [];
|
||||
|
||||
// Get core tasks
|
||||
const coreTasksPath = path.join(bmadDir, 'core', 'tasks');
|
||||
if (await fs.pathExists(coreTasksPath)) {
|
||||
const coreTasks = await this.scanDirectory(coreTasksPath, '.md');
|
||||
tasks.push(
|
||||
...coreTasks.map((t) => ({
|
||||
...t,
|
||||
module: 'core',
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// Get module tasks
|
||||
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_cfg') {
|
||||
const moduleTasksPath = path.join(bmadDir, entry.name, 'tasks');
|
||||
if (await fs.pathExists(moduleTasksPath)) {
|
||||
const moduleTasks = await this.scanDirectory(moduleTasksPath, '.md');
|
||||
tasks.push(
|
||||
...moduleTasks.map((t) => ({
|
||||
...t,
|
||||
module: entry.name,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a directory for files with specific extension
|
||||
* @param {string} dir - Directory to scan
|
||||
* @param {string} ext - File extension to match
|
||||
* @returns {Array} List of file info objects
|
||||
*/
|
||||
async scanDirectory(dir, ext) {
|
||||
const files = [];
|
||||
|
||||
if (!(await fs.pathExists(dir))) {
|
||||
return files;
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Recursively scan subdirectories
|
||||
const subFiles = await this.scanDirectory(fullPath, ext);
|
||||
files.push(...subFiles);
|
||||
} else if (entry.isFile() && entry.name.endsWith(ext)) {
|
||||
files.push({
|
||||
name: path.basename(entry.name, ext),
|
||||
path: fullPath,
|
||||
relativePath: path.relative(dir, fullPath),
|
||||
filename: entry.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create IDE command/rule file from agent or task
|
||||
* @param {string} content - File content
|
||||
* @param {Object} metadata - File metadata
|
||||
* @param {string} projectDir - The actual project directory path
|
||||
* @returns {string} Processed content
|
||||
*/
|
||||
processContent(content, metadata = {}, projectDir = null) {
|
||||
// Replace placeholders
|
||||
let processed = content;
|
||||
|
||||
// Inject activation block for agent files FIRST (before replacements)
|
||||
if (metadata.name && content.includes('<agent')) {
|
||||
processed = this.xmlHandler.injectActivationSimple(processed, metadata);
|
||||
}
|
||||
|
||||
// Use the actual project directory path if provided, otherwise default to 'bmad/'
|
||||
const projectRoot = projectDir ? projectDir + '/' : 'bmad/';
|
||||
|
||||
// Common replacements (including in the activation block)
|
||||
processed = processed.replaceAll('{project-root}', projectRoot);
|
||||
processed = processed.replaceAll('{module}', metadata.module || 'core');
|
||||
processed = processed.replaceAll('{agent}', metadata.name || '');
|
||||
processed = processed.replaceAll('{task}', metadata.name || '');
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure directory exists
|
||||
* @param {string} dirPath - Directory path
|
||||
*/
|
||||
async ensureDir(dirPath) {
|
||||
await fs.ensureDir(dirPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write file with content
|
||||
* @param {string} filePath - File path
|
||||
* @param {string} content - File content
|
||||
*/
|
||||
async writeFile(filePath, content) {
|
||||
await this.ensureDir(path.dirname(filePath));
|
||||
await fs.writeFile(filePath, content, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy file from source to destination
|
||||
* @param {string} source - Source file path
|
||||
* @param {string} dest - Destination file path
|
||||
*/
|
||||
async copyFile(source, dest) {
|
||||
await this.ensureDir(path.dirname(dest));
|
||||
await fs.copy(source, dest, { overwrite: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path exists
|
||||
* @param {string} pathToCheck - Path to check
|
||||
* @returns {boolean} True if path exists
|
||||
*/
|
||||
async exists(pathToCheck) {
|
||||
return await fs.pathExists(pathToCheck);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for exists method
|
||||
* @param {string} pathToCheck - Path to check
|
||||
* @returns {boolean} True if path exists
|
||||
*/
|
||||
async pathExists(pathToCheck) {
|
||||
return await fs.pathExists(pathToCheck);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content
|
||||
* @param {string} filePath - File path
|
||||
* @returns {string} File content
|
||||
*/
|
||||
async readFile(filePath) {
|
||||
return await fs.readFile(filePath, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format name as title
|
||||
* @param {string} name - Name to format
|
||||
* @returns {string} Formatted title
|
||||
*/
|
||||
formatTitle(name) {
|
||||
return name
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create agent configuration file
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} agent - Agent information
|
||||
*/
|
||||
async createAgentConfig(bmadDir, agent) {
|
||||
const agentConfigDir = path.join(bmadDir, '_cfg', 'agents');
|
||||
await this.ensureDir(agentConfigDir);
|
||||
|
||||
// Load agent config template
|
||||
const templatePath = getSourcePath('utility', 'models', 'agent-config-template.md');
|
||||
const templateContent = await this.readFile(templatePath);
|
||||
|
||||
const configContent = `# Agent Config: ${agent.name}
|
||||
|
||||
${templateContent}`;
|
||||
|
||||
const configPath = path.join(agentConfigDir, `${agent.module}-${agent.name}.md`);
|
||||
await this.writeFile(configPath, configContent);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { BaseIdeSetup };
|
||||
271
tools/cli/installers/lib/ide/auggie.js
Normal file
271
tools/cli/installers/lib/ide/auggie.js
Normal file
@@ -0,0 +1,271 @@
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { BaseIdeSetup } = require('./_base-ide');
|
||||
const chalk = require('chalk');
|
||||
const inquirer = require('inquirer');
|
||||
|
||||
/**
|
||||
* Auggie CLI setup handler
|
||||
* Allows flexible installation of agents to multiple locations
|
||||
*/
|
||||
class AuggieSetup extends BaseIdeSetup {
|
||||
constructor() {
|
||||
super('auggie', 'Auggie CLI');
|
||||
this.defaultLocations = [
|
||||
{ name: 'Project Directory (.auggie/commands)', value: '.auggie/commands', checked: true },
|
||||
{ name: 'User Home (~/.auggie/commands)', value: path.join(os.homedir(), '.auggie', 'commands') },
|
||||
{ name: 'Custom Location', value: 'custom' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect configuration choices before installation
|
||||
* @param {Object} options - Configuration options
|
||||
* @returns {Object} Collected configuration
|
||||
*/
|
||||
async collectConfiguration(options = {}) {
|
||||
const response = await inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'locations',
|
||||
message: 'Select Auggie CLI installation locations:',
|
||||
choices: this.defaultLocations,
|
||||
validate: (answers) => {
|
||||
if (answers.length === 0) {
|
||||
return 'Please select at least one location';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const locations = [];
|
||||
for (const loc of response.locations) {
|
||||
if (loc === 'custom') {
|
||||
const custom = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'path',
|
||||
message: 'Enter custom path for Auggie commands:',
|
||||
validate: (input) => {
|
||||
if (!input.trim()) {
|
||||
return 'Path cannot be empty';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
locations.push(custom.path);
|
||||
} else {
|
||||
locations.push(loc);
|
||||
}
|
||||
}
|
||||
|
||||
return { auggieLocations: locations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Auggie CLI configuration
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} options - Setup options
|
||||
*/
|
||||
async setup(projectDir, bmadDir, options = {}) {
|
||||
console.log(chalk.cyan(`Setting up ${this.name}...`));
|
||||
|
||||
// Use pre-collected configuration if available
|
||||
const config = options.preCollectedConfig || {};
|
||||
const locations = await this.getInstallLocations(projectDir, { ...options, auggieLocations: config.auggieLocations });
|
||||
|
||||
if (locations.length === 0) {
|
||||
console.log(chalk.yellow('No locations selected. Skipping Auggie CLI setup.'));
|
||||
return { success: false, reason: 'no-locations' };
|
||||
}
|
||||
|
||||
// Get agents and tasks
|
||||
const agents = await this.getAgents(bmadDir);
|
||||
const tasks = await this.getTasks(bmadDir);
|
||||
|
||||
let totalInstalled = 0;
|
||||
|
||||
// Install to each selected location
|
||||
for (const location of locations) {
|
||||
console.log(chalk.dim(`\n Installing to: ${location}`));
|
||||
|
||||
const agentsDir = path.join(location, 'agents');
|
||||
const tasksDir = path.join(location, 'tasks');
|
||||
|
||||
await this.ensureDir(agentsDir);
|
||||
await this.ensureDir(tasksDir);
|
||||
|
||||
// Install agents
|
||||
for (const agent of agents) {
|
||||
const content = await this.readFile(agent.path);
|
||||
const commandContent = this.createAgentCommand(agent, content);
|
||||
|
||||
const targetPath = path.join(agentsDir, `${agent.module}-${agent.name}.md`);
|
||||
await this.writeFile(targetPath, commandContent);
|
||||
totalInstalled++;
|
||||
}
|
||||
|
||||
// Install tasks
|
||||
for (const task of tasks) {
|
||||
const content = await this.readFile(task.path);
|
||||
const commandContent = this.createTaskCommand(task, content);
|
||||
|
||||
const targetPath = path.join(tasksDir, `${task.module}-${task.name}.md`);
|
||||
await this.writeFile(targetPath, commandContent);
|
||||
totalInstalled++;
|
||||
}
|
||||
|
||||
console.log(chalk.green(` ✓ Installed ${agents.length} agents and ${tasks.length} tasks`));
|
||||
}
|
||||
|
||||
console.log(chalk.green(`\n✓ ${this.name} configured:`));
|
||||
console.log(chalk.dim(` - ${totalInstalled} total commands installed`));
|
||||
console.log(chalk.dim(` - ${locations.length} location(s) configured`));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commands: totalInstalled,
|
||||
locations: locations.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get installation locations from user
|
||||
*/
|
||||
async getInstallLocations(projectDir, options) {
|
||||
if (options.auggieLocations) {
|
||||
// Process the pre-collected locations to resolve relative paths
|
||||
const processedLocations = [];
|
||||
for (const loc of options.auggieLocations) {
|
||||
if (loc === '.auggie/commands') {
|
||||
// Relative to project directory
|
||||
processedLocations.push(path.join(projectDir, loc));
|
||||
} else {
|
||||
processedLocations.push(loc);
|
||||
}
|
||||
}
|
||||
return processedLocations;
|
||||
}
|
||||
|
||||
const response = await inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'locations',
|
||||
message: 'Select Auggie CLI installation locations:',
|
||||
choices: this.defaultLocations,
|
||||
validate: (answers) => {
|
||||
if (answers.length === 0) {
|
||||
return 'Please select at least one location';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const locations = [];
|
||||
for (const loc of response.locations) {
|
||||
if (loc === 'custom') {
|
||||
const custom = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'path',
|
||||
message: 'Enter custom path for Auggie commands:',
|
||||
validate: (input) => {
|
||||
if (!input.trim()) {
|
||||
return 'Path cannot be empty';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
locations.push(custom.path);
|
||||
} else if (loc.startsWith('.auggie')) {
|
||||
// Relative to project directory
|
||||
locations.push(path.join(projectDir, loc));
|
||||
} else {
|
||||
locations.push(loc);
|
||||
}
|
||||
}
|
||||
|
||||
return locations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create agent command content
|
||||
*/
|
||||
createAgentCommand(agent, content) {
|
||||
const titleMatch = content.match(/title="([^"]+)"/);
|
||||
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
|
||||
|
||||
return `# ${title} Agent
|
||||
|
||||
## Activation
|
||||
Type \`@${agent.name}\` to activate this agent.
|
||||
|
||||
${content}
|
||||
|
||||
## Module
|
||||
BMAD ${agent.module.toUpperCase()} module
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create task command content
|
||||
*/
|
||||
createTaskCommand(task, content) {
|
||||
const nameMatch = content.match(/<name>([^<]+)<\/name>/);
|
||||
const taskName = nameMatch ? nameMatch[1] : this.formatTitle(task.name);
|
||||
|
||||
return `# ${taskName} Task
|
||||
|
||||
## Activation
|
||||
Type \`@task-${task.name}\` to execute this task.
|
||||
|
||||
${content}
|
||||
|
||||
## Module
|
||||
BMAD ${task.module.toUpperCase()} module
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup Auggie configuration
|
||||
*/
|
||||
async cleanup(projectDir) {
|
||||
const fs = require('fs-extra');
|
||||
|
||||
// Check common locations
|
||||
const locations = [path.join(os.homedir(), '.auggie', 'commands'), path.join(projectDir, '.auggie', 'commands')];
|
||||
|
||||
for (const location of locations) {
|
||||
const agentsDir = path.join(location, 'agents');
|
||||
const tasksDir = path.join(location, 'tasks');
|
||||
|
||||
if (await fs.pathExists(agentsDir)) {
|
||||
// Remove only BMAD files (those with module prefix)
|
||||
const files = await fs.readdir(agentsDir);
|
||||
for (const file of files) {
|
||||
if (file.includes('-') && file.endsWith('.md')) {
|
||||
await fs.remove(path.join(agentsDir, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (await fs.pathExists(tasksDir)) {
|
||||
const files = await fs.readdir(tasksDir);
|
||||
for (const file of files) {
|
||||
if (file.includes('-') && file.endsWith('.md')) {
|
||||
await fs.remove(path.join(tasksDir, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.dim('Cleaned up Auggie CLI configurations'));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { AuggieSetup };
|
||||
625
tools/cli/installers/lib/ide/claude-code.js
Normal file
625
tools/cli/installers/lib/ide/claude-code.js
Normal file
@@ -0,0 +1,625 @@
|
||||
const path = require('node:path');
|
||||
const { BaseIdeSetup } = require('./_base-ide');
|
||||
const chalk = require('chalk');
|
||||
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
|
||||
const { WorkflowCommandGenerator } = require('./workflow-command-generator');
|
||||
|
||||
/**
|
||||
* Claude Code IDE setup handler
|
||||
*/
|
||||
class ClaudeCodeSetup extends BaseIdeSetup {
|
||||
constructor() {
|
||||
super('claude-code', 'Claude Code', true); // preferred IDE
|
||||
this.configDir = '.claude';
|
||||
this.commandsDir = 'commands';
|
||||
this.agentsDir = 'agents';
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect configuration choices before installation
|
||||
* @param {Object} options - Configuration options
|
||||
* @returns {Object} Collected configuration
|
||||
*/
|
||||
async collectConfiguration(options = {}) {
|
||||
const config = {
|
||||
subagentChoices: null,
|
||||
installLocation: null,
|
||||
};
|
||||
|
||||
const sourceModulesPath = getSourcePath('modules');
|
||||
const modules = options.selectedModules || [];
|
||||
|
||||
for (const moduleName of modules) {
|
||||
// Check for Claude Code sub-module injection config in SOURCE directory
|
||||
const injectionConfigPath = path.join(sourceModulesPath, moduleName, 'sub-modules', 'claude-code', 'injections.yaml');
|
||||
|
||||
if (await this.exists(injectionConfigPath)) {
|
||||
const fs = require('fs-extra');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
try {
|
||||
// Load injection configuration
|
||||
const configContent = await fs.readFile(injectionConfigPath, 'utf8');
|
||||
const injectionConfig = yaml.load(configContent);
|
||||
|
||||
// Ask about subagents if they exist and we haven't asked yet
|
||||
if (injectionConfig.subagents && !config.subagentChoices) {
|
||||
config.subagentChoices = await this.promptSubagentInstallation(injectionConfig.subagents);
|
||||
|
||||
if (config.subagentChoices.install !== 'none') {
|
||||
// Ask for installation location
|
||||
const inquirer = require('inquirer');
|
||||
const locationAnswer = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'location',
|
||||
message: 'Where would you like to install Claude Code subagents?',
|
||||
choices: [
|
||||
{ name: 'Project level (.claude/agents/)', value: 'project' },
|
||||
{ name: 'User level (~/.claude/agents/)', value: 'user' },
|
||||
],
|
||||
default: 'project',
|
||||
},
|
||||
]);
|
||||
config.installLocation = locationAnswer.location;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(` Warning: Failed to process ${moduleName} features: ${error.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Claude Code IDE configuration
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} options - Setup options
|
||||
*/
|
||||
async setup(projectDir, bmadDir, options = {}) {
|
||||
// Store project directory for use in processContent
|
||||
this.projectDir = projectDir;
|
||||
|
||||
console.log(chalk.cyan(`Setting up ${this.name}...`));
|
||||
|
||||
// Create .claude/commands directory structure
|
||||
const claudeDir = path.join(projectDir, this.configDir);
|
||||
const commandsDir = path.join(claudeDir, this.commandsDir);
|
||||
const bmadCommandsDir = path.join(commandsDir, 'bmad');
|
||||
|
||||
await this.ensureDir(bmadCommandsDir);
|
||||
|
||||
// Get agents and tasks from SOURCE, not installed location
|
||||
// This ensures we process files with {project-root} placeholders intact
|
||||
const sourceDir = getSourcePath('modules');
|
||||
const agents = await this.getAgentsFromSource(sourceDir, options.selectedModules || []);
|
||||
const tasks = await this.getTasksFromSource(sourceDir, options.selectedModules || []);
|
||||
|
||||
// Create directories for each module
|
||||
const modules = new Set();
|
||||
for (const item of [...agents, ...tasks]) modules.add(item.module);
|
||||
|
||||
for (const module of modules) {
|
||||
await this.ensureDir(path.join(bmadCommandsDir, module));
|
||||
await this.ensureDir(path.join(bmadCommandsDir, module, 'agents'));
|
||||
await this.ensureDir(path.join(bmadCommandsDir, module, 'tasks'));
|
||||
}
|
||||
|
||||
// Process and copy agents
|
||||
let agentCount = 0;
|
||||
for (const agent of agents) {
|
||||
const content = await this.readAndProcess(agent.path, {
|
||||
module: agent.module,
|
||||
name: agent.name,
|
||||
});
|
||||
|
||||
const targetPath = path.join(bmadCommandsDir, agent.module, 'agents', `${agent.name}.md`);
|
||||
|
||||
await this.writeFile(targetPath, content);
|
||||
agentCount++;
|
||||
}
|
||||
|
||||
// Process and copy tasks
|
||||
let taskCount = 0;
|
||||
for (const task of tasks) {
|
||||
const content = await this.readAndProcess(task.path, {
|
||||
module: task.module,
|
||||
name: task.name,
|
||||
});
|
||||
|
||||
const targetPath = path.join(bmadCommandsDir, task.module, 'tasks', `${task.name}.md`);
|
||||
|
||||
await this.writeFile(targetPath, content);
|
||||
taskCount++;
|
||||
}
|
||||
|
||||
// Process Claude Code specific injections for installed modules
|
||||
// Use pre-collected configuration if available
|
||||
if (options.preCollectedConfig) {
|
||||
await this.processModuleInjectionsWithConfig(projectDir, bmadDir, options, options.preCollectedConfig);
|
||||
} else {
|
||||
await this.processModuleInjections(projectDir, bmadDir, options);
|
||||
}
|
||||
|
||||
// Skip CLAUDE.md creation - let user manage their own CLAUDE.md file
|
||||
// await this.createClaudeConfig(projectDir, modules);
|
||||
|
||||
// Generate workflow commands from manifest (if it exists)
|
||||
const workflowGen = new WorkflowCommandGenerator();
|
||||
const workflowResult = await workflowGen.generateWorkflowCommands(projectDir, bmadDir);
|
||||
|
||||
console.log(chalk.green(`✓ ${this.name} configured:`));
|
||||
console.log(chalk.dim(` - ${agentCount} agents installed`));
|
||||
console.log(chalk.dim(` - ${taskCount} tasks installed`));
|
||||
if (workflowResult.generated > 0) {
|
||||
console.log(chalk.dim(` - ${workflowResult.generated} workflow commands generated`));
|
||||
}
|
||||
console.log(chalk.dim(` - Commands directory: ${path.relative(projectDir, bmadCommandsDir)}`));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
agents: agentCount,
|
||||
tasks: taskCount,
|
||||
};
|
||||
}
|
||||
|
||||
// Method removed - CLAUDE.md file management left to user
|
||||
|
||||
/**
|
||||
* Read and process file content
|
||||
*/
|
||||
async readAndProcess(filePath, metadata) {
|
||||
const fs = require('fs-extra');
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
return this.processContent(content, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override processContent to use the actual project directory path
|
||||
*/
|
||||
processContent(content, metadata = {}) {
|
||||
// Use the base class method with the actual project directory
|
||||
return super.processContent(content, metadata, this.projectDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agents from source modules (not installed location)
|
||||
*/
|
||||
async getAgentsFromSource(sourceDir, selectedModules) {
|
||||
const fs = require('fs-extra');
|
||||
const agents = [];
|
||||
|
||||
// Add core agents
|
||||
const corePath = getModulePath('core');
|
||||
if (await fs.pathExists(path.join(corePath, 'agents'))) {
|
||||
const coreAgents = await this.getAgentsFromDir(path.join(corePath, 'agents'), 'core');
|
||||
agents.push(...coreAgents);
|
||||
}
|
||||
|
||||
// Add module agents
|
||||
for (const moduleName of selectedModules) {
|
||||
const modulePath = path.join(sourceDir, moduleName);
|
||||
const agentsPath = path.join(modulePath, 'agents');
|
||||
|
||||
if (await fs.pathExists(agentsPath)) {
|
||||
const moduleAgents = await this.getAgentsFromDir(agentsPath, moduleName);
|
||||
agents.push(...moduleAgents);
|
||||
}
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tasks from source modules (not installed location)
|
||||
*/
|
||||
async getTasksFromSource(sourceDir, selectedModules) {
|
||||
const fs = require('fs-extra');
|
||||
const tasks = [];
|
||||
|
||||
// Add core tasks
|
||||
const corePath = getModulePath('core');
|
||||
if (await fs.pathExists(path.join(corePath, 'tasks'))) {
|
||||
const coreTasks = await this.getTasksFromDir(path.join(corePath, 'tasks'), 'core');
|
||||
tasks.push(...coreTasks);
|
||||
}
|
||||
|
||||
// Add module tasks
|
||||
for (const moduleName of selectedModules) {
|
||||
const modulePath = path.join(sourceDir, moduleName);
|
||||
const tasksPath = path.join(modulePath, 'tasks');
|
||||
|
||||
if (await fs.pathExists(tasksPath)) {
|
||||
const moduleTasks = await this.getTasksFromDir(tasksPath, moduleName);
|
||||
tasks.push(...moduleTasks);
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agents from a specific directory
|
||||
*/
|
||||
async getAgentsFromDir(dirPath, moduleName) {
|
||||
const fs = require('fs-extra');
|
||||
const agents = [];
|
||||
|
||||
const files = await fs.readdir(dirPath);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
const filePath = path.join(dirPath, file);
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
// Skip web-only agents
|
||||
if (content.includes('localskip="true"')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
agents.push({
|
||||
path: filePath,
|
||||
name: file.replace('.md', ''),
|
||||
module: moduleName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tasks from a specific directory
|
||||
*/
|
||||
async getTasksFromDir(dirPath, moduleName) {
|
||||
const fs = require('fs-extra');
|
||||
const tasks = [];
|
||||
|
||||
const files = await fs.readdir(dirPath);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
tasks.push({
|
||||
path: path.join(dirPath, file),
|
||||
name: file.replace('.md', ''),
|
||||
module: moduleName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process module injections with pre-collected configuration
|
||||
*/
|
||||
async processModuleInjectionsWithConfig(projectDir, bmadDir, options, preCollectedConfig) {
|
||||
const fs = require('fs-extra');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
// Get list of installed modules
|
||||
const modules = options.selectedModules || [];
|
||||
const { subagentChoices, installLocation } = preCollectedConfig;
|
||||
|
||||
// Get the actual source directory (not the installation directory)
|
||||
const sourceModulesPath = getSourcePath('modules');
|
||||
|
||||
for (const moduleName of modules) {
|
||||
// Check for Claude Code sub-module injection config in SOURCE directory
|
||||
const injectionConfigPath = path.join(sourceModulesPath, moduleName, 'sub-modules', 'claude-code', 'injections.yaml');
|
||||
|
||||
if (await this.exists(injectionConfigPath)) {
|
||||
try {
|
||||
// Load injection configuration
|
||||
const configContent = await fs.readFile(injectionConfigPath, 'utf8');
|
||||
const config = yaml.load(configContent);
|
||||
|
||||
// Process content injections based on user choices
|
||||
if (config.injections && subagentChoices && subagentChoices.install !== 'none') {
|
||||
for (const injection of config.injections) {
|
||||
// Check if this injection is related to a selected subagent
|
||||
if (this.shouldInject(injection, subagentChoices)) {
|
||||
await this.injectContent(projectDir, injection, subagentChoices);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy selected subagents
|
||||
if (config.subagents && subagentChoices && subagentChoices.install !== 'none') {
|
||||
await this.copySelectedSubagents(
|
||||
projectDir,
|
||||
path.dirname(injectionConfigPath),
|
||||
config.subagents,
|
||||
subagentChoices,
|
||||
installLocation,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(` Warning: Failed to process ${moduleName} features: ${error.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Claude Code specific injections for installed modules
|
||||
* Looks for injections.yaml in each module's claude-code sub-module
|
||||
*/
|
||||
async processModuleInjections(projectDir, bmadDir, options) {
|
||||
const fs = require('fs-extra');
|
||||
const yaml = require('js-yaml');
|
||||
const inquirer = require('inquirer');
|
||||
|
||||
// Get list of installed modules
|
||||
const modules = options.selectedModules || [];
|
||||
let subagentChoices = null;
|
||||
let installLocation = null;
|
||||
|
||||
// Get the actual source directory (not the installation directory)
|
||||
const sourceModulesPath = getSourcePath('modules');
|
||||
|
||||
for (const moduleName of modules) {
|
||||
// Check for Claude Code sub-module injection config in SOURCE directory
|
||||
const injectionConfigPath = path.join(sourceModulesPath, moduleName, 'sub-modules', 'claude-code', 'injections.yaml');
|
||||
|
||||
if (await this.exists(injectionConfigPath)) {
|
||||
console.log(chalk.cyan(`\nConfiguring ${moduleName} Claude Code features...`));
|
||||
|
||||
try {
|
||||
// Load injection configuration
|
||||
const configContent = await fs.readFile(injectionConfigPath, 'utf8');
|
||||
const config = yaml.load(configContent);
|
||||
|
||||
// Ask about subagents if they exist and we haven't asked yet
|
||||
if (config.subagents && !subagentChoices) {
|
||||
subagentChoices = await this.promptSubagentInstallation(config.subagents);
|
||||
|
||||
if (subagentChoices.install !== 'none') {
|
||||
// Ask for installation location
|
||||
const locationAnswer = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'location',
|
||||
message: 'Where would you like to install Claude Code subagents?',
|
||||
choices: [
|
||||
{ name: 'Project level (.claude/agents/)', value: 'project' },
|
||||
{ name: 'User level (~/.claude/agents/)', value: 'user' },
|
||||
],
|
||||
default: 'project',
|
||||
},
|
||||
]);
|
||||
installLocation = locationAnswer.location;
|
||||
}
|
||||
}
|
||||
|
||||
// Process content injections based on user choices
|
||||
if (config.injections && subagentChoices && subagentChoices.install !== 'none') {
|
||||
for (const injection of config.injections) {
|
||||
// Check if this injection is related to a selected subagent
|
||||
if (this.shouldInject(injection, subagentChoices)) {
|
||||
await this.injectContent(projectDir, injection, subagentChoices);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy selected subagents
|
||||
if (config.subagents && subagentChoices && subagentChoices.install !== 'none') {
|
||||
await this.copySelectedSubagents(
|
||||
projectDir,
|
||||
path.dirname(injectionConfigPath),
|
||||
config.subagents,
|
||||
subagentChoices,
|
||||
installLocation,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(` Warning: Failed to process ${moduleName} features: ${error.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user for subagent installation preferences
|
||||
*/
|
||||
async promptSubagentInstallation(subagentConfig) {
|
||||
const inquirer = require('inquirer');
|
||||
|
||||
// First ask if they want to install subagents
|
||||
const { install } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'install',
|
||||
message: 'Would you like to install Claude Code subagents for enhanced functionality?',
|
||||
choices: [
|
||||
{ name: 'Yes, install all subagents', value: 'all' },
|
||||
{ name: 'Yes, let me choose specific subagents', value: 'selective' },
|
||||
{ name: 'No, skip subagent installation', value: 'none' },
|
||||
],
|
||||
default: 'all',
|
||||
},
|
||||
]);
|
||||
|
||||
if (install === 'selective') {
|
||||
// Show list of available subagents with descriptions
|
||||
const subagentInfo = {
|
||||
'market-researcher.md': 'Market research and competitive analysis',
|
||||
'requirements-analyst.md': 'Requirements extraction and validation',
|
||||
'technical-evaluator.md': 'Technology stack evaluation',
|
||||
'epic-optimizer.md': 'Epic and story breakdown optimization',
|
||||
'document-reviewer.md': 'Document quality review',
|
||||
};
|
||||
|
||||
const { selected } = await inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'selected',
|
||||
message: 'Select subagents to install:',
|
||||
choices: subagentConfig.files.map((file) => ({
|
||||
name: `${file.replace('.md', '')} - ${subagentInfo[file] || 'Specialized assistant'}`,
|
||||
value: file,
|
||||
checked: true,
|
||||
})),
|
||||
},
|
||||
]);
|
||||
|
||||
return { install: 'selective', selected };
|
||||
}
|
||||
|
||||
return { install };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an injection should be applied based on user choices
|
||||
*/
|
||||
shouldInject(injection, subagentChoices) {
|
||||
// If user chose no subagents, no injections
|
||||
if (subagentChoices.install === 'none') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If user chose all subagents, all injections apply
|
||||
if (subagentChoices.install === 'all') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For selective installation, check the 'requires' field
|
||||
if (subagentChoices.install === 'selective') {
|
||||
// If injection requires 'any' subagent and user selected at least one
|
||||
if (injection.requires === 'any' && subagentChoices.selected.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the required subagent was selected
|
||||
if (injection.requires) {
|
||||
const requiredAgent = injection.requires + '.md';
|
||||
return subagentChoices.selected.includes(requiredAgent);
|
||||
}
|
||||
|
||||
// Fallback: check if injection mentions a selected agent
|
||||
const selectedAgentNames = subagentChoices.selected.map((f) => f.replace('.md', ''));
|
||||
for (const agentName of selectedAgentNames) {
|
||||
if (injection.point && injection.point.includes(agentName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject content at specified point in file
|
||||
*/
|
||||
async injectContent(projectDir, injection, subagentChoices = null) {
|
||||
const fs = require('fs-extra');
|
||||
const targetPath = path.join(projectDir, injection.file);
|
||||
|
||||
if (await this.exists(targetPath)) {
|
||||
let content = await fs.readFile(targetPath, 'utf8');
|
||||
const marker = `<!-- IDE-INJECT-POINT: ${injection.point} -->`;
|
||||
|
||||
if (content.includes(marker)) {
|
||||
let injectionContent = injection.content;
|
||||
|
||||
// Filter content if selective subagents chosen
|
||||
if (subagentChoices && subagentChoices.install === 'selective' && injection.point === 'pm-agent-instructions') {
|
||||
injectionContent = this.filterAgentInstructions(injection.content, subagentChoices.selected);
|
||||
}
|
||||
|
||||
content = content.replace(marker, injectionContent);
|
||||
await fs.writeFile(targetPath, content);
|
||||
console.log(chalk.dim(` Injected: ${injection.point} → ${injection.file}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter agent instructions to only include selected subagents
|
||||
*/
|
||||
filterAgentInstructions(content, selectedFiles) {
|
||||
const selectedAgents = selectedFiles.map((f) => f.replace('.md', ''));
|
||||
const lines = content.split('\n');
|
||||
const filteredLines = [];
|
||||
|
||||
let includeNextLine = true;
|
||||
for (const line of lines) {
|
||||
// Always include structural lines
|
||||
if (line.includes('<llm') || line.includes('</llm>')) {
|
||||
filteredLines.push(line);
|
||||
includeNextLine = true;
|
||||
}
|
||||
// Check if line mentions a subagent
|
||||
else if (line.includes('subagent')) {
|
||||
let shouldInclude = false;
|
||||
for (const agent of selectedAgents) {
|
||||
if (line.includes(agent)) {
|
||||
shouldInclude = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (shouldInclude) {
|
||||
filteredLines.push(line);
|
||||
}
|
||||
}
|
||||
// Include general instructions
|
||||
else if (line.includes('When creating PRDs') || line.includes('ACTIVELY delegate')) {
|
||||
filteredLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Only return content if we have actual instructions
|
||||
if (filteredLines.length > 2) {
|
||||
// More than just llm tags
|
||||
return filteredLines.join('\n');
|
||||
}
|
||||
return ''; // Return empty if no relevant content
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy selected subagents to appropriate Claude agents directory
|
||||
*/
|
||||
async copySelectedSubagents(projectDir, moduleClaudeDir, subagentConfig, choices, location) {
|
||||
const fs = require('fs-extra');
|
||||
const sourceDir = path.join(moduleClaudeDir, subagentConfig.source);
|
||||
|
||||
// Determine target directory based on user choice
|
||||
let targetDir;
|
||||
if (location === 'user') {
|
||||
targetDir = path.join(require('node:os').homedir(), '.claude', 'agents');
|
||||
console.log(chalk.dim(` Installing subagents globally to: ~/.claude/agents/`));
|
||||
} else {
|
||||
targetDir = path.join(projectDir, '.claude', 'agents');
|
||||
console.log(chalk.dim(` Installing subagents to project: .claude/agents/`));
|
||||
}
|
||||
|
||||
// Ensure target directory exists
|
||||
await this.ensureDir(targetDir);
|
||||
|
||||
// Determine which files to copy
|
||||
let filesToCopy = [];
|
||||
if (choices.install === 'all') {
|
||||
filesToCopy = subagentConfig.files;
|
||||
} else if (choices.install === 'selective') {
|
||||
filesToCopy = choices.selected;
|
||||
}
|
||||
|
||||
// Copy selected subagent files
|
||||
for (const file of filesToCopy) {
|
||||
const sourcePath = path.join(sourceDir, file);
|
||||
const targetPath = path.join(targetDir, file);
|
||||
|
||||
if (await this.exists(sourcePath)) {
|
||||
await fs.copyFile(sourcePath, targetPath);
|
||||
console.log(chalk.green(` ✓ Installed: ${file.replace('.md', '')}`));
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToCopy.length > 0) {
|
||||
console.log(chalk.dim(` Total subagents installed: ${filesToCopy.length}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ClaudeCodeSetup };
|
||||
301
tools/cli/installers/lib/ide/cline.js
Normal file
301
tools/cli/installers/lib/ide/cline.js
Normal file
@@ -0,0 +1,301 @@
|
||||
const path = require('node:path');
|
||||
const { BaseIdeSetup } = require('./_base-ide');
|
||||
const chalk = require('chalk');
|
||||
const inquirer = require('inquirer');
|
||||
|
||||
/**
|
||||
* Cline IDE setup handler
|
||||
* Creates rules in .clinerules directory with ordering support
|
||||
*/
|
||||
class ClineSetup extends BaseIdeSetup {
|
||||
constructor() {
|
||||
super('cline', 'Cline');
|
||||
this.configDir = '.clinerules';
|
||||
this.defaultOrder = {
|
||||
core: 10,
|
||||
bmm: 20,
|
||||
cis: 30,
|
||||
other: 99,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect configuration choices before installation
|
||||
* @param {Object} options - Configuration options
|
||||
* @returns {Object} Collected configuration
|
||||
*/
|
||||
async collectConfiguration(options = {}) {
|
||||
const response = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'ordering',
|
||||
message: 'How should BMAD rules be ordered in Cline?',
|
||||
choices: [
|
||||
{ name: 'By module (core first, then modules)', value: 'module' },
|
||||
{ name: 'By importance (dev agents first)', value: 'importance' },
|
||||
{ name: 'Alphabetical (simple A-Z ordering)', value: 'alphabetical' },
|
||||
{ name: "Custom (I'll reorder manually)", value: 'custom' },
|
||||
],
|
||||
default: 'module',
|
||||
},
|
||||
]);
|
||||
|
||||
return { ordering: response.ordering };
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Cline IDE configuration
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} options - Setup options
|
||||
*/
|
||||
async setup(projectDir, bmadDir, options = {}) {
|
||||
console.log(chalk.cyan(`Setting up ${this.name}...`));
|
||||
|
||||
// Create .clinerules directory
|
||||
const clineRulesDir = path.join(projectDir, this.configDir);
|
||||
await this.ensureDir(clineRulesDir);
|
||||
|
||||
// Get agents and tasks
|
||||
const agents = await this.getAgents(bmadDir);
|
||||
const tasks = await this.getTasks(bmadDir);
|
||||
|
||||
// Use pre-collected configuration if available
|
||||
const config = options.preCollectedConfig || {};
|
||||
const orderingStrategy = config.ordering || options.ordering || 'module';
|
||||
|
||||
// Process agents as rules with ordering
|
||||
let ruleCount = 0;
|
||||
for (const agent of agents) {
|
||||
const content = await this.readFile(agent.path);
|
||||
const order = this.getOrder(agent, orderingStrategy);
|
||||
const processedContent = this.createAgentRule(agent, content, projectDir);
|
||||
|
||||
// Use numeric prefix for ordering
|
||||
const prefix = order.toString().padStart(2, '0');
|
||||
const targetPath = path.join(clineRulesDir, `${prefix}-${agent.module}-${agent.name}.md`);
|
||||
|
||||
await this.writeFile(targetPath, processedContent);
|
||||
ruleCount++;
|
||||
}
|
||||
|
||||
// Process tasks with ordering
|
||||
for (const task of tasks) {
|
||||
const content = await this.readFile(task.path);
|
||||
const order = this.getTaskOrder(task, orderingStrategy);
|
||||
const processedContent = this.createTaskRule(task, content);
|
||||
|
||||
// Tasks get higher order numbers to appear after agents
|
||||
const prefix = (order + 50).toString().padStart(2, '0');
|
||||
const targetPath = path.join(clineRulesDir, `${prefix}-task-${task.module}-${task.name}.md`);
|
||||
|
||||
await this.writeFile(targetPath, processedContent);
|
||||
ruleCount++;
|
||||
}
|
||||
|
||||
console.log(chalk.green(`✓ ${this.name} configured:`));
|
||||
console.log(chalk.dim(` - ${ruleCount} rules created in ${path.relative(projectDir, clineRulesDir)}`));
|
||||
console.log(chalk.dim(` - Ordering: ${orderingStrategy}`));
|
||||
|
||||
// Important message about toggle system
|
||||
console.log(chalk.yellow('\n ⚠️ IMPORTANT: Cline Toggle System'));
|
||||
console.log(chalk.cyan(' Rules are OFF by default to avoid context pollution'));
|
||||
console.log(chalk.dim(' To use BMAD agents:'));
|
||||
console.log(chalk.dim(' 1. Click rules icon below chat input'));
|
||||
console.log(chalk.dim(' 2. Toggle ON the specific agent you need'));
|
||||
console.log(chalk.dim(' 3. Type @{agent-name} to activate'));
|
||||
console.log(chalk.dim(' 4. Toggle OFF when done to free context'));
|
||||
console.log(chalk.dim('\n 💡 Best practice: Only enable 1-2 agents at a time'));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
rules: ruleCount,
|
||||
ordering: orderingStrategy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask user about rule ordering strategy
|
||||
*/
|
||||
async askOrderingStrategy() {
|
||||
const response = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'ordering',
|
||||
message: 'How should BMAD rules be ordered in Cline?',
|
||||
choices: [
|
||||
{ name: 'By module (core first, then modules)', value: 'module' },
|
||||
{ name: 'By importance (dev agents first)', value: 'importance' },
|
||||
{ name: 'Alphabetical (simple A-Z ordering)', value: 'alphabetical' },
|
||||
{ name: "Custom (I'll reorder manually)", value: 'custom' },
|
||||
],
|
||||
default: 'module',
|
||||
},
|
||||
]);
|
||||
|
||||
return response.ordering;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order number for an agent based on strategy
|
||||
*/
|
||||
getOrder(agent, strategy) {
|
||||
switch (strategy) {
|
||||
case 'module': {
|
||||
return this.defaultOrder[agent.module] || this.defaultOrder.other;
|
||||
}
|
||||
|
||||
case 'importance': {
|
||||
// Prioritize certain agent types
|
||||
if (agent.name.includes('dev') || agent.name.includes('code')) return 10;
|
||||
if (agent.name.includes('architect') || agent.name.includes('design')) return 15;
|
||||
if (agent.name.includes('test') || agent.name.includes('qa')) return 20;
|
||||
if (agent.name.includes('doc') || agent.name.includes('write')) return 25;
|
||||
if (agent.name.includes('review')) return 30;
|
||||
return 40;
|
||||
}
|
||||
|
||||
case 'alphabetical': {
|
||||
// Use a fixed number, files will sort alphabetically by name
|
||||
return 50;
|
||||
}
|
||||
|
||||
default: {
|
||||
// 'custom' or any other value - user will reorder manually
|
||||
return 99;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order number for a task
|
||||
*/
|
||||
getTaskOrder(task, strategy) {
|
||||
// Tasks always come after agents
|
||||
return this.getOrder(task, strategy) + 50;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rule content for an agent
|
||||
*/
|
||||
createAgentRule(agent, content, projectDir) {
|
||||
// Extract metadata
|
||||
const titleMatch = content.match(/title="([^"]+)"/);
|
||||
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
|
||||
|
||||
// Extract YAML content
|
||||
const yamlMatch = content.match(/```ya?ml\r?\n([\s\S]*?)```/);
|
||||
const yamlContent = yamlMatch ? yamlMatch[1] : content;
|
||||
|
||||
// Get relative path
|
||||
const relativePath = path.relative(projectDir, agent.path).replaceAll('\\', '/');
|
||||
|
||||
let ruleContent = `# ${title} Agent
|
||||
|
||||
This rule defines the ${title} persona and project standards.
|
||||
|
||||
## Role Definition
|
||||
|
||||
When the user types \`@${agent.name}\`, adopt this persona and follow these guidelines:
|
||||
|
||||
\`\`\`yaml
|
||||
${yamlContent}
|
||||
\`\`\`
|
||||
|
||||
## Project Standards
|
||||
|
||||
- Always maintain consistency with project documentation in BMAD directories
|
||||
- Follow the agent's specific guidelines and constraints
|
||||
- Update relevant project files when making changes
|
||||
- Reference the complete agent definition in [${relativePath}](${relativePath})
|
||||
|
||||
## Usage
|
||||
|
||||
Type \`@${agent.name}\` to activate this ${title} persona.
|
||||
|
||||
## Module
|
||||
|
||||
Part of the BMAD ${agent.module.toUpperCase()} module.
|
||||
`;
|
||||
|
||||
return ruleContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rule content for a task
|
||||
*/
|
||||
createTaskRule(task, content) {
|
||||
// Extract task name
|
||||
const nameMatch = content.match(/<name>([^<]+)<\/name>/);
|
||||
const taskName = nameMatch ? nameMatch[1] : this.formatTitle(task.name);
|
||||
|
||||
let ruleContent = `# ${taskName} Task
|
||||
|
||||
This rule defines the ${taskName} task workflow.
|
||||
|
||||
## Task Workflow
|
||||
|
||||
When this task is referenced, execute the following steps:
|
||||
|
||||
${content}
|
||||
|
||||
## Project Integration
|
||||
|
||||
- This task follows BMAD Method standards
|
||||
- Ensure all outputs align with project conventions
|
||||
- Update relevant documentation after task completion
|
||||
|
||||
## Usage
|
||||
|
||||
Reference with \`@task-${task.name}\` to access this workflow.
|
||||
|
||||
## Module
|
||||
|
||||
Part of the BMAD ${task.module.toUpperCase()} module.
|
||||
`;
|
||||
|
||||
return ruleContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format name as title
|
||||
*/
|
||||
formatTitle(name) {
|
||||
return name
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup Cline configuration
|
||||
*/
|
||||
async cleanup(projectDir) {
|
||||
const fs = require('fs-extra');
|
||||
const clineRulesDir = path.join(projectDir, this.configDir);
|
||||
|
||||
if (await fs.pathExists(clineRulesDir)) {
|
||||
// Remove all numbered BMAD rules
|
||||
const files = await fs.readdir(clineRulesDir);
|
||||
let removed = 0;
|
||||
|
||||
for (const file of files) {
|
||||
// Check if it matches our naming pattern (XX-module-name.md)
|
||||
if (/^\d{2}-.*\.md$/.test(file)) {
|
||||
const filePath = path.join(clineRulesDir, file);
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
// Verify it's a BMAD rule
|
||||
if (content.includes('BMAD') && content.includes('Module')) {
|
||||
await fs.remove(filePath);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.dim(`Removed ${removed} BMAD rules from Cline`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ClineSetup };
|
||||
267
tools/cli/installers/lib/ide/codex.js
Normal file
267
tools/cli/installers/lib/ide/codex.js
Normal file
@@ -0,0 +1,267 @@
|
||||
const path = require('node:path');
|
||||
const { BaseIdeSetup } = require('./_base-ide');
|
||||
const chalk = require('chalk');
|
||||
const inquirer = require('inquirer');
|
||||
|
||||
/**
|
||||
* Codex setup handler (supports both CLI and Web)
|
||||
* Creates comprehensive AGENTS.md file in project root
|
||||
*/
|
||||
class CodexSetup extends BaseIdeSetup {
|
||||
constructor() {
|
||||
super('codex', 'Codex', true); // preferred IDE
|
||||
this.agentsFile = 'AGENTS.md';
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect configuration choices before installation
|
||||
* @param {Object} options - Configuration options
|
||||
* @returns {Object} Collected configuration
|
||||
*/
|
||||
async collectConfiguration(options = {}) {
|
||||
const response = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'mode',
|
||||
message: 'Select Codex deployment mode:',
|
||||
choices: [
|
||||
{ name: 'CLI (Command-line interface)', value: 'cli' },
|
||||
{ name: 'Web (Browser-based interface)', value: 'web' },
|
||||
],
|
||||
default: 'cli',
|
||||
},
|
||||
]);
|
||||
|
||||
return { codexMode: response.mode };
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Codex configuration
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} options - Setup options
|
||||
*/
|
||||
async setup(projectDir, bmadDir, options = {}) {
|
||||
console.log(chalk.cyan(`Setting up ${this.name}...`));
|
||||
|
||||
// Use pre-collected configuration if available
|
||||
const config = options.preCollectedConfig || {};
|
||||
const mode = config.codexMode || options.codexMode || 'cli';
|
||||
|
||||
// Get agents and tasks
|
||||
const agents = await this.getAgents(bmadDir);
|
||||
const tasks = await this.getTasks(bmadDir);
|
||||
|
||||
// Create AGENTS.md content
|
||||
const content = this.createAgentsDocument(agents, tasks, mode);
|
||||
|
||||
// Write AGENTS.md file
|
||||
const agentsPath = path.join(projectDir, this.agentsFile);
|
||||
await this.writeFile(agentsPath, content);
|
||||
|
||||
// Handle mode-specific setup
|
||||
if (mode === 'web') {
|
||||
await this.setupWebMode(projectDir);
|
||||
} else {
|
||||
await this.setupCliMode(projectDir);
|
||||
}
|
||||
|
||||
console.log(chalk.green(`✓ ${this.name} configured:`));
|
||||
console.log(chalk.dim(` - Mode: ${mode === 'web' ? 'Web' : 'CLI'}`));
|
||||
console.log(chalk.dim(` - ${agents.length} agents documented`));
|
||||
console.log(chalk.dim(` - ${tasks.length} tasks documented`));
|
||||
console.log(chalk.dim(` - Agents file: ${this.agentsFile}`));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
mode,
|
||||
agents: agents.length,
|
||||
tasks: tasks.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Select Codex mode (CLI or Web)
|
||||
*/
|
||||
async selectMode() {
|
||||
const response = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'mode',
|
||||
message: 'Select Codex deployment mode:',
|
||||
choices: [
|
||||
{ name: 'CLI (Command-line interface)', value: 'cli' },
|
||||
{ name: 'Web (Browser-based interface)', value: 'web' },
|
||||
],
|
||||
default: 'cli',
|
||||
},
|
||||
]);
|
||||
|
||||
return response.mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create comprehensive agents document
|
||||
*/
|
||||
createAgentsDocument(agents, tasks, mode) {
|
||||
let content = `# BMAD Method - Agent Directory
|
||||
|
||||
This document contains all available BMAD agents and tasks for use with Codex ${mode === 'web' ? 'Web' : 'CLI'}.
|
||||
|
||||
## Quick Start
|
||||
|
||||
${
|
||||
mode === 'web'
|
||||
? `Access agents through the web interface:
|
||||
1. Navigate to the Agents section
|
||||
2. Select an agent to activate
|
||||
3. The agent persona will be active for your session`
|
||||
: `Activate agents in CLI:
|
||||
1. Reference agents using \`@{agent-name}\`
|
||||
2. Execute tasks using \`@task-{task-name}\`
|
||||
3. Agents remain active for the conversation`
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
## Available Agents
|
||||
|
||||
`;
|
||||
|
||||
// Group agents by module
|
||||
const agentsByModule = {};
|
||||
for (const agent of agents) {
|
||||
if (!agentsByModule[agent.module]) {
|
||||
agentsByModule[agent.module] = [];
|
||||
}
|
||||
agentsByModule[agent.module].push(agent);
|
||||
}
|
||||
|
||||
// Document each module's agents
|
||||
for (const [module, moduleAgents] of Object.entries(agentsByModule)) {
|
||||
content += `### ${module.toUpperCase()} Module\n\n`;
|
||||
|
||||
for (const agent of moduleAgents) {
|
||||
const agentContent = this.readFileSync(agent.path);
|
||||
const titleMatch = agentContent.match(/title="([^"]+)"/);
|
||||
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
|
||||
|
||||
const iconMatch = agentContent.match(/icon="([^"]+)"/);
|
||||
const icon = iconMatch ? iconMatch[1] : '🤖';
|
||||
|
||||
const whenToUseMatch = agentContent.match(/whenToUse="([^"]+)"/);
|
||||
const whenToUse = whenToUseMatch ? whenToUseMatch[1] : `Use for ${title} tasks`;
|
||||
|
||||
content += `#### ${icon} ${title} (\`@${agent.name}\`)\n\n`;
|
||||
content += `**When to use:** ${whenToUse}\n\n`;
|
||||
content += `**Activation:** Type \`@${agent.name}\` to activate this agent.\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
content += `---
|
||||
|
||||
## Available Tasks
|
||||
|
||||
`;
|
||||
|
||||
// Group tasks by module
|
||||
const tasksByModule = {};
|
||||
for (const task of tasks) {
|
||||
if (!tasksByModule[task.module]) {
|
||||
tasksByModule[task.module] = [];
|
||||
}
|
||||
tasksByModule[task.module].push(task);
|
||||
}
|
||||
|
||||
// Document each module's tasks
|
||||
for (const [module, moduleTasks] of Object.entries(tasksByModule)) {
|
||||
content += `### ${module.toUpperCase()} Module Tasks\n\n`;
|
||||
|
||||
for (const task of moduleTasks) {
|
||||
const taskContent = this.readFileSync(task.path);
|
||||
const nameMatch = taskContent.match(/<name>([^<]+)<\/name>/);
|
||||
const taskName = nameMatch ? nameMatch[1] : this.formatTitle(task.name);
|
||||
|
||||
content += `- **${taskName}** (\`@task-${task.name}\`)\n`;
|
||||
}
|
||||
content += '\n';
|
||||
}
|
||||
|
||||
content += `---
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
1. **One agent at a time**: Activate a single agent for focused assistance
|
||||
2. **Task execution**: Tasks are one-time workflows, not persistent personas
|
||||
3. **Module organization**: Agents and tasks are grouped by their source module
|
||||
4. **Context preservation**: ${mode === 'web' ? 'Sessions maintain agent context' : 'Conversations maintain agent context'}
|
||||
|
||||
---
|
||||
|
||||
*Generated by BMAD Method installer for Codex ${mode === 'web' ? 'Web' : 'CLI'}*
|
||||
`;
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file synchronously (for document generation)
|
||||
*/
|
||||
readFileSync(filePath) {
|
||||
const fs = require('node:fs');
|
||||
try {
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup for CLI mode
|
||||
*/
|
||||
async setupCliMode(projectDir) {
|
||||
// CLI mode - ensure .gitignore includes AGENTS.md if needed
|
||||
const fs = require('fs-extra');
|
||||
const gitignorePath = path.join(projectDir, '.gitignore');
|
||||
|
||||
if (await fs.pathExists(gitignorePath)) {
|
||||
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
|
||||
if (!gitignoreContent.includes('AGENTS.md')) {
|
||||
// User can decide whether to track this file
|
||||
console.log(chalk.dim(' Note: Consider adding AGENTS.md to .gitignore if desired'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup for Web mode
|
||||
*/
|
||||
async setupWebMode(projectDir) {
|
||||
// Web mode - add to .gitignore to avoid committing
|
||||
const fs = require('fs-extra');
|
||||
const gitignorePath = path.join(projectDir, '.gitignore');
|
||||
|
||||
if (await fs.pathExists(gitignorePath)) {
|
||||
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
|
||||
if (!gitignoreContent.includes('AGENTS.md')) {
|
||||
await fs.appendFile(gitignorePath, '\n# Codex Web agents file\nAGENTS.md\n');
|
||||
console.log(chalk.dim(' Added AGENTS.md to .gitignore for web deployment'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup Codex configuration
|
||||
*/
|
||||
async cleanup(projectDir) {
|
||||
const fs = require('fs-extra');
|
||||
const agentsPath = path.join(projectDir, this.agentsFile);
|
||||
|
||||
if (await fs.pathExists(agentsPath)) {
|
||||
await fs.remove(agentsPath);
|
||||
console.log(chalk.dim('Removed AGENTS.md file'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { CodexSetup };
|
||||
204
tools/cli/installers/lib/ide/crush.js
Normal file
204
tools/cli/installers/lib/ide/crush.js
Normal file
@@ -0,0 +1,204 @@
|
||||
const path = require('node:path');
|
||||
const { BaseIdeSetup } = require('./_base-ide');
|
||||
const chalk = require('chalk');
|
||||
|
||||
/**
|
||||
* Crush IDE setup handler
|
||||
* Creates commands in .crush/commands/ directory structure
|
||||
*/
|
||||
class CrushSetup extends BaseIdeSetup {
|
||||
constructor() {
|
||||
super('crush', 'Crush');
|
||||
this.configDir = '.crush';
|
||||
this.commandsDir = 'commands';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Crush IDE configuration
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} options - Setup options
|
||||
*/
|
||||
async setup(projectDir, bmadDir, options = {}) {
|
||||
console.log(chalk.cyan(`Setting up ${this.name}...`));
|
||||
|
||||
// Create .crush/commands/bmad directory structure
|
||||
const crushDir = path.join(projectDir, this.configDir);
|
||||
const commandsDir = path.join(crushDir, this.commandsDir, 'bmad');
|
||||
const agentsDir = path.join(commandsDir, 'agents');
|
||||
const tasksDir = path.join(commandsDir, 'tasks');
|
||||
|
||||
await this.ensureDir(agentsDir);
|
||||
await this.ensureDir(tasksDir);
|
||||
|
||||
// Get agents and tasks
|
||||
const agents = await this.getAgents(bmadDir);
|
||||
const tasks = await this.getTasks(bmadDir);
|
||||
|
||||
// Setup agents as commands
|
||||
let agentCount = 0;
|
||||
for (const agent of agents) {
|
||||
const content = await this.readFile(agent.path);
|
||||
const commandContent = this.createAgentCommand(agent, content, projectDir);
|
||||
|
||||
const targetPath = path.join(agentsDir, `${agent.module}-${agent.name}.md`);
|
||||
await this.writeFile(targetPath, commandContent);
|
||||
agentCount++;
|
||||
}
|
||||
|
||||
// Setup tasks as commands
|
||||
let taskCount = 0;
|
||||
for (const task of tasks) {
|
||||
const content = await this.readFile(task.path);
|
||||
const commandContent = this.createTaskCommand(task, content);
|
||||
|
||||
const targetPath = path.join(tasksDir, `${task.module}-${task.name}.md`);
|
||||
await this.writeFile(targetPath, commandContent);
|
||||
taskCount++;
|
||||
}
|
||||
|
||||
// Create module-specific subdirectories for better organization
|
||||
await this.organizeByModule(commandsDir, agents, tasks, bmadDir);
|
||||
|
||||
console.log(chalk.green(`✓ ${this.name} configured:`));
|
||||
console.log(chalk.dim(` - ${agentCount} agent commands created`));
|
||||
console.log(chalk.dim(` - ${taskCount} task commands created`));
|
||||
console.log(chalk.dim(` - Commands directory: ${path.relative(projectDir, commandsDir)}`));
|
||||
console.log(chalk.dim('\n Commands can be accessed via Crush command palette'));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
agents: agentCount,
|
||||
tasks: taskCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Organize commands by module
|
||||
*/
|
||||
async organizeByModule(commandsDir, agents, tasks, bmadDir) {
|
||||
// Get unique modules
|
||||
const modules = new Set();
|
||||
for (const agent of agents) modules.add(agent.module);
|
||||
for (const task of tasks) modules.add(task.module);
|
||||
|
||||
// Create module directories
|
||||
for (const module of modules) {
|
||||
const moduleDir = path.join(commandsDir, module);
|
||||
const moduleAgentsDir = path.join(moduleDir, 'agents');
|
||||
const moduleTasksDir = path.join(moduleDir, 'tasks');
|
||||
|
||||
await this.ensureDir(moduleAgentsDir);
|
||||
await this.ensureDir(moduleTasksDir);
|
||||
|
||||
// Copy module-specific agents
|
||||
const moduleAgents = agents.filter((a) => a.module === module);
|
||||
for (const agent of moduleAgents) {
|
||||
const content = await this.readFile(agent.path);
|
||||
const commandContent = this.createAgentCommand(agent, content, bmadDir);
|
||||
const targetPath = path.join(moduleAgentsDir, `${agent.name}.md`);
|
||||
await this.writeFile(targetPath, commandContent);
|
||||
}
|
||||
|
||||
// Copy module-specific tasks
|
||||
const moduleTasks = tasks.filter((t) => t.module === module);
|
||||
for (const task of moduleTasks) {
|
||||
const content = await this.readFile(task.path);
|
||||
const commandContent = this.createTaskCommand(task, content);
|
||||
const targetPath = path.join(moduleTasksDir, `${task.name}.md`);
|
||||
await this.writeFile(targetPath, commandContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create agent command content
|
||||
*/
|
||||
createAgentCommand(agent, content, projectDir) {
|
||||
// Extract metadata
|
||||
const titleMatch = content.match(/title="([^"]+)"/);
|
||||
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
|
||||
|
||||
const iconMatch = content.match(/icon="([^"]+)"/);
|
||||
const icon = iconMatch ? iconMatch[1] : '🤖';
|
||||
|
||||
// Get relative path
|
||||
const relativePath = path.relative(projectDir, agent.path).replaceAll('\\', '/');
|
||||
|
||||
let commandContent = `# /${agent.name} Command
|
||||
|
||||
When this command is used, adopt the following agent persona:
|
||||
|
||||
## ${icon} ${title} Agent
|
||||
|
||||
${content}
|
||||
|
||||
## Command Usage
|
||||
|
||||
This command activates the ${title} agent from the BMAD ${agent.module.toUpperCase()} module.
|
||||
|
||||
## File Reference
|
||||
|
||||
Complete agent definition: [${relativePath}](${relativePath})
|
||||
|
||||
## Module
|
||||
|
||||
Part of the BMAD ${agent.module.toUpperCase()} module.
|
||||
`;
|
||||
|
||||
return commandContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create task command content
|
||||
*/
|
||||
createTaskCommand(task, content) {
|
||||
// Extract task name
|
||||
const nameMatch = content.match(/<name>([^<]+)<\/name>/);
|
||||
const taskName = nameMatch ? nameMatch[1] : this.formatTitle(task.name);
|
||||
|
||||
let commandContent = `# /task-${task.name} Command
|
||||
|
||||
When this command is used, execute the following task:
|
||||
|
||||
## ${taskName} Task
|
||||
|
||||
${content}
|
||||
|
||||
## Command Usage
|
||||
|
||||
This command executes the ${taskName} task from the BMAD ${task.module.toUpperCase()} module.
|
||||
|
||||
## Module
|
||||
|
||||
Part of the BMAD ${task.module.toUpperCase()} module.
|
||||
`;
|
||||
|
||||
return commandContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format name as title
|
||||
*/
|
||||
formatTitle(name) {
|
||||
return name
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup Crush configuration
|
||||
*/
|
||||
async cleanup(projectDir) {
|
||||
const fs = require('fs-extra');
|
||||
const bmadCommandsDir = path.join(projectDir, this.configDir, this.commandsDir, 'bmad');
|
||||
|
||||
if (await fs.pathExists(bmadCommandsDir)) {
|
||||
await fs.remove(bmadCommandsDir);
|
||||
console.log(chalk.dim(`Removed BMAD commands from Crush`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { CrushSetup };
|
||||
224
tools/cli/installers/lib/ide/cursor.js
Normal file
224
tools/cli/installers/lib/ide/cursor.js
Normal file
@@ -0,0 +1,224 @@
|
||||
const path = require('node:path');
|
||||
const { BaseIdeSetup } = require('./_base-ide');
|
||||
const chalk = require('chalk');
|
||||
|
||||
/**
|
||||
* Cursor IDE setup handler
|
||||
*/
|
||||
class CursorSetup extends BaseIdeSetup {
|
||||
constructor() {
|
||||
super('cursor', 'Cursor', true); // preferred IDE
|
||||
this.configDir = '.cursor';
|
||||
this.rulesDir = 'rules';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Cursor IDE configuration
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} options - Setup options
|
||||
*/
|
||||
async setup(projectDir, bmadDir, options = {}) {
|
||||
console.log(chalk.cyan(`Setting up ${this.name}...`));
|
||||
|
||||
// Create .cursor/rules directory structure
|
||||
const cursorDir = path.join(projectDir, this.configDir);
|
||||
const rulesDir = path.join(cursorDir, this.rulesDir);
|
||||
const bmadRulesDir = path.join(rulesDir, 'bmad');
|
||||
|
||||
await this.ensureDir(bmadRulesDir);
|
||||
|
||||
// Get agents and tasks
|
||||
const agents = await this.getAgents(bmadDir);
|
||||
const tasks = await this.getTasks(bmadDir);
|
||||
|
||||
// Create directories for each module
|
||||
const modules = new Set();
|
||||
for (const item of [...agents, ...tasks]) modules.add(item.module);
|
||||
|
||||
for (const module of modules) {
|
||||
await this.ensureDir(path.join(bmadRulesDir, module));
|
||||
await this.ensureDir(path.join(bmadRulesDir, module, 'agents'));
|
||||
await this.ensureDir(path.join(bmadRulesDir, module, 'tasks'));
|
||||
}
|
||||
|
||||
// Process and copy agents
|
||||
let agentCount = 0;
|
||||
for (const agent of agents) {
|
||||
const content = await this.readAndProcess(agent.path, {
|
||||
module: agent.module,
|
||||
name: agent.name,
|
||||
});
|
||||
|
||||
const targetPath = path.join(bmadRulesDir, agent.module, 'agents', `${agent.name}.mdc`);
|
||||
|
||||
await this.writeFile(targetPath, content);
|
||||
agentCount++;
|
||||
}
|
||||
|
||||
// Process and copy tasks
|
||||
let taskCount = 0;
|
||||
for (const task of tasks) {
|
||||
const content = await this.readAndProcess(task.path, {
|
||||
module: task.module,
|
||||
name: task.name,
|
||||
});
|
||||
|
||||
const targetPath = path.join(bmadRulesDir, task.module, 'tasks', `${task.name}.mdc`);
|
||||
|
||||
await this.writeFile(targetPath, content);
|
||||
taskCount++;
|
||||
}
|
||||
|
||||
// Create BMAD index file (but NOT .cursorrules - user manages that)
|
||||
await this.createBMADIndex(bmadRulesDir, agents, tasks, modules);
|
||||
|
||||
console.log(chalk.green(`✓ ${this.name} configured:`));
|
||||
console.log(chalk.dim(` - ${agentCount} agents installed`));
|
||||
console.log(chalk.dim(` - ${taskCount} tasks installed`));
|
||||
console.log(chalk.dim(` - Rules directory: ${path.relative(projectDir, bmadRulesDir)}`));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
agents: agentCount,
|
||||
tasks: taskCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create BMAD index file for easy navigation
|
||||
*/
|
||||
async createBMADIndex(bmadRulesDir, agents, tasks, modules) {
|
||||
const indexPath = path.join(bmadRulesDir, 'index.mdc');
|
||||
|
||||
let content = `---
|
||||
description: BMAD Method - Master Index
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# BMAD Method - Cursor Rules Index
|
||||
|
||||
This is the master index for all BMAD agents and tasks available in your project.
|
||||
|
||||
## Installation Complete!
|
||||
|
||||
BMAD rules have been installed to: \`.cursor/rules/bmad/\`
|
||||
|
||||
**Note:** BMAD does not modify your \`.cursorrules\` file. You manage that separately.
|
||||
|
||||
## How to Use
|
||||
|
||||
- Reference specific agents: @bmad/{module}/agents/{agent-name}
|
||||
- Reference specific tasks: @bmad/{module}/tasks/{task-name}
|
||||
- Reference entire modules: @bmad/{module}
|
||||
- Reference this index: @bmad/index
|
||||
|
||||
## Available Modules
|
||||
|
||||
`;
|
||||
|
||||
for (const module of modules) {
|
||||
content += `### ${module.toUpperCase()}\n\n`;
|
||||
|
||||
// List agents for this module
|
||||
const moduleAgents = agents.filter((a) => a.module === module);
|
||||
if (moduleAgents.length > 0) {
|
||||
content += `**Agents:**\n`;
|
||||
for (const agent of moduleAgents) {
|
||||
content += `- @bmad/${module}/agents/${agent.name} - ${agent.name}\n`;
|
||||
}
|
||||
content += '\n';
|
||||
}
|
||||
|
||||
// List tasks for this module
|
||||
const moduleTasks = tasks.filter((t) => t.module === module);
|
||||
if (moduleTasks.length > 0) {
|
||||
content += `**Tasks:**\n`;
|
||||
for (const task of moduleTasks) {
|
||||
content += `- @bmad/${module}/tasks/${task.name} - ${task.name}\n`;
|
||||
}
|
||||
content += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
content += `
|
||||
## Quick Reference
|
||||
|
||||
- All BMAD rules are Manual type - reference them explicitly when needed
|
||||
- Agents provide persona-based assistance with specific expertise
|
||||
- Tasks are reusable workflows for common operations
|
||||
- Each agent includes an activation block for proper initialization
|
||||
|
||||
## Configuration
|
||||
|
||||
BMAD rules are configured as Manual rules (alwaysApply: false) to give you control
|
||||
over when they're included in your context. Reference them explicitly when you need
|
||||
specific agent expertise or task workflows.
|
||||
`;
|
||||
|
||||
await this.writeFile(indexPath, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and process file content
|
||||
*/
|
||||
async readAndProcess(filePath, metadata) {
|
||||
const fs = require('fs-extra');
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
return this.processContent(content, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override processContent to add MDC metadata header for Cursor
|
||||
* @param {string} content - File content
|
||||
* @param {Object} metadata - File metadata
|
||||
* @returns {string} Processed content with MDC header
|
||||
*/
|
||||
processContent(content, metadata = {}) {
|
||||
// First apply base processing (includes activation injection for agents)
|
||||
let processed = super.processContent(content, metadata);
|
||||
|
||||
// Determine the type and description based on content
|
||||
const isAgent = content.includes('<agent');
|
||||
const isTask = content.includes('<task');
|
||||
|
||||
let description = '';
|
||||
let globs = '';
|
||||
|
||||
if (isAgent) {
|
||||
// Extract agent title if available
|
||||
const titleMatch = content.match(/title="([^"]+)"/);
|
||||
const title = titleMatch ? titleMatch[1] : metadata.name;
|
||||
description = `BMAD ${metadata.module.toUpperCase()} Agent: ${title}`;
|
||||
|
||||
// Manual rules for agents don't need globs
|
||||
globs = '';
|
||||
} else if (isTask) {
|
||||
// Extract task name if available
|
||||
const nameMatch = content.match(/<name>([^<]+)<\/name>/);
|
||||
const taskName = nameMatch ? nameMatch[1] : metadata.name;
|
||||
description = `BMAD ${metadata.module.toUpperCase()} Task: ${taskName}`;
|
||||
|
||||
// Tasks might be auto-attached to certain file types
|
||||
globs = '';
|
||||
} else {
|
||||
description = `BMAD ${metadata.module.toUpperCase()}: ${metadata.name}`;
|
||||
globs = '';
|
||||
}
|
||||
|
||||
// Create MDC metadata header
|
||||
const mdcHeader = `---
|
||||
description: ${description}
|
||||
globs: ${globs}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
`;
|
||||
|
||||
// Add the MDC header to the processed content
|
||||
return mdcHeader + processed;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { CursorSetup };
|
||||
160
tools/cli/installers/lib/ide/gemini.js
Normal file
160
tools/cli/installers/lib/ide/gemini.js
Normal file
@@ -0,0 +1,160 @@
|
||||
const path = require('node:path');
|
||||
const { BaseIdeSetup } = require('./_base-ide');
|
||||
const chalk = require('chalk');
|
||||
|
||||
/**
|
||||
* Gemini CLI setup handler
|
||||
* Creates TOML files in .gemini/commands/ structure
|
||||
*/
|
||||
class GeminiSetup extends BaseIdeSetup {
|
||||
constructor() {
|
||||
super('gemini', 'Gemini CLI', true); // preferred IDE
|
||||
this.configDir = '.gemini';
|
||||
this.commandsDir = 'commands';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Gemini CLI configuration
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} options - Setup options
|
||||
*/
|
||||
async setup(projectDir, bmadDir, options = {}) {
|
||||
console.log(chalk.cyan(`Setting up ${this.name}...`));
|
||||
|
||||
// Create .gemini/commands/agents and .gemini/commands/tasks directories
|
||||
const geminiDir = path.join(projectDir, this.configDir);
|
||||
const commandsDir = path.join(geminiDir, this.commandsDir);
|
||||
const agentsDir = path.join(commandsDir, 'agents');
|
||||
const tasksDir = path.join(commandsDir, 'tasks');
|
||||
|
||||
await this.ensureDir(agentsDir);
|
||||
await this.ensureDir(tasksDir);
|
||||
|
||||
// Get agents and tasks
|
||||
const agents = await this.getAgents(bmadDir);
|
||||
const tasks = await this.getTasks(bmadDir);
|
||||
|
||||
// Install agents as TOML files
|
||||
let agentCount = 0;
|
||||
for (const agent of agents) {
|
||||
const content = await this.readFile(agent.path);
|
||||
const tomlContent = this.createAgentToml(agent, content, bmadDir);
|
||||
|
||||
const tomlPath = path.join(agentsDir, `${agent.name}.toml`);
|
||||
await this.writeFile(tomlPath, tomlContent);
|
||||
agentCount++;
|
||||
|
||||
console.log(chalk.green(` ✓ Added agent: /bmad:agents:${agent.name}`));
|
||||
}
|
||||
|
||||
// Install tasks as TOML files
|
||||
let taskCount = 0;
|
||||
for (const task of tasks) {
|
||||
const content = await this.readFile(task.path);
|
||||
const tomlContent = this.createTaskToml(task, content, bmadDir);
|
||||
|
||||
const tomlPath = path.join(tasksDir, `${task.name}.toml`);
|
||||
await this.writeFile(tomlPath, tomlContent);
|
||||
taskCount++;
|
||||
|
||||
console.log(chalk.green(` ✓ Added task: /bmad:tasks:${task.name}`));
|
||||
}
|
||||
|
||||
console.log(chalk.green(`✓ ${this.name} configured:`));
|
||||
console.log(chalk.dim(` - ${agentCount} agents configured`));
|
||||
console.log(chalk.dim(` - ${taskCount} tasks configured`));
|
||||
console.log(chalk.dim(` - Commands directory: ${path.relative(projectDir, commandsDir)}`));
|
||||
console.log(chalk.dim(` - Agent activation: /bmad:agents:{agent-name}`));
|
||||
console.log(chalk.dim(` - Task activation: /bmad:tasks:{task-name}`));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
agents: agentCount,
|
||||
tasks: taskCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create agent TOML content
|
||||
*/
|
||||
createAgentToml(agent, content, bmadDir) {
|
||||
// Extract metadata
|
||||
const titleMatch = content.match(/title="([^"]+)"/);
|
||||
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
|
||||
|
||||
// Get relative path from project root to agent file
|
||||
const relativePath = path.relative(process.cwd(), agent.path).replaceAll('\\', '/');
|
||||
|
||||
// Create TOML content
|
||||
const tomlContent = `description = "Activates the ${title} agent from the BMad Method."
|
||||
prompt = """
|
||||
CRITICAL: You are now the BMad '${title}' agent. Adopt its persona and capabilities as defined in the following configuration.
|
||||
|
||||
Read and internalize the full agent definition, following all instructions and maintaining this persona until explicitly told to switch or exit.
|
||||
|
||||
@${relativePath}
|
||||
"""
|
||||
`;
|
||||
|
||||
return tomlContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create task TOML content
|
||||
*/
|
||||
createTaskToml(task, content, bmadDir) {
|
||||
// Extract task name from XML if available
|
||||
const nameMatch = content.match(/<name>([^<]+)<\/name>/);
|
||||
const taskName = nameMatch ? nameMatch[1] : this.formatTitle(task.name);
|
||||
|
||||
// Get relative path from project root to task file
|
||||
const relativePath = path.relative(process.cwd(), task.path).replaceAll('\\', '/');
|
||||
|
||||
// Create TOML content
|
||||
const tomlContent = `description = "Executes the ${taskName} task from the BMad Method."
|
||||
prompt = """
|
||||
Execute the following BMad Method task workflow:
|
||||
|
||||
@${relativePath}
|
||||
|
||||
Follow all instructions and complete the task as defined.
|
||||
"""
|
||||
`;
|
||||
|
||||
return tomlContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup Gemini configuration
|
||||
*/
|
||||
async cleanup(projectDir) {
|
||||
const fs = require('fs-extra');
|
||||
const commandsDir = path.join(projectDir, this.configDir, this.commandsDir);
|
||||
const agentsDir = path.join(commandsDir, 'agents');
|
||||
const tasksDir = path.join(commandsDir, 'tasks');
|
||||
|
||||
// Remove BMAD TOML files
|
||||
if (await fs.pathExists(agentsDir)) {
|
||||
const files = await fs.readdir(agentsDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.toml')) {
|
||||
await fs.remove(path.join(agentsDir, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (await fs.pathExists(tasksDir)) {
|
||||
const files = await fs.readdir(tasksDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.toml')) {
|
||||
await fs.remove(path.join(tasksDir, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.dim(`Removed BMAD configuration from Gemini CLI`));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { GeminiSetup };
|
||||
289
tools/cli/installers/lib/ide/github-copilot.js
Normal file
289
tools/cli/installers/lib/ide/github-copilot.js
Normal file
@@ -0,0 +1,289 @@
|
||||
const path = require('node:path');
|
||||
const { BaseIdeSetup } = require('./_base-ide');
|
||||
const chalk = require('chalk');
|
||||
const inquirer = require('inquirer');
|
||||
|
||||
/**
|
||||
* GitHub Copilot setup handler
|
||||
* Creates chat modes in .github/chatmodes/ and configures VS Code settings
|
||||
*/
|
||||
class GitHubCopilotSetup extends BaseIdeSetup {
|
||||
constructor() {
|
||||
super('github-copilot', 'GitHub Copilot', true); // preferred IDE
|
||||
this.configDir = '.github';
|
||||
this.chatmodesDir = 'chatmodes';
|
||||
this.vscodeDir = '.vscode';
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect configuration choices before installation
|
||||
* @param {Object} options - Configuration options
|
||||
* @returns {Object} Collected configuration
|
||||
*/
|
||||
async collectConfiguration(options = {}) {
|
||||
const config = {};
|
||||
|
||||
console.log('\n' + chalk.blue(' 🔧 VS Code Settings Configuration'));
|
||||
console.log(chalk.dim(' GitHub Copilot works best with specific settings\n'));
|
||||
|
||||
const response = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'configChoice',
|
||||
message: 'How would you like to configure VS Code settings?',
|
||||
choices: [
|
||||
{ name: 'Use recommended defaults (fastest)', value: 'defaults' },
|
||||
{ name: 'Configure each setting manually', value: 'manual' },
|
||||
{ name: 'Skip settings configuration', value: 'skip' },
|
||||
],
|
||||
default: 'defaults',
|
||||
},
|
||||
]);
|
||||
config.vsCodeConfig = response.configChoice;
|
||||
|
||||
if (response.configChoice === 'manual') {
|
||||
config.manualSettings = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'maxRequests',
|
||||
message: 'Maximum requests per session (1-50)?',
|
||||
default: '15',
|
||||
validate: (input) => {
|
||||
const num = parseInt(input);
|
||||
return (num >= 1 && num <= 50) || 'Enter 1-50';
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'runTasks',
|
||||
message: 'Allow running workspace tasks?',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'mcpDiscovery',
|
||||
message: 'Enable MCP server discovery?',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'autoFix',
|
||||
message: 'Enable automatic error fixing?',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'autoApprove',
|
||||
message: 'Auto-approve tools (less secure)?',
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup GitHub Copilot configuration
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} options - Setup options
|
||||
*/
|
||||
async setup(projectDir, bmadDir, options = {}) {
|
||||
console.log(chalk.cyan(`Setting up ${this.name}...`));
|
||||
|
||||
// Configure VS Code settings using pre-collected config if available
|
||||
const config = options.preCollectedConfig || {};
|
||||
await this.configureVsCodeSettings(projectDir, { ...options, ...config });
|
||||
|
||||
// Create .github/chatmodes directory
|
||||
const githubDir = path.join(projectDir, this.configDir);
|
||||
const chatmodesDir = path.join(githubDir, this.chatmodesDir);
|
||||
await this.ensureDir(chatmodesDir);
|
||||
|
||||
// Get agents
|
||||
const agents = await this.getAgents(bmadDir);
|
||||
|
||||
// Create chat mode files
|
||||
let modeCount = 0;
|
||||
for (const agent of agents) {
|
||||
const content = await this.readFile(agent.path);
|
||||
const chatmodeContent = this.createChatmodeContent(agent, content);
|
||||
|
||||
const targetPath = path.join(chatmodesDir, `${agent.module}-${agent.name}.chatmode.md`);
|
||||
await this.writeFile(targetPath, chatmodeContent);
|
||||
modeCount++;
|
||||
|
||||
console.log(chalk.green(` ✓ Created chat mode: ${agent.module}-${agent.name}`));
|
||||
}
|
||||
|
||||
console.log(chalk.green(`✓ ${this.name} configured:`));
|
||||
console.log(chalk.dim(` - ${modeCount} chat modes created`));
|
||||
console.log(chalk.dim(` - Chat modes directory: ${path.relative(projectDir, chatmodesDir)}`));
|
||||
console.log(chalk.dim(` - VS Code settings configured`));
|
||||
console.log(chalk.dim('\n Chat modes available in VS Code Chat view'));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chatmodes: modeCount,
|
||||
settings: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure VS Code settings for GitHub Copilot
|
||||
*/
|
||||
async configureVsCodeSettings(projectDir, options) {
|
||||
const fs = require('fs-extra');
|
||||
const vscodeDir = path.join(projectDir, this.vscodeDir);
|
||||
const settingsPath = path.join(vscodeDir, 'settings.json');
|
||||
|
||||
await this.ensureDir(vscodeDir);
|
||||
|
||||
// Read existing settings
|
||||
let existingSettings = {};
|
||||
if (await fs.pathExists(settingsPath)) {
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf8');
|
||||
existingSettings = JSON.parse(content);
|
||||
console.log(chalk.yellow(' Found existing .vscode/settings.json'));
|
||||
} catch {
|
||||
console.warn(chalk.yellow(' Could not parse settings.json, creating new'));
|
||||
}
|
||||
}
|
||||
|
||||
// Use pre-collected configuration or skip if not available
|
||||
let configChoice = options.vsCodeConfig;
|
||||
if (!configChoice) {
|
||||
// If no pre-collected config, skip configuration
|
||||
console.log(chalk.yellow(' ⚠ No configuration collected, skipping VS Code settings'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (configChoice === 'skip') {
|
||||
console.log(chalk.yellow(' ⚠ Skipping VS Code settings'));
|
||||
return;
|
||||
}
|
||||
|
||||
let bmadSettings = {};
|
||||
|
||||
if (configChoice === 'defaults') {
|
||||
bmadSettings = {
|
||||
'chat.agent.enabled': true,
|
||||
'chat.agent.maxRequests': 15,
|
||||
'github.copilot.chat.agent.runTasks': true,
|
||||
'chat.mcp.discovery.enabled': true,
|
||||
'github.copilot.chat.agent.autoFix': true,
|
||||
'chat.tools.autoApprove': false,
|
||||
};
|
||||
console.log(chalk.green(' ✓ Using recommended defaults'));
|
||||
} else {
|
||||
// Manual configuration - use pre-collected settings
|
||||
const manual = options.manualSettings || {};
|
||||
|
||||
bmadSettings = {
|
||||
'chat.agent.enabled': true,
|
||||
'chat.agent.maxRequests': parseInt(manual.maxRequests || 15),
|
||||
'github.copilot.chat.agent.runTasks': manual.runTasks === undefined ? true : manual.runTasks,
|
||||
'chat.mcp.discovery.enabled': manual.mcpDiscovery === undefined ? true : manual.mcpDiscovery,
|
||||
'github.copilot.chat.agent.autoFix': manual.autoFix === undefined ? true : manual.autoFix,
|
||||
'chat.tools.autoApprove': manual.autoApprove || false,
|
||||
};
|
||||
}
|
||||
|
||||
// Merge settings (existing take precedence)
|
||||
const mergedSettings = { ...bmadSettings, ...existingSettings };
|
||||
|
||||
// Write settings
|
||||
await fs.writeFile(settingsPath, JSON.stringify(mergedSettings, null, 2));
|
||||
console.log(chalk.green(' ✓ VS Code settings configured'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create chat mode content
|
||||
*/
|
||||
createChatmodeContent(agent, content) {
|
||||
// Extract metadata
|
||||
const titleMatch = content.match(/title="([^"]+)"/);
|
||||
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
|
||||
|
||||
const whenToUseMatch = content.match(/whenToUse="([^"]+)"/);
|
||||
const description = whenToUseMatch ? whenToUseMatch[1] : `Activates the ${title} agent persona.`;
|
||||
|
||||
// Available GitHub Copilot tools
|
||||
const tools = [
|
||||
'changes',
|
||||
'codebase',
|
||||
'fetch',
|
||||
'findTestFiles',
|
||||
'githubRepo',
|
||||
'problems',
|
||||
'usages',
|
||||
'editFiles',
|
||||
'runCommands',
|
||||
'runTasks',
|
||||
'runTests',
|
||||
'search',
|
||||
'searchResults',
|
||||
'terminalLastCommand',
|
||||
'terminalSelection',
|
||||
'testFailure',
|
||||
];
|
||||
|
||||
let chatmodeContent = `---
|
||||
description: "${description.replaceAll('"', String.raw`\"`)}"
|
||||
tools: ${JSON.stringify(tools)}
|
||||
---
|
||||
|
||||
# ${title} Agent
|
||||
|
||||
${content}
|
||||
|
||||
## Module
|
||||
|
||||
Part of the BMAD ${agent.module.toUpperCase()} module.
|
||||
`;
|
||||
|
||||
return chatmodeContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format name as title
|
||||
*/
|
||||
formatTitle(name) {
|
||||
return name
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup GitHub Copilot configuration
|
||||
*/
|
||||
async cleanup(projectDir) {
|
||||
const fs = require('fs-extra');
|
||||
const chatmodesDir = path.join(projectDir, this.configDir, this.chatmodesDir);
|
||||
|
||||
if (await fs.pathExists(chatmodesDir)) {
|
||||
// Remove BMAD chat modes
|
||||
const files = await fs.readdir(chatmodesDir);
|
||||
let removed = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.chatmode.md')) {
|
||||
const filePath = path.join(chatmodesDir, file);
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
if (content.includes('BMAD') && content.includes('Module')) {
|
||||
await fs.remove(filePath);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.dim(`Removed ${removed} BMAD chat modes from GitHub Copilot`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { GitHubCopilotSetup };
|
||||
142
tools/cli/installers/lib/ide/iflow.js
Normal file
142
tools/cli/installers/lib/ide/iflow.js
Normal file
@@ -0,0 +1,142 @@
|
||||
const path = require('node:path');
|
||||
const { BaseIdeSetup } = require('./_base-ide');
|
||||
const chalk = require('chalk');
|
||||
|
||||
/**
|
||||
* iFlow CLI setup handler
|
||||
* Creates commands in .iflow/commands/ directory structure
|
||||
*/
|
||||
class IFlowSetup extends BaseIdeSetup {
|
||||
constructor() {
|
||||
super('iflow', 'iFlow CLI');
|
||||
this.configDir = '.iflow';
|
||||
this.commandsDir = 'commands';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup iFlow CLI configuration
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} options - Setup options
|
||||
*/
|
||||
async setup(projectDir, bmadDir, options = {}) {
|
||||
console.log(chalk.cyan(`Setting up ${this.name}...`));
|
||||
|
||||
// Create .iflow/commands/bmad directory structure
|
||||
const iflowDir = path.join(projectDir, this.configDir);
|
||||
const commandsDir = path.join(iflowDir, this.commandsDir, 'bmad');
|
||||
const agentsDir = path.join(commandsDir, 'agents');
|
||||
const tasksDir = path.join(commandsDir, 'tasks');
|
||||
|
||||
await this.ensureDir(agentsDir);
|
||||
await this.ensureDir(tasksDir);
|
||||
|
||||
// Get agents and tasks
|
||||
const agents = await this.getAgents(bmadDir);
|
||||
const tasks = await this.getTasks(bmadDir);
|
||||
|
||||
// Setup agents as commands
|
||||
let agentCount = 0;
|
||||
for (const agent of agents) {
|
||||
const content = await this.readFile(agent.path);
|
||||
const commandContent = this.createAgentCommand(agent, content);
|
||||
|
||||
const targetPath = path.join(agentsDir, `${agent.module}-${agent.name}.md`);
|
||||
await this.writeFile(targetPath, commandContent);
|
||||
agentCount++;
|
||||
}
|
||||
|
||||
// Setup tasks as commands
|
||||
let taskCount = 0;
|
||||
for (const task of tasks) {
|
||||
const content = await this.readFile(task.path);
|
||||
const commandContent = this.createTaskCommand(task, content);
|
||||
|
||||
const targetPath = path.join(tasksDir, `${task.module}-${task.name}.md`);
|
||||
await this.writeFile(targetPath, commandContent);
|
||||
taskCount++;
|
||||
}
|
||||
|
||||
console.log(chalk.green(`✓ ${this.name} configured:`));
|
||||
console.log(chalk.dim(` - ${agentCount} agent commands created`));
|
||||
console.log(chalk.dim(` - ${taskCount} task commands created`));
|
||||
console.log(chalk.dim(` - Commands directory: ${path.relative(projectDir, commandsDir)}`));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
agents: agentCount,
|
||||
tasks: taskCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create agent command content
|
||||
*/
|
||||
createAgentCommand(agent, content) {
|
||||
// Extract metadata
|
||||
const titleMatch = content.match(/title="([^"]+)"/);
|
||||
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
|
||||
|
||||
let commandContent = `# /${agent.name} Command
|
||||
|
||||
When this command is used, adopt the following agent persona:
|
||||
|
||||
## ${title} Agent
|
||||
|
||||
${content}
|
||||
|
||||
## Usage
|
||||
|
||||
This command activates the ${title} agent from the BMAD ${agent.module.toUpperCase()} module.
|
||||
|
||||
## Module
|
||||
|
||||
Part of the BMAD ${agent.module.toUpperCase()} module.
|
||||
`;
|
||||
|
||||
return commandContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create task command content
|
||||
*/
|
||||
createTaskCommand(task, content) {
|
||||
// Extract task name
|
||||
const nameMatch = content.match(/<name>([^<]+)<\/name>/);
|
||||
const taskName = nameMatch ? nameMatch[1] : this.formatTitle(task.name);
|
||||
|
||||
let commandContent = `# /task-${task.name} Command
|
||||
|
||||
When this command is used, execute the following task:
|
||||
|
||||
## ${taskName} Task
|
||||
|
||||
${content}
|
||||
|
||||
## Usage
|
||||
|
||||
This command executes the ${taskName} task from the BMAD ${task.module.toUpperCase()} module.
|
||||
|
||||
## Module
|
||||
|
||||
Part of the BMAD ${task.module.toUpperCase()} module.
|
||||
`;
|
||||
|
||||
return commandContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup iFlow configuration
|
||||
*/
|
||||
async cleanup(projectDir) {
|
||||
const fs = require('fs-extra');
|
||||
const bmadCommandsDir = path.join(projectDir, this.configDir, this.commandsDir, 'bmad');
|
||||
|
||||
if (await fs.pathExists(bmadCommandsDir)) {
|
||||
await fs.remove(bmadCommandsDir);
|
||||
console.log(chalk.dim(`Removed BMAD commands from iFlow CLI`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { IFlowSetup };
|
||||
171
tools/cli/installers/lib/ide/kilo.js
Normal file
171
tools/cli/installers/lib/ide/kilo.js
Normal file
@@ -0,0 +1,171 @@
|
||||
const path = require('node:path');
|
||||
const { BaseIdeSetup } = require('./_base-ide');
|
||||
const chalk = require('chalk');
|
||||
|
||||
/**
|
||||
* KiloCode IDE setup handler
|
||||
* Creates custom modes in .kilocodemodes file (similar to Roo)
|
||||
*/
|
||||
class KiloSetup extends BaseIdeSetup {
|
||||
constructor() {
|
||||
super('kilo', 'Kilo Code');
|
||||
this.configFile = '.kilocodemodes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup KiloCode IDE configuration
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} options - Setup options
|
||||
*/
|
||||
async setup(projectDir, bmadDir, options = {}) {
|
||||
console.log(chalk.cyan(`Setting up ${this.name}...`));
|
||||
|
||||
// Check for existing .kilocodemodes file
|
||||
const kiloModesPath = path.join(projectDir, this.configFile);
|
||||
let existingModes = [];
|
||||
let existingContent = '';
|
||||
|
||||
if (await this.pathExists(kiloModesPath)) {
|
||||
existingContent = await this.readFile(kiloModesPath);
|
||||
// Parse existing modes
|
||||
const modeMatches = existingContent.matchAll(/- slug: ([\w-]+)/g);
|
||||
for (const match of modeMatches) {
|
||||
existingModes.push(match[1]);
|
||||
}
|
||||
console.log(chalk.yellow(`Found existing .kilocodemodes file with ${existingModes.length} modes`));
|
||||
}
|
||||
|
||||
// Get agents
|
||||
const agents = await this.getAgents(bmadDir);
|
||||
|
||||
// Create modes content
|
||||
let newModesContent = '';
|
||||
let addedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const agent of agents) {
|
||||
const slug = `bmad-${agent.module}-${agent.name}`;
|
||||
|
||||
// Skip if already exists
|
||||
if (existingModes.includes(slug)) {
|
||||
console.log(chalk.dim(` Skipping ${slug} - already exists`));
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = await this.readFile(agent.path);
|
||||
const modeEntry = this.createModeEntry(agent, content, projectDir);
|
||||
|
||||
newModesContent += modeEntry;
|
||||
addedCount++;
|
||||
}
|
||||
|
||||
// Build final content
|
||||
let finalContent = '';
|
||||
if (existingContent) {
|
||||
finalContent = existingContent.trim() + '\n' + newModesContent;
|
||||
} else {
|
||||
finalContent = 'customModes:\n' + newModesContent;
|
||||
}
|
||||
|
||||
// Write .kilocodemodes file
|
||||
await this.writeFile(kiloModesPath, finalContent);
|
||||
|
||||
console.log(chalk.green(`✓ ${this.name} configured:`));
|
||||
console.log(chalk.dim(` - ${addedCount} modes added`));
|
||||
if (skippedCount > 0) {
|
||||
console.log(chalk.dim(` - ${skippedCount} modes skipped (already exist)`));
|
||||
}
|
||||
console.log(chalk.dim(` - Configuration file: ${this.configFile}`));
|
||||
console.log(chalk.dim('\n Modes will be available when you open this project in KiloCode'));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
modes: addedCount,
|
||||
skipped: skippedCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mode entry for an agent
|
||||
*/
|
||||
createModeEntry(agent, content, projectDir) {
|
||||
// Extract metadata
|
||||
const titleMatch = content.match(/title="([^"]+)"/);
|
||||
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
|
||||
|
||||
const iconMatch = content.match(/icon="([^"]+)"/);
|
||||
const icon = iconMatch ? iconMatch[1] : '🤖';
|
||||
|
||||
const whenToUseMatch = content.match(/whenToUse="([^"]+)"/);
|
||||
const whenToUse = whenToUseMatch ? whenToUseMatch[1] : `Use for ${title} tasks`;
|
||||
|
||||
const roleDefinitionMatch = content.match(/roleDefinition="([^"]+)"/);
|
||||
const roleDefinition = roleDefinitionMatch
|
||||
? roleDefinitionMatch[1]
|
||||
: `You are a ${title} specializing in ${title.toLowerCase()} tasks.`;
|
||||
|
||||
// Get relative path
|
||||
const relativePath = path.relative(projectDir, agent.path).replaceAll('\\', '/');
|
||||
|
||||
// Build mode entry (KiloCode uses same schema as Roo)
|
||||
const slug = `bmad-${agent.module}-${agent.name}`;
|
||||
let modeEntry = ` - slug: ${slug}\n`;
|
||||
modeEntry += ` name: '${icon} ${title}'\n`;
|
||||
modeEntry += ` roleDefinition: ${roleDefinition}\n`;
|
||||
modeEntry += ` whenToUse: ${whenToUse}\n`;
|
||||
modeEntry += ` customInstructions: CRITICAL Read the full YAML from ${relativePath} start activation to alter your state of being follow startup section instructions stay in this being until told to exit this mode\n`;
|
||||
modeEntry += ` groups:\n`;
|
||||
modeEntry += ` - read\n`;
|
||||
modeEntry += ` - edit\n`;
|
||||
|
||||
return modeEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format name as title
|
||||
*/
|
||||
formatTitle(name) {
|
||||
return name
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup KiloCode configuration
|
||||
*/
|
||||
async cleanup(projectDir) {
|
||||
const fs = require('fs-extra');
|
||||
const kiloModesPath = path.join(projectDir, this.configFile);
|
||||
|
||||
if (await fs.pathExists(kiloModesPath)) {
|
||||
const content = await fs.readFile(kiloModesPath, 'utf8');
|
||||
|
||||
// Remove BMAD modes only
|
||||
const lines = content.split('\n');
|
||||
const filteredLines = [];
|
||||
let skipMode = false;
|
||||
let removedCount = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (/^\s*- slug: bmad-/.test(line)) {
|
||||
skipMode = true;
|
||||
removedCount++;
|
||||
} else if (skipMode && /^\s*- slug: /.test(line)) {
|
||||
skipMode = false;
|
||||
}
|
||||
|
||||
if (!skipMode) {
|
||||
filteredLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(kiloModesPath, filteredLines.join('\n'));
|
||||
console.log(chalk.dim(`Removed ${removedCount} BMAD modes from .kilocodemodes`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { KiloSetup };
|
||||
203
tools/cli/installers/lib/ide/manager.js
Normal file
203
tools/cli/installers/lib/ide/manager.js
Normal file
@@ -0,0 +1,203 @@
|
||||
const fs = require('fs-extra');
|
||||
const path = require('node:path');
|
||||
const chalk = require('chalk');
|
||||
|
||||
/**
|
||||
* IDE Manager - handles IDE-specific setup
|
||||
* Dynamically discovers and loads IDE handlers
|
||||
*/
|
||||
class IdeManager {
|
||||
constructor() {
|
||||
this.handlers = new Map();
|
||||
this.loadHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically load all IDE handlers from directory
|
||||
*/
|
||||
loadHandlers() {
|
||||
const ideDir = __dirname;
|
||||
|
||||
try {
|
||||
// Get all JS files in the IDE directory
|
||||
const files = fs.readdirSync(ideDir).filter((file) => {
|
||||
// Skip base class, manager, utility files (starting with _), and helper modules
|
||||
return file.endsWith('.js') && !file.startsWith('_') && file !== 'manager.js' && file !== 'workflow-command-generator.js';
|
||||
});
|
||||
|
||||
// Sort alphabetically for consistent ordering
|
||||
files.sort();
|
||||
|
||||
for (const file of files) {
|
||||
const moduleName = path.basename(file, '.js');
|
||||
|
||||
try {
|
||||
const modulePath = path.join(ideDir, file);
|
||||
const HandlerModule = require(modulePath);
|
||||
|
||||
// Get the first exported class (handles various export styles)
|
||||
const HandlerClass = HandlerModule.default || HandlerModule[Object.keys(HandlerModule)[0]];
|
||||
|
||||
if (HandlerClass) {
|
||||
const instance = new HandlerClass();
|
||||
// Use the name property from the instance (set in constructor)
|
||||
this.handlers.set(instance.name, instance);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(` Warning: Could not load ${moduleName}: ${error.message}`));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red('Failed to load IDE handlers:'), error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available IDEs with their metadata
|
||||
* @returns {Array} Array of IDE information objects
|
||||
*/
|
||||
getAvailableIdes() {
|
||||
const ides = [];
|
||||
|
||||
for (const [key, handler] of this.handlers) {
|
||||
ides.push({
|
||||
value: key,
|
||||
name: handler.displayName || handler.name || key,
|
||||
preferred: handler.preferred || false,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: preferred first, then alphabetical
|
||||
ides.sort((a, b) => {
|
||||
if (a.preferred && !b.preferred) return -1;
|
||||
if (!a.preferred && b.preferred) return 1;
|
||||
// Ensure both names exist before comparing
|
||||
const nameA = a.name || '';
|
||||
const nameB = b.name || '';
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
return ides;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preferred IDEs
|
||||
* @returns {Array} Array of preferred IDE information
|
||||
*/
|
||||
getPreferredIdes() {
|
||||
return this.getAvailableIdes().filter((ide) => ide.preferred);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get non-preferred IDEs
|
||||
* @returns {Array} Array of non-preferred IDE information
|
||||
*/
|
||||
getOtherIdes() {
|
||||
return this.getAvailableIdes().filter((ide) => !ide.preferred);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup IDE configuration
|
||||
* @param {string} ideName - Name of the IDE
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} options - Setup options
|
||||
*/
|
||||
async setup(ideName, projectDir, bmadDir, options = {}) {
|
||||
const handler = this.handlers.get(ideName.toLowerCase());
|
||||
|
||||
if (!handler) {
|
||||
console.warn(chalk.yellow(`⚠️ IDE '${ideName}' is not yet supported`));
|
||||
console.log(chalk.dim('Supported IDEs:', [...this.handlers.keys()].join(', ')));
|
||||
return { success: false, reason: 'unsupported' };
|
||||
}
|
||||
|
||||
try {
|
||||
await handler.setup(projectDir, bmadDir, options);
|
||||
return { success: true, ide: ideName };
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Failed to setup ${ideName}:`), error.message);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup IDE configurations
|
||||
* @param {string} projectDir - Project directory
|
||||
*/
|
||||
async cleanup(projectDir) {
|
||||
const results = [];
|
||||
|
||||
for (const [name, handler] of this.handlers) {
|
||||
try {
|
||||
await handler.cleanup(projectDir);
|
||||
results.push({ ide: name, success: true });
|
||||
} catch (error) {
|
||||
results.push({ ide: name, success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of supported IDEs
|
||||
* @returns {Array} List of supported IDE names
|
||||
*/
|
||||
getSupportedIdes() {
|
||||
return [...this.handlers.keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IDE is supported
|
||||
* @param {string} ideName - Name of the IDE
|
||||
* @returns {boolean} True if IDE is supported
|
||||
*/
|
||||
isSupported(ideName) {
|
||||
return this.handlers.has(ideName.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect installed IDEs
|
||||
* @param {string} projectDir - Project directory
|
||||
* @returns {Array} List of detected IDEs
|
||||
*/
|
||||
async detectInstalledIdes(projectDir) {
|
||||
const detected = [];
|
||||
|
||||
// Check for IDE-specific directories
|
||||
const ideChecks = {
|
||||
cursor: '.cursor',
|
||||
'claude-code': '.claude',
|
||||
windsurf: '.windsurf',
|
||||
cline: '.clinerules',
|
||||
roo: '.roomodes',
|
||||
trae: '.trae',
|
||||
kilo: '.kilocodemodes',
|
||||
gemini: '.gemini',
|
||||
qwen: '.qwen',
|
||||
crush: '.crush',
|
||||
iflow: '.iflow',
|
||||
auggie: '.auggie',
|
||||
'github-copilot': '.github/chatmodes',
|
||||
vscode: '.vscode',
|
||||
idea: '.idea',
|
||||
};
|
||||
|
||||
for (const [ide, dir] of Object.entries(ideChecks)) {
|
||||
const idePath = path.join(projectDir, dir);
|
||||
if (await fs.pathExists(idePath)) {
|
||||
detected.push(ide);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for AGENTS.md (Codex)
|
||||
if (await fs.pathExists(path.join(projectDir, 'AGENTS.md'))) {
|
||||
detected.push('codex');
|
||||
}
|
||||
|
||||
return detected;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { IdeManager };
|
||||
188
tools/cli/installers/lib/ide/qwen.js
Normal file
188
tools/cli/installers/lib/ide/qwen.js
Normal file
@@ -0,0 +1,188 @@
|
||||
const path = require('node:path');
|
||||
const { BaseIdeSetup } = require('./_base-ide');
|
||||
const chalk = require('chalk');
|
||||
|
||||
/**
|
||||
* Qwen Code setup handler
|
||||
* Creates concatenated QWEN.md file in .qwen/bmad-method/ (similar to Gemini)
|
||||
*/
|
||||
class QwenSetup extends BaseIdeSetup {
|
||||
constructor() {
|
||||
super('qwen', 'Qwen Code');
|
||||
this.configDir = '.qwen';
|
||||
this.bmadDir = 'bmad-method';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Qwen Code configuration
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} options - Setup options
|
||||
*/
|
||||
async setup(projectDir, bmadDir, options = {}) {
|
||||
console.log(chalk.cyan(`Setting up ${this.name}...`));
|
||||
|
||||
// Create .qwen/bmad-method directory
|
||||
const qwenDir = path.join(projectDir, this.configDir);
|
||||
const bmadMethodDir = path.join(qwenDir, this.bmadDir);
|
||||
await this.ensureDir(bmadMethodDir);
|
||||
|
||||
// Update existing settings.json if present
|
||||
await this.updateSettings(qwenDir);
|
||||
|
||||
// Clean up old agents directory if exists
|
||||
await this.cleanupOldAgents(qwenDir);
|
||||
|
||||
// Get agents
|
||||
const agents = await this.getAgents(bmadDir);
|
||||
|
||||
// Create concatenated content for all agents
|
||||
let concatenatedContent = `# BMAD Method - Qwen Code Configuration
|
||||
|
||||
This file contains all BMAD agents configured for use with Qwen Code.
|
||||
Agents can be activated by typing \`*{agent-name}\` in your prompts.
|
||||
|
||||
---
|
||||
|
||||
`;
|
||||
|
||||
let agentCount = 0;
|
||||
for (const agent of agents) {
|
||||
const content = await this.readFile(agent.path);
|
||||
const agentSection = this.createAgentSection(agent, content, projectDir);
|
||||
|
||||
concatenatedContent += agentSection;
|
||||
concatenatedContent += '\n\n---\n\n';
|
||||
agentCount++;
|
||||
|
||||
console.log(chalk.green(` ✓ Added agent: *${agent.name}`));
|
||||
}
|
||||
|
||||
// Write QWEN.md
|
||||
const qwenMdPath = path.join(bmadMethodDir, 'QWEN.md');
|
||||
await this.writeFile(qwenMdPath, concatenatedContent);
|
||||
|
||||
console.log(chalk.green(`✓ ${this.name} configured:`));
|
||||
console.log(chalk.dim(` - ${agentCount} agents configured`));
|
||||
console.log(chalk.dim(` - Configuration file: ${path.relative(projectDir, qwenMdPath)}`));
|
||||
console.log(chalk.dim(` - Agents activated with: *{agent-name}`));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
agents: agentCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update settings.json to remove old agent references
|
||||
*/
|
||||
async updateSettings(qwenDir) {
|
||||
const fs = require('fs-extra');
|
||||
const settingsPath = path.join(qwenDir, 'settings.json');
|
||||
|
||||
if (await fs.pathExists(settingsPath)) {
|
||||
try {
|
||||
const settingsContent = await fs.readFile(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(settingsContent);
|
||||
let updated = false;
|
||||
|
||||
// Remove agent file references from contextFileName
|
||||
if (settings.contextFileName && Array.isArray(settings.contextFileName)) {
|
||||
const originalLength = settings.contextFileName.length;
|
||||
settings.contextFileName = settings.contextFileName.filter((fileName) => !fileName.startsWith('agents/'));
|
||||
|
||||
if (settings.contextFileName.length !== originalLength) {
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
||||
console.log(chalk.green(' ✓ Updated .qwen/settings.json'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(chalk.yellow(' ⚠ Could not update settings.json:'), error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old agents directory
|
||||
*/
|
||||
async cleanupOldAgents(qwenDir) {
|
||||
const fs = require('fs-extra');
|
||||
const agentsDir = path.join(qwenDir, 'agents');
|
||||
|
||||
if (await fs.pathExists(agentsDir)) {
|
||||
await fs.remove(agentsDir);
|
||||
console.log(chalk.green(' ✓ Removed old agents directory'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create agent section for concatenated file
|
||||
*/
|
||||
createAgentSection(agent, content, projectDir) {
|
||||
// Extract metadata
|
||||
const titleMatch = content.match(/title="([^"]+)"/);
|
||||
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
|
||||
|
||||
// Extract YAML content
|
||||
const yamlMatch = content.match(/```ya?ml\r?\n([\s\S]*?)```/);
|
||||
const yamlContent = yamlMatch ? yamlMatch[1] : content;
|
||||
|
||||
// Get relative path
|
||||
const relativePath = path.relative(projectDir, agent.path).replaceAll('\\', '/');
|
||||
|
||||
let section = `# ${agent.name.toUpperCase()} Agent Rule
|
||||
|
||||
This rule is triggered when the user types \`*${agent.name}\` and activates the ${title} agent persona.
|
||||
|
||||
## Agent Activation
|
||||
|
||||
CRITICAL: Read the full YAML, start activation to alter your state of being, follow startup section instructions, stay in this being until told to exit this mode:
|
||||
|
||||
\`\`\`yaml
|
||||
${yamlContent}
|
||||
\`\`\`
|
||||
|
||||
## File Reference
|
||||
|
||||
The complete agent definition is available in [${relativePath}](${relativePath}).
|
||||
|
||||
## Usage
|
||||
|
||||
When the user types \`*${agent.name}\`, activate this ${title} persona and follow all instructions defined in the YAML configuration above.
|
||||
|
||||
## Module
|
||||
|
||||
Part of the BMAD ${agent.module.toUpperCase()} module.`;
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format name as title
|
||||
*/
|
||||
formatTitle(name) {
|
||||
return name
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup Qwen configuration
|
||||
*/
|
||||
async cleanup(projectDir) {
|
||||
const fs = require('fs-extra');
|
||||
const bmadMethodDir = path.join(projectDir, this.configDir, this.bmadDir);
|
||||
|
||||
if (await fs.pathExists(bmadMethodDir)) {
|
||||
await fs.remove(bmadMethodDir);
|
||||
console.log(chalk.dim(`Removed BMAD configuration from Qwen Code`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { QwenSetup };
|
||||
288
tools/cli/installers/lib/ide/roo.js
Normal file
288
tools/cli/installers/lib/ide/roo.js
Normal file
@@ -0,0 +1,288 @@
|
||||
const path = require('node:path');
|
||||
const { BaseIdeSetup } = require('./_base-ide');
|
||||
const chalk = require('chalk');
|
||||
const inquirer = require('inquirer');
|
||||
|
||||
/**
|
||||
* Roo IDE setup handler
|
||||
* Creates custom modes in .roomodes file
|
||||
*/
|
||||
class RooSetup extends BaseIdeSetup {
|
||||
constructor() {
|
||||
super('roo', 'Roo Code');
|
||||
this.configFile = '.roomodes';
|
||||
this.defaultPermissions = {
|
||||
dev: {
|
||||
description: 'Development files',
|
||||
fileRegex: String.raw`.*\.(js|jsx|ts|tsx|py|java|cpp|c|h|cs|go|rs|php|rb|swift)$`,
|
||||
},
|
||||
config: {
|
||||
description: 'Configuration files',
|
||||
fileRegex: String.raw`.*\.(json|yaml|yml|toml|xml|ini|env|config)$`,
|
||||
},
|
||||
docs: {
|
||||
description: 'Documentation files',
|
||||
fileRegex: String.raw`.*\.(md|mdx|rst|txt|doc|docx)$`,
|
||||
},
|
||||
styles: {
|
||||
description: 'Style and design files',
|
||||
fileRegex: String.raw`.*\.(css|scss|sass|less|stylus)$`,
|
||||
},
|
||||
all: {
|
||||
description: 'All files',
|
||||
fileRegex: '.*',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect configuration choices before installation
|
||||
* @param {Object} options - Configuration options
|
||||
* @returns {Object} Collected configuration
|
||||
*/
|
||||
async collectConfiguration(options = {}) {
|
||||
const response = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'permissions',
|
||||
message: 'Select default file edit permissions for BMAD agents:',
|
||||
choices: [
|
||||
{ name: 'Development files only (js, ts, py, etc.)', value: 'dev' },
|
||||
{ name: 'Configuration files only (json, yaml, xml, etc.)', value: 'config' },
|
||||
{ name: 'Documentation files only (md, txt, doc, etc.)', value: 'docs' },
|
||||
{ name: 'All files (unrestricted access)', value: 'all' },
|
||||
{ name: 'Custom per agent (will be configured individually)', value: 'custom' },
|
||||
],
|
||||
default: 'dev',
|
||||
},
|
||||
]);
|
||||
|
||||
return { permissions: response.permissions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Roo IDE configuration
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} options - Setup options
|
||||
*/
|
||||
async setup(projectDir, bmadDir, options = {}) {
|
||||
console.log(chalk.cyan(`Setting up ${this.name}...`));
|
||||
|
||||
// Check for existing .roomodes file
|
||||
const roomodesPath = path.join(projectDir, this.configFile);
|
||||
let existingModes = [];
|
||||
let existingContent = '';
|
||||
|
||||
if (await this.pathExists(roomodesPath)) {
|
||||
existingContent = await this.readFile(roomodesPath);
|
||||
// Parse existing modes to avoid duplicates
|
||||
const modeMatches = existingContent.matchAll(/- slug: ([\w-]+)/g);
|
||||
for (const match of modeMatches) {
|
||||
existingModes.push(match[1]);
|
||||
}
|
||||
console.log(chalk.yellow(`Found existing .roomodes file with ${existingModes.length} modes`));
|
||||
}
|
||||
|
||||
// Get agents
|
||||
const agents = await this.getAgents(bmadDir);
|
||||
|
||||
// Use pre-collected configuration if available
|
||||
const config = options.preCollectedConfig || {};
|
||||
let permissionChoice = config.permissions || options.permissions || 'dev';
|
||||
|
||||
// Create modes content
|
||||
let newModesContent = '';
|
||||
let addedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const agent of agents) {
|
||||
const slug = `bmad-${agent.module}-${agent.name}`;
|
||||
|
||||
// Skip if already exists
|
||||
if (existingModes.includes(slug)) {
|
||||
console.log(chalk.dim(` Skipping ${slug} - already exists`));
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = await this.readFile(agent.path);
|
||||
const modeEntry = this.createModeEntry(agent, content, permissionChoice, projectDir);
|
||||
|
||||
newModesContent += modeEntry;
|
||||
addedCount++;
|
||||
console.log(chalk.green(` ✓ Added mode: ${slug}`));
|
||||
}
|
||||
|
||||
// Build final content
|
||||
let finalContent = '';
|
||||
if (existingContent) {
|
||||
// Append to existing content
|
||||
finalContent = existingContent.trim() + '\n' + newModesContent;
|
||||
} else {
|
||||
// Create new .roomodes file
|
||||
finalContent = 'customModes:\n' + newModesContent;
|
||||
}
|
||||
|
||||
// Write .roomodes file
|
||||
await this.writeFile(roomodesPath, finalContent);
|
||||
|
||||
console.log(chalk.green(`✓ ${this.name} configured:`));
|
||||
console.log(chalk.dim(` - ${addedCount} modes added`));
|
||||
if (skippedCount > 0) {
|
||||
console.log(chalk.dim(` - ${skippedCount} modes skipped (already exist)`));
|
||||
}
|
||||
console.log(chalk.dim(` - Configuration file: ${this.configFile}`));
|
||||
console.log(chalk.dim(` - Permission level: ${permissionChoice}`));
|
||||
console.log(chalk.dim('\n Modes will be available when you open this project in Roo Code'));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
modes: addedCount,
|
||||
skipped: skippedCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask user about permission configuration
|
||||
*/
|
||||
async askPermissions() {
|
||||
const response = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'permissions',
|
||||
message: 'Select default file edit permissions for BMAD agents:',
|
||||
choices: [
|
||||
{ name: 'Development files only (js, ts, py, etc.)', value: 'dev' },
|
||||
{ name: 'Configuration files only (json, yaml, xml, etc.)', value: 'config' },
|
||||
{ name: 'Documentation files only (md, txt, doc, etc.)', value: 'docs' },
|
||||
{ name: 'All files (unrestricted access)', value: 'all' },
|
||||
{ name: 'Custom per agent (will be configured individually)', value: 'custom' },
|
||||
],
|
||||
default: 'dev',
|
||||
},
|
||||
]);
|
||||
|
||||
return response.permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mode entry for an agent
|
||||
*/
|
||||
createModeEntry(agent, content, permissionChoice, projectDir) {
|
||||
// Extract metadata from agent content
|
||||
const titleMatch = content.match(/title="([^"]+)"/);
|
||||
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
|
||||
|
||||
const iconMatch = content.match(/icon="([^"]+)"/);
|
||||
const icon = iconMatch ? iconMatch[1] : '🤖';
|
||||
|
||||
const whenToUseMatch = content.match(/whenToUse="([^"]+)"/);
|
||||
const whenToUse = whenToUseMatch ? whenToUseMatch[1] : `Use for ${title} tasks`;
|
||||
|
||||
const roleDefinitionMatch = content.match(/roleDefinition="([^"]+)"/);
|
||||
const roleDefinition = roleDefinitionMatch
|
||||
? roleDefinitionMatch[1]
|
||||
: `You are a ${title} specializing in ${title.toLowerCase()} tasks and responsibilities.`;
|
||||
|
||||
// Get relative path
|
||||
const relativePath = path.relative(projectDir, agent.path).replaceAll('\\', '/');
|
||||
|
||||
// Determine permissions
|
||||
const permissions = this.getPermissionsForAgent(agent, permissionChoice);
|
||||
|
||||
// Build mode entry
|
||||
const slug = `bmad-${agent.module}-${agent.name}`;
|
||||
let modeEntry = ` - slug: ${slug}\n`;
|
||||
modeEntry += ` name: '${icon} ${title}'\n`;
|
||||
|
||||
if (permissions && permissions.description) {
|
||||
modeEntry += ` description: '${permissions.description}'\n`;
|
||||
}
|
||||
|
||||
modeEntry += ` roleDefinition: ${roleDefinition}\n`;
|
||||
modeEntry += ` whenToUse: ${whenToUse}\n`;
|
||||
modeEntry += ` customInstructions: CRITICAL Read the full YAML from ${relativePath} start activation to alter your state of being follow startup section instructions stay in this being until told to exit this mode\n`;
|
||||
modeEntry += ` groups:\n`;
|
||||
modeEntry += ` - read\n`;
|
||||
|
||||
if (permissions && permissions.fileRegex) {
|
||||
modeEntry += ` - - edit\n`;
|
||||
modeEntry += ` - fileRegex: ${permissions.fileRegex}\n`;
|
||||
modeEntry += ` description: ${permissions.description}\n`;
|
||||
} else {
|
||||
modeEntry += ` - edit\n`;
|
||||
}
|
||||
|
||||
return modeEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permissions configuration for an agent
|
||||
*/
|
||||
getPermissionsForAgent(agent, permissionChoice) {
|
||||
if (permissionChoice === 'custom') {
|
||||
// Custom logic based on agent name/module
|
||||
if (agent.name.includes('dev') || agent.name.includes('code')) {
|
||||
return this.defaultPermissions.dev;
|
||||
} else if (agent.name.includes('doc') || agent.name.includes('write')) {
|
||||
return this.defaultPermissions.docs;
|
||||
} else if (agent.name.includes('config') || agent.name.includes('setup')) {
|
||||
return this.defaultPermissions.config;
|
||||
} else if (agent.name.includes('style') || agent.name.includes('css')) {
|
||||
return this.defaultPermissions.styles;
|
||||
}
|
||||
// Default to all for custom agents
|
||||
return this.defaultPermissions.all;
|
||||
}
|
||||
|
||||
return this.defaultPermissions[permissionChoice] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format name as title
|
||||
*/
|
||||
formatTitle(name) {
|
||||
return name
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup Roo configuration
|
||||
*/
|
||||
async cleanup(projectDir) {
|
||||
const fs = require('fs-extra');
|
||||
const roomodesPath = path.join(projectDir, this.configFile);
|
||||
|
||||
if (await fs.pathExists(roomodesPath)) {
|
||||
const content = await fs.readFile(roomodesPath, 'utf8');
|
||||
|
||||
// Remove BMAD modes only
|
||||
const lines = content.split('\n');
|
||||
const filteredLines = [];
|
||||
let skipMode = false;
|
||||
let removedCount = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (/^\s*- slug: bmad-/.test(line)) {
|
||||
skipMode = true;
|
||||
removedCount++;
|
||||
} else if (skipMode && /^\s*- slug: /.test(line)) {
|
||||
skipMode = false;
|
||||
}
|
||||
|
||||
if (!skipMode) {
|
||||
filteredLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Write back filtered content
|
||||
await fs.writeFile(roomodesPath, filteredLines.join('\n'));
|
||||
console.log(chalk.dim(`Removed ${removedCount} BMAD modes from .roomodes`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { RooSetup };
|
||||
182
tools/cli/installers/lib/ide/trae.js
Normal file
182
tools/cli/installers/lib/ide/trae.js
Normal file
@@ -0,0 +1,182 @@
|
||||
const path = require('node:path');
|
||||
const { BaseIdeSetup } = require('./_base-ide');
|
||||
const chalk = require('chalk');
|
||||
|
||||
/**
|
||||
* Trae IDE setup handler
|
||||
*/
|
||||
class TraeSetup extends BaseIdeSetup {
|
||||
constructor() {
|
||||
super('trae', 'Trae');
|
||||
this.configDir = '.trae';
|
||||
this.rulesDir = 'rules';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Trae IDE configuration
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} options - Setup options
|
||||
*/
|
||||
async setup(projectDir, bmadDir, options = {}) {
|
||||
console.log(chalk.cyan(`Setting up ${this.name}...`));
|
||||
|
||||
// Create .trae/rules directory
|
||||
const traeDir = path.join(projectDir, this.configDir);
|
||||
const rulesDir = path.join(traeDir, this.rulesDir);
|
||||
|
||||
await this.ensureDir(rulesDir);
|
||||
|
||||
// Get agents and tasks
|
||||
const agents = await this.getAgents(bmadDir);
|
||||
const tasks = await this.getTasks(bmadDir);
|
||||
|
||||
// Process agents as rules
|
||||
let ruleCount = 0;
|
||||
for (const agent of agents) {
|
||||
const content = await this.readFile(agent.path);
|
||||
const processedContent = this.createAgentRule(agent, content, bmadDir, projectDir);
|
||||
|
||||
const targetPath = path.join(rulesDir, `${agent.module}-${agent.name}.md`);
|
||||
await this.writeFile(targetPath, processedContent);
|
||||
ruleCount++;
|
||||
}
|
||||
|
||||
// Process tasks as rules
|
||||
for (const task of tasks) {
|
||||
const content = await this.readFile(task.path);
|
||||
const processedContent = this.createTaskRule(task, content);
|
||||
|
||||
const targetPath = path.join(rulesDir, `task-${task.module}-${task.name}.md`);
|
||||
await this.writeFile(targetPath, processedContent);
|
||||
ruleCount++;
|
||||
}
|
||||
|
||||
console.log(chalk.green(`✓ ${this.name} configured:`));
|
||||
console.log(chalk.dim(` - ${ruleCount} rules created`));
|
||||
console.log(chalk.dim(` - Rules directory: ${path.relative(projectDir, rulesDir)}`));
|
||||
console.log(chalk.dim(` - Agents can be activated with @{agent-name}`));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
rules: ruleCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rule content for an agent
|
||||
*/
|
||||
createAgentRule(agent, content, bmadDir, projectDir) {
|
||||
// Extract metadata from agent content
|
||||
const titleMatch = content.match(/title="([^"]+)"/);
|
||||
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
|
||||
|
||||
const iconMatch = content.match(/icon="([^"]+)"/);
|
||||
const icon = iconMatch ? iconMatch[1] : '🤖';
|
||||
|
||||
// Extract YAML content if available
|
||||
const yamlMatch = content.match(/```ya?ml\r?\n([\s\S]*?)```/);
|
||||
const yamlContent = yamlMatch ? yamlMatch[1] : content;
|
||||
|
||||
// Calculate relative path for reference
|
||||
const relativePath = path.relative(projectDir, agent.path).replaceAll('\\', '/');
|
||||
|
||||
let ruleContent = `# ${title} Agent Rule
|
||||
|
||||
This rule is triggered when the user types \`@${agent.name}\` and activates the ${title} agent persona.
|
||||
|
||||
## Agent Activation
|
||||
|
||||
CRITICAL: Read the full YAML, start activation to alter your state of being, follow startup section instructions, stay in this being until told to exit this mode:
|
||||
|
||||
\`\`\`yaml
|
||||
${yamlContent}
|
||||
\`\`\`
|
||||
|
||||
## File Reference
|
||||
|
||||
The complete agent definition is available in [${relativePath}](${relativePath}).
|
||||
|
||||
## Usage
|
||||
|
||||
When the user types \`@${agent.name}\`, activate this ${title} persona and follow all instructions defined in the YAML configuration above.
|
||||
|
||||
## Module
|
||||
|
||||
Part of the BMAD ${agent.module.toUpperCase()} module.
|
||||
`;
|
||||
|
||||
return ruleContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rule content for a task
|
||||
*/
|
||||
createTaskRule(task, content) {
|
||||
// Extract task name from content
|
||||
const nameMatch = content.match(/<name>([^<]+)<\/name>/);
|
||||
const taskName = nameMatch ? nameMatch[1] : this.formatTitle(task.name);
|
||||
|
||||
let ruleContent = `# ${taskName} Task Rule
|
||||
|
||||
This rule defines the ${taskName} task workflow.
|
||||
|
||||
## Task Definition
|
||||
|
||||
When this task is triggered, execute the following workflow:
|
||||
|
||||
${content}
|
||||
|
||||
## Usage
|
||||
|
||||
Reference this task with \`@task-${task.name}\` to execute the defined workflow.
|
||||
|
||||
## Module
|
||||
|
||||
Part of the BMAD ${task.module.toUpperCase()} module.
|
||||
`;
|
||||
|
||||
return ruleContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format agent/task name as title
|
||||
*/
|
||||
formatTitle(name) {
|
||||
return name
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup Trae configuration
|
||||
*/
|
||||
async cleanup(projectDir) {
|
||||
const fs = require('fs-extra');
|
||||
const rulesPath = path.join(projectDir, this.configDir, this.rulesDir);
|
||||
|
||||
if (await fs.pathExists(rulesPath)) {
|
||||
// Only remove BMAD rules
|
||||
const files = await fs.readdir(rulesPath);
|
||||
let removed = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
const filePath = path.join(rulesPath, file);
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
// Check if it's a BMAD rule
|
||||
if (content.includes('BMAD') && content.includes('module')) {
|
||||
await fs.remove(filePath);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.dim(`Removed ${removed} BMAD rules from Trae`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { TraeSetup };
|
||||
149
tools/cli/installers/lib/ide/windsurf.js
Normal file
149
tools/cli/installers/lib/ide/windsurf.js
Normal file
@@ -0,0 +1,149 @@
|
||||
const path = require('node:path');
|
||||
const { BaseIdeSetup } = require('./_base-ide');
|
||||
const chalk = require('chalk');
|
||||
|
||||
/**
|
||||
* Windsurf IDE setup handler
|
||||
*/
|
||||
class WindsurfSetup extends BaseIdeSetup {
|
||||
constructor() {
|
||||
super('windsurf', 'Windsurf', true); // preferred IDE
|
||||
this.configDir = '.windsurf';
|
||||
this.workflowsDir = 'workflows';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Windsurf IDE configuration
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} options - Setup options
|
||||
*/
|
||||
async setup(projectDir, bmadDir, options = {}) {
|
||||
console.log(chalk.cyan(`Setting up ${this.name}...`));
|
||||
|
||||
// Create .windsurf/workflows directory structure
|
||||
const windsurfDir = path.join(projectDir, this.configDir);
|
||||
const workflowsDir = path.join(windsurfDir, this.workflowsDir);
|
||||
|
||||
await this.ensureDir(workflowsDir);
|
||||
|
||||
// Get agents and tasks
|
||||
const agents = await this.getAgents(bmadDir);
|
||||
const tasks = await this.getTasks(bmadDir);
|
||||
|
||||
// Create directories for each module
|
||||
const modules = new Set();
|
||||
for (const item of [...agents, ...tasks]) modules.add(item.module);
|
||||
|
||||
for (const module of modules) {
|
||||
await this.ensureDir(path.join(workflowsDir, module));
|
||||
await this.ensureDir(path.join(workflowsDir, module, 'agents'));
|
||||
await this.ensureDir(path.join(workflowsDir, module, 'tasks'));
|
||||
}
|
||||
|
||||
// Process agents as workflows with organized structure
|
||||
let agentCount = 0;
|
||||
for (const agent of agents) {
|
||||
const content = await this.readFile(agent.path);
|
||||
const processedContent = this.createWorkflowContent(agent, content);
|
||||
|
||||
// Organized path: module/agents/agent-name.md
|
||||
const targetPath = path.join(workflowsDir, agent.module, 'agents', `${agent.name}.md`);
|
||||
await this.writeFile(targetPath, processedContent);
|
||||
agentCount++;
|
||||
}
|
||||
|
||||
// Process tasks as workflows with organized structure
|
||||
let taskCount = 0;
|
||||
for (const task of tasks) {
|
||||
const content = await this.readFile(task.path);
|
||||
const processedContent = this.createTaskWorkflowContent(task, content);
|
||||
|
||||
// Organized path: module/tasks/task-name.md
|
||||
const targetPath = path.join(workflowsDir, task.module, 'tasks', `${task.name}.md`);
|
||||
await this.writeFile(targetPath, processedContent);
|
||||
taskCount++;
|
||||
}
|
||||
|
||||
console.log(chalk.green(`✓ ${this.name} configured:`));
|
||||
console.log(chalk.dim(` - ${agentCount} agents installed`));
|
||||
console.log(chalk.dim(` - ${taskCount} tasks installed`));
|
||||
console.log(chalk.dim(` - Organized in modules: ${[...modules].join(', ')}`));
|
||||
console.log(chalk.dim(` - Workflows directory: ${path.relative(projectDir, workflowsDir)}`));
|
||||
|
||||
// Provide additional configuration hints
|
||||
if (options.showHints !== false) {
|
||||
console.log(chalk.dim('\n Windsurf workflow settings:'));
|
||||
console.log(chalk.dim(' - auto_execution_mode: 3 (recommended for agents)'));
|
||||
console.log(chalk.dim(' - auto_execution_mode: 2 (recommended for tasks)'));
|
||||
console.log(chalk.dim(' - Workflows can be triggered via the Windsurf menu'));
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
agents: agentCount,
|
||||
tasks: taskCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create workflow content for an agent
|
||||
*/
|
||||
createWorkflowContent(agent, content) {
|
||||
// Create simple Windsurf frontmatter matching original format
|
||||
let workflowContent = `---
|
||||
description: ${agent.name}
|
||||
auto_execution_mode: 3
|
||||
---
|
||||
|
||||
${content}`;
|
||||
|
||||
return workflowContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create workflow content for a task
|
||||
*/
|
||||
createTaskWorkflowContent(task, content) {
|
||||
// Create simple Windsurf frontmatter matching original format
|
||||
let workflowContent = `---
|
||||
description: task-${task.name}
|
||||
auto_execution_mode: 2
|
||||
---
|
||||
|
||||
${content}`;
|
||||
|
||||
return workflowContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup Windsurf configuration
|
||||
*/
|
||||
async cleanup(projectDir) {
|
||||
const fs = require('fs-extra');
|
||||
const windsurfPath = path.join(projectDir, this.configDir, this.workflowsDir);
|
||||
|
||||
if (await fs.pathExists(windsurfPath)) {
|
||||
// Only remove BMAD workflows, not all workflows
|
||||
const files = await fs.readdir(windsurfPath);
|
||||
let removed = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.includes('-') && file.endsWith('.md')) {
|
||||
const filePath = path.join(windsurfPath, file);
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
// Check if it's a BMAD workflow
|
||||
if (content.includes('tags: [bmad')) {
|
||||
await fs.remove(filePath);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.dim(`Removed ${removed} BMAD workflows from Windsurf`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { WindsurfSetup };
|
||||
162
tools/cli/installers/lib/ide/workflow-command-generator.js
Normal file
162
tools/cli/installers/lib/ide/workflow-command-generator.js
Normal file
@@ -0,0 +1,162 @@
|
||||
const path = require('node:path');
|
||||
const fs = require('fs-extra');
|
||||
const csv = require('csv-parse/sync');
|
||||
const chalk = require('chalk');
|
||||
|
||||
/**
|
||||
* Generates Claude Code command files for each workflow in the manifest
|
||||
*/
|
||||
class WorkflowCommandGenerator {
|
||||
constructor() {
|
||||
this.templatePath = path.join(__dirname, 'workflow-command-template.md');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate workflow commands from the manifest CSV
|
||||
* @param {string} projectDir - Project directory
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
*/
|
||||
async generateWorkflowCommands(projectDir, bmadDir) {
|
||||
const manifestPath = path.join(bmadDir, '_cfg', 'workflow-manifest.csv');
|
||||
|
||||
if (!(await fs.pathExists(manifestPath))) {
|
||||
console.log(chalk.yellow('Workflow manifest not found. Skipping command generation.'));
|
||||
return { generated: 0 };
|
||||
}
|
||||
|
||||
// Read and parse the CSV manifest
|
||||
const csvContent = await fs.readFile(manifestPath, 'utf8');
|
||||
const workflows = csv.parse(csvContent, {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
});
|
||||
|
||||
// Base commands directory
|
||||
const baseCommandsDir = path.join(projectDir, '.claude', 'commands', 'bmad');
|
||||
|
||||
let generatedCount = 0;
|
||||
|
||||
// Generate a command file for each workflow, organized by module
|
||||
for (const workflow of workflows) {
|
||||
// Create module directory structure: commands/bmad/{module}/workflows/
|
||||
const moduleWorkflowsDir = path.join(baseCommandsDir, workflow.module, 'workflows');
|
||||
await fs.ensureDir(moduleWorkflowsDir);
|
||||
|
||||
// Use just the workflow name as filename (no prefix)
|
||||
const commandContent = await this.generateCommandContent(workflow, bmadDir);
|
||||
const commandPath = path.join(moduleWorkflowsDir, `${workflow.name}.md`);
|
||||
|
||||
await fs.writeFile(commandPath, commandContent);
|
||||
generatedCount++;
|
||||
}
|
||||
|
||||
// Also create a workflow launcher README in each module
|
||||
await this.createModuleWorkflowLaunchers(baseCommandsDir, workflows, bmadDir);
|
||||
|
||||
return { generated: generatedCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate command content for a workflow
|
||||
*/
|
||||
async generateCommandContent(workflow, bmadDir) {
|
||||
// Load the template
|
||||
const template = await fs.readFile(this.templatePath, 'utf8');
|
||||
|
||||
// Convert source path to installed path
|
||||
// From: /Users/.../src/modules/bmm/workflows/.../workflow.yaml
|
||||
// To: {project-root}/bmad/bmm/workflows/.../workflow.yaml
|
||||
let workflowPath = workflow.path;
|
||||
|
||||
// Extract the relative path from source
|
||||
if (workflowPath.includes('/src/modules/')) {
|
||||
const match = workflowPath.match(/\/src\/modules\/(.+)/);
|
||||
if (match) {
|
||||
workflowPath = `{project-root}/bmad/${match[1]}`;
|
||||
}
|
||||
} else if (workflowPath.includes('/src/core/')) {
|
||||
const match = workflowPath.match(/\/src\/core\/(.+)/);
|
||||
if (match) {
|
||||
workflowPath = `{project-root}/bmad/core/${match[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Replace template variables
|
||||
return template
|
||||
.replaceAll('{{name}}', workflow.name)
|
||||
.replaceAll('{{module}}', workflow.module)
|
||||
.replaceAll('{{description}}', workflow.description)
|
||||
.replaceAll('{{workflow_path}}', workflowPath)
|
||||
.replaceAll('{{interactive}}', workflow.interactive)
|
||||
.replaceAll('{{author}}', workflow.author || 'BMAD');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create workflow launcher files for each module
|
||||
*/
|
||||
async createModuleWorkflowLaunchers(baseCommandsDir, workflows, bmadDir) {
|
||||
// Group workflows by module
|
||||
const workflowsByModule = {};
|
||||
for (const workflow of workflows) {
|
||||
if (!workflowsByModule[workflow.module]) {
|
||||
workflowsByModule[workflow.module] = [];
|
||||
}
|
||||
|
||||
// Convert path for display
|
||||
let workflowPath = workflow.path;
|
||||
if (workflowPath.includes('/src/modules/')) {
|
||||
const match = workflowPath.match(/\/src\/modules\/(.+)/);
|
||||
if (match) {
|
||||
workflowPath = `{project-root}/bmad/${match[1]}`;
|
||||
}
|
||||
} else if (workflowPath.includes('/src/core/')) {
|
||||
const match = workflowPath.match(/\/src\/core\/(.+)/);
|
||||
if (match) {
|
||||
workflowPath = `{project-root}/bmad/core/${match[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
workflowsByModule[workflow.module].push({
|
||||
...workflow,
|
||||
displayPath: workflowPath,
|
||||
});
|
||||
}
|
||||
|
||||
// Create a launcher file for each module
|
||||
for (const [module, moduleWorkflows] of Object.entries(workflowsByModule)) {
|
||||
let content = `# ${module.toUpperCase()} Workflows
|
||||
|
||||
## Available Workflows in ${module}
|
||||
|
||||
`;
|
||||
|
||||
for (const workflow of moduleWorkflows) {
|
||||
content += `**${workflow.name}**\n`;
|
||||
content += `- Path: \`${workflow.displayPath}\`\n`;
|
||||
content += `- ${workflow.description}\n\n`;
|
||||
}
|
||||
|
||||
content += `
|
||||
## Execution
|
||||
|
||||
When running any workflow:
|
||||
1. LOAD {project-root}/bmad/core/tasks/workflow.md
|
||||
2. Pass the workflow path as 'workflow-config' parameter
|
||||
3. Follow workflow.md instructions EXACTLY
|
||||
4. Save outputs after EACH section
|
||||
|
||||
## Modes
|
||||
- Normal: Full interaction
|
||||
- #yolo: Skip optional steps
|
||||
`;
|
||||
|
||||
// Write module-specific launcher
|
||||
const moduleWorkflowsDir = path.join(baseCommandsDir, module, 'workflows');
|
||||
await fs.ensureDir(moduleWorkflowsDir);
|
||||
const launcherPath = path.join(moduleWorkflowsDir, 'README.md');
|
||||
await fs.writeFile(launcherPath, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { WorkflowCommandGenerator };
|
||||
11
tools/cli/installers/lib/ide/workflow-command-template.md
Normal file
11
tools/cli/installers/lib/ide/workflow-command-template.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# {{name}}
|
||||
|
||||
IT IS CRITICAL THAT YOU FOLLOW THESE STEPS - while staying in character as the current agent persona you may have loaded:
|
||||
|
||||
<steps CRITICAL="TRUE">
|
||||
1. Always LOAD the FULL {project-root}/bmad/core/tasks/workflow.md
|
||||
2. READ its entire contents - this is the CORE OS for EXECUTING the specific workflow-config {{workflow_path}}
|
||||
3. Pass the yaml path {{workflow_path}} as 'workflow-config' parameter to the workflow.md instructions
|
||||
4. Follow workflow.md instructions EXACTLY as written
|
||||
5. Save outputs after EACH section when generating any documents from templates
|
||||
</steps>
|
||||
452
tools/cli/installers/lib/modules/manager.js
Normal file
452
tools/cli/installers/lib/modules/manager.js
Normal file
@@ -0,0 +1,452 @@
|
||||
const path = require('node:path');
|
||||
const fs = require('fs-extra');
|
||||
const yaml = require('js-yaml');
|
||||
const chalk = require('chalk');
|
||||
const { XmlHandler } = require('../../../lib/xml-handler');
|
||||
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
|
||||
|
||||
/**
|
||||
* Manages the installation, updating, and removal of BMAD modules.
|
||||
* Handles module discovery, dependency resolution, configuration processing,
|
||||
* and agent file management including XML activation block injection.
|
||||
*
|
||||
* @class ModuleManager
|
||||
* @requires fs-extra
|
||||
* @requires js-yaml
|
||||
* @requires chalk
|
||||
* @requires XmlHandler
|
||||
*
|
||||
* @example
|
||||
* const manager = new ModuleManager();
|
||||
* const modules = await manager.listAvailable();
|
||||
* await manager.install('core-module', '/path/to/bmad');
|
||||
*/
|
||||
class ModuleManager {
|
||||
constructor() {
|
||||
// Path to source modules directory
|
||||
this.modulesSourcePath = getSourcePath('modules');
|
||||
this.xmlHandler = new XmlHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available modules
|
||||
* @returns {Array} List of available modules with metadata
|
||||
*/
|
||||
async listAvailable() {
|
||||
const modules = [];
|
||||
|
||||
if (!(await fs.pathExists(this.modulesSourcePath))) {
|
||||
console.warn(chalk.yellow('Warning: src/modules directory not found'));
|
||||
return modules;
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(this.modulesSourcePath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const modulePath = path.join(this.modulesSourcePath, entry.name);
|
||||
// Check for new structure first
|
||||
const installerConfigPath = path.join(modulePath, '_module-installer', 'install-menu-config.yaml');
|
||||
// Fallback to old structure
|
||||
const configPath = path.join(modulePath, 'config.yaml');
|
||||
|
||||
const moduleInfo = {
|
||||
id: entry.name,
|
||||
path: modulePath,
|
||||
name: entry.name.toUpperCase(),
|
||||
description: 'BMAD Module',
|
||||
version: '5.0.0',
|
||||
};
|
||||
|
||||
// Try to read module config for metadata (prefer new location)
|
||||
const configToRead = (await fs.pathExists(installerConfigPath)) ? installerConfigPath : configPath;
|
||||
if (await fs.pathExists(configToRead)) {
|
||||
try {
|
||||
const configContent = await fs.readFile(configToRead, 'utf8');
|
||||
const config = yaml.load(configContent);
|
||||
|
||||
// Use the code property as the id if available
|
||||
if (config.code) {
|
||||
moduleInfo.id = config.code;
|
||||
}
|
||||
|
||||
moduleInfo.name = config.name || moduleInfo.name;
|
||||
moduleInfo.description = config.description || moduleInfo.description;
|
||||
moduleInfo.version = config.version || moduleInfo.version;
|
||||
moduleInfo.dependencies = config.dependencies || [];
|
||||
moduleInfo.defaultSelected = config.default_selected === undefined ? false : config.default_selected;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to read config for ${entry.name}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
modules.push(moduleInfo);
|
||||
}
|
||||
}
|
||||
|
||||
return modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a module
|
||||
* @param {string} moduleName - Name of the module to install
|
||||
* @param {string} bmadDir - Target bmad directory
|
||||
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
||||
* @param {Object} options - Additional installation options
|
||||
* @param {Array<string>} options.installedIDEs - Array of IDE codes that were installed
|
||||
* @param {Object} options.moduleConfig - Module configuration from config collector
|
||||
* @param {Object} options.logger - Logger instance for output
|
||||
*/
|
||||
async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) {
|
||||
const sourcePath = path.join(this.modulesSourcePath, moduleName);
|
||||
const targetPath = path.join(bmadDir, moduleName);
|
||||
|
||||
// Check if source module exists
|
||||
if (!(await fs.pathExists(sourcePath))) {
|
||||
throw new Error(`Module '${moduleName}' not found in ${this.modulesSourcePath}`);
|
||||
}
|
||||
|
||||
// Check if already installed
|
||||
if (await fs.pathExists(targetPath)) {
|
||||
console.log(chalk.yellow(`Module '${moduleName}' already installed, updating...`));
|
||||
await fs.remove(targetPath);
|
||||
}
|
||||
|
||||
// Copy module files with filtering
|
||||
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback);
|
||||
|
||||
// Process agent files to inject activation block
|
||||
await this.processAgentFiles(targetPath, moduleName);
|
||||
|
||||
// Call module-specific installer if it exists (unless explicitly skipped)
|
||||
if (!options.skipModuleInstaller) {
|
||||
await this.runModuleInstaller(moduleName, bmadDir, options);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
module: moduleName,
|
||||
path: targetPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing module
|
||||
* @param {string} moduleName - Name of the module to update
|
||||
* @param {string} bmadDir - Target bmad directory
|
||||
* @param {boolean} force - Force update (overwrite modifications)
|
||||
*/
|
||||
async update(moduleName, bmadDir, force = false) {
|
||||
const sourcePath = path.join(this.modulesSourcePath, moduleName);
|
||||
const targetPath = path.join(bmadDir, moduleName);
|
||||
|
||||
// Check if source module exists
|
||||
if (!(await fs.pathExists(sourcePath))) {
|
||||
throw new Error(`Module '${moduleName}' not found in source`);
|
||||
}
|
||||
|
||||
// Check if module is installed
|
||||
if (!(await fs.pathExists(targetPath))) {
|
||||
throw new Error(`Module '${moduleName}' is not installed`);
|
||||
}
|
||||
|
||||
if (force) {
|
||||
// Force update - remove and reinstall
|
||||
await fs.remove(targetPath);
|
||||
return await this.install(moduleName, bmadDir);
|
||||
} else {
|
||||
// Selective update - preserve user modifications
|
||||
await this.syncModule(sourcePath, targetPath);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
module: moduleName,
|
||||
path: targetPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a module
|
||||
* @param {string} moduleName - Name of the module to remove
|
||||
* @param {string} bmadDir - Target bmad directory
|
||||
*/
|
||||
async remove(moduleName, bmadDir) {
|
||||
const targetPath = path.join(bmadDir, moduleName);
|
||||
|
||||
if (!(await fs.pathExists(targetPath))) {
|
||||
throw new Error(`Module '${moduleName}' is not installed`);
|
||||
}
|
||||
|
||||
await fs.remove(targetPath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
module: moduleName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a module is installed
|
||||
* @param {string} moduleName - Name of the module
|
||||
* @param {string} bmadDir - Target bmad directory
|
||||
* @returns {boolean} True if module is installed
|
||||
*/
|
||||
async isInstalled(moduleName, bmadDir) {
|
||||
const targetPath = path.join(bmadDir, moduleName);
|
||||
return await fs.pathExists(targetPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get installed module info
|
||||
* @param {string} moduleName - Name of the module
|
||||
* @param {string} bmadDir - Target bmad directory
|
||||
* @returns {Object|null} Module info or null if not installed
|
||||
*/
|
||||
async getInstalledInfo(moduleName, bmadDir) {
|
||||
const targetPath = path.join(bmadDir, moduleName);
|
||||
|
||||
if (!(await fs.pathExists(targetPath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configPath = path.join(targetPath, 'config.yaml');
|
||||
const moduleInfo = {
|
||||
id: moduleName,
|
||||
path: targetPath,
|
||||
installed: true,
|
||||
};
|
||||
|
||||
if (await fs.pathExists(configPath)) {
|
||||
try {
|
||||
const configContent = await fs.readFile(configPath, 'utf8');
|
||||
const config = yaml.load(configContent);
|
||||
Object.assign(moduleInfo, config);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to read installed module config:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return moduleInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy module with filtering for localskip agents
|
||||
* @param {string} sourcePath - Source module path
|
||||
* @param {string} targetPath - Target module path
|
||||
*/
|
||||
async copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback = null) {
|
||||
// Get all files in source
|
||||
const sourceFiles = await this.getFileList(sourcePath);
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
// Skip sub-modules directory - these are IDE-specific and handled separately
|
||||
if (file.startsWith('sub-modules/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip _module-installer directory - it's only needed at install time
|
||||
if (file.startsWith('_module-installer/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip config.yaml templates - we'll generate clean ones with actual values
|
||||
if (file === 'config.yaml' || file.endsWith('/config.yaml')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceFile = path.join(sourcePath, file);
|
||||
const targetFile = path.join(targetPath, file);
|
||||
|
||||
// Check if this is an agent file
|
||||
if (file.startsWith('agents/') && file.endsWith('.md')) {
|
||||
// Read the file to check for localskip
|
||||
const content = await fs.readFile(sourceFile, 'utf8');
|
||||
|
||||
// Check for localskip="true" in the agent tag
|
||||
const agentMatch = content.match(/<agent[^>]*\slocalskip="true"[^>]*>/);
|
||||
if (agentMatch) {
|
||||
console.log(chalk.dim(` Skipping web-only agent: ${path.basename(file)}`));
|
||||
continue; // Skip this agent
|
||||
}
|
||||
}
|
||||
|
||||
// Copy the file
|
||||
await fs.ensureDir(path.dirname(targetFile));
|
||||
await fs.copy(sourceFile, targetFile, { overwrite: true });
|
||||
|
||||
// Track the file if callback provided
|
||||
if (fileTrackingCallback) {
|
||||
fileTrackingCallback(targetFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process agent files to inject activation block
|
||||
* @param {string} modulePath - Path to installed module
|
||||
* @param {string} moduleName - Module name
|
||||
*/
|
||||
async processAgentFiles(modulePath, moduleName) {
|
||||
const agentsPath = path.join(modulePath, 'agents');
|
||||
|
||||
// Check if agents directory exists
|
||||
if (!(await fs.pathExists(agentsPath))) {
|
||||
return; // No agents to process
|
||||
}
|
||||
|
||||
// Get all agent files
|
||||
const agentFiles = await fs.readdir(agentsPath);
|
||||
|
||||
for (const agentFile of agentFiles) {
|
||||
if (!agentFile.endsWith('.md')) continue;
|
||||
|
||||
const agentPath = path.join(agentsPath, agentFile);
|
||||
let content = await fs.readFile(agentPath, 'utf8');
|
||||
|
||||
// Check if content has agent XML and no activation block
|
||||
if (content.includes('<agent') && !content.includes('<activation')) {
|
||||
// Inject the activation block using XML handler
|
||||
content = this.xmlHandler.injectActivationSimple(content);
|
||||
await fs.writeFile(agentPath, content, 'utf8');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run module-specific installer if it exists
|
||||
* @param {string} moduleName - Name of the module
|
||||
* @param {string} bmadDir - Target bmad directory
|
||||
* @param {Object} options - Installation options
|
||||
*/
|
||||
async runModuleInstaller(moduleName, bmadDir, options = {}) {
|
||||
// Special handling for core module - it's in src/core not src/modules
|
||||
let sourcePath;
|
||||
if (moduleName === 'core') {
|
||||
sourcePath = getSourcePath('core');
|
||||
} else {
|
||||
sourcePath = path.join(this.modulesSourcePath, moduleName);
|
||||
}
|
||||
|
||||
const installerPath = path.join(sourcePath, '_module-installer', 'installer.js');
|
||||
|
||||
// Check if module has a custom installer
|
||||
if (!(await fs.pathExists(installerPath))) {
|
||||
return; // No custom installer
|
||||
}
|
||||
|
||||
try {
|
||||
// Load the module installer
|
||||
const moduleInstaller = require(installerPath);
|
||||
|
||||
if (typeof moduleInstaller.install === 'function') {
|
||||
// Get project root (parent of bmad directory)
|
||||
const projectRoot = path.dirname(bmadDir);
|
||||
|
||||
// Prepare logger (use console if not provided)
|
||||
const logger = options.logger || {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
};
|
||||
|
||||
// Call the module installer
|
||||
const result = await moduleInstaller.install({
|
||||
projectRoot,
|
||||
config: options.moduleConfig || {},
|
||||
installedIDEs: options.installedIDEs || [],
|
||||
logger,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
console.warn(chalk.yellow(`Module installer for ${moduleName} returned false`));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error running module installer for ${moduleName}: ${error.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Private: Process module configuration
|
||||
* @param {string} modulePath - Path to installed module
|
||||
* @param {string} moduleName - Module name
|
||||
*/
|
||||
async processModuleConfig(modulePath, moduleName) {
|
||||
const configPath = path.join(modulePath, 'config.yaml');
|
||||
|
||||
if (await fs.pathExists(configPath)) {
|
||||
try {
|
||||
let configContent = await fs.readFile(configPath, 'utf8');
|
||||
|
||||
// Replace path placeholders
|
||||
configContent = configContent.replaceAll('{project-root}', `bmad/${moduleName}`);
|
||||
configContent = configContent.replaceAll('{module}', moduleName);
|
||||
|
||||
await fs.writeFile(configPath, configContent, 'utf8');
|
||||
} catch (error) {
|
||||
console.warn(`Failed to process module config:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Private: Sync module files (preserving user modifications)
|
||||
* @param {string} sourcePath - Source module path
|
||||
* @param {string} targetPath - Target module path
|
||||
*/
|
||||
async syncModule(sourcePath, targetPath) {
|
||||
// Get list of all source files
|
||||
const sourceFiles = await this.getFileList(sourcePath);
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
const sourceFile = path.join(sourcePath, file);
|
||||
const targetFile = path.join(targetPath, file);
|
||||
|
||||
// Check if target file exists and has been modified
|
||||
if (await fs.pathExists(targetFile)) {
|
||||
const sourceStats = await fs.stat(sourceFile);
|
||||
const targetStats = await fs.stat(targetFile);
|
||||
|
||||
// Skip if target is newer (user modified)
|
||||
if (targetStats.mtime > sourceStats.mtime) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy file
|
||||
await fs.ensureDir(path.dirname(targetFile));
|
||||
await fs.copy(sourceFile, targetFile, { overwrite: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Private: Get list of all files in a directory
|
||||
* @param {string} dir - Directory path
|
||||
* @param {string} baseDir - Base directory for relative paths
|
||||
* @returns {Array} List of relative file paths
|
||||
*/
|
||||
async getFileList(dir, baseDir = dir) {
|
||||
const files = [];
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Skip _module-installer directories
|
||||
if (entry.name === '_module-installer') {
|
||||
continue;
|
||||
}
|
||||
const subFiles = await this.getFileList(fullPath, baseDir);
|
||||
files.push(...subFiles);
|
||||
} else {
|
||||
files.push(path.relative(baseDir, fullPath));
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ModuleManager };
|
||||
Reference in New Issue
Block a user