Compare commits
6 Commits
37def90dd7
...
b032b5a590
| Author | SHA1 | Date |
|---|---|---|
|
|
b032b5a590 | |
|
|
36c21dbada | |
|
|
e67d54161d | |
|
|
3287073530 | |
|
|
2f28ef1b80 | |
|
|
f73ea2cb18 |
|
|
@ -5,6 +5,7 @@ agent:
|
||||||
title: Business Analyst
|
title: Business Analyst
|
||||||
icon: 📊
|
icon: 📊
|
||||||
module: bmm
|
module: bmm
|
||||||
|
capabilities: "market research, competitive analysis, requirements elicitation, domain expertise"
|
||||||
hasSidecar: false
|
hasSidecar: false
|
||||||
|
|
||||||
persona:
|
persona:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ agent:
|
||||||
title: Architect
|
title: Architect
|
||||||
icon: 🏗️
|
icon: 🏗️
|
||||||
module: bmm
|
module: bmm
|
||||||
|
capabilities: "distributed systems, cloud infrastructure, API design, scalable patterns"
|
||||||
hasSidecar: false
|
hasSidecar: false
|
||||||
|
|
||||||
persona:
|
persona:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ agent:
|
||||||
title: Developer Agent
|
title: Developer Agent
|
||||||
icon: 💻
|
icon: 💻
|
||||||
module: bmm
|
module: bmm
|
||||||
|
capabilities: "story execution, test-driven development, code implementation"
|
||||||
hasSidecar: false
|
hasSidecar: false
|
||||||
|
|
||||||
persona:
|
persona:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ agent:
|
||||||
title: Product Manager
|
title: Product Manager
|
||||||
icon: 📋
|
icon: 📋
|
||||||
module: bmm
|
module: bmm
|
||||||
|
capabilities: "PRD creation, requirements discovery, stakeholder alignment, user interviews"
|
||||||
hasSidecar: false
|
hasSidecar: false
|
||||||
|
|
||||||
persona:
|
persona:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ agent:
|
||||||
title: QA Engineer
|
title: QA Engineer
|
||||||
icon: 🧪
|
icon: 🧪
|
||||||
module: bmm
|
module: bmm
|
||||||
|
capabilities: "test automation, API testing, E2E testing, coverage analysis"
|
||||||
hasSidecar: false
|
hasSidecar: false
|
||||||
|
|
||||||
persona:
|
persona:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ agent:
|
||||||
title: Quick Flow Solo Dev
|
title: Quick Flow Solo Dev
|
||||||
icon: 🚀
|
icon: 🚀
|
||||||
module: bmm
|
module: bmm
|
||||||
|
capabilities: "rapid spec creation, lean implementation, minimum ceremony"
|
||||||
hasSidecar: false
|
hasSidecar: false
|
||||||
|
|
||||||
persona:
|
persona:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ agent:
|
||||||
title: Scrum Master
|
title: Scrum Master
|
||||||
icon: 🏃
|
icon: 🏃
|
||||||
module: bmm
|
module: bmm
|
||||||
|
capabilities: "sprint planning, story preparation, agile ceremonies, backlog management"
|
||||||
hasSidecar: false
|
hasSidecar: false
|
||||||
|
|
||||||
persona:
|
persona:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ agent:
|
||||||
title: Technical Writer
|
title: Technical Writer
|
||||||
icon: 📚
|
icon: 📚
|
||||||
module: bmm
|
module: bmm
|
||||||
|
capabilities: "documentation, Mermaid diagrams, standards compliance, concept explanation"
|
||||||
hasSidecar: true
|
hasSidecar: true
|
||||||
|
|
||||||
persona:
|
persona:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ agent:
|
||||||
title: UX Designer
|
title: UX Designer
|
||||||
icon: 🎨
|
icon: 🎨
|
||||||
module: bmm
|
module: bmm
|
||||||
|
capabilities: "user research, interaction design, UI patterns, experience strategy"
|
||||||
hasSidecar: false
|
hasSidecar: false
|
||||||
|
|
||||||
persona:
|
persona:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ agent:
|
||||||
name: "BMad Master"
|
name: "BMad Master"
|
||||||
title: "BMad Master Executor, Knowledge Custodian, and Workflow Orchestrator"
|
title: "BMad Master Executor, Knowledge Custodian, and Workflow Orchestrator"
|
||||||
icon: "🧙"
|
icon: "🧙"
|
||||||
|
capabilities: "runtime resource management, workflow orchestration, task execution, knowledge custodian"
|
||||||
hasSidecar: false
|
hasSidecar: false
|
||||||
|
|
||||||
persona:
|
persona:
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,19 @@ class ConfigCollector {
|
||||||
this.collectedConfig = {};
|
this.collectedConfig = {};
|
||||||
this.existingConfig = null;
|
this.existingConfig = null;
|
||||||
this.currentProjectDir = null;
|
this.currentProjectDir = null;
|
||||||
|
this._moduleManagerInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a cached ModuleManager instance (lazy initialization)
|
||||||
|
* @returns {Object} ModuleManager instance
|
||||||
|
*/
|
||||||
|
_getModuleManager() {
|
||||||
|
if (!this._moduleManagerInstance) {
|
||||||
|
const { ModuleManager } = require('../modules/manager');
|
||||||
|
this._moduleManagerInstance = new ModuleManager();
|
||||||
|
}
|
||||||
|
return this._moduleManagerInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -129,6 +142,70 @@ class ConfigCollector {
|
||||||
return foundAny;
|
return foundAny;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-scan module schemas to gather metadata for the configuration gateway prompt.
|
||||||
|
* Returns info about which modules have configurable options.
|
||||||
|
* @param {Array} modules - List of non-core module names
|
||||||
|
* @returns {Promise<Array>} Array of {moduleName, displayName, questionCount, hasFieldsWithoutDefaults}
|
||||||
|
*/
|
||||||
|
async scanModuleSchemas(modules) {
|
||||||
|
const metadataFields = new Set(['code', 'name', 'header', 'subheader', 'default_selected']);
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const moduleName of modules) {
|
||||||
|
// Resolve module.yaml path - custom paths first, then standard location, then ModuleManager search
|
||||||
|
let moduleConfigPath = null;
|
||||||
|
const customPath = this.customModulePaths?.get(moduleName);
|
||||||
|
if (customPath) {
|
||||||
|
moduleConfigPath = path.join(customPath, 'module.yaml');
|
||||||
|
} else {
|
||||||
|
const standardPath = path.join(getModulePath(moduleName), 'module.yaml');
|
||||||
|
if (await fs.pathExists(standardPath)) {
|
||||||
|
moduleConfigPath = standardPath;
|
||||||
|
} else {
|
||||||
|
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
|
||||||
|
if (moduleSourcePath) {
|
||||||
|
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!moduleConfigPath || !(await fs.pathExists(moduleConfigPath))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(moduleConfigPath, 'utf8');
|
||||||
|
const moduleConfig = yaml.parse(content);
|
||||||
|
if (!moduleConfig) continue;
|
||||||
|
|
||||||
|
const displayName = moduleConfig.header || `${moduleName.toUpperCase()} Module`;
|
||||||
|
const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt');
|
||||||
|
const questionKeys = configKeys.filter((key) => {
|
||||||
|
if (metadataFields.has(key)) return false;
|
||||||
|
const item = moduleConfig[key];
|
||||||
|
return item && typeof item === 'object' && item.prompt;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasFieldsWithoutDefaults = questionKeys.some((key) => {
|
||||||
|
const item = moduleConfig[key];
|
||||||
|
return item.default === undefined || item.default === null || item.default === '';
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
moduleName,
|
||||||
|
displayName,
|
||||||
|
questionCount: questionKeys.length,
|
||||||
|
hasFieldsWithoutDefaults,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await prompts.log.warn(`Could not read schema for module "${moduleName}": ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect configuration for all modules
|
* Collect configuration for all modules
|
||||||
* @param {Array} modules - List of modules to configure (including 'core')
|
* @param {Array} modules - List of modules to configure (including 'core')
|
||||||
|
|
@ -141,6 +218,7 @@ class ConfigCollector {
|
||||||
// Store custom module paths for use in collectModuleConfig
|
// Store custom module paths for use in collectModuleConfig
|
||||||
this.customModulePaths = options.customModulePaths || new Map();
|
this.customModulePaths = options.customModulePaths || new Map();
|
||||||
this.skipPrompts = options.skipPrompts || false;
|
this.skipPrompts = options.skipPrompts || false;
|
||||||
|
this.modulesToCustomize = undefined;
|
||||||
await this.loadExistingConfig(projectDir);
|
await this.loadExistingConfig(projectDir);
|
||||||
|
|
||||||
// Check if core was already collected (e.g., in early collection phase)
|
// Check if core was already collected (e.g., in early collection phase)
|
||||||
|
|
@ -154,10 +232,95 @@ class ConfigCollector {
|
||||||
this.allAnswers = {};
|
this.allAnswers = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const moduleName of allModules) {
|
// Split processing: core first, then gateway, then remaining modules
|
||||||
|
const coreModules = allModules.filter((m) => m === 'core');
|
||||||
|
const nonCoreModules = allModules.filter((m) => m !== 'core');
|
||||||
|
|
||||||
|
// Collect core config first (always fully prompted)
|
||||||
|
for (const moduleName of coreModules) {
|
||||||
await this.collectModuleConfig(moduleName, projectDir);
|
await this.collectModuleConfig(moduleName, projectDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show batch configuration gateway for non-core modules
|
||||||
|
// Scan all non-core module schemas for display names and config metadata
|
||||||
|
let scannedModules = [];
|
||||||
|
if (!this.skipPrompts && nonCoreModules.length > 0) {
|
||||||
|
scannedModules = await this.scanModuleSchemas(nonCoreModules);
|
||||||
|
const customizableModules = scannedModules.filter((m) => m.questionCount > 0);
|
||||||
|
|
||||||
|
if (customizableModules.length > 0) {
|
||||||
|
const configMode = await prompts.select({
|
||||||
|
message: 'Module configuration',
|
||||||
|
choices: [
|
||||||
|
{ name: 'Express Setup', value: 'express', hint: 'accept all defaults (recommended)' },
|
||||||
|
{ name: 'Customize', value: 'customize', hint: 'choose modules to configure' },
|
||||||
|
],
|
||||||
|
default: 'express',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (configMode === 'customize') {
|
||||||
|
const choices = customizableModules.map((m) => ({
|
||||||
|
name: `${m.displayName} (${m.questionCount} option${m.questionCount === 1 ? '' : 's'})`,
|
||||||
|
value: m.moduleName,
|
||||||
|
hint: m.hasFieldsWithoutDefaults ? 'has fields without defaults' : undefined,
|
||||||
|
checked: m.hasFieldsWithoutDefaults,
|
||||||
|
}));
|
||||||
|
const selected = await prompts.multiselect({
|
||||||
|
message: 'Select modules to customize:',
|
||||||
|
choices,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
this.modulesToCustomize = new Set(selected);
|
||||||
|
} else {
|
||||||
|
// Express mode: no modules to customize
|
||||||
|
this.modulesToCustomize = new Set();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// All non-core modules have zero config - no gateway needed
|
||||||
|
this.modulesToCustomize = new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect remaining non-core modules
|
||||||
|
if (this.modulesToCustomize === undefined) {
|
||||||
|
// No gateway was shown (skipPrompts, no non-core modules, or direct call) - process all normally
|
||||||
|
for (const moduleName of nonCoreModules) {
|
||||||
|
await this.collectModuleConfig(moduleName, projectDir);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Split into default modules (tasks progress) and customized modules (interactive)
|
||||||
|
const defaultModules = nonCoreModules.filter((m) => !this.modulesToCustomize.has(m));
|
||||||
|
const customizeModules = nonCoreModules.filter((m) => this.modulesToCustomize.has(m));
|
||||||
|
|
||||||
|
// Run default modules with a single spinner
|
||||||
|
if (defaultModules.length > 0) {
|
||||||
|
// Build display name map from all scanned modules for pre-call spinner messages
|
||||||
|
const displayNameMap = new Map();
|
||||||
|
for (const m of scannedModules) {
|
||||||
|
displayNameMap.set(m.moduleName, m.displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configSpinner = await prompts.spinner();
|
||||||
|
configSpinner.start('Configuring modules...');
|
||||||
|
for (const moduleName of defaultModules) {
|
||||||
|
const displayName = displayNameMap.get(moduleName) || moduleName.toUpperCase();
|
||||||
|
configSpinner.message(`Configuring ${displayName}...`);
|
||||||
|
try {
|
||||||
|
this._silentConfig = true;
|
||||||
|
await this.collectModuleConfig(moduleName, projectDir);
|
||||||
|
} finally {
|
||||||
|
this._silentConfig = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configSpinner.stop('Module configuration complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run customized modules individually (may show interactive prompts)
|
||||||
|
for (const moduleName of customizeModules) {
|
||||||
|
await this.collectModuleConfig(moduleName, projectDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add metadata
|
// Add metadata
|
||||||
this.collectedConfig._meta = {
|
this.collectedConfig._meta = {
|
||||||
version: require(path.join(getProjectRoot(), 'package.json')).version,
|
version: require(path.join(getProjectRoot(), 'package.json')).version,
|
||||||
|
|
@ -194,10 +357,7 @@ class ConfigCollector {
|
||||||
|
|
||||||
// If not found in src/modules, we need to find it by searching the project
|
// If not found in src/modules, we need to find it by searching the project
|
||||||
if (!(await fs.pathExists(moduleConfigPath))) {
|
if (!(await fs.pathExists(moduleConfigPath))) {
|
||||||
// Use the module manager to find the module source
|
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
|
||||||
const { ModuleManager } = require('../modules/manager');
|
|
||||||
const moduleManager = new ModuleManager();
|
|
||||||
const moduleSourcePath = await moduleManager.findModuleSource(moduleName);
|
|
||||||
|
|
||||||
if (moduleSourcePath) {
|
if (moduleSourcePath) {
|
||||||
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
||||||
|
|
@ -211,9 +371,7 @@ class ConfigCollector {
|
||||||
configPath = moduleConfigPath;
|
configPath = moduleConfigPath;
|
||||||
} else {
|
} else {
|
||||||
// Check if this is a custom module with custom.yaml
|
// Check if this is a custom module with custom.yaml
|
||||||
const { ModuleManager } = require('../modules/manager');
|
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
|
||||||
const moduleManager = new ModuleManager();
|
|
||||||
const moduleSourcePath = await moduleManager.findModuleSource(moduleName);
|
|
||||||
|
|
||||||
if (moduleSourcePath) {
|
if (moduleSourcePath) {
|
||||||
const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml');
|
const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml');
|
||||||
|
|
@ -507,10 +665,7 @@ class ConfigCollector {
|
||||||
|
|
||||||
// If not found in src/modules or custom paths, search the project
|
// If not found in src/modules or custom paths, search the project
|
||||||
if (!(await fs.pathExists(moduleConfigPath))) {
|
if (!(await fs.pathExists(moduleConfigPath))) {
|
||||||
// Use the module manager to find the module source
|
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
|
||||||
const { ModuleManager } = require('../modules/manager');
|
|
||||||
const moduleManager = new ModuleManager();
|
|
||||||
const moduleSourcePath = await moduleManager.findModuleSource(moduleName);
|
|
||||||
|
|
||||||
if (moduleSourcePath) {
|
if (moduleSourcePath) {
|
||||||
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
||||||
|
|
@ -579,12 +734,12 @@ class ConfigCollector {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await prompts.log.step(moduleDisplayName);
|
if (!this._silentConfig) await prompts.log.step(`Configuring ${moduleDisplayName}`);
|
||||||
let customize = true;
|
let useDefaults = true;
|
||||||
if (moduleName === 'core') {
|
if (moduleName === 'core') {
|
||||||
// Core module: no confirm prompt, continues directly
|
useDefaults = false; // Core: always show all questions
|
||||||
} else {
|
} else if (this.modulesToCustomize === undefined) {
|
||||||
// Non-core modules: show "Accept Defaults?" confirm prompt (clack adds spacing)
|
// Fallback: original per-module confirm (backward compat for direct calls)
|
||||||
const customizeAnswer = await prompts.prompt([
|
const customizeAnswer = await prompts.prompt([
|
||||||
{
|
{
|
||||||
type: 'confirm',
|
type: 'confirm',
|
||||||
|
|
@ -593,10 +748,13 @@ class ConfigCollector {
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
customize = customizeAnswer.customize;
|
useDefaults = customizeAnswer.customize;
|
||||||
|
} else {
|
||||||
|
// Batch mode: use defaults unless module was selected for customization
|
||||||
|
useDefaults = !this.modulesToCustomize.has(moduleName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (customize && moduleName !== 'core') {
|
if (useDefaults && moduleName !== 'core') {
|
||||||
// Accept defaults - only ask questions that have NO default value
|
// Accept defaults - only ask questions that have NO default value
|
||||||
const questionsWithoutDefaults = questions.filter((q) => q.default === undefined || q.default === null || q.default === '');
|
const questionsWithoutDefaults = questions.filter((q) => q.default === undefined || q.default === null || q.default === '');
|
||||||
|
|
||||||
|
|
@ -726,16 +884,18 @@ class ConfigCollector {
|
||||||
const actualConfigKeys = configKeys.filter((key) => !metadataFields.has(key));
|
const actualConfigKeys = configKeys.filter((key) => !metadataFields.has(key));
|
||||||
const hasNoConfig = actualConfigKeys.length === 0;
|
const hasNoConfig = actualConfigKeys.length === 0;
|
||||||
|
|
||||||
if (hasNoConfig && (moduleConfig.subheader || moduleConfig.header)) {
|
if (!this._silentConfig) {
|
||||||
await prompts.log.step(moduleDisplayName);
|
if (hasNoConfig && (moduleConfig.subheader || moduleConfig.header)) {
|
||||||
if (moduleConfig.subheader) {
|
await prompts.log.step(moduleDisplayName);
|
||||||
await prompts.log.message(` \u2713 ${moduleConfig.subheader}`);
|
if (moduleConfig.subheader) {
|
||||||
|
await prompts.log.message(` \u2713 ${moduleConfig.subheader}`);
|
||||||
|
} else {
|
||||||
|
await prompts.log.message(` \u2713 No custom configuration required`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await prompts.log.message(` \u2713 No custom configuration required`);
|
// Module has config but just no questions to ask
|
||||||
|
await prompts.log.message(` \u2713 ${moduleName.toUpperCase()} module configured`);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Module has config but just no questions to ask
|
|
||||||
await prompts.log.message(` \u2713 ${moduleName.toUpperCase()} module configured`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -882,6 +882,9 @@ class Installer {
|
||||||
// Shared resolution result across task callbacks (closure-scoped, not on `this`)
|
// Shared resolution result across task callbacks (closure-scoped, not on `this`)
|
||||||
let taskResolution;
|
let taskResolution;
|
||||||
|
|
||||||
|
// Collect directory creation results for output after tasks() completes
|
||||||
|
const dirResults = { createdDirs: [], movedDirs: [], createdWdsFolders: [] };
|
||||||
|
|
||||||
// Build task list conditionally
|
// Build task list conditionally
|
||||||
const installTasks = [];
|
const installTasks = [];
|
||||||
|
|
||||||
|
|
@ -992,6 +995,10 @@ class Installer {
|
||||||
[moduleName]: { ...config.coreConfig, ...customInfo.config, ...collectedModuleConfig },
|
[moduleName]: { ...config.coreConfig, ...customInfo.config, ...collectedModuleConfig },
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
if (!resolution || !resolution.byModule) {
|
||||||
|
addResult(`Module: ${moduleName}`, 'warn', 'skipped (no resolution data)');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (moduleName === 'core') {
|
if (moduleName === 'core') {
|
||||||
await this.installCoreWithDependencies(bmadDir, resolution.byModule[moduleName]);
|
await this.installCoreWithDependencies(bmadDir, resolution.byModule[moduleName]);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1003,6 +1010,9 @@ class Installer {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install partial modules (only dependencies)
|
// Install partial modules (only dependencies)
|
||||||
|
if (!resolution || !resolution.byModule) {
|
||||||
|
return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`;
|
||||||
|
}
|
||||||
for (const [module, files] of Object.entries(resolution.byModule)) {
|
for (const [module, files] of Object.entries(resolution.byModule)) {
|
||||||
if (!allModules.includes(module) && module !== 'core') {
|
if (!allModules.includes(module) && module !== 'core') {
|
||||||
const totalFiles =
|
const totalFiles =
|
||||||
|
|
@ -1024,8 +1034,66 @@ class Installer {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configuration generation task
|
// Module directory creation task
|
||||||
installTasks.push({
|
installTasks.push({
|
||||||
|
title: 'Creating module directories',
|
||||||
|
task: async (message) => {
|
||||||
|
const resolution = taskResolution;
|
||||||
|
if (!resolution || !resolution.byModule) {
|
||||||
|
addResult('Module directories', 'warn', 'no resolution data');
|
||||||
|
return 'Module directories skipped (no resolution data)';
|
||||||
|
}
|
||||||
|
const verboseMode = process.env.BMAD_VERBOSE_INSTALL === 'true' || config.verbose;
|
||||||
|
const moduleLogger = {
|
||||||
|
log: async (msg) => (verboseMode ? await prompts.log.message(msg) : undefined),
|
||||||
|
error: async (msg) => await prompts.log.error(msg),
|
||||||
|
warn: async (msg) => await prompts.log.warn(msg),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Core module directories
|
||||||
|
if (config.installCore || resolution.byModule.core) {
|
||||||
|
const result = await this.moduleManager.createModuleDirectories('core', bmadDir, {
|
||||||
|
installedIDEs: config.ides || [],
|
||||||
|
moduleConfig: moduleConfigs.core || {},
|
||||||
|
existingModuleConfig: this.configCollector.existingConfig?.core || {},
|
||||||
|
coreConfig: moduleConfigs.core || {},
|
||||||
|
logger: moduleLogger,
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
if (result) {
|
||||||
|
dirResults.createdDirs.push(...result.createdDirs);
|
||||||
|
dirResults.movedDirs.push(...(result.movedDirs || []));
|
||||||
|
dirResults.createdWdsFolders.push(...result.createdWdsFolders);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-selected module directories
|
||||||
|
if (config.modules && config.modules.length > 0) {
|
||||||
|
for (const moduleName of config.modules) {
|
||||||
|
message(`Setting up ${moduleName}...`);
|
||||||
|
const result = await this.moduleManager.createModuleDirectories(moduleName, bmadDir, {
|
||||||
|
installedIDEs: config.ides || [],
|
||||||
|
moduleConfig: moduleConfigs[moduleName] || {},
|
||||||
|
existingModuleConfig: this.configCollector.existingConfig?.[moduleName] || {},
|
||||||
|
coreConfig: moduleConfigs.core || {},
|
||||||
|
logger: moduleLogger,
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
if (result) {
|
||||||
|
dirResults.createdDirs.push(...result.createdDirs);
|
||||||
|
dirResults.movedDirs.push(...(result.movedDirs || []));
|
||||||
|
dirResults.createdWdsFolders.push(...result.createdWdsFolders);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addResult('Module directories', 'ok');
|
||||||
|
return 'Module directories created';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configuration generation task (stored as named reference for deferred execution)
|
||||||
|
const configTask = {
|
||||||
title: 'Generating configurations',
|
title: 'Generating configurations',
|
||||||
task: async (message) => {
|
task: async (message) => {
|
||||||
// Generate clean config.yaml files for each installed module
|
// Generate clean config.yaml files for each installed module
|
||||||
|
|
@ -1075,9 +1143,30 @@ class Installer {
|
||||||
|
|
||||||
return 'Configurations generated';
|
return 'Configurations generated';
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
installTasks.push(configTask);
|
||||||
|
|
||||||
await prompts.tasks(installTasks);
|
// Run all tasks except config (which runs after directory output)
|
||||||
|
const mainTasks = installTasks.filter((t) => t !== configTask);
|
||||||
|
await prompts.tasks(mainTasks);
|
||||||
|
|
||||||
|
// Render directory creation output right after directory task
|
||||||
|
const color = await prompts.getColor();
|
||||||
|
if (dirResults.movedDirs.length > 0) {
|
||||||
|
const lines = dirResults.movedDirs.map((d) => ` ${d}`).join('\n');
|
||||||
|
await prompts.log.message(color.cyan(`Moved directories:\n${lines}`));
|
||||||
|
}
|
||||||
|
if (dirResults.createdDirs.length > 0) {
|
||||||
|
const lines = dirResults.createdDirs.map((d) => ` ${d}`).join('\n');
|
||||||
|
await prompts.log.message(color.yellow(`Created directories:\n${lines}`));
|
||||||
|
}
|
||||||
|
if (dirResults.createdWdsFolders.length > 0) {
|
||||||
|
const lines = dirResults.createdWdsFolders.map((f) => color.dim(` \u2713 ${f}/`)).join('\n');
|
||||||
|
await prompts.log.message(color.cyan(`Created WDS folder structure:\n${lines}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now run configuration generation
|
||||||
|
await prompts.tasks([configTask]);
|
||||||
|
|
||||||
// Resolution is now available via closure-scoped taskResolution
|
// Resolution is now available via closure-scoped taskResolution
|
||||||
const resolution = taskResolution;
|
const resolution = taskResolution;
|
||||||
|
|
@ -1094,7 +1183,7 @@ class Installer {
|
||||||
} else {
|
} else {
|
||||||
const needsPrompting = validIdes.some((ide) => !ideConfigurations[ide]);
|
const needsPrompting = validIdes.some((ide) => !ideConfigurations[ide]);
|
||||||
const ideSpinner = await prompts.spinner();
|
const ideSpinner = await prompts.spinner();
|
||||||
ideSpinner.start('Configuring IDEs...');
|
ideSpinner.start('Configuring tools...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const ide of validIdes) {
|
for (const ide of validIdes) {
|
||||||
|
|
@ -1134,12 +1223,12 @@ class Installer {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsPrompting && !ideSpinner.isSpinning) {
|
if (needsPrompting && !ideSpinner.isSpinning) {
|
||||||
ideSpinner.start('Configuring IDEs...');
|
ideSpinner.start('Configuring tools...');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (ideSpinner.isSpinning) {
|
if (ideSpinner.isSpinning) {
|
||||||
ideSpinner.stop('IDE configuration complete');
|
ideSpinner.stop('Tool configuration complete');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1150,58 +1239,6 @@ class Installer {
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
const postIdeTasks = [];
|
const postIdeTasks = [];
|
||||||
|
|
||||||
// Collect directory creation results for output after tasks() completes
|
|
||||||
const dirResults = { createdDirs: [], createdWdsFolders: [] };
|
|
||||||
|
|
||||||
// Module directory creation task
|
|
||||||
postIdeTasks.push({
|
|
||||||
title: 'Running module installers',
|
|
||||||
task: async (message) => {
|
|
||||||
const verboseMode = process.env.BMAD_VERBOSE_INSTALL === 'true' || config.verbose;
|
|
||||||
const moduleLogger = {
|
|
||||||
log: async (msg) => (verboseMode ? await prompts.log.message(msg) : undefined),
|
|
||||||
error: async (msg) => await prompts.log.error(msg),
|
|
||||||
warn: async (msg) => await prompts.log.warn(msg),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Core module directories
|
|
||||||
if (config.installCore || resolution.byModule.core) {
|
|
||||||
const result = await this.moduleManager.createModuleDirectories('core', bmadDir, {
|
|
||||||
installedIDEs: config.ides || [],
|
|
||||||
moduleConfig: moduleConfigs.core || {},
|
|
||||||
coreConfig: moduleConfigs.core || {},
|
|
||||||
logger: moduleLogger,
|
|
||||||
silent: true,
|
|
||||||
});
|
|
||||||
if (result) {
|
|
||||||
dirResults.createdDirs.push(...result.createdDirs);
|
|
||||||
dirResults.createdWdsFolders.push(...result.createdWdsFolders);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// User-selected module directories
|
|
||||||
if (config.modules && config.modules.length > 0) {
|
|
||||||
for (const moduleName of config.modules) {
|
|
||||||
message(`Setting up ${moduleName}...`);
|
|
||||||
const result = await this.moduleManager.createModuleDirectories(moduleName, bmadDir, {
|
|
||||||
installedIDEs: config.ides || [],
|
|
||||||
moduleConfig: moduleConfigs[moduleName] || {},
|
|
||||||
coreConfig: moduleConfigs.core || {},
|
|
||||||
logger: moduleLogger,
|
|
||||||
silent: true,
|
|
||||||
});
|
|
||||||
if (result) {
|
|
||||||
dirResults.createdDirs.push(...result.createdDirs);
|
|
||||||
dirResults.createdWdsFolders.push(...result.createdWdsFolders);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addResult('Module installers', 'ok');
|
|
||||||
return 'Module setup complete';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// File restoration task (only for updates)
|
// File restoration task (only for updates)
|
||||||
if (
|
if (
|
||||||
config._isUpdate &&
|
config._isUpdate &&
|
||||||
|
|
@ -1265,18 +1302,6 @@ class Installer {
|
||||||
|
|
||||||
await prompts.tasks(postIdeTasks);
|
await prompts.tasks(postIdeTasks);
|
||||||
|
|
||||||
// Render directory creation output after tasks() to avoid breaking progress display
|
|
||||||
if (dirResults.createdDirs.length > 0) {
|
|
||||||
const color = await prompts.getColor();
|
|
||||||
const lines = dirResults.createdDirs.map((d) => ` ${d}`).join('\n');
|
|
||||||
await prompts.log.message(color.yellow(`Created directories:\n${lines}`));
|
|
||||||
}
|
|
||||||
if (dirResults.createdWdsFolders.length > 0) {
|
|
||||||
const color = await prompts.getColor();
|
|
||||||
const lines = dirResults.createdWdsFolders.map((f) => color.dim(` ✓ ${f}/`)).join('\n');
|
|
||||||
await prompts.log.message(color.cyan(`Created WDS folder structure:\n${lines}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve restored file info for summary
|
// Retrieve restored file info for summary
|
||||||
const customFiles = config._restoredCustomFiles || [];
|
const customFiles = config._restoredCustomFiles || [];
|
||||||
const modifiedFiles = config._restoredModifiedFiles || [];
|
const modifiedFiles = config._restoredModifiedFiles || [];
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,7 @@ class ManifestGenerator {
|
||||||
const nameMatch = content.match(/name="([^"]+)"/);
|
const nameMatch = content.match(/name="([^"]+)"/);
|
||||||
const titleMatch = content.match(/title="([^"]+)"/);
|
const titleMatch = content.match(/title="([^"]+)"/);
|
||||||
const iconMatch = content.match(/icon="([^"]+)"/);
|
const iconMatch = content.match(/icon="([^"]+)"/);
|
||||||
|
const capabilitiesMatch = content.match(/capabilities="([^"]+)"/);
|
||||||
|
|
||||||
// Extract persona fields
|
// Extract persona fields
|
||||||
const roleMatch = content.match(/<role>([^<]+)<\/role>/);
|
const roleMatch = content.match(/<role>([^<]+)<\/role>/);
|
||||||
|
|
@ -343,6 +344,7 @@ class ManifestGenerator {
|
||||||
displayName: nameMatch ? nameMatch[1] : agentName,
|
displayName: nameMatch ? nameMatch[1] : agentName,
|
||||||
title: titleMatch ? titleMatch[1] : '',
|
title: titleMatch ? titleMatch[1] : '',
|
||||||
icon: iconMatch ? iconMatch[1] : '',
|
icon: iconMatch ? iconMatch[1] : '',
|
||||||
|
capabilities: capabilitiesMatch ? this.cleanForCSV(capabilitiesMatch[1]) : '',
|
||||||
role: roleMatch ? this.cleanForCSV(roleMatch[1]) : '',
|
role: roleMatch ? this.cleanForCSV(roleMatch[1]) : '',
|
||||||
identity: identityMatch ? this.cleanForCSV(identityMatch[1]) : '',
|
identity: identityMatch ? this.cleanForCSV(identityMatch[1]) : '',
|
||||||
communicationStyle: styleMatch ? this.cleanForCSV(styleMatch[1]) : '',
|
communicationStyle: styleMatch ? this.cleanForCSV(styleMatch[1]) : '',
|
||||||
|
|
@ -785,7 +787,7 @@ class ManifestGenerator {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CSV header with persona fields
|
// Create CSV header with persona fields
|
||||||
let csvContent = 'name,displayName,title,icon,role,identity,communicationStyle,principles,module,path\n';
|
let csvContent = 'name,displayName,title,icon,capabilities,role,identity,communicationStyle,principles,module,path\n';
|
||||||
|
|
||||||
// Combine existing and new agents, preferring new data for duplicates
|
// Combine existing and new agents, preferring new data for duplicates
|
||||||
const allAgents = new Map();
|
const allAgents = new Map();
|
||||||
|
|
@ -803,6 +805,7 @@ class ManifestGenerator {
|
||||||
displayName: agent.displayName,
|
displayName: agent.displayName,
|
||||||
title: agent.title,
|
title: agent.title,
|
||||||
icon: agent.icon,
|
icon: agent.icon,
|
||||||
|
capabilities: agent.capabilities,
|
||||||
role: agent.role,
|
role: agent.role,
|
||||||
identity: agent.identity,
|
identity: agent.identity,
|
||||||
communicationStyle: agent.communicationStyle,
|
communicationStyle: agent.communicationStyle,
|
||||||
|
|
@ -819,6 +822,7 @@ class ManifestGenerator {
|
||||||
escapeCsv(record.displayName),
|
escapeCsv(record.displayName),
|
||||||
escapeCsv(record.title),
|
escapeCsv(record.title),
|
||||||
escapeCsv(record.icon),
|
escapeCsv(record.icon),
|
||||||
|
escapeCsv(record.capabilities),
|
||||||
escapeCsv(record.role),
|
escapeCsv(record.role),
|
||||||
escapeCsv(record.identity),
|
escapeCsv(record.identity),
|
||||||
escapeCsv(record.communicationStyle),
|
escapeCsv(record.communicationStyle),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,655 @@
|
||||||
|
const path = require('node:path');
|
||||||
|
const { BaseIdeSetup } = require('./_base-ide');
|
||||||
|
const chalk = require('chalk');
|
||||||
|
const { AgentCommandGenerator } = require('./shared/agent-command-generator');
|
||||||
|
const { BMAD_FOLDER_NAME, toDashPath } = require('./shared/path-utils');
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const csv = require('csv-parse/sync');
|
||||||
|
const yaml = require('yaml');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub Copilot setup handler
|
||||||
|
* Creates agents in .github/agents/, prompts in .github/prompts/,
|
||||||
|
* copilot-instructions.md, and configures VS Code settings
|
||||||
|
*/
|
||||||
|
class GitHubCopilotSetup extends BaseIdeSetup {
|
||||||
|
constructor() {
|
||||||
|
super('github-copilot', 'GitHub Copilot', false);
|
||||||
|
// Don't set configDir to '.github' — nearly every GitHub repo has that directory,
|
||||||
|
// which would cause the base detect() to false-positive. Use detectionPaths instead.
|
||||||
|
this.configDir = null;
|
||||||
|
this.githubDir = '.github';
|
||||||
|
this.agentsDir = 'agents';
|
||||||
|
this.promptsDir = 'prompts';
|
||||||
|
this.detectionPaths = ['.github/copilot-instructions.md', '.github/agents'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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}...`));
|
||||||
|
|
||||||
|
// Create .github/agents and .github/prompts directories
|
||||||
|
const githubDir = path.join(projectDir, this.githubDir);
|
||||||
|
const agentsDir = path.join(githubDir, this.agentsDir);
|
||||||
|
const promptsDir = path.join(githubDir, this.promptsDir);
|
||||||
|
await this.ensureDir(agentsDir);
|
||||||
|
await this.ensureDir(promptsDir);
|
||||||
|
|
||||||
|
// Preserve any customised tool permissions from existing files before cleanup
|
||||||
|
this.existingToolPermissions = await this.collectExistingToolPermissions(projectDir);
|
||||||
|
|
||||||
|
// Clean up any existing BMAD files before reinstalling
|
||||||
|
await this.cleanup(projectDir);
|
||||||
|
|
||||||
|
// Load agent manifest for enriched descriptions
|
||||||
|
const agentManifest = await this.loadAgentManifest(bmadDir);
|
||||||
|
|
||||||
|
// Generate agent launchers
|
||||||
|
const agentGen = new AgentCommandGenerator(this.bmadFolderName);
|
||||||
|
const { artifacts: agentArtifacts } = await agentGen.collectAgentArtifacts(bmadDir, options.selectedModules || []);
|
||||||
|
|
||||||
|
// Create agent .agent.md files
|
||||||
|
let agentCount = 0;
|
||||||
|
for (const artifact of agentArtifacts) {
|
||||||
|
const agentMeta = agentManifest.get(artifact.name);
|
||||||
|
|
||||||
|
// Compute fileName first so we can look up any existing tool permissions
|
||||||
|
const dashName = toDashPath(artifact.relativePath);
|
||||||
|
const fileName = dashName.replace(/\.md$/, '.agent.md');
|
||||||
|
const toolsStr = this.getToolsForFile(fileName);
|
||||||
|
const agentContent = this.createAgentContent(artifact, agentMeta, toolsStr);
|
||||||
|
const targetPath = path.join(agentsDir, fileName);
|
||||||
|
await this.writeFile(targetPath, agentContent);
|
||||||
|
agentCount++;
|
||||||
|
|
||||||
|
console.log(chalk.green(` ✓ Created agent: ${fileName}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate prompt files from bmad-help.csv
|
||||||
|
const promptCount = await this.generatePromptFiles(projectDir, bmadDir, agentArtifacts, agentManifest);
|
||||||
|
|
||||||
|
// Generate copilot-instructions.md
|
||||||
|
await this.generateCopilotInstructions(projectDir, bmadDir, agentManifest);
|
||||||
|
|
||||||
|
console.log(chalk.green(`\n✓ ${this.name} configured:`));
|
||||||
|
console.log(chalk.dim(` - ${agentCount} agents created in .github/agents/`));
|
||||||
|
console.log(chalk.dim(` - ${promptCount} prompts created in .github/prompts/`));
|
||||||
|
console.log(chalk.dim(` - copilot-instructions.md generated`));
|
||||||
|
console.log(chalk.dim(` - Destination: .github/`));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
results: {
|
||||||
|
agents: agentCount,
|
||||||
|
workflows: promptCount,
|
||||||
|
tasks: 0,
|
||||||
|
tools: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load agent manifest CSV into a Map keyed by agent name
|
||||||
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
|
* @returns {Map} Agent metadata keyed by name
|
||||||
|
*/
|
||||||
|
async loadAgentManifest(bmadDir) {
|
||||||
|
const manifestPath = path.join(bmadDir, '_config', 'agent-manifest.csv');
|
||||||
|
const agents = new Map();
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(manifestPath))) {
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const csvContent = await fs.readFile(manifestPath, 'utf8');
|
||||||
|
const records = csv.parse(csvContent, {
|
||||||
|
columns: true,
|
||||||
|
skip_empty_lines: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
agents.set(record.name, record);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Gracefully degrade if manifest is unreadable/malformed
|
||||||
|
}
|
||||||
|
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load bmad-help.csv to drive prompt generation
|
||||||
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
|
* @returns {Array|null} Parsed CSV rows
|
||||||
|
*/
|
||||||
|
async loadBmadHelp(bmadDir) {
|
||||||
|
const helpPath = path.join(bmadDir, '_config', 'bmad-help.csv');
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(helpPath))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const csvContent = await fs.readFile(helpPath, 'utf8');
|
||||||
|
return csv.parse(csvContent, {
|
||||||
|
columns: true,
|
||||||
|
skip_empty_lines: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Gracefully degrade if help CSV is unreadable/malformed
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create agent .agent.md content with enriched description
|
||||||
|
* @param {Object} artifact - Agent artifact from AgentCommandGenerator
|
||||||
|
* @param {Object|undefined} manifestEntry - Agent manifest entry with metadata
|
||||||
|
* @returns {string} Agent file content
|
||||||
|
*/
|
||||||
|
createAgentContent(artifact, manifestEntry, toolsStr) {
|
||||||
|
// Build enriched description from manifest metadata
|
||||||
|
let description;
|
||||||
|
if (manifestEntry) {
|
||||||
|
const persona = manifestEntry.displayName || artifact.name;
|
||||||
|
const title = manifestEntry.title || this.formatTitle(artifact.name);
|
||||||
|
const capabilities = manifestEntry.capabilities || 'agent capabilities';
|
||||||
|
description = `${persona} — ${title}: ${capabilities}`;
|
||||||
|
} else {
|
||||||
|
description = `Activates the ${this.formatTitle(artifact.name)} agent persona.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the agent file path for the activation block
|
||||||
|
const agentPath = artifact.agentPath || artifact.relativePath;
|
||||||
|
const agentFilePath = `{project-root}/${this.bmadFolderName}/${agentPath}`;
|
||||||
|
|
||||||
|
return `---
|
||||||
|
description: '${description.replaceAll("'", "''")}'
|
||||||
|
tools: ${toolsStr}
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
You must fully embody this agent's persona and follow all activation instructions exactly as specified.
|
||||||
|
|
||||||
|
<agent-activation CRITICAL="TRUE">
|
||||||
|
1. LOAD the FULL agent file from ${agentFilePath}
|
||||||
|
2. READ its entire contents - this contains the complete agent persona, menu, and instructions
|
||||||
|
3. FOLLOW every step in the <activation> section precisely
|
||||||
|
4. DISPLAY the welcome/greeting as instructed
|
||||||
|
5. PRESENT the numbered menu
|
||||||
|
6. WAIT for user input before proceeding
|
||||||
|
</agent-activation>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate .prompt.md files for workflows, tasks, tech-writer commands, and agent activators
|
||||||
|
* @param {string} projectDir - Project directory
|
||||||
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
|
* @param {Array} agentArtifacts - Agent artifacts for activator generation
|
||||||
|
* @param {Map} agentManifest - Agent manifest data
|
||||||
|
* @returns {number} Count of prompts generated
|
||||||
|
*/
|
||||||
|
async generatePromptFiles(projectDir, bmadDir, agentArtifacts, agentManifest) {
|
||||||
|
const promptsDir = path.join(projectDir, this.githubDir, this.promptsDir);
|
||||||
|
let promptCount = 0;
|
||||||
|
|
||||||
|
// Load bmad-help.csv to drive workflow/task prompt generation
|
||||||
|
const helpEntries = await this.loadBmadHelp(bmadDir);
|
||||||
|
|
||||||
|
if (helpEntries) {
|
||||||
|
for (const entry of helpEntries) {
|
||||||
|
const command = entry.command;
|
||||||
|
if (!command) continue; // Skip entries without a command (tech-writer commands have no command column)
|
||||||
|
|
||||||
|
const workflowFile = entry['workflow-file'];
|
||||||
|
if (!workflowFile) continue; // Skip entries with no workflow file path
|
||||||
|
const promptFileName = `${command}.prompt.md`;
|
||||||
|
const toolsStr = this.getToolsForFile(promptFileName);
|
||||||
|
const promptContent = this.createWorkflowPromptContent(entry, workflowFile, toolsStr);
|
||||||
|
const promptPath = path.join(promptsDir, promptFileName);
|
||||||
|
await this.writeFile(promptPath, promptContent);
|
||||||
|
promptCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tech-writer command prompts (entries with no command column)
|
||||||
|
for (const entry of helpEntries) {
|
||||||
|
if (entry.command) continue; // Already handled above
|
||||||
|
const techWriterPrompt = this.createTechWriterPromptContent(entry);
|
||||||
|
if (techWriterPrompt) {
|
||||||
|
const promptFileName = `${techWriterPrompt.fileName}.prompt.md`;
|
||||||
|
const promptPath = path.join(promptsDir, promptFileName);
|
||||||
|
await this.writeFile(promptPath, techWriterPrompt.content);
|
||||||
|
promptCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate agent activator prompts (Pattern D)
|
||||||
|
for (const artifact of agentArtifacts) {
|
||||||
|
const agentMeta = agentManifest.get(artifact.name);
|
||||||
|
const fileName = `bmad-${artifact.name}.prompt.md`;
|
||||||
|
const toolsStr = this.getToolsForFile(fileName);
|
||||||
|
const promptContent = this.createAgentActivatorPromptContent(artifact, agentMeta, toolsStr);
|
||||||
|
const promptPath = path.join(promptsDir, fileName);
|
||||||
|
await this.writeFile(promptPath, promptContent);
|
||||||
|
promptCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return promptCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create prompt content for a workflow/task entry from bmad-help.csv
|
||||||
|
* Determines the pattern (A, B, or A for .xml tasks) based on file extension
|
||||||
|
* @param {Object} entry - bmad-help.csv row
|
||||||
|
* @param {string} workflowFile - Workflow file path
|
||||||
|
* @returns {string} Prompt file content
|
||||||
|
*/
|
||||||
|
createWorkflowPromptContent(entry, workflowFile, toolsStr) {
|
||||||
|
const description = this.escapeYamlSingleQuote(this.createPromptDescription(entry.name));
|
||||||
|
// bmm/config.yaml is safe to hardcode here: these prompts are only generated when
|
||||||
|
// bmad-help.csv exists (bmm module data), so bmm is guaranteed to be installed.
|
||||||
|
const configLine = `1. Load {project-root}/${this.bmadFolderName}/bmm/config.yaml and store ALL fields as session variables`;
|
||||||
|
|
||||||
|
let body;
|
||||||
|
if (workflowFile.endsWith('.yaml')) {
|
||||||
|
// Pattern B: YAML-based workflows — use workflow engine
|
||||||
|
body = `${configLine}
|
||||||
|
2. Load the workflow engine at {project-root}/${this.bmadFolderName}/core/tasks/workflow.xml
|
||||||
|
3. Load and execute the workflow configuration at {project-root}/${workflowFile} using the engine from step 2`;
|
||||||
|
} else if (workflowFile.endsWith('.xml')) {
|
||||||
|
// Pattern A variant: XML tasks — load and execute directly
|
||||||
|
body = `${configLine}
|
||||||
|
2. Load and execute the task at {project-root}/${workflowFile}`;
|
||||||
|
} else {
|
||||||
|
// Pattern A: MD workflows — load and follow directly
|
||||||
|
body = `${configLine}
|
||||||
|
2. Load and follow the workflow at {project-root}/${workflowFile}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `---
|
||||||
|
description: '${description}'
|
||||||
|
agent: 'agent'
|
||||||
|
tools: ${toolsStr}
|
||||||
|
---
|
||||||
|
|
||||||
|
${body}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a short 2-5 word description for a prompt from the entry name
|
||||||
|
* @param {string} name - Entry name from bmad-help.csv
|
||||||
|
* @returns {string} Short description
|
||||||
|
*/
|
||||||
|
createPromptDescription(name) {
|
||||||
|
const descriptionMap = {
|
||||||
|
'Brainstorm Project': 'Brainstorm ideas',
|
||||||
|
'Market Research': 'Market research',
|
||||||
|
'Domain Research': 'Domain research',
|
||||||
|
'Technical Research': 'Technical research',
|
||||||
|
'Create Brief': 'Create product brief',
|
||||||
|
'Create PRD': 'Create PRD',
|
||||||
|
'Validate PRD': 'Validate PRD',
|
||||||
|
'Edit PRD': 'Edit PRD',
|
||||||
|
'Create UX': 'Create UX design',
|
||||||
|
'Create Architecture': 'Create architecture',
|
||||||
|
'Create Epics and Stories': 'Create epics and stories',
|
||||||
|
'Check Implementation Readiness': 'Check implementation readiness',
|
||||||
|
'Sprint Planning': 'Sprint planning',
|
||||||
|
'Sprint Status': 'Sprint status',
|
||||||
|
'Create Story': 'Create story',
|
||||||
|
'Validate Story': 'Validate story',
|
||||||
|
'Dev Story': 'Dev story',
|
||||||
|
'QA Automation Test': 'QA automation',
|
||||||
|
'Code Review': 'Code review',
|
||||||
|
Retrospective: 'Retrospective',
|
||||||
|
'Document Project': 'Document project',
|
||||||
|
'Generate Project Context': 'Generate project context',
|
||||||
|
'Quick Spec': 'Quick spec',
|
||||||
|
'Quick Dev': 'Quick dev',
|
||||||
|
'Correct Course': 'Correct course',
|
||||||
|
Brainstorming: 'Brainstorm ideas',
|
||||||
|
'Party Mode': 'Party mode',
|
||||||
|
'bmad-help': 'BMAD help',
|
||||||
|
'Index Docs': 'Index documents',
|
||||||
|
'Shard Document': 'Shard document',
|
||||||
|
'Editorial Review - Prose': 'Editorial review prose',
|
||||||
|
'Editorial Review - Structure': 'Editorial review structure',
|
||||||
|
'Adversarial Review (General)': 'Adversarial review',
|
||||||
|
};
|
||||||
|
|
||||||
|
return descriptionMap[name] || name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create prompt content for tech-writer agent-only commands (Pattern C)
|
||||||
|
* @param {Object} entry - bmad-help.csv row
|
||||||
|
* @returns {Object|null} { fileName, content } or null if not a tech-writer command
|
||||||
|
*/
|
||||||
|
createTechWriterPromptContent(entry) {
|
||||||
|
if (entry['agent-name'] !== 'tech-writer') return null;
|
||||||
|
|
||||||
|
const techWriterCommands = {
|
||||||
|
'Write Document': { code: 'WD', file: 'bmad-bmm-write-document', description: 'Write document' },
|
||||||
|
'Update Standards': { code: 'US', file: 'bmad-bmm-update-standards', description: 'Update standards' },
|
||||||
|
'Mermaid Generate': { code: 'MG', file: 'bmad-bmm-mermaid-generate', description: 'Mermaid generate' },
|
||||||
|
'Validate Document': { code: 'VD', file: 'bmad-bmm-validate-document', description: 'Validate document' },
|
||||||
|
'Explain Concept': { code: 'EC', file: 'bmad-bmm-explain-concept', description: 'Explain concept' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const cmd = techWriterCommands[entry.name];
|
||||||
|
if (!cmd) return null;
|
||||||
|
|
||||||
|
const safeDescription = this.escapeYamlSingleQuote(cmd.description);
|
||||||
|
const toolsStr = this.getToolsForFile(`${cmd.file}.prompt.md`);
|
||||||
|
|
||||||
|
const content = `---
|
||||||
|
description: '${safeDescription}'
|
||||||
|
agent: 'agent'
|
||||||
|
tools: ${toolsStr}
|
||||||
|
---
|
||||||
|
|
||||||
|
1. Load {project-root}/${this.bmadFolderName}/bmm/config.yaml and store ALL fields as session variables
|
||||||
|
2. Load the full agent file from {project-root}/${this.bmadFolderName}/bmm/agents/tech-writer/tech-writer.md and activate the Paige (Technical Writer) persona
|
||||||
|
3. Execute the ${entry.name} menu command (${cmd.code})
|
||||||
|
`;
|
||||||
|
|
||||||
|
return { fileName: cmd.file, content };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create agent activator prompt content (Pattern D)
|
||||||
|
* @param {Object} artifact - Agent artifact
|
||||||
|
* @param {Object|undefined} manifestEntry - Agent manifest entry
|
||||||
|
* @returns {string} Prompt file content
|
||||||
|
*/
|
||||||
|
createAgentActivatorPromptContent(artifact, manifestEntry, toolsStr) {
|
||||||
|
let description;
|
||||||
|
if (manifestEntry) {
|
||||||
|
description = manifestEntry.title || this.formatTitle(artifact.name);
|
||||||
|
} else {
|
||||||
|
description = this.formatTitle(artifact.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeDescription = this.escapeYamlSingleQuote(description);
|
||||||
|
const agentPath = artifact.agentPath || artifact.relativePath;
|
||||||
|
const agentFilePath = `{project-root}/${this.bmadFolderName}/${agentPath}`;
|
||||||
|
|
||||||
|
// bmm/config.yaml is safe to hardcode: agent activators are only generated from
|
||||||
|
// bmm agent artifacts, so bmm is guaranteed to be installed.
|
||||||
|
return `---
|
||||||
|
description: '${safeDescription}'
|
||||||
|
agent: 'agent'
|
||||||
|
tools: ${toolsStr}
|
||||||
|
---
|
||||||
|
|
||||||
|
1. Load {project-root}/${this.bmadFolderName}/bmm/config.yaml and store ALL fields as session variables
|
||||||
|
2. Load the full agent file from ${agentFilePath}
|
||||||
|
3. Follow ALL activation instructions in the agent file
|
||||||
|
4. Display the welcome/greeting as instructed
|
||||||
|
5. Present the numbered menu
|
||||||
|
6. Wait for user input before proceeding
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate copilot-instructions.md from module config
|
||||||
|
* @param {string} projectDir - Project directory
|
||||||
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
|
* @param {Map} agentManifest - Agent manifest data
|
||||||
|
*/
|
||||||
|
async generateCopilotInstructions(projectDir, bmadDir, agentManifest) {
|
||||||
|
const configVars = await this.loadModuleConfig(bmadDir);
|
||||||
|
|
||||||
|
// Build the agents table from the manifest
|
||||||
|
let agentsTable = '| Agent | Persona | Title | Capabilities |\n|---|---|---|---|\n';
|
||||||
|
const agentOrder = [
|
||||||
|
'bmad-master',
|
||||||
|
'analyst',
|
||||||
|
'architect',
|
||||||
|
'dev',
|
||||||
|
'pm',
|
||||||
|
'qa',
|
||||||
|
'quick-flow-solo-dev',
|
||||||
|
'sm',
|
||||||
|
'tech-writer',
|
||||||
|
'ux-designer',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const agentName of agentOrder) {
|
||||||
|
const meta = agentManifest.get(agentName);
|
||||||
|
if (meta) {
|
||||||
|
const capabilities = meta.capabilities || 'agent capabilities';
|
||||||
|
const cleanTitle = (meta.title || '').replaceAll('""', '"');
|
||||||
|
agentsTable += `| ${agentName} | ${meta.displayName} | ${cleanTitle} | ${capabilities} |\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bmad = this.bmadFolderName;
|
||||||
|
const bmadSection = `# BMAD Method — Project Instructions
|
||||||
|
|
||||||
|
## Project Configuration
|
||||||
|
|
||||||
|
- **Project**: ${configVars.project_name || '{{project_name}}'}
|
||||||
|
- **User**: ${configVars.user_name || '{{user_name}}'}
|
||||||
|
- **Communication Language**: ${configVars.communication_language || '{{communication_language}}'}
|
||||||
|
- **Document Output Language**: ${configVars.document_output_language || '{{document_output_language}}'}
|
||||||
|
- **User Skill Level**: ${configVars.user_skill_level || '{{user_skill_level}}'}
|
||||||
|
- **Output Folder**: ${configVars.output_folder || '{{output_folder}}'}
|
||||||
|
- **Planning Artifacts**: ${configVars.planning_artifacts || '{{planning_artifacts}}'}
|
||||||
|
- **Implementation Artifacts**: ${configVars.implementation_artifacts || '{{implementation_artifacts}}'}
|
||||||
|
- **Project Knowledge**: ${configVars.project_knowledge || '{{project_knowledge}}'}
|
||||||
|
|
||||||
|
## BMAD Runtime Structure
|
||||||
|
|
||||||
|
- **Agent definitions**: \`${bmad}/bmm/agents/\` (BMM module) and \`${bmad}/core/agents/\` (core)
|
||||||
|
- **Workflow definitions**: \`${bmad}/bmm/workflows/\` (organized by phase)
|
||||||
|
- **Core tasks**: \`${bmad}/core/tasks/\` (help, editorial review, indexing, sharding, adversarial review)
|
||||||
|
- **Core workflows**: \`${bmad}/core/workflows/\` (brainstorming, party-mode, advanced-elicitation)
|
||||||
|
- **Workflow engine**: \`${bmad}/core/tasks/workflow.xml\` (executes YAML-based workflows)
|
||||||
|
- **Module configuration**: \`${bmad}/bmm/config.yaml\`
|
||||||
|
- **Core configuration**: \`${bmad}/core/config.yaml\`
|
||||||
|
- **Agent manifest**: \`${bmad}/_config/agent-manifest.csv\`
|
||||||
|
- **Workflow manifest**: \`${bmad}/_config/workflow-manifest.csv\`
|
||||||
|
- **Help manifest**: \`${bmad}/_config/bmad-help.csv\`
|
||||||
|
- **Agent memory**: \`${bmad}/_memory/\`
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- Always load \`${bmad}/bmm/config.yaml\` before any agent activation or workflow execution
|
||||||
|
- Store all config fields as session variables: \`{user_name}\`, \`{communication_language}\`, \`{output_folder}\`, \`{planning_artifacts}\`, \`{implementation_artifacts}\`, \`{project_knowledge}\`
|
||||||
|
- MD-based workflows execute directly — load and follow the \`.md\` file
|
||||||
|
- YAML-based workflows require the workflow engine — load \`workflow.xml\` first, then pass the \`.yaml\` config
|
||||||
|
- Follow step-based workflow execution: load steps JIT, never multiple at once
|
||||||
|
- Save outputs after EACH step when using the workflow engine
|
||||||
|
- The \`{project-root}\` variable resolves to the workspace root at runtime
|
||||||
|
|
||||||
|
## Available Agents
|
||||||
|
|
||||||
|
${agentsTable}
|
||||||
|
## Slash Commands
|
||||||
|
|
||||||
|
Type \`/bmad-\` in Copilot Chat to see all available BMAD workflows and agent activators. Agents are also available in the agents dropdown.`;
|
||||||
|
|
||||||
|
const instructionsPath = path.join(projectDir, this.githubDir, 'copilot-instructions.md');
|
||||||
|
const markerStart = '<!-- BMAD:START -->';
|
||||||
|
const markerEnd = '<!-- BMAD:END -->';
|
||||||
|
const markedContent = `${markerStart}\n${bmadSection}\n${markerEnd}`;
|
||||||
|
|
||||||
|
if (await fs.pathExists(instructionsPath)) {
|
||||||
|
const existing = await fs.readFile(instructionsPath, 'utf8');
|
||||||
|
const startIdx = existing.indexOf(markerStart);
|
||||||
|
const endIdx = existing.indexOf(markerEnd);
|
||||||
|
|
||||||
|
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
||||||
|
// Replace only the BMAD section between markers
|
||||||
|
const before = existing.slice(0, startIdx);
|
||||||
|
const after = existing.slice(endIdx + markerEnd.length);
|
||||||
|
const merged = `${before}${markedContent}${after}`;
|
||||||
|
await this.writeFile(instructionsPath, merged);
|
||||||
|
console.log(chalk.green(' ✓ Updated BMAD section in copilot-instructions.md'));
|
||||||
|
} else {
|
||||||
|
// Existing file without markers — back it up before overwriting
|
||||||
|
const backupPath = `${instructionsPath}.bak`;
|
||||||
|
await fs.copy(instructionsPath, backupPath);
|
||||||
|
console.log(chalk.yellow(` ⚠ Backed up existing copilot-instructions.md → copilot-instructions.md.bak`));
|
||||||
|
await this.writeFile(instructionsPath, `${markedContent}\n`);
|
||||||
|
console.log(chalk.green(' ✓ Generated copilot-instructions.md (with BMAD markers)'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No existing file — create fresh with markers
|
||||||
|
await this.writeFile(instructionsPath, `${markedContent}\n`);
|
||||||
|
console.log(chalk.green(' ✓ Generated copilot-instructions.md'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load module config.yaml for template variables
|
||||||
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
|
* @returns {Object} Config variables
|
||||||
|
*/
|
||||||
|
async loadModuleConfig(bmadDir) {
|
||||||
|
const bmmConfigPath = path.join(bmadDir, 'bmm', 'config.yaml');
|
||||||
|
const coreConfigPath = path.join(bmadDir, 'core', 'config.yaml');
|
||||||
|
|
||||||
|
for (const configPath of [bmmConfigPath, coreConfigPath]) {
|
||||||
|
if (await fs.pathExists(configPath)) {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(configPath, 'utf8');
|
||||||
|
return yaml.parse(content) || {};
|
||||||
|
} catch {
|
||||||
|
// Fall through to next config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape a string for use inside YAML single-quoted values.
|
||||||
|
* In YAML, the only escape inside single quotes is '' for a literal '.
|
||||||
|
* @param {string} value - Raw string
|
||||||
|
* @returns {string} Escaped string safe for YAML single-quoted context
|
||||||
|
*/
|
||||||
|
escapeYamlSingleQuote(value) {
|
||||||
|
return (value || '').replaceAll("'", "''");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan existing agent and prompt files for customised tool permissions before cleanup.
|
||||||
|
* Returns a Map<filename, toolsArray> so permissions can be preserved across reinstalls.
|
||||||
|
* @param {string} projectDir - Project directory
|
||||||
|
* @returns {Map} Existing tool permissions keyed by filename
|
||||||
|
*/
|
||||||
|
async collectExistingToolPermissions(projectDir) {
|
||||||
|
const permissions = new Map();
|
||||||
|
const dirs = [
|
||||||
|
[path.join(projectDir, this.githubDir, this.agentsDir), /^bmad.*\.agent\.md$/],
|
||||||
|
[path.join(projectDir, this.githubDir, this.promptsDir), /^bmad-.*\.prompt\.md$/],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [dir, pattern] of dirs) {
|
||||||
|
if (!(await fs.pathExists(dir))) continue;
|
||||||
|
const files = await fs.readdir(dir);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (!pattern.test(file)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(path.join(dir, file), 'utf8');
|
||||||
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||||
|
if (!fmMatch) continue;
|
||||||
|
|
||||||
|
const frontmatter = yaml.parse(fmMatch[1]);
|
||||||
|
if (frontmatter && Array.isArray(frontmatter.tools)) {
|
||||||
|
permissions.set(file, frontmatter.tools);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip unreadable files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the tools array string for a file, preserving any existing customisation.
|
||||||
|
* Falls back to the default tools if no prior customisation exists.
|
||||||
|
* @param {string} fileName - Target filename (e.g. 'bmad-agent-bmm-pm.agent.md')
|
||||||
|
* @returns {string} YAML inline array string
|
||||||
|
*/
|
||||||
|
getToolsForFile(fileName) {
|
||||||
|
const defaultTools = ['read', 'edit', 'search', 'execute'];
|
||||||
|
const tools = (this.existingToolPermissions && this.existingToolPermissions.get(fileName)) || defaultTools;
|
||||||
|
return '[' + tools.map((t) => `'${t}'`).join(', ') + ']';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format name as title
|
||||||
|
*/
|
||||||
|
formatTitle(name) {
|
||||||
|
return name
|
||||||
|
.split('-')
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup GitHub Copilot configuration - surgically remove only BMAD files
|
||||||
|
*/
|
||||||
|
async cleanup(projectDir) {
|
||||||
|
// Clean up agents directory
|
||||||
|
const agentsDir = path.join(projectDir, this.githubDir, this.agentsDir);
|
||||||
|
if (await fs.pathExists(agentsDir)) {
|
||||||
|
const files = await fs.readdir(agentsDir);
|
||||||
|
let removed = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.startsWith('bmad') && (file.endsWith('.agent.md') || file.endsWith('.md'))) {
|
||||||
|
await fs.remove(path.join(agentsDir, file));
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed > 0) {
|
||||||
|
console.log(chalk.dim(` Cleaned up ${removed} existing BMAD agents`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up prompts directory
|
||||||
|
const promptsDir = path.join(projectDir, this.githubDir, this.promptsDir);
|
||||||
|
if (await fs.pathExists(promptsDir)) {
|
||||||
|
const files = await fs.readdir(promptsDir);
|
||||||
|
let removed = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.startsWith('bmad-') && file.endsWith('.prompt.md')) {
|
||||||
|
await fs.remove(path.join(promptsDir, file));
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed > 0) {
|
||||||
|
console.log(chalk.dim(` Cleaned up ${removed} existing BMAD prompts`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: copilot-instructions.md is NOT cleaned up here.
|
||||||
|
// generateCopilotInstructions() handles marker-based replacement in a single
|
||||||
|
// read-modify-write pass, which correctly preserves user content outside the markers.
|
||||||
|
// Stripping markers here would cause generation to treat the file as legacy (no markers)
|
||||||
|
// and overwrite user content.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { GitHubCopilotSetup };
|
||||||
|
|
@ -8,7 +8,7 @@ const prompts = require('../../../lib/prompts');
|
||||||
* Dynamically discovers and loads IDE handlers
|
* Dynamically discovers and loads IDE handlers
|
||||||
*
|
*
|
||||||
* Loading strategy:
|
* Loading strategy:
|
||||||
* 1. Custom installer files (codex.js, kilo.js) - for platforms with unique installation logic
|
* 1. Custom installer files (codex.js, github-copilot.js, kilo.js) - for platforms with unique installation logic
|
||||||
* 2. Config-driven handlers (from platform-codes.yaml) - for standard IDE installation patterns
|
* 2. Config-driven handlers (from platform-codes.yaml) - for standard IDE installation patterns
|
||||||
*/
|
*/
|
||||||
class IdeManager {
|
class IdeManager {
|
||||||
|
|
@ -44,7 +44,7 @@ class IdeManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dynamically load all IDE handlers
|
* Dynamically load all IDE handlers
|
||||||
* 1. Load custom installer files first (codex.js, kilo.js)
|
* 1. Load custom installer files first (codex.js, github-copilot.js, kilo.js)
|
||||||
* 2. Load config-driven handlers from platform-codes.yaml
|
* 2. Load config-driven handlers from platform-codes.yaml
|
||||||
*/
|
*/
|
||||||
async loadHandlers() {
|
async loadHandlers() {
|
||||||
|
|
@ -61,7 +61,7 @@ class IdeManager {
|
||||||
*/
|
*/
|
||||||
async loadCustomInstallerFiles() {
|
async loadCustomInstallerFiles() {
|
||||||
const ideDir = __dirname;
|
const ideDir = __dirname;
|
||||||
const customFiles = ['codex.js', 'kilo.js'];
|
const customFiles = ['codex.js', 'github-copilot.js', 'kilo.js'];
|
||||||
|
|
||||||
for (const file of customFiles) {
|
for (const file of customFiles) {
|
||||||
const filePath = path.join(ideDir, file);
|
const filePath = path.join(ideDir, file);
|
||||||
|
|
|
||||||
|
|
@ -89,11 +89,7 @@ platforms:
|
||||||
preferred: false
|
preferred: false
|
||||||
category: ide
|
category: ide
|
||||||
description: "GitHub's AI pair programmer"
|
description: "GitHub's AI pair programmer"
|
||||||
installer:
|
# No installer config - uses custom github-copilot.js
|
||||||
targets:
|
|
||||||
- target_dir: .github/agents
|
|
||||||
template_type: copilot_agents
|
|
||||||
artifact_types: [agents]
|
|
||||||
|
|
||||||
iflow:
|
iflow:
|
||||||
name: "iFlow"
|
name: "iFlow"
|
||||||
|
|
|
||||||
|
|
@ -1247,17 +1247,20 @@ class ModuleManager {
|
||||||
/**
|
/**
|
||||||
* Create directories declared in module.yaml's `directories` key
|
* Create directories declared in module.yaml's `directories` key
|
||||||
* This replaces the security-risky module installer pattern with declarative config
|
* This replaces the security-risky module installer pattern with declarative config
|
||||||
|
* During updates, if a directory path changed, moves the old directory to the new path
|
||||||
* @param {string} moduleName - Name of the module
|
* @param {string} moduleName - Name of the module
|
||||||
* @param {string} bmadDir - Target bmad directory
|
* @param {string} bmadDir - Target bmad directory
|
||||||
* @param {Object} options - Installation options
|
* @param {Object} options - Installation options
|
||||||
* @param {Object} options.moduleConfig - Module configuration from config collector
|
* @param {Object} options.moduleConfig - Module configuration from config collector
|
||||||
|
* @param {Object} options.existingModuleConfig - Previous module config (for detecting path changes during updates)
|
||||||
* @param {Object} options.coreConfig - Core configuration
|
* @param {Object} options.coreConfig - Core configuration
|
||||||
* @returns {Promise<{createdDirs: string[], createdWdsFolders: string[]}>} Created directories info
|
* @returns {Promise<{createdDirs: string[], movedDirs: string[], createdWdsFolders: string[]}>} Created directories info
|
||||||
*/
|
*/
|
||||||
async createModuleDirectories(moduleName, bmadDir, options = {}) {
|
async createModuleDirectories(moduleName, bmadDir, options = {}) {
|
||||||
const moduleConfig = options.moduleConfig || {};
|
const moduleConfig = options.moduleConfig || {};
|
||||||
|
const existingModuleConfig = options.existingModuleConfig || {};
|
||||||
const projectRoot = path.dirname(bmadDir);
|
const projectRoot = path.dirname(bmadDir);
|
||||||
const emptyResult = { createdDirs: [], createdWdsFolders: [] };
|
const emptyResult = { createdDirs: [], movedDirs: [], createdWdsFolders: [] };
|
||||||
|
|
||||||
// Special handling for core module - it's in src/core not src/modules
|
// Special handling for core module - it's in src/core not src/modules
|
||||||
let sourcePath;
|
let sourcePath;
|
||||||
|
|
@ -1291,6 +1294,7 @@ class ModuleManager {
|
||||||
const directories = moduleYaml.directories;
|
const directories = moduleYaml.directories;
|
||||||
const wdsFolders = moduleYaml.wds_folders || [];
|
const wdsFolders = moduleYaml.wds_folders || [];
|
||||||
const createdDirs = [];
|
const createdDirs = [];
|
||||||
|
const movedDirs = [];
|
||||||
const createdWdsFolders = [];
|
const createdWdsFolders = [];
|
||||||
|
|
||||||
for (const dirRef of directories) {
|
for (const dirRef of directories) {
|
||||||
|
|
@ -1325,9 +1329,74 @@ class ModuleManager {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create directory if it doesn't exist
|
// Check if directory path changed from previous config (update/modify scenario)
|
||||||
if (!(await fs.pathExists(fullPath))) {
|
const oldDirValue = existingModuleConfig[configKey];
|
||||||
const dirName = configKey.replaceAll('_', ' ');
|
let oldFullPath = null;
|
||||||
|
let oldDirPath = null;
|
||||||
|
if (oldDirValue && typeof oldDirValue === 'string') {
|
||||||
|
// F3: Normalize both values before comparing to avoid false negatives
|
||||||
|
// from trailing slashes, separator differences, or prefix format variations
|
||||||
|
let normalizedOld = oldDirValue.replace(/^\{project-root\}\/?/, '');
|
||||||
|
normalizedOld = path.normalize(normalizedOld.replaceAll('{project-root}', ''));
|
||||||
|
const normalizedNew = path.normalize(dirPath);
|
||||||
|
|
||||||
|
if (normalizedOld !== normalizedNew) {
|
||||||
|
oldDirPath = normalizedOld;
|
||||||
|
oldFullPath = path.join(projectRoot, oldDirPath);
|
||||||
|
const normalizedOldAbsolute = path.normalize(oldFullPath);
|
||||||
|
if (!normalizedOldAbsolute.startsWith(normalizedRoot + path.sep) && normalizedOldAbsolute !== normalizedRoot) {
|
||||||
|
oldFullPath = null; // Old path escapes project root, ignore it
|
||||||
|
}
|
||||||
|
|
||||||
|
// F13: Prevent parent/child move (e.g. docs/planning → docs/planning/v2)
|
||||||
|
if (oldFullPath) {
|
||||||
|
const normalizedNewAbsolute = path.normalize(fullPath);
|
||||||
|
if (
|
||||||
|
normalizedOldAbsolute.startsWith(normalizedNewAbsolute + path.sep) ||
|
||||||
|
normalizedNewAbsolute.startsWith(normalizedOldAbsolute + path.sep)
|
||||||
|
) {
|
||||||
|
const color = await prompts.getColor();
|
||||||
|
await prompts.log.warn(
|
||||||
|
color.yellow(
|
||||||
|
`${configKey}: cannot move between parent/child paths (${oldDirPath} / ${dirPath}), creating new directory instead`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
oldFullPath = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirName = configKey.replaceAll('_', ' ');
|
||||||
|
|
||||||
|
if (oldFullPath && (await fs.pathExists(oldFullPath)) && !(await fs.pathExists(fullPath))) {
|
||||||
|
// Path changed and old dir exists → move old to new location
|
||||||
|
// F1: Use fs.move() instead of fs.rename() for cross-device/volume support
|
||||||
|
// F2: Wrap in try/catch — fallback to creating new dir on failure
|
||||||
|
try {
|
||||||
|
await fs.ensureDir(path.dirname(fullPath));
|
||||||
|
await fs.move(oldFullPath, fullPath);
|
||||||
|
movedDirs.push(`${dirName}: ${oldDirPath} → ${dirPath}`);
|
||||||
|
} catch (moveError) {
|
||||||
|
const color = await prompts.getColor();
|
||||||
|
await prompts.log.warn(
|
||||||
|
color.yellow(
|
||||||
|
`Failed to move ${oldDirPath} → ${dirPath}: ${moveError.message}\n Creating new directory instead. Please move contents from the old directory manually.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await fs.ensureDir(fullPath);
|
||||||
|
createdDirs.push(`${dirName}: ${dirPath}`);
|
||||||
|
}
|
||||||
|
} else if (oldFullPath && (await fs.pathExists(oldFullPath)) && (await fs.pathExists(fullPath))) {
|
||||||
|
// F5: Both old and new directories exist — warn user about potential orphaned documents
|
||||||
|
const color = await prompts.getColor();
|
||||||
|
await prompts.log.warn(
|
||||||
|
color.yellow(
|
||||||
|
`${dirName}: path changed but both directories exist:\n Old: ${oldDirPath}\n New: ${dirPath}\n Old directory may contain orphaned documents — please review and merge manually.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (!(await fs.pathExists(fullPath))) {
|
||||||
|
// New directory doesn't exist yet → create it
|
||||||
createdDirs.push(`${dirName}: ${dirPath}`);
|
createdDirs.push(`${dirName}: ${dirPath}`);
|
||||||
await fs.ensureDir(fullPath);
|
await fs.ensureDir(fullPath);
|
||||||
}
|
}
|
||||||
|
|
@ -1344,7 +1413,7 @@ class ModuleManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { createdDirs, createdWdsFolders };
|
return { createdDirs, movedDirs, createdWdsFolders };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,9 @@ async function compileToXml(agentYaml, agentName = '', targetPath = '') {
|
||||||
`title="${meta.title || ''}"`,
|
`title="${meta.title || ''}"`,
|
||||||
`icon="${meta.icon || '🤖'}"`,
|
`icon="${meta.icon || '🤖'}"`,
|
||||||
];
|
];
|
||||||
|
if (meta.capabilities) {
|
||||||
|
agentAttrs.push(`capabilities="${escapeXml(meta.capabilities)}"`);
|
||||||
|
}
|
||||||
|
|
||||||
xml += `<agent ${agentAttrs.join(' ')}>\n`;
|
xml += `<agent ${agentAttrs.join(' ')}>\n`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@ class UI {
|
||||||
const installedVersion = existingInstall.version || 'unknown';
|
const installedVersion = existingInstall.version || 'unknown';
|
||||||
|
|
||||||
// Check if version is pre beta
|
// Check if version is pre beta
|
||||||
const shouldProceed = await this.showLegacyVersionWarning(installedVersion, currentVersion, path.basename(bmadDir));
|
const shouldProceed = await this.showLegacyVersionWarning(installedVersion, currentVersion, path.basename(bmadDir), options);
|
||||||
|
|
||||||
// If user chose to cancel, exit the installer
|
// If user chose to cancel, exit the installer
|
||||||
if (!shouldProceed) {
|
if (!shouldProceed) {
|
||||||
|
|
@ -227,6 +227,14 @@ class UI {
|
||||||
}
|
}
|
||||||
actionType = options.action;
|
actionType = options.action;
|
||||||
await prompts.log.info(`Using action from command-line: ${actionType}`);
|
await prompts.log.info(`Using action from command-line: ${actionType}`);
|
||||||
|
} else if (options.yes) {
|
||||||
|
// Default to quick-update if available, otherwise first available choice
|
||||||
|
if (choices.length === 0) {
|
||||||
|
throw new Error('No valid actions available for this installation');
|
||||||
|
}
|
||||||
|
const hasQuickUpdate = choices.some((c) => c.value === 'quick-update');
|
||||||
|
actionType = hasQuickUpdate ? 'quick-update' : choices[0].value;
|
||||||
|
await prompts.log.info(`Non-interactive mode (--yes): defaulting to ${actionType}`);
|
||||||
} else {
|
} else {
|
||||||
actionType = await prompts.select({
|
actionType = await prompts.select({
|
||||||
message: 'How would you like to proceed?',
|
message: 'How would you like to proceed?',
|
||||||
|
|
@ -242,6 +250,7 @@ class UI {
|
||||||
actionType: 'quick-update',
|
actionType: 'quick-update',
|
||||||
directory: confirmedDirectory,
|
directory: confirmedDirectory,
|
||||||
customContent: { hasCustomContent: false },
|
customContent: { hasCustomContent: false },
|
||||||
|
skipPrompts: options.yes || false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -252,6 +261,7 @@ class UI {
|
||||||
actionType: 'compile-agents',
|
actionType: 'compile-agents',
|
||||||
directory: confirmedDirectory,
|
directory: confirmedDirectory,
|
||||||
customContent: { hasCustomContent: false },
|
customContent: { hasCustomContent: false },
|
||||||
|
skipPrompts: options.yes || false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,6 +282,11 @@ class UI {
|
||||||
.map((m) => m.trim())
|
.map((m) => m.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`);
|
await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`);
|
||||||
|
} else if (options.yes) {
|
||||||
|
selectedModules = await this.getDefaultModules(installedModuleIds);
|
||||||
|
await prompts.log.info(
|
||||||
|
`Non-interactive mode (--yes): using default modules (installed + defaults): ${selectedModules.join(', ')}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
selectedModules = await this.selectAllModules(installedModuleIds);
|
selectedModules = await this.selectAllModules(installedModuleIds);
|
||||||
}
|
}
|
||||||
|
|
@ -330,6 +345,22 @@ class UI {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
} else if (options.yes) {
|
||||||
|
// Non-interactive mode: preserve existing custom modules (matches default: false)
|
||||||
|
const cacheDir = path.join(bmadDir, '_config', 'custom');
|
||||||
|
if (await fs.pathExists(cacheDir)) {
|
||||||
|
const entries = await fs.readdir(cacheDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
customModuleResult.selectedCustomModules.push(entry.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await prompts.log.info(
|
||||||
|
`Non-interactive mode (--yes): preserving ${customModuleResult.selectedCustomModules.length} existing custom module(s)`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await prompts.log.info('Non-interactive mode (--yes): no existing custom modules found');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const changeCustomModules = await prompts.confirm({
|
const changeCustomModules = await prompts.confirm({
|
||||||
message: 'Modify custom modules, agents, or workflows?',
|
message: 'Modify custom modules, agents, or workflows?',
|
||||||
|
|
@ -378,6 +409,7 @@ class UI {
|
||||||
skipIde: toolSelection.skipIde,
|
skipIde: toolSelection.skipIde,
|
||||||
coreConfig: coreConfig,
|
coreConfig: coreConfig,
|
||||||
customContent: customModuleResult.customContentConfig,
|
customContent: customModuleResult.customContentConfig,
|
||||||
|
skipPrompts: options.yes || false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -529,6 +561,27 @@ class UI {
|
||||||
if (configuredIdes.length > 0) {
|
if (configuredIdes.length > 0) {
|
||||||
const allTools = [...preferredIdes, ...otherIdes];
|
const allTools = [...preferredIdes, ...otherIdes];
|
||||||
|
|
||||||
|
// Non-interactive: handle --tools and --yes flags before interactive prompt
|
||||||
|
if (options.tools) {
|
||||||
|
if (options.tools.toLowerCase() === 'none') {
|
||||||
|
await prompts.log.info('Skipping tool configuration (--tools none)');
|
||||||
|
return { ides: [], skipIde: true };
|
||||||
|
}
|
||||||
|
const selectedIdes = options.tools
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
await prompts.log.info(`Using tools from command-line: ${selectedIdes.join(', ')}`);
|
||||||
|
await this.displaySelectedTools(selectedIdes, preferredIdes, allTools);
|
||||||
|
return { ides: selectedIdes, skipIde: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.yes) {
|
||||||
|
await prompts.log.info(`Non-interactive mode (--yes): keeping configured tools: ${configuredIdes.join(', ')}`);
|
||||||
|
await this.displaySelectedTools(configuredIdes, preferredIdes, allTools);
|
||||||
|
return { ides: configuredIdes, skipIde: false };
|
||||||
|
}
|
||||||
|
|
||||||
// Sort: configured tools first, then preferred, then others
|
// Sort: configured tools first, then preferred, then others
|
||||||
const sortedTools = [
|
const sortedTools = [
|
||||||
...allTools.filter((ide) => configuredIdes.includes(ide.value)),
|
...allTools.filter((ide) => configuredIdes.includes(ide.value)),
|
||||||
|
|
@ -1630,7 +1683,7 @@ class UI {
|
||||||
* @param {string} bmadFolderName - Name of the BMAD folder
|
* @param {string} bmadFolderName - Name of the BMAD folder
|
||||||
* @returns {Promise<boolean>} True if user wants to proceed, false if they cancel
|
* @returns {Promise<boolean>} True if user wants to proceed, false if they cancel
|
||||||
*/
|
*/
|
||||||
async showLegacyVersionWarning(installedVersion, currentVersion, bmadFolderName) {
|
async showLegacyVersionWarning(installedVersion, currentVersion, bmadFolderName, options = {}) {
|
||||||
if (!this.isLegacyVersion(installedVersion)) {
|
if (!this.isLegacyVersion(installedVersion)) {
|
||||||
return true; // Not legacy, proceed
|
return true; // Not legacy, proceed
|
||||||
}
|
}
|
||||||
|
|
@ -1656,6 +1709,11 @@ class UI {
|
||||||
await prompts.log.warn('VERSION WARNING');
|
await prompts.log.warn('VERSION WARNING');
|
||||||
await prompts.note(warningContent, 'Version Warning');
|
await prompts.note(warningContent, 'Version Warning');
|
||||||
|
|
||||||
|
if (options.yes) {
|
||||||
|
await prompts.log.warn('Non-interactive mode (--yes): auto-proceeding with legacy update');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const proceed = await prompts.select({
|
const proceed = await prompts.select({
|
||||||
message: 'How would you like to proceed?',
|
message: 'How would you like to proceed?',
|
||||||
choices: [
|
choices: [
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,7 @@ function buildMetadataSchema(expectedModule) {
|
||||||
title: createNonEmptyString('agent.metadata.title'),
|
title: createNonEmptyString('agent.metadata.title'),
|
||||||
icon: createNonEmptyString('agent.metadata.icon'),
|
icon: createNonEmptyString('agent.metadata.icon'),
|
||||||
module: createNonEmptyString('agent.metadata.module').optional(),
|
module: createNonEmptyString('agent.metadata.module').optional(),
|
||||||
|
capabilities: createNonEmptyString('agent.metadata.capabilities').optional(),
|
||||||
hasSidecar: z.boolean(),
|
hasSidecar: z.boolean(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue