Compare commits
No commits in common. "c31e334dd80f7ae2e01fa026c45989d374222e5e" and "a567170501b0aa56dedf2c934a5619a6e6071286" have entirely different histories.
c31e334dd8
...
a567170501
|
|
@ -148,6 +148,8 @@ async function runTests() {
|
||||||
|
|
||||||
assert(windsurfInstaller?.target_dir === '.windsurf/skills', 'Windsurf target_dir uses native skills path');
|
assert(windsurfInstaller?.target_dir === '.windsurf/skills', 'Windsurf target_dir uses native skills path');
|
||||||
|
|
||||||
|
assert(windsurfInstaller?.skill_format === true, 'Windsurf installer enables native skill output');
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(windsurfInstaller?.legacy_targets) && windsurfInstaller.legacy_targets.includes('.windsurf/workflows'),
|
Array.isArray(windsurfInstaller?.legacy_targets) && windsurfInstaller.legacy_targets.includes('.windsurf/workflows'),
|
||||||
'Windsurf installer cleans legacy workflow output',
|
'Windsurf installer cleans legacy workflow output',
|
||||||
|
|
@ -194,6 +196,8 @@ async function runTests() {
|
||||||
|
|
||||||
assert(kiroInstaller?.target_dir === '.kiro/skills', 'Kiro target_dir uses native skills path');
|
assert(kiroInstaller?.target_dir === '.kiro/skills', 'Kiro target_dir uses native skills path');
|
||||||
|
|
||||||
|
assert(kiroInstaller?.skill_format === true, 'Kiro installer enables native skill output');
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(kiroInstaller?.legacy_targets) && kiroInstaller.legacy_targets.includes('.kiro/steering'),
|
Array.isArray(kiroInstaller?.legacy_targets) && kiroInstaller.legacy_targets.includes('.kiro/steering'),
|
||||||
'Kiro installer cleans legacy steering output',
|
'Kiro installer cleans legacy steering output',
|
||||||
|
|
@ -240,6 +244,8 @@ async function runTests() {
|
||||||
|
|
||||||
assert(antigravityInstaller?.target_dir === '.agent/skills', 'Antigravity target_dir uses native skills path');
|
assert(antigravityInstaller?.target_dir === '.agent/skills', 'Antigravity target_dir uses native skills path');
|
||||||
|
|
||||||
|
assert(antigravityInstaller?.skill_format === true, 'Antigravity installer enables native skill output');
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(antigravityInstaller?.legacy_targets) && antigravityInstaller.legacy_targets.includes('.agent/workflows'),
|
Array.isArray(antigravityInstaller?.legacy_targets) && antigravityInstaller.legacy_targets.includes('.agent/workflows'),
|
||||||
'Antigravity installer cleans legacy workflow output',
|
'Antigravity installer cleans legacy workflow output',
|
||||||
|
|
@ -286,6 +292,8 @@ async function runTests() {
|
||||||
|
|
||||||
assert(auggieInstaller?.target_dir === '.augment/skills', 'Auggie target_dir uses native skills path');
|
assert(auggieInstaller?.target_dir === '.augment/skills', 'Auggie target_dir uses native skills path');
|
||||||
|
|
||||||
|
assert(auggieInstaller?.skill_format === true, 'Auggie installer enables native skill output');
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(auggieInstaller?.legacy_targets) && auggieInstaller.legacy_targets.includes('.augment/commands'),
|
Array.isArray(auggieInstaller?.legacy_targets) && auggieInstaller.legacy_targets.includes('.augment/commands'),
|
||||||
'Auggie installer cleans legacy command output',
|
'Auggie installer cleans legacy command output',
|
||||||
|
|
@ -337,6 +345,8 @@ async function runTests() {
|
||||||
|
|
||||||
assert(opencodeInstaller?.target_dir === '.opencode/skills', 'OpenCode target_dir uses native skills path');
|
assert(opencodeInstaller?.target_dir === '.opencode/skills', 'OpenCode target_dir uses native skills path');
|
||||||
|
|
||||||
|
assert(opencodeInstaller?.skill_format === true, 'OpenCode installer enables native skill output');
|
||||||
|
|
||||||
assert(opencodeInstaller?.ancestor_conflict_check === true, 'OpenCode installer enables ancestor conflict checks');
|
assert(opencodeInstaller?.ancestor_conflict_check === true, 'OpenCode installer enables ancestor conflict checks');
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
|
|
@ -401,6 +411,8 @@ async function runTests() {
|
||||||
|
|
||||||
assert(claudeInstaller?.target_dir === '.claude/skills', 'Claude Code target_dir uses native skills path');
|
assert(claudeInstaller?.target_dir === '.claude/skills', 'Claude Code target_dir uses native skills path');
|
||||||
|
|
||||||
|
assert(claudeInstaller?.skill_format === true, 'Claude Code installer enables native skill output');
|
||||||
|
|
||||||
assert(claudeInstaller?.ancestor_conflict_check === true, 'Claude Code installer enables ancestor conflict checks');
|
assert(claudeInstaller?.ancestor_conflict_check === true, 'Claude Code installer enables ancestor conflict checks');
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
|
|
@ -492,6 +504,8 @@ async function runTests() {
|
||||||
|
|
||||||
assert(codexInstaller?.target_dir === '.agents/skills', 'Codex target_dir uses native skills path');
|
assert(codexInstaller?.target_dir === '.agents/skills', 'Codex target_dir uses native skills path');
|
||||||
|
|
||||||
|
assert(codexInstaller?.skill_format === true, 'Codex installer enables native skill output');
|
||||||
|
|
||||||
assert(codexInstaller?.ancestor_conflict_check === true, 'Codex installer enables ancestor conflict checks');
|
assert(codexInstaller?.ancestor_conflict_check === true, 'Codex installer enables ancestor conflict checks');
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
|
|
@ -580,6 +594,8 @@ async function runTests() {
|
||||||
|
|
||||||
assert(cursorInstaller?.target_dir === '.cursor/skills', 'Cursor target_dir uses native skills path');
|
assert(cursorInstaller?.target_dir === '.cursor/skills', 'Cursor target_dir uses native skills path');
|
||||||
|
|
||||||
|
assert(cursorInstaller?.skill_format === true, 'Cursor installer enables native skill output');
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(cursorInstaller?.legacy_targets) && cursorInstaller.legacy_targets.includes('.cursor/commands'),
|
Array.isArray(cursorInstaller?.legacy_targets) && cursorInstaller.legacy_targets.includes('.cursor/commands'),
|
||||||
'Cursor installer cleans legacy command output',
|
'Cursor installer cleans legacy command output',
|
||||||
|
|
@ -632,6 +648,8 @@ async function runTests() {
|
||||||
|
|
||||||
assert(rooInstaller?.target_dir === '.roo/skills', 'Roo target_dir uses native skills path');
|
assert(rooInstaller?.target_dir === '.roo/skills', 'Roo target_dir uses native skills path');
|
||||||
|
|
||||||
|
assert(rooInstaller?.skill_format === true, 'Roo installer enables native skill output');
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(rooInstaller?.legacy_targets) && rooInstaller.legacy_targets.includes('.roo/commands'),
|
Array.isArray(rooInstaller?.legacy_targets) && rooInstaller.legacy_targets.includes('.roo/commands'),
|
||||||
'Roo installer cleans legacy command output',
|
'Roo installer cleans legacy command output',
|
||||||
|
|
@ -738,6 +756,8 @@ async function runTests() {
|
||||||
|
|
||||||
assert(copilotInstaller?.target_dir === '.github/skills', 'GitHub Copilot target_dir uses native skills path');
|
assert(copilotInstaller?.target_dir === '.github/skills', 'GitHub Copilot target_dir uses native skills path');
|
||||||
|
|
||||||
|
assert(copilotInstaller?.skill_format === true, 'GitHub Copilot installer enables native skill output');
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(copilotInstaller?.legacy_targets) && copilotInstaller.legacy_targets.includes('.github/agents'),
|
Array.isArray(copilotInstaller?.legacy_targets) && copilotInstaller.legacy_targets.includes('.github/agents'),
|
||||||
'GitHub Copilot installer cleans legacy agents output',
|
'GitHub Copilot installer cleans legacy agents output',
|
||||||
|
|
@ -818,6 +838,8 @@ async function runTests() {
|
||||||
|
|
||||||
assert(clineInstaller?.target_dir === '.cline/skills', 'Cline target_dir uses native skills path');
|
assert(clineInstaller?.target_dir === '.cline/skills', 'Cline target_dir uses native skills path');
|
||||||
|
|
||||||
|
assert(clineInstaller?.skill_format === true, 'Cline installer enables native skill output');
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(clineInstaller?.legacy_targets) && clineInstaller.legacy_targets.includes('.clinerules/workflows'),
|
Array.isArray(clineInstaller?.legacy_targets) && clineInstaller.legacy_targets.includes('.clinerules/workflows'),
|
||||||
'Cline installer cleans legacy workflow output',
|
'Cline installer cleans legacy workflow output',
|
||||||
|
|
@ -878,6 +900,8 @@ async function runTests() {
|
||||||
|
|
||||||
assert(codebuddyInstaller?.target_dir === '.codebuddy/skills', 'CodeBuddy target_dir uses native skills path');
|
assert(codebuddyInstaller?.target_dir === '.codebuddy/skills', 'CodeBuddy target_dir uses native skills path');
|
||||||
|
|
||||||
|
assert(codebuddyInstaller?.skill_format === true, 'CodeBuddy installer enables native skill output');
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(codebuddyInstaller?.legacy_targets) && codebuddyInstaller.legacy_targets.includes('.codebuddy/commands'),
|
Array.isArray(codebuddyInstaller?.legacy_targets) && codebuddyInstaller.legacy_targets.includes('.codebuddy/commands'),
|
||||||
'CodeBuddy installer cleans legacy command output',
|
'CodeBuddy installer cleans legacy command output',
|
||||||
|
|
@ -936,6 +960,8 @@ async function runTests() {
|
||||||
|
|
||||||
assert(crushInstaller?.target_dir === '.crush/skills', 'Crush target_dir uses native skills path');
|
assert(crushInstaller?.target_dir === '.crush/skills', 'Crush target_dir uses native skills path');
|
||||||
|
|
||||||
|
assert(crushInstaller?.skill_format === true, 'Crush installer enables native skill output');
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(crushInstaller?.legacy_targets) && crushInstaller.legacy_targets.includes('.crush/commands'),
|
Array.isArray(crushInstaller?.legacy_targets) && crushInstaller.legacy_targets.includes('.crush/commands'),
|
||||||
'Crush installer cleans legacy command output',
|
'Crush installer cleans legacy command output',
|
||||||
|
|
@ -994,6 +1020,8 @@ async function runTests() {
|
||||||
|
|
||||||
assert(traeInstaller?.target_dir === '.trae/skills', 'Trae target_dir uses native skills path');
|
assert(traeInstaller?.target_dir === '.trae/skills', 'Trae target_dir uses native skills path');
|
||||||
|
|
||||||
|
assert(traeInstaller?.skill_format === true, 'Trae installer enables native skill output');
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(traeInstaller?.legacy_targets) && traeInstaller.legacy_targets.includes('.trae/rules'),
|
Array.isArray(traeInstaller?.legacy_targets) && traeInstaller.legacy_targets.includes('.trae/rules'),
|
||||||
'Trae installer cleans legacy rules output',
|
'Trae installer cleans legacy rules output',
|
||||||
|
|
@ -1109,6 +1137,8 @@ async function runTests() {
|
||||||
|
|
||||||
assert(geminiInstaller?.target_dir === '.gemini/skills', 'Gemini target_dir uses native skills path');
|
assert(geminiInstaller?.target_dir === '.gemini/skills', 'Gemini target_dir uses native skills path');
|
||||||
|
|
||||||
|
assert(geminiInstaller?.skill_format === true, 'Gemini installer enables native skill output');
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(geminiInstaller?.legacy_targets) && geminiInstaller.legacy_targets.includes('.gemini/commands'),
|
Array.isArray(geminiInstaller?.legacy_targets) && geminiInstaller.legacy_targets.includes('.gemini/commands'),
|
||||||
'Gemini installer cleans legacy commands output',
|
'Gemini installer cleans legacy commands output',
|
||||||
|
|
@ -1165,6 +1195,7 @@ async function runTests() {
|
||||||
const iflowInstaller = platformCodes24.platforms.iflow?.installer;
|
const iflowInstaller = platformCodes24.platforms.iflow?.installer;
|
||||||
|
|
||||||
assert(iflowInstaller?.target_dir === '.iflow/skills', 'iFlow target_dir uses native skills path');
|
assert(iflowInstaller?.target_dir === '.iflow/skills', 'iFlow target_dir uses native skills path');
|
||||||
|
assert(iflowInstaller?.skill_format === true, 'iFlow installer enables native skill output');
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(iflowInstaller?.legacy_targets) && iflowInstaller.legacy_targets.includes('.iflow/commands'),
|
Array.isArray(iflowInstaller?.legacy_targets) && iflowInstaller.legacy_targets.includes('.iflow/commands'),
|
||||||
'iFlow installer cleans legacy commands output',
|
'iFlow installer cleans legacy commands output',
|
||||||
|
|
@ -1214,6 +1245,7 @@ async function runTests() {
|
||||||
const qwenInstaller = platformCodes25.platforms.qwen?.installer;
|
const qwenInstaller = platformCodes25.platforms.qwen?.installer;
|
||||||
|
|
||||||
assert(qwenInstaller?.target_dir === '.qwen/skills', 'QwenCoder target_dir uses native skills path');
|
assert(qwenInstaller?.target_dir === '.qwen/skills', 'QwenCoder target_dir uses native skills path');
|
||||||
|
assert(qwenInstaller?.skill_format === true, 'QwenCoder installer enables native skill output');
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(qwenInstaller?.legacy_targets) && qwenInstaller.legacy_targets.includes('.qwen/commands'),
|
Array.isArray(qwenInstaller?.legacy_targets) && qwenInstaller.legacy_targets.includes('.qwen/commands'),
|
||||||
'QwenCoder installer cleans legacy commands output',
|
'QwenCoder installer cleans legacy commands output',
|
||||||
|
|
@ -1263,6 +1295,7 @@ async function runTests() {
|
||||||
const rovoInstaller = platformCodes26.platforms['rovo-dev']?.installer;
|
const rovoInstaller = platformCodes26.platforms['rovo-dev']?.installer;
|
||||||
|
|
||||||
assert(rovoInstaller?.target_dir === '.rovodev/skills', 'Rovo Dev target_dir uses native skills path');
|
assert(rovoInstaller?.target_dir === '.rovodev/skills', 'Rovo Dev target_dir uses native skills path');
|
||||||
|
assert(rovoInstaller?.skill_format === true, 'Rovo Dev installer enables native skill output');
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(rovoInstaller?.legacy_targets) && rovoInstaller.legacy_targets.includes('.rovodev/workflows'),
|
Array.isArray(rovoInstaller?.legacy_targets) && rovoInstaller.legacy_targets.includes('.rovodev/workflows'),
|
||||||
'Rovo Dev installer cleans legacy workflows output',
|
'Rovo Dev installer cleans legacy workflows output',
|
||||||
|
|
@ -1398,6 +1431,8 @@ async function runTests() {
|
||||||
const piInstaller = platformCodes28.platforms.pi?.installer;
|
const piInstaller = platformCodes28.platforms.pi?.installer;
|
||||||
|
|
||||||
assert(piInstaller?.target_dir === '.pi/skills', 'Pi target_dir uses native skills path');
|
assert(piInstaller?.target_dir === '.pi/skills', 'Pi target_dir uses native skills path');
|
||||||
|
assert(piInstaller?.skill_format === true, 'Pi installer enables native skill output');
|
||||||
|
assert(piInstaller?.template_type === 'default', 'Pi installer uses default skill template');
|
||||||
|
|
||||||
tempProjectDir28 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-pi-test-'));
|
tempProjectDir28 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-pi-test-'));
|
||||||
installedBmadDir28 = await createTestBmadFixture();
|
installedBmadDir28 = await createTestBmadFixture();
|
||||||
|
|
@ -1738,6 +1773,8 @@ async function runTests() {
|
||||||
const onaInstaller = platformCodes32.platforms.ona?.installer;
|
const onaInstaller = platformCodes32.platforms.ona?.installer;
|
||||||
|
|
||||||
assert(onaInstaller?.target_dir === '.ona/skills', 'Ona target_dir uses native skills path');
|
assert(onaInstaller?.target_dir === '.ona/skills', 'Ona target_dir uses native skills path');
|
||||||
|
assert(onaInstaller?.skill_format === true, 'Ona installer enables native skill output');
|
||||||
|
assert(onaInstaller?.template_type === 'default', 'Ona installer uses default skill template');
|
||||||
|
|
||||||
tempProjectDir32 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-ona-test-'));
|
tempProjectDir32 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-ona-test-'));
|
||||||
installedBmadDir32 = await createTestBmadFixture();
|
installedBmadDir32 = await createTestBmadFixture();
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,8 @@ module.exports = {
|
||||||
|
|
||||||
const existingInstall = await installer.getStatus(projectDir);
|
const existingInstall = await installer.getStatus(projectDir);
|
||||||
const version = existingInstall.version || 'unknown';
|
const version = existingInstall.version || 'unknown';
|
||||||
const modules = existingInstall.moduleIds.join(', ');
|
const modules = (existingInstall.modules || []).map((m) => m.id || m.name).join(', ');
|
||||||
const ides = existingInstall.ides.join(', ');
|
const ides = (existingInstall.ides || []).join(', ');
|
||||||
|
|
||||||
const outputFolder = await installer.getOutputFolder(projectDir);
|
const outputFolder = await installer.getOutputFolder(projectDir);
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,52 +0,0 @@
|
||||||
/**
|
|
||||||
* Clean install configuration built from user input.
|
|
||||||
* User input comes from either UI answers or headless CLI flags.
|
|
||||||
*/
|
|
||||||
class Config {
|
|
||||||
constructor({ directory, modules, ides, skipPrompts, verbose, actionType, coreConfig, moduleConfigs, quickUpdate }) {
|
|
||||||
this.directory = directory;
|
|
||||||
this.modules = Object.freeze([...modules]);
|
|
||||||
this.ides = Object.freeze([...ides]);
|
|
||||||
this.skipPrompts = skipPrompts;
|
|
||||||
this.verbose = verbose;
|
|
||||||
this.actionType = actionType;
|
|
||||||
this.coreConfig = coreConfig;
|
|
||||||
this.moduleConfigs = moduleConfigs;
|
|
||||||
this._quickUpdate = quickUpdate;
|
|
||||||
Object.freeze(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a clean install config from raw user input.
|
|
||||||
* @param {Object} userInput - UI answers or CLI flags
|
|
||||||
* @returns {Config}
|
|
||||||
*/
|
|
||||||
static build(userInput) {
|
|
||||||
const modules = [...(userInput.modules || [])];
|
|
||||||
if (userInput.installCore && !modules.includes('core')) {
|
|
||||||
modules.unshift('core');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Config({
|
|
||||||
directory: userInput.directory,
|
|
||||||
modules,
|
|
||||||
ides: userInput.skipIde ? [] : [...(userInput.ides || [])],
|
|
||||||
skipPrompts: userInput.skipPrompts || false,
|
|
||||||
verbose: userInput.verbose || false,
|
|
||||||
actionType: userInput.actionType,
|
|
||||||
coreConfig: userInput.coreConfig || {},
|
|
||||||
moduleConfigs: userInput.moduleConfigs || null,
|
|
||||||
quickUpdate: userInput._quickUpdate || false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
hasCoreConfig() {
|
|
||||||
return this.coreConfig && Object.keys(this.coreConfig).length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
isQuickUpdate() {
|
|
||||||
return this._quickUpdate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { Config };
|
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
const path = require('node:path');
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const yaml = require('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: [],
|
||||||
|
customModules: [],
|
||||||
|
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;
|
||||||
|
// Copy custom modules if they exist
|
||||||
|
if (manifestData.customModules) {
|
||||||
|
result.customModules = manifestData.customModules;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.parse(configContent);
|
||||||
|
if (!result.version && config.version) {
|
||||||
|
result.version = config.version;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore config read errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for modules
|
||||||
|
// If manifest exists, use it as the source of truth for installed modules
|
||||||
|
// Otherwise fall back to directory scanning (legacy installations)
|
||||||
|
if (manifestData && manifestData.modules && manifestData.modules.length > 0) {
|
||||||
|
// Use manifest module list - these are officially installed modules
|
||||||
|
for (const moduleId of manifestData.modules) {
|
||||||
|
const modulePath = path.join(bmadDir, moduleId);
|
||||||
|
const moduleConfigPath = path.join(modulePath, 'config.yaml');
|
||||||
|
|
||||||
|
const moduleInfo = {
|
||||||
|
id: moduleId,
|
||||||
|
path: modulePath,
|
||||||
|
version: 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (await fs.pathExists(moduleConfigPath)) {
|
||||||
|
try {
|
||||||
|
const configContent = await fs.readFile(moduleConfigPath, 'utf8');
|
||||||
|
const config = yaml.parse(configContent);
|
||||||
|
moduleInfo.version = config.version || 'unknown';
|
||||||
|
moduleInfo.name = config.name || moduleId;
|
||||||
|
moduleInfo.description = config.description;
|
||||||
|
} catch {
|
||||||
|
// Ignore config read errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.modules.push(moduleInfo);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: scan directory for modules (legacy installations without manifest)
|
||||||
|
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config') {
|
||||||
|
const modulePath = path.join(bmadDir, entry.name);
|
||||||
|
const moduleConfigPath = path.join(modulePath, 'config.yaml');
|
||||||
|
|
||||||
|
// Only treat it as a module if it has a config.yaml
|
||||||
|
if (await fs.pathExists(moduleConfigPath)) {
|
||||||
|
const moduleInfo = {
|
||||||
|
id: entry.name,
|
||||||
|
path: modulePath,
|
||||||
|
version: 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configContent = await fs.readFile(moduleConfigPath, 'utf8');
|
||||||
|
const config = yaml.parse(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) {
|
||||||
|
// Filter out any undefined/null values
|
||||||
|
result.ides = result.manifest.ides.filter((ide) => ide && typeof ide === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 .bmad-method folder
|
||||||
|
* @param {string} projectDir - Project directory to check
|
||||||
|
* @returns {{ hasLegacyV4: boolean, offenders: string[] }}
|
||||||
|
*/
|
||||||
|
async detectLegacyV4(projectDir) {
|
||||||
|
const offenders = [];
|
||||||
|
|
||||||
|
// Check for .bmad-method folder
|
||||||
|
const bmadMethodPath = path.join(projectDir, '.bmad-method');
|
||||||
|
if (await fs.pathExists(bmadMethodPath)) {
|
||||||
|
offenders.push(bmadMethodPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hasLegacyV4: offenders.length > 0, offenders };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { Detector };
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
const path = require('node:path');
|
|
||||||
const fs = require('fs-extra');
|
|
||||||
const yaml = require('yaml');
|
|
||||||
const { Manifest } = require('./manifest');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable snapshot of an existing BMAD installation.
|
|
||||||
* Pure query object — no filesystem operations after construction.
|
|
||||||
*/
|
|
||||||
class ExistingInstall {
|
|
||||||
#version;
|
|
||||||
|
|
||||||
constructor({ installed, version, hasCore, modules, ides, customModules }) {
|
|
||||||
this.installed = installed;
|
|
||||||
this.#version = version;
|
|
||||||
this.hasCore = hasCore;
|
|
||||||
this.modules = Object.freeze(modules.map((m) => Object.freeze({ ...m })));
|
|
||||||
this.moduleIds = Object.freeze(this.modules.map((m) => m.id));
|
|
||||||
this.ides = Object.freeze([...ides]);
|
|
||||||
this.customModules = Object.freeze([...customModules]);
|
|
||||||
Object.freeze(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
get version() {
|
|
||||||
if (!this.installed) {
|
|
||||||
throw new Error('version is not available when nothing is installed');
|
|
||||||
}
|
|
||||||
return this.#version;
|
|
||||||
}
|
|
||||||
|
|
||||||
static empty() {
|
|
||||||
return new ExistingInstall({
|
|
||||||
installed: false,
|
|
||||||
version: null,
|
|
||||||
hasCore: false,
|
|
||||||
modules: [],
|
|
||||||
ides: [],
|
|
||||||
customModules: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan a bmad directory and return an immutable snapshot of what's installed.
|
|
||||||
* @param {string} bmadDir - Path to bmad directory
|
|
||||||
* @returns {Promise<ExistingInstall>}
|
|
||||||
*/
|
|
||||||
static async detect(bmadDir) {
|
|
||||||
if (!(await fs.pathExists(bmadDir))) {
|
|
||||||
return ExistingInstall.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
let version = null;
|
|
||||||
let hasCore = false;
|
|
||||||
const modules = [];
|
|
||||||
let ides = [];
|
|
||||||
let customModules = [];
|
|
||||||
|
|
||||||
const manifest = new Manifest();
|
|
||||||
const manifestData = await manifest.read(bmadDir);
|
|
||||||
if (manifestData) {
|
|
||||||
version = manifestData.version;
|
|
||||||
if (manifestData.customModules) {
|
|
||||||
customModules = manifestData.customModules;
|
|
||||||
}
|
|
||||||
if (manifestData.ides) {
|
|
||||||
ides = manifestData.ides.filter((ide) => ide && typeof ide === 'string');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const corePath = path.join(bmadDir, 'core');
|
|
||||||
if (await fs.pathExists(corePath)) {
|
|
||||||
hasCore = true;
|
|
||||||
|
|
||||||
if (!version) {
|
|
||||||
const coreConfigPath = path.join(corePath, 'config.yaml');
|
|
||||||
if (await fs.pathExists(coreConfigPath)) {
|
|
||||||
try {
|
|
||||||
const configContent = await fs.readFile(coreConfigPath, 'utf8');
|
|
||||||
const config = yaml.parse(configContent);
|
|
||||||
if (config.version) {
|
|
||||||
version = config.version;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore config read errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (manifestData && manifestData.modules && manifestData.modules.length > 0) {
|
|
||||||
for (const moduleId of manifestData.modules) {
|
|
||||||
const modulePath = path.join(bmadDir, moduleId);
|
|
||||||
const moduleConfigPath = path.join(modulePath, 'config.yaml');
|
|
||||||
|
|
||||||
const moduleInfo = {
|
|
||||||
id: moduleId,
|
|
||||||
path: modulePath,
|
|
||||||
version: 'unknown',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (await fs.pathExists(moduleConfigPath)) {
|
|
||||||
try {
|
|
||||||
const configContent = await fs.readFile(moduleConfigPath, 'utf8');
|
|
||||||
const config = yaml.parse(configContent);
|
|
||||||
moduleInfo.version = config.version || 'unknown';
|
|
||||||
moduleInfo.name = config.name || moduleId;
|
|
||||||
moduleInfo.description = config.description;
|
|
||||||
} catch {
|
|
||||||
// Ignore config read errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
modules.push(moduleInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const installed = hasCore || modules.length > 0 || !!manifestData;
|
|
||||||
|
|
||||||
if (!installed) {
|
|
||||||
return ExistingInstall.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ExistingInstall({ installed, version, hasCore, modules, ides, customModules });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { ExistingInstall };
|
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
const path = require('node:path');
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const yaml = require('yaml');
|
||||||
|
const prompts = require('../../../lib/prompts');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages IDE configuration persistence
|
||||||
|
* Saves and loads IDE-specific configurations to/from bmad/_config/ides/
|
||||||
|
*/
|
||||||
|
class IdeConfigManager {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get path to IDE config directory
|
||||||
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
|
* @returns {string} Path to IDE config directory
|
||||||
|
*/
|
||||||
|
getIdeConfigDir(bmadDir) {
|
||||||
|
return path.join(bmadDir, '_config', 'ides');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get path to specific IDE config file
|
||||||
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
|
* @param {string} ideName - IDE name (e.g., 'claude-code')
|
||||||
|
* @returns {string} Path to IDE config file
|
||||||
|
*/
|
||||||
|
getIdeConfigPath(bmadDir, ideName) {
|
||||||
|
return path.join(this.getIdeConfigDir(bmadDir), `${ideName}.yaml`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save IDE configuration
|
||||||
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
|
* @param {string} ideName - IDE name
|
||||||
|
* @param {Object} configuration - IDE-specific configuration object
|
||||||
|
*/
|
||||||
|
async saveIdeConfig(bmadDir, ideName, configuration) {
|
||||||
|
const configDir = this.getIdeConfigDir(bmadDir);
|
||||||
|
await fs.ensureDir(configDir);
|
||||||
|
|
||||||
|
const configPath = this.getIdeConfigPath(bmadDir, ideName);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Check if config already exists to preserve configured_date
|
||||||
|
let configuredDate = now;
|
||||||
|
if (await fs.pathExists(configPath)) {
|
||||||
|
try {
|
||||||
|
const existing = await this.loadIdeConfig(bmadDir, ideName);
|
||||||
|
if (existing && existing.configured_date) {
|
||||||
|
configuredDate = existing.configured_date;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors reading existing config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const configData = {
|
||||||
|
ide: ideName,
|
||||||
|
configured_date: configuredDate,
|
||||||
|
last_updated: now,
|
||||||
|
configuration: configuration || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean the config to remove any non-serializable values (like functions)
|
||||||
|
const cleanConfig = structuredClone(configData);
|
||||||
|
|
||||||
|
const yamlContent = yaml.stringify(cleanConfig, {
|
||||||
|
indent: 2,
|
||||||
|
lineWidth: 0,
|
||||||
|
sortKeys: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure POSIX-compliant final newline
|
||||||
|
const content = yamlContent.endsWith('\n') ? yamlContent : yamlContent + '\n';
|
||||||
|
await fs.writeFile(configPath, content, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load IDE configuration
|
||||||
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
|
* @param {string} ideName - IDE name
|
||||||
|
* @returns {Object|null} IDE configuration or null if not found
|
||||||
|
*/
|
||||||
|
async loadIdeConfig(bmadDir, ideName) {
|
||||||
|
const configPath = this.getIdeConfigPath(bmadDir, ideName);
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(configPath))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(configPath, 'utf8');
|
||||||
|
const config = yaml.parse(content);
|
||||||
|
return config;
|
||||||
|
} catch (error) {
|
||||||
|
await prompts.log.warn(`Failed to load IDE config for ${ideName}: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all IDE configurations
|
||||||
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
|
* @returns {Object} Map of IDE name to configuration
|
||||||
|
*/
|
||||||
|
async loadAllIdeConfigs(bmadDir) {
|
||||||
|
const configDir = this.getIdeConfigDir(bmadDir);
|
||||||
|
const configs = {};
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(configDir))) {
|
||||||
|
return configs;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(configDir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith('.yaml')) {
|
||||||
|
const ideName = file.replace('.yaml', '');
|
||||||
|
const config = await this.loadIdeConfig(bmadDir, ideName);
|
||||||
|
if (config) {
|
||||||
|
configs[ideName] = config.configuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await prompts.log.warn(`Failed to load IDE configs: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if IDE has saved configuration
|
||||||
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
|
* @param {string} ideName - IDE name
|
||||||
|
* @returns {boolean} True if configuration exists
|
||||||
|
*/
|
||||||
|
async hasIdeConfig(bmadDir, ideName) {
|
||||||
|
const configPath = this.getIdeConfigPath(bmadDir, ideName);
|
||||||
|
return await fs.pathExists(configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete IDE configuration
|
||||||
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
|
* @param {string} ideName - IDE name
|
||||||
|
*/
|
||||||
|
async deleteIdeConfig(bmadDir, ideName) {
|
||||||
|
const configPath = this.getIdeConfigPath(bmadDir, ideName);
|
||||||
|
if (await fs.pathExists(configPath)) {
|
||||||
|
await fs.remove(configPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { IdeConfigManager };
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,657 @@
|
||||||
|
const path = require('node:path');
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const prompts = require('../../../lib/prompts');
|
||||||
|
const { getSourcePath } = require('../../../lib/project-root');
|
||||||
|
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.configFile = null; // Override in subclasses when detection is file-based
|
||||||
|
this.detectionPaths = []; // Additional paths that indicate the IDE is configured
|
||||||
|
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the bmad folder name for placeholder replacement
|
||||||
|
* @param {string} bmadFolderName - The bmad folder name
|
||||||
|
*/
|
||||||
|
setBmadFolderName(bmadFolderName) {
|
||||||
|
this.bmadFolderName = bmadFolderName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, options = {}) {
|
||||||
|
// 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_FOLDER_NAME);
|
||||||
|
if (await fs.pathExists(bmadRulesPath)) {
|
||||||
|
await fs.remove(bmadRulesPath);
|
||||||
|
if (!options.silent) await prompts.log.message(`Removed ${this.name} BMAD configuration`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install a custom agent launcher - subclasses should override
|
||||||
|
* @param {string} projectDir - Project directory
|
||||||
|
* @param {string} agentName - Agent name (e.g., "fred-commit-poet")
|
||||||
|
* @param {string} agentPath - Path to compiled agent (relative to project root)
|
||||||
|
* @param {Object} metadata - Agent metadata
|
||||||
|
* @returns {Object|null} Info about created command, or null if not supported
|
||||||
|
*/
|
||||||
|
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
|
||||||
|
// Default implementation - subclasses can override
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether this IDE already has configuration in the project
|
||||||
|
* Subclasses can override for custom logic
|
||||||
|
* @param {string} projectDir - Project directory
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
async detect(projectDir) {
|
||||||
|
const pathsToCheck = [];
|
||||||
|
|
||||||
|
if (this.configDir) {
|
||||||
|
pathsToCheck.push(path.join(projectDir, this.configDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.configFile) {
|
||||||
|
pathsToCheck.push(path.join(projectDir, this.configFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(this.detectionPaths)) {
|
||||||
|
for (const candidate of this.detectionPaths) {
|
||||||
|
if (!candidate) continue;
|
||||||
|
const resolved = path.isAbsolute(candidate) ? candidate : path.join(projectDir, candidate);
|
||||||
|
pathsToCheck.push(resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of pathsToCheck) {
|
||||||
|
if (await fs.pathExists(candidate)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 !== '_config' && entry.name !== 'agents') {
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get standalone agents from bmad/agents/ directory
|
||||||
|
const standaloneAgentsDir = path.join(bmadDir, 'agents');
|
||||||
|
if (await fs.pathExists(standaloneAgentsDir)) {
|
||||||
|
const agentDirs = await fs.readdir(standaloneAgentsDir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const agentDir of agentDirs) {
|
||||||
|
if (!agentDir.isDirectory()) continue;
|
||||||
|
|
||||||
|
const agentDirPath = path.join(standaloneAgentsDir, agentDir.name);
|
||||||
|
const agentFiles = await fs.readdir(agentDirPath);
|
||||||
|
|
||||||
|
for (const file of agentFiles) {
|
||||||
|
if (!file.endsWith('.md')) continue;
|
||||||
|
if (file.includes('.customize.')) continue;
|
||||||
|
|
||||||
|
const filePath = path.join(agentDirPath, file);
|
||||||
|
const content = await fs.readFile(filePath, 'utf8');
|
||||||
|
|
||||||
|
if (content.includes('localskip="true"')) continue;
|
||||||
|
|
||||||
|
agents.push({
|
||||||
|
name: file.replace('.md', ''),
|
||||||
|
path: filePath,
|
||||||
|
relativePath: path.relative(standaloneAgentsDir, filePath),
|
||||||
|
filename: file,
|
||||||
|
module: 'standalone', // Mark as standalone agent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of tasks from BMAD installation
|
||||||
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
|
* @param {boolean} standaloneOnly - If true, only return standalone tasks
|
||||||
|
* @returns {Array} List of task files
|
||||||
|
*/
|
||||||
|
async getTasks(bmadDir, standaloneOnly = false) {
|
||||||
|
const tasks = [];
|
||||||
|
|
||||||
|
// Get core tasks (scan for both .md and .xml)
|
||||||
|
const coreTasksPath = path.join(bmadDir, 'core', 'tasks');
|
||||||
|
if (await fs.pathExists(coreTasksPath)) {
|
||||||
|
const coreTasks = await this.scanDirectoryWithStandalone(coreTasksPath, ['.md', '.xml']);
|
||||||
|
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 !== '_config' && entry.name !== 'agents') {
|
||||||
|
const moduleTasksPath = path.join(bmadDir, entry.name, 'tasks');
|
||||||
|
if (await fs.pathExists(moduleTasksPath)) {
|
||||||
|
const moduleTasks = await this.scanDirectoryWithStandalone(moduleTasksPath, ['.md', '.xml']);
|
||||||
|
tasks.push(
|
||||||
|
...moduleTasks.map((t) => ({
|
||||||
|
...t,
|
||||||
|
module: entry.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by standalone if requested
|
||||||
|
if (standaloneOnly) {
|
||||||
|
return tasks.filter((t) => t.standalone === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of tools from BMAD installation
|
||||||
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
|
* @param {boolean} standaloneOnly - If true, only return standalone tools
|
||||||
|
* @returns {Array} List of tool files
|
||||||
|
*/
|
||||||
|
async getTools(bmadDir, standaloneOnly = false) {
|
||||||
|
const tools = [];
|
||||||
|
|
||||||
|
// Get core tools (scan for both .md and .xml)
|
||||||
|
const coreToolsPath = path.join(bmadDir, 'core', 'tools');
|
||||||
|
if (await fs.pathExists(coreToolsPath)) {
|
||||||
|
const coreTools = await this.scanDirectoryWithStandalone(coreToolsPath, ['.md', '.xml']);
|
||||||
|
tools.push(
|
||||||
|
...coreTools.map((t) => ({
|
||||||
|
...t,
|
||||||
|
module: 'core',
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get module tools
|
||||||
|
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config' && entry.name !== 'agents') {
|
||||||
|
const moduleToolsPath = path.join(bmadDir, entry.name, 'tools');
|
||||||
|
if (await fs.pathExists(moduleToolsPath)) {
|
||||||
|
const moduleTools = await this.scanDirectoryWithStandalone(moduleToolsPath, ['.md', '.xml']);
|
||||||
|
tools.push(
|
||||||
|
...moduleTools.map((t) => ({
|
||||||
|
...t,
|
||||||
|
module: entry.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by standalone if requested
|
||||||
|
if (standaloneOnly) {
|
||||||
|
return tools.filter((t) => t.standalone === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of workflows from BMAD installation
|
||||||
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
|
* @param {boolean} standaloneOnly - If true, only return standalone workflows
|
||||||
|
* @returns {Array} List of workflow files
|
||||||
|
*/
|
||||||
|
async getWorkflows(bmadDir, standaloneOnly = false) {
|
||||||
|
const workflows = [];
|
||||||
|
|
||||||
|
// Get core workflows
|
||||||
|
const coreWorkflowsPath = path.join(bmadDir, 'core', 'workflows');
|
||||||
|
if (await fs.pathExists(coreWorkflowsPath)) {
|
||||||
|
const coreWorkflows = await this.findWorkflowFiles(coreWorkflowsPath);
|
||||||
|
workflows.push(
|
||||||
|
...coreWorkflows.map((w) => ({
|
||||||
|
...w,
|
||||||
|
module: 'core',
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get module workflows
|
||||||
|
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config' && entry.name !== 'agents') {
|
||||||
|
const moduleWorkflowsPath = path.join(bmadDir, entry.name, 'workflows');
|
||||||
|
if (await fs.pathExists(moduleWorkflowsPath)) {
|
||||||
|
const moduleWorkflows = await this.findWorkflowFiles(moduleWorkflowsPath);
|
||||||
|
workflows.push(
|
||||||
|
...moduleWorkflows.map((w) => ({
|
||||||
|
...w,
|
||||||
|
module: entry.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by standalone if requested
|
||||||
|
if (standaloneOnly) {
|
||||||
|
return workflows.filter((w) => w.standalone === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return workflows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively find workflow.md files
|
||||||
|
* @param {string} dir - Directory to search
|
||||||
|
* @param {string} [rootDir] - Original root directory (used internally for recursion)
|
||||||
|
* @returns {Array} List of workflow file info objects
|
||||||
|
*/
|
||||||
|
async findWorkflowFiles(dir, rootDir = null) {
|
||||||
|
rootDir = rootDir || dir;
|
||||||
|
const workflows = [];
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(dir))) {
|
||||||
|
return workflows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
// Recursively search subdirectories
|
||||||
|
const subWorkflows = await this.findWorkflowFiles(fullPath, rootDir);
|
||||||
|
workflows.push(...subWorkflows);
|
||||||
|
} else if (entry.isFile() && entry.name === 'workflow.md') {
|
||||||
|
// Read workflow.md frontmatter to get name and standalone property
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(fullPath, 'utf8');
|
||||||
|
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||||
|
if (!frontmatterMatch) continue;
|
||||||
|
|
||||||
|
const workflowData = yaml.parse(frontmatterMatch[1]);
|
||||||
|
|
||||||
|
if (workflowData && workflowData.name) {
|
||||||
|
// Workflows are standalone by default unless explicitly false
|
||||||
|
const standalone = workflowData.standalone !== false && workflowData.standalone !== 'false';
|
||||||
|
workflows.push({
|
||||||
|
name: workflowData.name,
|
||||||
|
path: fullPath,
|
||||||
|
relativePath: path.relative(rootDir, fullPath),
|
||||||
|
filename: entry.name,
|
||||||
|
description: workflowData.description || '',
|
||||||
|
standalone: standalone,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip invalid workflow files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return workflows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan a directory for files with specific extension(s)
|
||||||
|
* @param {string} dir - Directory to scan
|
||||||
|
* @param {string|Array<string>} ext - File extension(s) to match (e.g., '.md' or ['.md', '.xml'])
|
||||||
|
* @param {string} [rootDir] - Original root directory (used internally for recursion)
|
||||||
|
* @returns {Array} List of file info objects
|
||||||
|
*/
|
||||||
|
async scanDirectory(dir, ext, rootDir = null) {
|
||||||
|
rootDir = rootDir || dir;
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(dir))) {
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize ext to array
|
||||||
|
const extensions = Array.isArray(ext) ? ext : [ext];
|
||||||
|
|
||||||
|
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, rootDir);
|
||||||
|
files.push(...subFiles);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
// Check if file matches any of the extensions
|
||||||
|
const matchedExt = extensions.find((e) => entry.name.endsWith(e));
|
||||||
|
if (matchedExt) {
|
||||||
|
files.push({
|
||||||
|
name: path.basename(entry.name, matchedExt),
|
||||||
|
path: fullPath,
|
||||||
|
relativePath: path.relative(rootDir, fullPath),
|
||||||
|
filename: entry.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan a directory for files with specific extension(s) and check standalone attribute
|
||||||
|
* @param {string} dir - Directory to scan
|
||||||
|
* @param {string|Array<string>} ext - File extension(s) to match (e.g., '.md' or ['.md', '.xml'])
|
||||||
|
* @param {string} [rootDir] - Original root directory (used internally for recursion)
|
||||||
|
* @returns {Array} List of file info objects with standalone property
|
||||||
|
*/
|
||||||
|
async scanDirectoryWithStandalone(dir, ext, rootDir = null) {
|
||||||
|
rootDir = rootDir || dir;
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(dir))) {
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize ext to array
|
||||||
|
const extensions = Array.isArray(ext) ? ext : [ext];
|
||||||
|
|
||||||
|
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.scanDirectoryWithStandalone(fullPath, ext, rootDir);
|
||||||
|
files.push(...subFiles);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
// Check if file matches any of the extensions
|
||||||
|
const matchedExt = extensions.find((e) => entry.name.endsWith(e));
|
||||||
|
if (matchedExt) {
|
||||||
|
// Read file content to check for standalone attribute
|
||||||
|
// All non-internal files are considered standalone by default
|
||||||
|
let standalone = true;
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(fullPath, 'utf8');
|
||||||
|
|
||||||
|
// Skip internal/engine files (not user-facing)
|
||||||
|
if (content.includes('internal="true"')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for explicit standalone: false
|
||||||
|
if (entry.name.endsWith('.xml')) {
|
||||||
|
// For XML files, check for standalone="false" attribute
|
||||||
|
const tagMatch = content.match(/<(task|tool)[^>]*standalone="false"/);
|
||||||
|
standalone = !tagMatch;
|
||||||
|
} else if (entry.name.endsWith('.md')) {
|
||||||
|
// For MD files, parse YAML frontmatter
|
||||||
|
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||||
|
if (frontmatterMatch) {
|
||||||
|
try {
|
||||||
|
const yaml = require('yaml');
|
||||||
|
const frontmatter = yaml.parse(frontmatterMatch[1]);
|
||||||
|
standalone = frontmatter.standalone !== false && frontmatter.standalone !== 'false';
|
||||||
|
} catch {
|
||||||
|
// If YAML parsing fails, default to standalone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No frontmatter means standalone (default)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If we can't read the file, default to standalone
|
||||||
|
standalone = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
name: path.basename(entry.name, matchedExt),
|
||||||
|
path: fullPath,
|
||||||
|
relativePath: path.relative(rootDir, fullPath),
|
||||||
|
filename: entry.name,
|
||||||
|
standalone: standalone,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Only replace {project-root} if a specific projectDir is provided
|
||||||
|
// Otherwise leave the placeholder intact
|
||||||
|
// Note: Don't add trailing slash - paths in source include leading slash
|
||||||
|
if (projectDir) {
|
||||||
|
processed = processed.replaceAll('{project-root}', projectDir);
|
||||||
|
}
|
||||||
|
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 (replaces _bmad placeholder)
|
||||||
|
* @param {string} filePath - File path
|
||||||
|
* @param {string} content - File content
|
||||||
|
*/
|
||||||
|
async writeFile(filePath, content) {
|
||||||
|
// Replace _bmad placeholder if present
|
||||||
|
if (typeof content === 'string' && content.includes('_bmad')) {
|
||||||
|
content = content.replaceAll('_bmad', this.bmadFolderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace escape sequence _bmad with literal _bmad
|
||||||
|
if (typeof content === 'string' && content.includes('_bmad')) {
|
||||||
|
content = content.replaceAll('_bmad', '_bmad');
|
||||||
|
}
|
||||||
|
await this.ensureDir(path.dirname(filePath));
|
||||||
|
await fs.writeFile(filePath, content, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy file from source to destination (replaces _bmad placeholder in text files)
|
||||||
|
* @param {string} source - Source file path
|
||||||
|
* @param {string} dest - Destination file path
|
||||||
|
*/
|
||||||
|
async copyFile(source, dest) {
|
||||||
|
// List of text file extensions that should have placeholder replacement
|
||||||
|
const textExtensions = ['.md', '.yaml', '.yml', '.txt', '.json', '.js', '.ts', '.html', '.css', '.sh', '.bat', '.csv'];
|
||||||
|
const ext = path.extname(source).toLowerCase();
|
||||||
|
|
||||||
|
await this.ensureDir(path.dirname(dest));
|
||||||
|
|
||||||
|
// Check if this is a text file that might contain placeholders
|
||||||
|
if (textExtensions.includes(ext)) {
|
||||||
|
try {
|
||||||
|
// Read the file content
|
||||||
|
let content = await fs.readFile(source, 'utf8');
|
||||||
|
|
||||||
|
// Replace _bmad placeholder with actual folder name
|
||||||
|
if (content.includes('_bmad')) {
|
||||||
|
content = content.replaceAll('_bmad', this.bmadFolderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace escape sequence _bmad with literal _bmad
|
||||||
|
if (content.includes('_bmad')) {
|
||||||
|
content = content.replaceAll('_bmad', '_bmad');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to dest with replaced content
|
||||||
|
await fs.writeFile(dest, content, 'utf8');
|
||||||
|
} catch {
|
||||||
|
// If reading as text fails, fall back to regular copy
|
||||||
|
await fs.copy(source, dest, { overwrite: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Binary file or other file type - just copy directly
|
||||||
|
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(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten a relative path to a single filename for flat slash command naming
|
||||||
|
* @deprecated Use toColonPath() or toDashPath() from shared/path-utils.js instead
|
||||||
|
* Example: 'module/agents/name.md' -> 'bmad-module-agents-name.md'
|
||||||
|
* Used by IDEs that ignore directory structure for slash commands (e.g., Antigravity, Codex)
|
||||||
|
* @param {string} relativePath - Relative path to flatten
|
||||||
|
* @returns {string} Flattened filename with 'bmad-' prefix
|
||||||
|
*/
|
||||||
|
flattenFilename(relativePath) {
|
||||||
|
const sanitized = relativePath.replaceAll(/[/\\]/g, '-');
|
||||||
|
return `bmad-${sanitized}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create agent configuration file
|
||||||
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
|
* @param {Object} agent - Agent information
|
||||||
|
*/
|
||||||
|
async createAgentConfig(bmadDir, agent) {
|
||||||
|
const agentConfigDir = path.join(bmadDir, '_config', '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 };
|
||||||
|
|
@ -2,9 +2,9 @@ const os = require('node:os');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
|
const { BaseIdeSetup } = require('./_base-ide');
|
||||||
const prompts = require('../../../lib/prompts');
|
const prompts = require('../../../lib/prompts');
|
||||||
const csv = require('csv-parse/sync');
|
const csv = require('csv-parse/sync');
|
||||||
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Config-driven IDE setup handler
|
* Config-driven IDE setup handler
|
||||||
|
|
@ -15,35 +15,31 @@ const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Config-driven from platform-codes.yaml
|
* - Config-driven from platform-codes.yaml
|
||||||
* - Verbatim skill installation from skill-manifest.csv
|
* - Template-based content generation
|
||||||
* - Legacy directory cleanup and IDE-specific marker removal
|
* - Multi-target installation support (e.g., GitHub Copilot)
|
||||||
|
* - Artifact type filtering (agents, workflows, tasks, tools)
|
||||||
*/
|
*/
|
||||||
class ConfigDrivenIdeSetup {
|
class ConfigDrivenIdeSetup extends BaseIdeSetup {
|
||||||
constructor(platformCode, platformConfig) {
|
constructor(platformCode, platformConfig) {
|
||||||
this.name = platformCode;
|
super(platformCode, platformConfig.name, platformConfig.preferred);
|
||||||
this.displayName = platformConfig.name || platformCode;
|
|
||||||
this.preferred = platformConfig.preferred || false;
|
|
||||||
this.platformConfig = platformConfig;
|
this.platformConfig = platformConfig;
|
||||||
this.installerConfig = platformConfig.installer || null;
|
this.installerConfig = platformConfig.installer || null;
|
||||||
this.bmadFolderName = BMAD_FOLDER_NAME;
|
|
||||||
|
|
||||||
// Set configDir from target_dir so detect() works
|
// Set configDir from target_dir so base-class detect() works
|
||||||
this.configDir = this.installerConfig?.target_dir || null;
|
if (this.installerConfig?.target_dir) {
|
||||||
|
this.configDir = this.installerConfig.target_dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
setBmadFolderName(bmadFolderName) {
|
|
||||||
this.bmadFolderName = bmadFolderName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect whether this IDE already has configuration in the project.
|
* Detect whether this IDE already has configuration in the project.
|
||||||
* Checks for bmad-prefixed entries in target_dir.
|
* For skill_format platforms, checks for bmad-prefixed entries in target_dir
|
||||||
|
* (matching old codex.js behavior) instead of just checking directory existence.
|
||||||
* @param {string} projectDir - Project directory
|
* @param {string} projectDir - Project directory
|
||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
async detect(projectDir) {
|
async detect(projectDir) {
|
||||||
if (!this.configDir) return false;
|
if (this.installerConfig?.skill_format && this.configDir) {
|
||||||
|
|
||||||
const dir = path.join(projectDir || process.cwd(), this.configDir);
|
const dir = path.join(projectDir || process.cwd(), this.configDir);
|
||||||
if (await fs.pathExists(dir)) {
|
if (await fs.pathExists(dir)) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -55,6 +51,8 @@ class ConfigDrivenIdeSetup {
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
return super.detect(projectDir);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main setup method - called by IdeManager
|
* Main setup method - called by IdeManager
|
||||||
|
|
@ -92,6 +90,12 @@ class ConfigDrivenIdeSetup {
|
||||||
return { success: false, reason: 'no-config' };
|
return { success: false, reason: 'no-config' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle multi-target installations (e.g., GitHub Copilot)
|
||||||
|
if (this.installerConfig.targets) {
|
||||||
|
return this.installToMultipleTargets(projectDir, bmadDir, this.installerConfig.targets, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle single-target installations
|
||||||
if (this.installerConfig.target_dir) {
|
if (this.installerConfig.target_dir) {
|
||||||
return this.installToTarget(projectDir, bmadDir, this.installerConfig, options);
|
return this.installToTarget(projectDir, bmadDir, this.installerConfig, options);
|
||||||
}
|
}
|
||||||
|
|
@ -109,8 +113,13 @@ class ConfigDrivenIdeSetup {
|
||||||
*/
|
*/
|
||||||
async installToTarget(projectDir, bmadDir, config, options) {
|
async installToTarget(projectDir, bmadDir, config, options) {
|
||||||
const { target_dir } = config;
|
const { target_dir } = config;
|
||||||
|
|
||||||
|
if (!config.skill_format) {
|
||||||
|
return { success: false, reason: 'missing-skill-format', error: 'Installer config missing skill_format — cannot install skills' };
|
||||||
|
}
|
||||||
|
|
||||||
const targetPath = path.join(projectDir, target_dir);
|
const targetPath = path.join(projectDir, target_dir);
|
||||||
await fs.ensureDir(targetPath);
|
await this.ensureDir(targetPath);
|
||||||
|
|
||||||
this.skillWriteTracker = new Set();
|
this.skillWriteTracker = new Set();
|
||||||
const results = { skills: 0 };
|
const results = { skills: 0 };
|
||||||
|
|
@ -123,6 +132,351 @@ class ConfigDrivenIdeSetup {
|
||||||
return { success: true, results };
|
return { success: true, results };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install to multiple target directories
|
||||||
|
* @param {string} projectDir - Project directory
|
||||||
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
|
* @param {Array} targets - Array of target configurations
|
||||||
|
* @param {Object} options - Setup options
|
||||||
|
* @returns {Promise<Object>} Installation result
|
||||||
|
*/
|
||||||
|
async installToMultipleTargets(projectDir, bmadDir, targets, options) {
|
||||||
|
const allResults = { skills: 0 };
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
const result = await this.installToTarget(projectDir, bmadDir, target, options);
|
||||||
|
if (result.success) {
|
||||||
|
allResults.skills += result.results.skills || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, results: allResults };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load template based on type and configuration
|
||||||
|
* @param {string} templateType - Template type (claude, windsurf, etc.)
|
||||||
|
* @param {string} artifactType - Artifact type (agent, workflow, task, tool)
|
||||||
|
* @param {Object} config - Installation configuration
|
||||||
|
* @param {string} fallbackTemplateType - Fallback template type if requested template not found
|
||||||
|
* @returns {Promise<{content: string, extension: string}>} Template content and extension
|
||||||
|
*/
|
||||||
|
async loadTemplate(templateType, artifactType, config = {}, fallbackTemplateType = null) {
|
||||||
|
const { header_template, body_template } = config;
|
||||||
|
|
||||||
|
// Check for separate header/body templates
|
||||||
|
if (header_template || body_template) {
|
||||||
|
const content = await this.loadSplitTemplates(templateType, artifactType, header_template, body_template);
|
||||||
|
// Allow config to override extension, default to .md
|
||||||
|
const ext = config.extension || '.md';
|
||||||
|
const normalizedExt = ext.startsWith('.') ? ext : `.${ext}`;
|
||||||
|
return { content, extension: normalizedExt };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load combined template - try multiple extensions
|
||||||
|
// If artifactType is empty, templateType already contains full name (e.g., 'gemini-workflow-yaml')
|
||||||
|
const templateBaseName = artifactType ? `${templateType}-${artifactType}` : templateType;
|
||||||
|
const templateDir = path.join(__dirname, 'templates', 'combined');
|
||||||
|
const extensions = ['.md', '.toml', '.yaml', '.yml'];
|
||||||
|
|
||||||
|
for (const ext of extensions) {
|
||||||
|
const templatePath = path.join(templateDir, templateBaseName + ext);
|
||||||
|
if (await fs.pathExists(templatePath)) {
|
||||||
|
const content = await fs.readFile(templatePath, 'utf8');
|
||||||
|
return { content, extension: ext };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to default template (if provided)
|
||||||
|
if (fallbackTemplateType) {
|
||||||
|
for (const ext of extensions) {
|
||||||
|
const fallbackPath = path.join(templateDir, `${fallbackTemplateType}${ext}`);
|
||||||
|
if (await fs.pathExists(fallbackPath)) {
|
||||||
|
const content = await fs.readFile(fallbackPath, 'utf8');
|
||||||
|
return { content, extension: ext };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ultimate fallback - minimal template
|
||||||
|
return { content: this.getDefaultTemplate(artifactType), extension: '.md' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load split templates (header + body)
|
||||||
|
* @param {string} templateType - Template type
|
||||||
|
* @param {string} artifactType - Artifact type
|
||||||
|
* @param {string} headerTpl - Header template name
|
||||||
|
* @param {string} bodyTpl - Body template name
|
||||||
|
* @returns {Promise<string>} Combined template content
|
||||||
|
*/
|
||||||
|
async loadSplitTemplates(templateType, artifactType, headerTpl, bodyTpl) {
|
||||||
|
let header = '';
|
||||||
|
let body = '';
|
||||||
|
|
||||||
|
// Load header template
|
||||||
|
if (headerTpl) {
|
||||||
|
const headerPath = path.join(__dirname, 'templates', 'split', headerTpl);
|
||||||
|
if (await fs.pathExists(headerPath)) {
|
||||||
|
header = await fs.readFile(headerPath, 'utf8');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use default header for template type
|
||||||
|
const defaultHeaderPath = path.join(__dirname, 'templates', 'split', templateType, 'header.md');
|
||||||
|
if (await fs.pathExists(defaultHeaderPath)) {
|
||||||
|
header = await fs.readFile(defaultHeaderPath, 'utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load body template
|
||||||
|
if (bodyTpl) {
|
||||||
|
const bodyPath = path.join(__dirname, 'templates', 'split', bodyTpl);
|
||||||
|
if (await fs.pathExists(bodyPath)) {
|
||||||
|
body = await fs.readFile(bodyPath, 'utf8');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use default body for template type
|
||||||
|
const defaultBodyPath = path.join(__dirname, 'templates', 'split', templateType, 'body.md');
|
||||||
|
if (await fs.pathExists(defaultBodyPath)) {
|
||||||
|
body = await fs.readFile(defaultBodyPath, 'utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine header and body
|
||||||
|
return `${header}\n${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default minimal template
|
||||||
|
* @param {string} artifactType - Artifact type
|
||||||
|
* @returns {string} Default template
|
||||||
|
*/
|
||||||
|
getDefaultTemplate(artifactType) {
|
||||||
|
if (artifactType === 'agent') {
|
||||||
|
return `---
|
||||||
|
name: '{{name}}'
|
||||||
|
description: '{{description}}'
|
||||||
|
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 {project-root}/{{bmadFolderName}}/{{path}}
|
||||||
|
2. READ its entire contents - this contains the complete agent persona, menu, and instructions
|
||||||
|
3. FOLLOW every step in the <activation> section precisely
|
||||||
|
</agent-activation>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return `---
|
||||||
|
name: '{{name}}'
|
||||||
|
description: '{{description}}'
|
||||||
|
---
|
||||||
|
|
||||||
|
# {{name}}
|
||||||
|
|
||||||
|
LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render template with artifact data
|
||||||
|
* @param {string} template - Template content
|
||||||
|
* @param {Object} artifact - Artifact data
|
||||||
|
* @returns {string} Rendered content
|
||||||
|
*/
|
||||||
|
renderTemplate(template, artifact) {
|
||||||
|
// Use the appropriate path property based on artifact type
|
||||||
|
let pathToUse = artifact.relativePath || '';
|
||||||
|
switch (artifact.type) {
|
||||||
|
case 'agent-launcher': {
|
||||||
|
pathToUse = artifact.agentPath || artifact.relativePath || '';
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'workflow-command': {
|
||||||
|
pathToUse = artifact.workflowPath || artifact.relativePath || '';
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'task':
|
||||||
|
case 'tool': {
|
||||||
|
pathToUse = artifact.path || artifact.relativePath || '';
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// No default
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace _bmad placeholder with actual folder name BEFORE inserting paths,
|
||||||
|
// so that paths containing '_bmad' are not corrupted by the blanket replacement.
|
||||||
|
let rendered = template.replaceAll('_bmad', this.bmadFolderName);
|
||||||
|
|
||||||
|
// Replace {{bmadFolderName}} placeholder if present
|
||||||
|
rendered = rendered.replaceAll('{{bmadFolderName}}', this.bmadFolderName);
|
||||||
|
|
||||||
|
rendered = rendered
|
||||||
|
.replaceAll('{{name}}', artifact.name || '')
|
||||||
|
.replaceAll('{{module}}', artifact.module || 'core')
|
||||||
|
.replaceAll('{{path}}', pathToUse)
|
||||||
|
.replaceAll('{{description}}', artifact.description || `${artifact.name} ${artifact.type || ''}`)
|
||||||
|
.replaceAll('{{workflow_path}}', pathToUse);
|
||||||
|
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write artifact as a skill directory with SKILL.md inside.
|
||||||
|
* Writes artifact as a skill directory with SKILL.md inside.
|
||||||
|
* @param {string} targetPath - Base skills directory
|
||||||
|
* @param {Object} artifact - Artifact data
|
||||||
|
* @param {string} content - Rendered template content
|
||||||
|
*/
|
||||||
|
async writeSkillFile(targetPath, artifact, content) {
|
||||||
|
const { resolveSkillName } = require('./shared/path-utils');
|
||||||
|
|
||||||
|
// Get the skill name (prefers canonicalId, falls back to path-derived) and remove .md
|
||||||
|
const flatName = resolveSkillName(artifact);
|
||||||
|
const skillName = path.basename(flatName.replace(/\.md$/, ''));
|
||||||
|
|
||||||
|
if (!skillName) {
|
||||||
|
throw new Error(`Cannot derive skill name for artifact: ${artifact.relativePath || JSON.stringify(artifact)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create skill directory
|
||||||
|
const skillDir = path.join(targetPath, skillName);
|
||||||
|
await this.ensureDir(skillDir);
|
||||||
|
this.skillWriteTracker?.add(skillName);
|
||||||
|
|
||||||
|
// Transform content: rewrite frontmatter for skills format
|
||||||
|
const skillContent = this.transformToSkillFormat(content, skillName);
|
||||||
|
|
||||||
|
await this.writeFile(path.join(skillDir, 'SKILL.md'), skillContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform artifact content to Agent Skills format.
|
||||||
|
* Rewrites frontmatter to contain only unquoted name and description.
|
||||||
|
* @param {string} content - Original content with YAML frontmatter
|
||||||
|
* @param {string} skillName - Skill name (must match directory name)
|
||||||
|
* @returns {string} Transformed content
|
||||||
|
*/
|
||||||
|
transformToSkillFormat(content, skillName) {
|
||||||
|
// Normalize line endings
|
||||||
|
content = content.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
||||||
|
|
||||||
|
// Parse frontmatter
|
||||||
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||||
|
if (!fmMatch) {
|
||||||
|
// No frontmatter -- wrap with minimal frontmatter
|
||||||
|
const fm = yaml.stringify({ name: skillName, description: skillName }).trimEnd();
|
||||||
|
return `---\n${fm}\n---\n\n${content}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontmatter = fmMatch[1];
|
||||||
|
const body = fmMatch[2];
|
||||||
|
|
||||||
|
// Parse frontmatter with yaml library to extract description
|
||||||
|
let description;
|
||||||
|
try {
|
||||||
|
const parsed = yaml.parse(frontmatter);
|
||||||
|
const rawDesc = parsed?.description;
|
||||||
|
description = typeof rawDesc === 'string' && rawDesc ? rawDesc : `${skillName} skill`;
|
||||||
|
} catch {
|
||||||
|
description = `${skillName} skill`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new frontmatter with only name and description, unquoted
|
||||||
|
const newFrontmatter = yaml.stringify({ name: skillName, description: String(description) }, { lineWidth: 0 }).trimEnd();
|
||||||
|
return `---\n${newFrontmatter}\n---\n${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install a custom agent launcher.
|
||||||
|
* For skill_format platforms, produces <skillDir>/SKILL.md.
|
||||||
|
* For flat platforms, produces a single file in target_dir.
|
||||||
|
* @param {string} projectDir - Project directory
|
||||||
|
* @param {string} agentName - Agent name (e.g., "fred-commit-poet")
|
||||||
|
* @param {string} agentPath - Path to compiled agent (relative to project root)
|
||||||
|
* @param {Object} metadata - Agent metadata
|
||||||
|
* @returns {Object|null} Info about created file/skill
|
||||||
|
*/
|
||||||
|
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
|
||||||
|
if (!this.installerConfig?.target_dir) return null;
|
||||||
|
|
||||||
|
const { customAgentDashName } = require('./shared/path-utils');
|
||||||
|
const targetPath = path.join(projectDir, this.installerConfig.target_dir);
|
||||||
|
await this.ensureDir(targetPath);
|
||||||
|
|
||||||
|
// Build artifact to reuse existing template rendering.
|
||||||
|
// The default-agent template already includes the _bmad/ prefix before {{path}},
|
||||||
|
// but agentPath is relative to project root (e.g. "_bmad/custom/agents/fred.md").
|
||||||
|
// Strip the bmadFolderName prefix so the template doesn't produce a double path.
|
||||||
|
const bmadPrefix = this.bmadFolderName + '/';
|
||||||
|
const normalizedPath = agentPath.startsWith(bmadPrefix) ? agentPath.slice(bmadPrefix.length) : agentPath;
|
||||||
|
|
||||||
|
const artifact = {
|
||||||
|
type: 'agent-launcher',
|
||||||
|
name: agentName,
|
||||||
|
description: metadata?.description || `${agentName} agent`,
|
||||||
|
agentPath: normalizedPath,
|
||||||
|
relativePath: normalizedPath,
|
||||||
|
module: 'custom',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { content: template } = await this.loadTemplate(
|
||||||
|
this.installerConfig.template_type || 'default',
|
||||||
|
'agent',
|
||||||
|
this.installerConfig,
|
||||||
|
'default-agent',
|
||||||
|
);
|
||||||
|
const content = this.renderTemplate(template, artifact);
|
||||||
|
|
||||||
|
if (this.installerConfig.skill_format) {
|
||||||
|
const skillName = customAgentDashName(agentName).replace(/\.md$/, '');
|
||||||
|
const skillDir = path.join(targetPath, skillName);
|
||||||
|
await this.ensureDir(skillDir);
|
||||||
|
const skillContent = this.transformToSkillFormat(content, skillName);
|
||||||
|
const skillPath = path.join(skillDir, 'SKILL.md');
|
||||||
|
await this.writeFile(skillPath, skillContent);
|
||||||
|
return { path: path.relative(projectDir, skillPath), command: `$${skillName}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat file output
|
||||||
|
const filename = customAgentDashName(agentName);
|
||||||
|
const filePath = path.join(targetPath, filename);
|
||||||
|
await this.writeFile(filePath, content);
|
||||||
|
return { path: path.relative(projectDir, filePath), command: agentName };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate filename for artifact
|
||||||
|
* @param {Object} artifact - Artifact data
|
||||||
|
* @param {string} artifactType - Artifact type (agent, workflow, task, tool)
|
||||||
|
* @param {string} extension - File extension to use (e.g., '.md', '.toml')
|
||||||
|
* @returns {string} Generated filename
|
||||||
|
*/
|
||||||
|
generateFilename(artifact, artifactType, extension = '.md') {
|
||||||
|
const { resolveSkillName } = require('./shared/path-utils');
|
||||||
|
|
||||||
|
// Reuse central logic to ensure consistent naming conventions
|
||||||
|
// Prefers canonicalId from manifest when available, falls back to path-derived name
|
||||||
|
const standardName = resolveSkillName(artifact);
|
||||||
|
|
||||||
|
// Clean up potential double extensions from source files (e.g. .yaml.md, .xml.md -> .md)
|
||||||
|
// This handles any extensions that might slip through toDashPath()
|
||||||
|
const baseName = standardName.replace(/\.(md|yaml|yml|json|xml|toml)\.md$/i, '.md');
|
||||||
|
|
||||||
|
// If using default markdown, preserve the bmad-agent- prefix for agents
|
||||||
|
if (extension === '.md') {
|
||||||
|
return baseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other extensions (e.g., .toml), replace .md extension
|
||||||
|
// Note: agent prefix is preserved even with non-markdown extensions
|
||||||
|
return baseName.replace(/\.md$/, extension);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install verbatim native SKILL.md directories from skill-manifest.csv.
|
* Install verbatim native SKILL.md directories from skill-manifest.csv.
|
||||||
* Copies the entire source directory as-is into the IDE skill directory.
|
* Copies the entire source directory as-is into the IDE skill directory.
|
||||||
|
|
@ -244,8 +598,22 @@ class ConfigDrivenIdeSetup {
|
||||||
await this.cleanupRovoDevPrompts(projectDir, options);
|
await this.cleanupRovoDevPrompts(projectDir, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean target directory
|
// Clean all target directories
|
||||||
if (this.installerConfig?.target_dir) {
|
if (this.installerConfig?.targets) {
|
||||||
|
const parentDirs = new Set();
|
||||||
|
for (const target of this.installerConfig.targets) {
|
||||||
|
await this.cleanupTarget(projectDir, target.target_dir, options);
|
||||||
|
// Track parent directories for empty-dir cleanup
|
||||||
|
const parentDir = path.dirname(target.target_dir);
|
||||||
|
if (parentDir && parentDir !== '.') {
|
||||||
|
parentDirs.add(parentDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// After all targets cleaned, remove empty parent directories (recursive up to projectDir)
|
||||||
|
for (const parentDir of parentDirs) {
|
||||||
|
await this.removeEmptyParents(projectDir, parentDir);
|
||||||
|
}
|
||||||
|
} else if (this.installerConfig?.target_dir) {
|
||||||
await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options);
|
await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -343,7 +711,6 @@ class ConfigDrivenIdeSetup {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip BMAD-owned content from .github/copilot-instructions.md.
|
* Strip BMAD-owned content from .github/copilot-instructions.md.
|
||||||
* The old custom installer injected content between <!-- BMAD:START --> and <!-- BMAD:END --> markers.
|
* The old custom installer injected content between <!-- BMAD:START --> and <!-- BMAD:END --> markers.
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,23 @@ class IdeManager {
|
||||||
return results;
|
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
|
* Detect installed IDEs
|
||||||
* @param {string} projectDir - Project directory
|
* @param {string} projectDir - Project directory
|
||||||
|
|
@ -242,6 +259,41 @@ class IdeManager {
|
||||||
|
|
||||||
return detected;
|
return detected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install custom agent launchers for specified IDEs
|
||||||
|
* @param {Array} ides - List of IDE names to install for
|
||||||
|
* @param {string} projectDir - Project directory
|
||||||
|
* @param {string} agentName - Agent name (e.g., "fred-commit-poet")
|
||||||
|
* @param {string} agentPath - Path to compiled agent (relative to project root)
|
||||||
|
* @param {Object} metadata - Agent metadata
|
||||||
|
* @returns {Object} Results for each IDE
|
||||||
|
*/
|
||||||
|
async installCustomAgentLaunchers(ides, projectDir, agentName, agentPath, metadata) {
|
||||||
|
const results = {};
|
||||||
|
|
||||||
|
for (const ideName of ides) {
|
||||||
|
const handler = this.handlers.get(ideName.toLowerCase());
|
||||||
|
|
||||||
|
if (!handler) {
|
||||||
|
await prompts.log.warn(`IDE '${ideName}' is not yet supported for custom agent installation`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof handler.installCustomAgentLauncher === 'function') {
|
||||||
|
const result = await handler.installCustomAgentLauncher(projectDir, agentName, agentPath, metadata);
|
||||||
|
if (result) {
|
||||||
|
results[ideName] = result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await prompts.log.warn(`Failed to install ${ideName} launcher: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { IdeManager };
|
module.exports = { IdeManager };
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,64 @@ async function loadPlatformCodes() {
|
||||||
return _cachedPlatformCodes;
|
return _cachedPlatformCodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get platform information by code
|
||||||
|
* @param {string} platformCode - Platform code (e.g., 'claude-code', 'cursor')
|
||||||
|
* @returns {Object|null} Platform info or null if not found
|
||||||
|
*/
|
||||||
|
function getPlatformInfo(platformCode) {
|
||||||
|
if (!_cachedPlatformCodes) {
|
||||||
|
throw new Error('Platform codes not loaded. Call loadPlatformCodes() first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return _cachedPlatformCodes.platforms[platformCode] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all preferred platforms
|
||||||
|
* @returns {Promise<Array>} Array of preferred platform codes
|
||||||
|
*/
|
||||||
|
async function getPreferredPlatforms() {
|
||||||
|
const config = await loadPlatformCodes();
|
||||||
|
return Object.entries(config.platforms)
|
||||||
|
.filter(([_, info]) => info.preferred)
|
||||||
|
.map(([code, _]) => code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all platform codes by category
|
||||||
|
* @param {string} category - Category to filter by (ide, cli, tool, etc.)
|
||||||
|
* @returns {Promise<Array>} Array of platform codes in the category
|
||||||
|
*/
|
||||||
|
async function getPlatformsByCategory(category) {
|
||||||
|
const config = await loadPlatformCodes();
|
||||||
|
return Object.entries(config.platforms)
|
||||||
|
.filter(([_, info]) => info.category === category)
|
||||||
|
.map(([code, _]) => code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all platforms with installer config
|
||||||
|
* @returns {Promise<Array>} Array of platform codes that have installer config
|
||||||
|
*/
|
||||||
|
async function getConfigDrivenPlatforms() {
|
||||||
|
const config = await loadPlatformCodes();
|
||||||
|
return Object.entries(config.platforms)
|
||||||
|
.filter(([_, info]) => info.installer)
|
||||||
|
.map(([code, _]) => code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get platforms that use custom installers (no installer config)
|
||||||
|
* @returns {Promise<Array>} Array of platform codes with custom installers
|
||||||
|
*/
|
||||||
|
async function getCustomInstallerPlatforms() {
|
||||||
|
const config = await loadPlatformCodes();
|
||||||
|
return Object.entries(config.platforms)
|
||||||
|
.filter(([_, info]) => !info.installer)
|
||||||
|
.map(([code, _]) => code);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the cached platform codes (useful for testing)
|
* Clear the cached platform codes (useful for testing)
|
||||||
*/
|
*/
|
||||||
|
|
@ -33,5 +91,10 @@ function clearCache() {
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
loadPlatformCodes,
|
loadPlatformCodes,
|
||||||
|
getPlatformInfo,
|
||||||
|
getPreferredPlatforms,
|
||||||
|
getPlatformsByCategory,
|
||||||
|
getConfigDrivenPlatforms,
|
||||||
|
getCustomInstallerPlatforms,
|
||||||
clearCache,
|
clearCache,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,133 +1,196 @@
|
||||||
# BMAD Platform Codes Configuration
|
# BMAD Platform Codes Configuration
|
||||||
|
# Central configuration for all platform/IDE codes used in the BMAD system
|
||||||
#
|
#
|
||||||
# Each platform entry has:
|
# This file defines:
|
||||||
|
# 1. Platform metadata (name, preferred status, category, description)
|
||||||
|
# 2. Installer configuration (target directories, templates, artifact types)
|
||||||
|
#
|
||||||
|
# Format:
|
||||||
|
# code: Platform identifier used internally
|
||||||
# name: Display name shown to users
|
# name: Display name shown to users
|
||||||
# preferred: Whether shown as a recommended option on install
|
# preferred: Whether this platform is shown as a recommended option on install
|
||||||
# suspended: (optional) Message explaining why install is blocked
|
# category: Type of platform (ide, cli, tool, service)
|
||||||
# installer:
|
# description: Brief description of the platform
|
||||||
# target_dir: Directory where skill directories are installed
|
# installer: Installation configuration (optional - omit for custom installers)
|
||||||
# legacy_targets: (optional) Old target dirs to clean up on reinstall
|
|
||||||
# ancestor_conflict_check: (optional) Refuse install when ancestor dir has BMAD files
|
|
||||||
|
|
||||||
platforms:
|
platforms:
|
||||||
antigravity:
|
antigravity:
|
||||||
name: "Google Antigravity"
|
name: "Google Antigravity"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: ide
|
||||||
|
description: "Google's AI development environment"
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .agent/workflows
|
- .agent/workflows
|
||||||
target_dir: .agent/skills
|
target_dir: .agent/skills
|
||||||
|
template_type: antigravity
|
||||||
|
skill_format: true
|
||||||
|
|
||||||
auggie:
|
auggie:
|
||||||
name: "Auggie"
|
name: "Auggie"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: cli
|
||||||
|
description: "AI development tool"
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .augment/commands
|
- .augment/commands
|
||||||
target_dir: .augment/skills
|
target_dir: .augment/skills
|
||||||
|
template_type: default
|
||||||
|
skill_format: true
|
||||||
|
|
||||||
claude-code:
|
claude-code:
|
||||||
name: "Claude Code"
|
name: "Claude Code"
|
||||||
preferred: true
|
preferred: true
|
||||||
|
category: cli
|
||||||
|
description: "Anthropic's official CLI for Claude"
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .claude/commands
|
- .claude/commands
|
||||||
target_dir: .claude/skills
|
target_dir: .claude/skills
|
||||||
|
template_type: default
|
||||||
|
skill_format: true
|
||||||
ancestor_conflict_check: true
|
ancestor_conflict_check: true
|
||||||
|
|
||||||
cline:
|
cline:
|
||||||
name: "Cline"
|
name: "Cline"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: ide
|
||||||
|
description: "AI coding assistant"
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .clinerules/workflows
|
- .clinerules/workflows
|
||||||
target_dir: .cline/skills
|
target_dir: .cline/skills
|
||||||
|
template_type: default
|
||||||
|
skill_format: true
|
||||||
|
|
||||||
codex:
|
codex:
|
||||||
name: "Codex"
|
name: "Codex"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: cli
|
||||||
|
description: "OpenAI Codex integration"
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .codex/prompts
|
- .codex/prompts
|
||||||
- ~/.codex/prompts
|
- ~/.codex/prompts
|
||||||
target_dir: .agents/skills
|
target_dir: .agents/skills
|
||||||
|
template_type: default
|
||||||
|
skill_format: true
|
||||||
ancestor_conflict_check: true
|
ancestor_conflict_check: true
|
||||||
|
artifact_types: [agents, workflows, tasks]
|
||||||
|
|
||||||
codebuddy:
|
codebuddy:
|
||||||
name: "CodeBuddy"
|
name: "CodeBuddy"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: ide
|
||||||
|
description: "Tencent Cloud Code Assistant - AI-powered coding companion"
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .codebuddy/commands
|
- .codebuddy/commands
|
||||||
target_dir: .codebuddy/skills
|
target_dir: .codebuddy/skills
|
||||||
|
template_type: default
|
||||||
|
skill_format: true
|
||||||
|
|
||||||
crush:
|
crush:
|
||||||
name: "Crush"
|
name: "Crush"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: ide
|
||||||
|
description: "AI development assistant"
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .crush/commands
|
- .crush/commands
|
||||||
target_dir: .crush/skills
|
target_dir: .crush/skills
|
||||||
|
template_type: default
|
||||||
|
skill_format: true
|
||||||
|
|
||||||
cursor:
|
cursor:
|
||||||
name: "Cursor"
|
name: "Cursor"
|
||||||
preferred: true
|
preferred: true
|
||||||
|
category: ide
|
||||||
|
description: "AI-first code editor"
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .cursor/commands
|
- .cursor/commands
|
||||||
target_dir: .cursor/skills
|
target_dir: .cursor/skills
|
||||||
|
template_type: default
|
||||||
|
skill_format: true
|
||||||
|
|
||||||
gemini:
|
gemini:
|
||||||
name: "Gemini CLI"
|
name: "Gemini CLI"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: cli
|
||||||
|
description: "Google's CLI for Gemini"
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .gemini/commands
|
- .gemini/commands
|
||||||
target_dir: .gemini/skills
|
target_dir: .gemini/skills
|
||||||
|
template_type: default
|
||||||
|
skill_format: true
|
||||||
|
|
||||||
github-copilot:
|
github-copilot:
|
||||||
name: "GitHub Copilot"
|
name: "GitHub Copilot"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: ide
|
||||||
|
description: "GitHub's AI pair programmer"
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .github/agents
|
- .github/agents
|
||||||
- .github/prompts
|
- .github/prompts
|
||||||
target_dir: .github/skills
|
target_dir: .github/skills
|
||||||
|
template_type: default
|
||||||
|
skill_format: true
|
||||||
|
|
||||||
iflow:
|
iflow:
|
||||||
name: "iFlow"
|
name: "iFlow"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: ide
|
||||||
|
description: "AI workflow automation"
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .iflow/commands
|
- .iflow/commands
|
||||||
target_dir: .iflow/skills
|
target_dir: .iflow/skills
|
||||||
|
template_type: default
|
||||||
|
skill_format: true
|
||||||
|
|
||||||
kilo:
|
kilo:
|
||||||
name: "KiloCoder"
|
name: "KiloCoder"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: ide
|
||||||
|
description: "AI coding platform"
|
||||||
suspended: "Kilo Code does not yet support the Agent Skills standard. Support is paused until they implement it. See https://github.com/kilocode/kilo-code/issues for updates."
|
suspended: "Kilo Code does not yet support the Agent Skills standard. Support is paused until they implement it. See https://github.com/kilocode/kilo-code/issues for updates."
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .kilocode/workflows
|
- .kilocode/workflows
|
||||||
target_dir: .kilocode/skills
|
target_dir: .kilocode/skills
|
||||||
|
template_type: default
|
||||||
|
skill_format: true
|
||||||
|
|
||||||
kiro:
|
kiro:
|
||||||
name: "Kiro"
|
name: "Kiro"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: ide
|
||||||
|
description: "Amazon's AI-powered IDE"
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .kiro/steering
|
- .kiro/steering
|
||||||
target_dir: .kiro/skills
|
target_dir: .kiro/skills
|
||||||
|
template_type: kiro
|
||||||
|
skill_format: true
|
||||||
|
|
||||||
ona:
|
ona:
|
||||||
name: "Ona"
|
name: "Ona"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: ide
|
||||||
|
description: "Ona AI development environment"
|
||||||
installer:
|
installer:
|
||||||
target_dir: .ona/skills
|
target_dir: .ona/skills
|
||||||
|
template_type: default
|
||||||
|
skill_format: true
|
||||||
|
|
||||||
opencode:
|
opencode:
|
||||||
name: "OpenCode"
|
name: "OpenCode"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: ide
|
||||||
|
description: "OpenCode terminal coding assistant"
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .opencode/agents
|
- .opencode/agents
|
||||||
|
|
@ -135,56 +198,144 @@ platforms:
|
||||||
- .opencode/agent
|
- .opencode/agent
|
||||||
- .opencode/command
|
- .opencode/command
|
||||||
target_dir: .opencode/skills
|
target_dir: .opencode/skills
|
||||||
|
template_type: opencode
|
||||||
|
skill_format: true
|
||||||
ancestor_conflict_check: true
|
ancestor_conflict_check: true
|
||||||
|
|
||||||
pi:
|
pi:
|
||||||
name: "Pi"
|
name: "Pi"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: cli
|
||||||
|
description: "Provider-agnostic terminal-native AI coding agent"
|
||||||
installer:
|
installer:
|
||||||
target_dir: .pi/skills
|
target_dir: .pi/skills
|
||||||
|
template_type: default
|
||||||
|
skill_format: true
|
||||||
|
|
||||||
qoder:
|
qoder:
|
||||||
name: "Qoder"
|
name: "Qoder"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: ide
|
||||||
|
description: "Qoder AI coding assistant"
|
||||||
installer:
|
installer:
|
||||||
target_dir: .qoder/skills
|
target_dir: .qoder/skills
|
||||||
|
template_type: default
|
||||||
|
skill_format: true
|
||||||
|
|
||||||
qwen:
|
qwen:
|
||||||
name: "QwenCoder"
|
name: "QwenCoder"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: ide
|
||||||
|
description: "Qwen AI coding assistant"
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .qwen/commands
|
- .qwen/commands
|
||||||
target_dir: .qwen/skills
|
target_dir: .qwen/skills
|
||||||
|
template_type: default
|
||||||
|
skill_format: true
|
||||||
|
|
||||||
roo:
|
roo:
|
||||||
name: "Roo Code"
|
name: "Roo Code"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: ide
|
||||||
|
description: "Enhanced Cline fork"
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .roo/commands
|
- .roo/commands
|
||||||
target_dir: .roo/skills
|
target_dir: .roo/skills
|
||||||
|
template_type: default
|
||||||
|
skill_format: true
|
||||||
|
|
||||||
rovo-dev:
|
rovo-dev:
|
||||||
name: "Rovo Dev"
|
name: "Rovo Dev"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: ide
|
||||||
|
description: "Atlassian's Rovo development environment"
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .rovodev/workflows
|
- .rovodev/workflows
|
||||||
target_dir: .rovodev/skills
|
target_dir: .rovodev/skills
|
||||||
|
template_type: default
|
||||||
|
skill_format: true
|
||||||
|
|
||||||
trae:
|
trae:
|
||||||
name: "Trae"
|
name: "Trae"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: ide
|
||||||
|
description: "AI coding tool"
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .trae/rules
|
- .trae/rules
|
||||||
target_dir: .trae/skills
|
target_dir: .trae/skills
|
||||||
|
template_type: default
|
||||||
|
skill_format: true
|
||||||
|
|
||||||
windsurf:
|
windsurf:
|
||||||
name: "Windsurf"
|
name: "Windsurf"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
category: ide
|
||||||
|
description: "AI-powered IDE with cascade flows"
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .windsurf/workflows
|
- .windsurf/workflows
|
||||||
target_dir: .windsurf/skills
|
target_dir: .windsurf/skills
|
||||||
|
template_type: windsurf
|
||||||
|
skill_format: true
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Installer Config Schema
|
||||||
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# installer:
|
||||||
|
# target_dir: string # Directory where artifacts are installed
|
||||||
|
# template_type: string # Default template type to use
|
||||||
|
# header_template: string (optional) # Override for header/frontmatter template
|
||||||
|
# body_template: string (optional) # Override for body/content template
|
||||||
|
# legacy_targets: array (optional) # Old target dirs to clean up on reinstall (migration)
|
||||||
|
# - string # Relative path, e.g. .opencode/agent
|
||||||
|
# targets: array (optional) # For multi-target installations
|
||||||
|
# - target_dir: string
|
||||||
|
# template_type: string
|
||||||
|
# artifact_types: [agents, workflows, tasks, tools]
|
||||||
|
# artifact_types: array (optional) # Filter which artifacts to install (default: all)
|
||||||
|
# skip_existing: boolean (optional) # Skip files that already exist (default: false)
|
||||||
|
# skill_format: boolean (optional) # Use directory-per-skill output: <name>/SKILL.md
|
||||||
|
# # with clean frontmatter (name + description, unquoted)
|
||||||
|
# ancestor_conflict_check: boolean (optional) # Refuse install when ancestor dir has BMAD files
|
||||||
|
# # in the same target_dir (for IDEs that inherit
|
||||||
|
# # skills from parent directories)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Platform Categories
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
categories:
|
||||||
|
ide:
|
||||||
|
name: "Integrated Development Environment"
|
||||||
|
description: "Full-featured code editors with AI assistance"
|
||||||
|
|
||||||
|
cli:
|
||||||
|
name: "Command Line Interface"
|
||||||
|
description: "Terminal-based tools"
|
||||||
|
|
||||||
|
tool:
|
||||||
|
name: "Development Tool"
|
||||||
|
description: "Standalone development utilities"
|
||||||
|
|
||||||
|
service:
|
||||||
|
name: "Cloud Service"
|
||||||
|
description: "Cloud-based development platforms"
|
||||||
|
|
||||||
|
extension:
|
||||||
|
name: "Editor Extension"
|
||||||
|
description: "Plugins for existing editors"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Naming Conventions and Rules
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
conventions:
|
||||||
|
code_format: "lowercase-kebab-case"
|
||||||
|
name_format: "Title Case"
|
||||||
|
max_code_length: 20
|
||||||
|
allowed_characters: "a-z0-9-"
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class CustomModules {
|
||||||
}
|
}
|
||||||
|
|
||||||
// From manifest (regular updates)
|
// From manifest (regular updates)
|
||||||
if (config._isUpdate && config._existingInstall) {
|
if (config._isUpdate && config._existingInstall && config._existingInstall.customModules) {
|
||||||
for (const customModule of config._existingInstall.customModules) {
|
for (const customModule of config._existingInstall.customModules) {
|
||||||
let absoluteSourcePath = customModule.sourcePath;
|
let absoluteSourcePath = customModule.sourcePath;
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,213 @@
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const yaml = require('yaml');
|
||||||
|
const path = require('node:path');
|
||||||
|
const packageJson = require('../../../package.json');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration utility class
|
||||||
|
*/
|
||||||
|
class Config {
|
||||||
|
/**
|
||||||
|
* Load a YAML configuration file
|
||||||
|
* @param {string} configPath - Path to config file
|
||||||
|
* @returns {Object} Parsed configuration
|
||||||
|
*/
|
||||||
|
async loadYaml(configPath) {
|
||||||
|
if (!(await fs.pathExists(configPath))) {
|
||||||
|
throw new Error(`Configuration file not found: ${configPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile(configPath, 'utf8');
|
||||||
|
return yaml.parse(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save configuration to YAML file
|
||||||
|
* @param {string} configPath - Path to config file
|
||||||
|
* @param {Object} config - Configuration object
|
||||||
|
*/
|
||||||
|
async saveYaml(configPath, config) {
|
||||||
|
const yamlContent = yaml.dump(config, {
|
||||||
|
indent: 2,
|
||||||
|
lineWidth: 120,
|
||||||
|
noRefs: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs.ensureDir(path.dirname(configPath));
|
||||||
|
// Ensure POSIX-compliant final newline
|
||||||
|
const content = yamlContent.endsWith('\n') ? yamlContent : yamlContent + '\n';
|
||||||
|
await fs.writeFile(configPath, content, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process configuration file (replace placeholders)
|
||||||
|
* @param {string} configPath - Path to config file
|
||||||
|
* @param {Object} replacements - Replacement values
|
||||||
|
*/
|
||||||
|
async processConfig(configPath, replacements = {}) {
|
||||||
|
let content = await fs.readFile(configPath, 'utf8');
|
||||||
|
|
||||||
|
// Standard replacements
|
||||||
|
const standardReplacements = {
|
||||||
|
'{project-root}': replacements.root || '',
|
||||||
|
'{module}': replacements.module || '',
|
||||||
|
'{version}': replacements.version || packageJson.version,
|
||||||
|
'{date}': new Date().toISOString().split('T')[0],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply all replacements
|
||||||
|
const allReplacements = { ...standardReplacements, ...replacements };
|
||||||
|
|
||||||
|
for (const [placeholder, value] of Object.entries(allReplacements)) {
|
||||||
|
if (typeof placeholder === 'string' && typeof value === 'string') {
|
||||||
|
const regex = new RegExp(placeholder.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`), 'g');
|
||||||
|
content = content.replace(regex, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(configPath, content, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge configurations
|
||||||
|
* @param {Object} base - Base configuration
|
||||||
|
* @param {Object} override - Override configuration
|
||||||
|
* @returns {Object} Merged configuration
|
||||||
|
*/
|
||||||
|
mergeConfigs(base, override) {
|
||||||
|
return this.deepMerge(base, override);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep merge two objects
|
||||||
|
* @param {Object} target - Target object
|
||||||
|
* @param {Object} source - Source object
|
||||||
|
* @returns {Object} Merged object
|
||||||
|
*/
|
||||||
|
deepMerge(target, source) {
|
||||||
|
const output = { ...target };
|
||||||
|
|
||||||
|
if (this.isObject(target) && this.isObject(source)) {
|
||||||
|
for (const key of Object.keys(source)) {
|
||||||
|
if (this.isObject(source[key])) {
|
||||||
|
if (key in target) {
|
||||||
|
output[key] = this.deepMerge(target[key], source[key]);
|
||||||
|
} else {
|
||||||
|
output[key] = source[key];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output[key] = source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if value is an object
|
||||||
|
* @param {*} item - Item to check
|
||||||
|
* @returns {boolean} True if object
|
||||||
|
*/
|
||||||
|
isObject(item) {
|
||||||
|
return item && typeof item === 'object' && !Array.isArray(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate configuration against schema
|
||||||
|
* @param {Object} config - Configuration to validate
|
||||||
|
* @param {Object} schema - Validation schema
|
||||||
|
* @returns {Object} Validation result
|
||||||
|
*/
|
||||||
|
validateConfig(config, schema) {
|
||||||
|
const errors = [];
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
// Check required fields
|
||||||
|
if (schema.required) {
|
||||||
|
for (const field of schema.required) {
|
||||||
|
if (!(field in config)) {
|
||||||
|
errors.push(`Missing required field: ${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check field types
|
||||||
|
if (schema.properties) {
|
||||||
|
for (const [field, spec] of Object.entries(schema.properties)) {
|
||||||
|
if (field in config) {
|
||||||
|
const value = config[field];
|
||||||
|
const expectedType = spec.type;
|
||||||
|
|
||||||
|
if (expectedType === 'array' && !Array.isArray(value)) {
|
||||||
|
errors.push(`Field '${field}' should be an array`);
|
||||||
|
} else if (expectedType === 'object' && !this.isObject(value)) {
|
||||||
|
errors.push(`Field '${field}' should be an object`);
|
||||||
|
} else if (expectedType === 'string' && typeof value !== 'string') {
|
||||||
|
errors.push(`Field '${field}' should be a string`);
|
||||||
|
} else if (expectedType === 'number' && typeof value !== 'number') {
|
||||||
|
errors.push(`Field '${field}' should be a number`);
|
||||||
|
} else if (expectedType === 'boolean' && typeof value !== 'boolean') {
|
||||||
|
errors.push(`Field '${field}' should be a boolean`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check enum values
|
||||||
|
if (spec.enum && !spec.enum.includes(value)) {
|
||||||
|
errors.push(`Field '${field}' must be one of: ${spec.enum.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get configuration value with fallback
|
||||||
|
* @param {Object} config - Configuration object
|
||||||
|
* @param {string} path - Dot-notation path to value
|
||||||
|
* @param {*} defaultValue - Default value if not found
|
||||||
|
* @returns {*} Configuration value
|
||||||
|
*/
|
||||||
|
getValue(config, path, defaultValue = null) {
|
||||||
|
const keys = path.split('.');
|
||||||
|
let current = config;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (current && typeof current === 'object' && key in current) {
|
||||||
|
current = current[key];
|
||||||
|
} else {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set configuration value
|
||||||
|
* @param {Object} config - Configuration object
|
||||||
|
* @param {string} path - Dot-notation path to value
|
||||||
|
* @param {*} value - Value to set
|
||||||
|
*/
|
||||||
|
setValue(config, path, value) {
|
||||||
|
const keys = path.split('.');
|
||||||
|
const lastKey = keys.pop();
|
||||||
|
let current = config;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!(key in current) || typeof current[key] !== 'object') {
|
||||||
|
current[key] = {};
|
||||||
|
}
|
||||||
|
current = current[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
current[lastKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { Config };
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const path = require('node:path');
|
||||||
|
const yaml = require('yaml');
|
||||||
|
const { getProjectRoot } = require('./project-root');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform Codes Manager
|
||||||
|
* Loads and provides access to the centralized platform codes configuration
|
||||||
|
*/
|
||||||
|
class PlatformCodes {
|
||||||
|
constructor() {
|
||||||
|
this.configPath = path.join(getProjectRoot(), 'tools', 'platform-codes.yaml');
|
||||||
|
this.loadConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the platform codes configuration
|
||||||
|
*/
|
||||||
|
loadConfig() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(this.configPath)) {
|
||||||
|
const content = fs.readFileSync(this.configPath, 'utf8');
|
||||||
|
this.config = yaml.parse(content);
|
||||||
|
} else {
|
||||||
|
console.warn(`Platform codes config not found at ${this.configPath}`);
|
||||||
|
this.config = { platforms: {} };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error loading platform codes: ${error.message}`);
|
||||||
|
this.config = { platforms: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all platform codes
|
||||||
|
* @returns {Object} All platform configurations
|
||||||
|
*/
|
||||||
|
getAllPlatforms() {
|
||||||
|
return this.config.platforms || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific platform configuration
|
||||||
|
* @param {string} code - Platform code
|
||||||
|
* @returns {Object|null} Platform configuration or null if not found
|
||||||
|
*/
|
||||||
|
getPlatform(code) {
|
||||||
|
return this.config.platforms[code] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a platform code is valid
|
||||||
|
* @param {string} code - Platform code to validate
|
||||||
|
* @returns {boolean} True if valid
|
||||||
|
*/
|
||||||
|
isValidPlatform(code) {
|
||||||
|
return code in this.config.platforms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all preferred platforms
|
||||||
|
* @returns {Array} Array of preferred platform codes
|
||||||
|
*/
|
||||||
|
getPreferredPlatforms() {
|
||||||
|
return Object.entries(this.config.platforms)
|
||||||
|
.filter(([, config]) => config.preferred)
|
||||||
|
.map(([code]) => code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get platforms by category
|
||||||
|
* @param {string} category - Category to filter by
|
||||||
|
* @returns {Array} Array of platform codes in the category
|
||||||
|
*/
|
||||||
|
getPlatformsByCategory(category) {
|
||||||
|
return Object.entries(this.config.platforms)
|
||||||
|
.filter(([, config]) => config.category === category)
|
||||||
|
.map(([code]) => code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get platform display name
|
||||||
|
* @param {string} code - Platform code
|
||||||
|
* @returns {string} Display name or code if not found
|
||||||
|
*/
|
||||||
|
getDisplayName(code) {
|
||||||
|
const platform = this.getPlatform(code);
|
||||||
|
return platform ? platform.name : code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate platform code format
|
||||||
|
* @param {string} code - Platform code to validate
|
||||||
|
* @returns {boolean} True if format is valid
|
||||||
|
*/
|
||||||
|
isValidFormat(code) {
|
||||||
|
const conventions = this.config.conventions || {};
|
||||||
|
const pattern = conventions.allowed_characters || 'a-z0-9-';
|
||||||
|
const maxLength = conventions.max_code_length || 20;
|
||||||
|
|
||||||
|
const regex = new RegExp(`^[${pattern}]+$`);
|
||||||
|
return regex.test(code) && code.length <= maxLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all platform codes as array
|
||||||
|
* @returns {Array} Array of platform codes
|
||||||
|
*/
|
||||||
|
getCodes() {
|
||||||
|
return Object.keys(this.config.platforms);
|
||||||
|
}
|
||||||
|
config = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
module.exports = new PlatformCodes();
|
||||||
|
|
@ -51,11 +51,125 @@ class UI {
|
||||||
confirmedDirectory = await this.getConfirmedDirectory();
|
confirmedDirectory = await this.getConfirmedDirectory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preflight: Check for legacy BMAD v4 footprints immediately after getting directory
|
||||||
|
const { Detector } = require('../installers/lib/core/detector');
|
||||||
const { Installer } = require('../installers/lib/core/installer');
|
const { Installer } = require('../installers/lib/core/installer');
|
||||||
|
const detector = new Detector();
|
||||||
const installer = new Installer();
|
const installer = new Installer();
|
||||||
const { bmadDir } = await installer.findBmadDir(confirmedDirectory);
|
const legacyV4 = await detector.detectLegacyV4(confirmedDirectory);
|
||||||
|
if (legacyV4.hasLegacyV4) {
|
||||||
|
await installer.handleLegacyV4Migration(confirmedDirectory, legacyV4);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if there's an existing BMAD installation
|
// Check for legacy folders and prompt for rename before showing any menus
|
||||||
|
let hasLegacyCfg = false;
|
||||||
|
let hasLegacyBmadFolder = false;
|
||||||
|
let bmadDir = null;
|
||||||
|
let legacyBmadPath = null;
|
||||||
|
|
||||||
|
// First check for legacy .bmad folder (instead of _bmad)
|
||||||
|
// Only check if directory exists
|
||||||
|
if (await fs.pathExists(confirmedDirectory)) {
|
||||||
|
const entries = await fs.readdir(confirmedDirectory, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory() && (entry.name === '.bmad' || entry.name === 'bmad')) {
|
||||||
|
hasLegacyBmadFolder = true;
|
||||||
|
legacyBmadPath = path.join(confirmedDirectory, entry.name);
|
||||||
|
bmadDir = legacyBmadPath;
|
||||||
|
|
||||||
|
// Check if it has _cfg folder
|
||||||
|
const cfgPath = path.join(legacyBmadPath, '_cfg');
|
||||||
|
if (await fs.pathExists(cfgPath)) {
|
||||||
|
hasLegacyCfg = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no .bmad or bmad found, check for current installations _bmad
|
||||||
|
if (!hasLegacyBmadFolder) {
|
||||||
|
const bmadResult = await installer.findBmadDir(confirmedDirectory);
|
||||||
|
bmadDir = bmadResult.bmadDir;
|
||||||
|
hasLegacyCfg = bmadResult.hasLegacyCfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle legacy .bmad or _cfg folder - these are very old (v4 or alpha)
|
||||||
|
// Show version warning instead of offering conversion
|
||||||
|
if (hasLegacyBmadFolder || hasLegacyCfg) {
|
||||||
|
await prompts.log.warn('LEGACY INSTALLATION DETECTED');
|
||||||
|
await prompts.note(
|
||||||
|
'Found a ".bmad"/"bmad" folder, or a legacy "_cfg" folder under the bmad folder -\n' +
|
||||||
|
'this is from an old BMAD version that is out of date for automatic upgrade,\n' +
|
||||||
|
'manual intervention required.\n\n' +
|
||||||
|
'You have a legacy version installed (v4 or alpha).\n' +
|
||||||
|
'Legacy installations may have compatibility issues.\n\n' +
|
||||||
|
'For the best experience, we strongly recommend:\n' +
|
||||||
|
' 1. Delete your current BMAD installation folder (.bmad or bmad)\n' +
|
||||||
|
' 2. Run a fresh installation\n\n' +
|
||||||
|
'If you do not want to start fresh, you can attempt to proceed beyond this\n' +
|
||||||
|
'point IF you have ensured the bmad folder is named _bmad, and under it there\n' +
|
||||||
|
'is a _config folder. If you have a folder under your bmad folder named _cfg,\n' +
|
||||||
|
'you would need to rename it _config, and then restart the installer.\n\n' +
|
||||||
|
'Benefits of a fresh install:\n' +
|
||||||
|
' \u2022 Cleaner configuration without legacy artifacts\n' +
|
||||||
|
' \u2022 All new features properly configured\n' +
|
||||||
|
' \u2022 Fewer potential conflicts\n\n' +
|
||||||
|
'If you have already produced output from an earlier alpha version, you can\n' +
|
||||||
|
'still retain those artifacts. After installation, ensure you configured during\n' +
|
||||||
|
'install the proper file locations for artifacts depending on the module you\n' +
|
||||||
|
'are using, or move the files to the proper locations.',
|
||||||
|
'Legacy Installation Detected',
|
||||||
|
);
|
||||||
|
|
||||||
|
const proceed = await prompts.select({
|
||||||
|
message: 'How would you like to proceed?',
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
name: 'Cancel and do a fresh install (recommended)',
|
||||||
|
value: 'cancel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Proceed anyway (will attempt update, potentially may fail or have unstable behavior)',
|
||||||
|
value: 'proceed',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default: 'cancel',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (proceed === 'cancel') {
|
||||||
|
await prompts.note('1. Delete the existing bmad folder in your project\n' + "2. Run 'bmad install' again", 'To do a fresh install');
|
||||||
|
process.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = await prompts.spinner();
|
||||||
|
s.start('Updating folder structure...');
|
||||||
|
try {
|
||||||
|
// Handle .bmad folder
|
||||||
|
if (hasLegacyBmadFolder) {
|
||||||
|
const newBmadPath = path.join(confirmedDirectory, '_bmad');
|
||||||
|
await fs.move(legacyBmadPath, newBmadPath);
|
||||||
|
bmadDir = newBmadPath;
|
||||||
|
s.stop(`Renamed "${path.basename(legacyBmadPath)}" to "_bmad"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle _cfg folder (either from .bmad or standalone)
|
||||||
|
const cfgPath = path.join(bmadDir, '_cfg');
|
||||||
|
if (await fs.pathExists(cfgPath)) {
|
||||||
|
s.start('Renaming configuration folder...');
|
||||||
|
const newCfgPath = path.join(bmadDir, '_config');
|
||||||
|
await fs.move(cfgPath, newCfgPath);
|
||||||
|
s.stop('Renamed "_cfg" to "_config"');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
s.stop('Failed to update folder structure');
|
||||||
|
await prompts.log.error(`Error: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's an existing BMAD installation (after any folder renames)
|
||||||
const hasExistingInstall = await fs.pathExists(bmadDir);
|
const hasExistingInstall = await fs.pathExists(bmadDir);
|
||||||
|
|
||||||
let customContentConfig = { hasCustomContent: false };
|
let customContentConfig = { hasCustomContent: false };
|
||||||
|
|
@ -74,6 +188,15 @@ class UI {
|
||||||
const currentVersion = require(packageJsonPath).version;
|
const currentVersion = require(packageJsonPath).version;
|
||||||
const installedVersion = existingInstall.version || 'unknown';
|
const installedVersion = existingInstall.version || 'unknown';
|
||||||
|
|
||||||
|
// Check if version is pre beta
|
||||||
|
const shouldProceed = await this.showLegacyVersionWarning(installedVersion, currentVersion, path.basename(bmadDir), options);
|
||||||
|
|
||||||
|
// If user chose to cancel, exit the installer
|
||||||
|
if (!shouldProceed) {
|
||||||
|
process.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Build menu choices dynamically
|
// Build menu choices dynamically
|
||||||
const choices = [];
|
const choices = [];
|
||||||
|
|
||||||
|
|
@ -308,7 +431,7 @@ class UI {
|
||||||
// Get tool selection
|
// Get tool selection
|
||||||
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||||
|
|
||||||
const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, options);
|
const coreConfig = await this.collectCoreConfig(confirmedDirectory, options);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
actionType: 'update',
|
actionType: 'update',
|
||||||
|
|
@ -316,8 +439,7 @@ class UI {
|
||||||
modules: selectedModules,
|
modules: selectedModules,
|
||||||
ides: toolSelection.ides,
|
ides: toolSelection.ides,
|
||||||
skipIde: toolSelection.skipIde,
|
skipIde: toolSelection.skipIde,
|
||||||
coreConfig: moduleConfigs.core || {},
|
coreConfig: coreConfig,
|
||||||
moduleConfigs: moduleConfigs,
|
|
||||||
customContent: customModuleResult.customContentConfig,
|
customContent: customModuleResult.customContentConfig,
|
||||||
skipPrompts: options.yes || false,
|
skipPrompts: options.yes || false,
|
||||||
};
|
};
|
||||||
|
|
@ -427,7 +549,7 @@ class UI {
|
||||||
selectedModules.unshift('core');
|
selectedModules.unshift('core');
|
||||||
}
|
}
|
||||||
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||||
const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, options);
|
const coreConfig = await this.collectCoreConfig(confirmedDirectory, options);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
actionType: 'install',
|
actionType: 'install',
|
||||||
|
|
@ -435,8 +557,7 @@ class UI {
|
||||||
modules: selectedModules,
|
modules: selectedModules,
|
||||||
ides: toolSelection.ides,
|
ides: toolSelection.ides,
|
||||||
skipIde: toolSelection.skipIde,
|
skipIde: toolSelection.skipIde,
|
||||||
coreConfig: moduleConfigs.core || {},
|
coreConfig: coreConfig,
|
||||||
moduleConfigs: moduleConfigs,
|
|
||||||
customContent: customContentConfig,
|
customContent: customContentConfig,
|
||||||
skipPrompts: options.yes || false,
|
skipPrompts: options.yes || false,
|
||||||
};
|
};
|
||||||
|
|
@ -452,12 +573,15 @@ class UI {
|
||||||
* @returns {Object} Tool configuration
|
* @returns {Object} Tool configuration
|
||||||
*/
|
*/
|
||||||
async promptToolSelection(projectDir, options = {}) {
|
async promptToolSelection(projectDir, options = {}) {
|
||||||
const { ExistingInstall } = require('../installers/lib/core/existing-install');
|
// Check for existing configured IDEs - use findBmadDir to detect custom folder names
|
||||||
|
const { Detector } = require('../installers/lib/core/detector');
|
||||||
const { Installer } = require('../installers/lib/core/installer');
|
const { Installer } = require('../installers/lib/core/installer');
|
||||||
|
const detector = new Detector();
|
||||||
const installer = new Installer();
|
const installer = new Installer();
|
||||||
const { bmadDir } = await installer.findBmadDir(projectDir || process.cwd());
|
const bmadResult = await installer.findBmadDir(projectDir || process.cwd());
|
||||||
const existingInstall = await ExistingInstall.detect(bmadDir);
|
const bmadDir = bmadResult.bmadDir;
|
||||||
const configuredIdes = existingInstall.ides;
|
const existingInstall = await detector.detect(bmadDir);
|
||||||
|
const configuredIdes = existingInstall.ides || [];
|
||||||
|
|
||||||
// Get IDE manager to fetch available IDEs dynamically
|
// Get IDE manager to fetch available IDEs dynamically
|
||||||
const { IdeManager } = require('../installers/lib/ide/manager');
|
const { IdeManager } = require('../installers/lib/ide/manager');
|
||||||
|
|
@ -690,29 +814,29 @@ class UI {
|
||||||
* @returns {Object} Object with existingInstall, installedModuleIds, and bmadDir
|
* @returns {Object} Object with existingInstall, installedModuleIds, and bmadDir
|
||||||
*/
|
*/
|
||||||
async getExistingInstallation(directory) {
|
async getExistingInstallation(directory) {
|
||||||
const { ExistingInstall } = require('../installers/lib/core/existing-install');
|
const { Detector } = require('../installers/lib/core/detector');
|
||||||
const { Installer } = require('../installers/lib/core/installer');
|
const { Installer } = require('../installers/lib/core/installer');
|
||||||
|
const detector = new Detector();
|
||||||
const installer = new Installer();
|
const installer = new Installer();
|
||||||
const { bmadDir } = await installer.findBmadDir(directory);
|
const bmadDirResult = await installer.findBmadDir(directory);
|
||||||
const existingInstall = await ExistingInstall.detect(bmadDir);
|
const bmadDir = bmadDirResult.bmadDir;
|
||||||
const installedModuleIds = new Set(existingInstall.moduleIds);
|
const existingInstall = await detector.detect(bmadDir);
|
||||||
|
const installedModuleIds = new Set(existingInstall.modules.map((mod) => mod.id));
|
||||||
|
|
||||||
return { existingInstall, installedModuleIds, bmadDir };
|
return { existingInstall, installedModuleIds, bmadDir };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect all module configurations (core + selected modules).
|
* Collect core configuration
|
||||||
* All interactive prompting happens here in the UI layer.
|
|
||||||
* @param {string} directory - Installation directory
|
* @param {string} directory - Installation directory
|
||||||
* @param {string[]} modules - Modules to configure (including 'core')
|
|
||||||
* @param {Object} options - Command-line options
|
* @param {Object} options - Command-line options
|
||||||
* @returns {Object} Collected module configurations keyed by module name
|
* @returns {Object} Core configuration
|
||||||
*/
|
*/
|
||||||
async collectModuleConfigs(directory, modules, options = {}) {
|
async collectCoreConfig(directory, options = {}) {
|
||||||
const { OfficialModules } = require('../installers/lib/modules/official-modules');
|
const { ConfigCollector } = require('../installers/lib/core/config-collector');
|
||||||
const configCollector = new OfficialModules();
|
const configCollector = new ConfigCollector();
|
||||||
|
|
||||||
// Seed core config from CLI options if provided
|
// If options are provided, set them directly
|
||||||
if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) {
|
if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) {
|
||||||
const coreConfig = {};
|
const coreConfig = {};
|
||||||
if (options.userName) {
|
if (options.userName) {
|
||||||
|
|
@ -734,6 +858,8 @@ class UI {
|
||||||
|
|
||||||
// Load existing config to merge with provided options
|
// Load existing config to merge with provided options
|
||||||
await configCollector.loadExistingConfig(directory);
|
await configCollector.loadExistingConfig(directory);
|
||||||
|
|
||||||
|
// Merge provided options with existing config (or defaults)
|
||||||
const existingConfig = configCollector.collectedConfig.core || {};
|
const existingConfig = configCollector.collectedConfig.core || {};
|
||||||
configCollector.collectedConfig.core = { ...existingConfig, ...coreConfig };
|
configCollector.collectedConfig.core = { ...existingConfig, ...coreConfig };
|
||||||
|
|
||||||
|
|
@ -749,6 +875,7 @@ class UI {
|
||||||
await configCollector.loadExistingConfig(directory);
|
await configCollector.loadExistingConfig(directory);
|
||||||
const existingConfig = configCollector.collectedConfig.core || {};
|
const existingConfig = configCollector.collectedConfig.core || {};
|
||||||
|
|
||||||
|
// If no existing config, use defaults
|
||||||
if (Object.keys(existingConfig).length === 0) {
|
if (Object.keys(existingConfig).length === 0) {
|
||||||
let safeUsername;
|
let safeUsername;
|
||||||
try {
|
try {
|
||||||
|
|
@ -765,14 +892,16 @@ class UI {
|
||||||
};
|
};
|
||||||
await prompts.log.info('Using default configuration (--yes flag)');
|
await prompts.log.info('Using default configuration (--yes flag)');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Load existing configs first if they exist
|
||||||
|
await configCollector.loadExistingConfig(directory);
|
||||||
|
// Now collect with existing values as defaults (false = don't skip loading, true = skip completion message)
|
||||||
|
await configCollector.collectModuleConfig('core', directory, false, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all module configs — core is skipped if already seeded above
|
const coreConfig = configCollector.collectedConfig.core;
|
||||||
await configCollector.collectAllConfigurations(modules, directory, {
|
// Ensure we always have a core config object, even if empty
|
||||||
skipPrompts: options.yes || false,
|
return coreConfig || {};
|
||||||
});
|
|
||||||
|
|
||||||
return configCollector.collectedConfig;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1259,18 +1388,50 @@ class UI {
|
||||||
return path.resolve(expanded);
|
return path.resolve(expanded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load existing configurations to use as defaults
|
||||||
|
* @param {string} directory - Installation directory
|
||||||
|
* @returns {Object} Existing configurations
|
||||||
|
*/
|
||||||
|
async loadExistingConfigurations(directory) {
|
||||||
|
const configs = {
|
||||||
|
hasCustomContent: false,
|
||||||
|
coreConfig: {},
|
||||||
|
ideConfig: { ides: [], skipIde: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load core config
|
||||||
|
configs.coreConfig = await this.collectCoreConfig(directory);
|
||||||
|
|
||||||
|
// Load IDE configuration
|
||||||
|
const configuredIdes = await this.getConfiguredIdes(directory);
|
||||||
|
if (configuredIdes.length > 0) {
|
||||||
|
configs.ideConfig.ides = configuredIdes;
|
||||||
|
configs.ideConfig.skipIde = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs;
|
||||||
|
} catch {
|
||||||
|
// If loading fails, return empty configs
|
||||||
|
await prompts.log.warn('Could not load existing configurations');
|
||||||
|
return configs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get configured IDEs from existing installation
|
* Get configured IDEs from existing installation
|
||||||
* @param {string} directory - Installation directory
|
* @param {string} directory - Installation directory
|
||||||
* @returns {Array} List of configured IDEs
|
* @returns {Array} List of configured IDEs
|
||||||
*/
|
*/
|
||||||
async getConfiguredIdes(directory) {
|
async getConfiguredIdes(directory) {
|
||||||
const { ExistingInstall } = require('../installers/lib/core/existing-install');
|
const { Detector } = require('../installers/lib/core/detector');
|
||||||
const { Installer } = require('../installers/lib/core/installer');
|
const { Installer } = require('../installers/lib/core/installer');
|
||||||
|
const detector = new Detector();
|
||||||
const installer = new Installer();
|
const installer = new Installer();
|
||||||
const { bmadDir } = await installer.findBmadDir(directory);
|
const bmadResult = await installer.findBmadDir(directory);
|
||||||
const existingInstall = await ExistingInstall.detect(bmadDir);
|
const existingInstall = await detector.detect(bmadResult.bmadDir);
|
||||||
return existingInstall.ides;
|
return existingInstall.ides || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1549,6 +1710,82 @@ class UI {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if installed version is a legacy version that needs fresh install
|
||||||
|
* @param {string} installedVersion - The installed version
|
||||||
|
* @returns {boolean} True if legacy (v4 or any alpha)
|
||||||
|
*/
|
||||||
|
isLegacyVersion(installedVersion) {
|
||||||
|
if (!installedVersion || installedVersion === 'unknown') {
|
||||||
|
return true; // Treat unknown as legacy for safety
|
||||||
|
}
|
||||||
|
// Check if version string contains -alpha or -Alpha (any v6 alpha)
|
||||||
|
return /-alpha\./i.test(installedVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show warning for legacy version (v4 or alpha) and ask if user wants to proceed
|
||||||
|
* @param {string} installedVersion - The installed version
|
||||||
|
* @param {string} currentVersion - The current version
|
||||||
|
* @param {string} bmadFolderName - Name of the BMAD folder
|
||||||
|
* @returns {Promise<boolean>} True if user wants to proceed, false if they cancel
|
||||||
|
*/
|
||||||
|
async showLegacyVersionWarning(installedVersion, currentVersion, bmadFolderName, options = {}) {
|
||||||
|
if (!this.isLegacyVersion(installedVersion)) {
|
||||||
|
return true; // Not legacy, proceed
|
||||||
|
}
|
||||||
|
|
||||||
|
let warningContent;
|
||||||
|
if (installedVersion === 'unknown') {
|
||||||
|
warningContent = 'Unable to detect your installed BMAD version.\n' + 'This appears to be a legacy or unsupported installation.';
|
||||||
|
} else {
|
||||||
|
warningContent =
|
||||||
|
`You are updating from ${installedVersion} to ${currentVersion}.\n` + 'You have a legacy version installed (v4 or alpha).';
|
||||||
|
}
|
||||||
|
|
||||||
|
warningContent +=
|
||||||
|
'\n\nFor the best experience, we recommend:\n' +
|
||||||
|
' 1. Delete your current BMAD installation folder\n' +
|
||||||
|
` (the "${bmadFolderName}/" folder in your project)\n` +
|
||||||
|
' 2. Run a fresh installation\n\n' +
|
||||||
|
'Benefits of a fresh install:\n' +
|
||||||
|
' \u2022 Cleaner configuration without legacy artifacts\n' +
|
||||||
|
' \u2022 All new features properly configured\n' +
|
||||||
|
' \u2022 Fewer potential conflicts';
|
||||||
|
|
||||||
|
await prompts.log.warn('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({
|
||||||
|
message: 'How would you like to proceed?',
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
name: 'Proceed with update anyway (may have issues)',
|
||||||
|
value: 'proceed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cancel (recommended - do a fresh install instead)',
|
||||||
|
value: 'cancel',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default: 'cancel',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (proceed === 'cancel') {
|
||||||
|
await prompts.note(
|
||||||
|
`1. Delete the "${bmadFolderName}/" folder in your project\n` + "2. Run 'bmad install' again",
|
||||||
|
'To do a fresh install',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return proceed === 'proceed';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display module versions with update availability
|
* Display module versions with update availability
|
||||||
* @param {Array} modules - Array of module info objects with version info
|
* @param {Array} modules - Array of module info objects with version info
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue