refactor(installer): flatten IDE hierarchy and remove dead code

Delete BaseIdeSetup class and merge surviving members into
ConfigDrivenIdeSetup, eliminating the inheritance hierarchy.
Remove unused template system, multi-target support,
installCustomAgentLauncher, and vestigial config keys
(template_type, skill_format, artifact_types) from
platform-codes.yaml. Freeze Config instance and arrays.
Move Config class to installers/lib/core/. Remove force flag
from update paths. Convert OfficialModules.collectConfigs to
static build().
This commit is contained in:
Alex Verkhovsky 2026-03-22 01:18:36 -06:00
parent 3e54815961
commit 793edd0026
9 changed files with 113 additions and 1467 deletions

View File

@ -148,8 +148,6 @@ async function runTests() {
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(
Array.isArray(windsurfInstaller?.legacy_targets) && windsurfInstaller.legacy_targets.includes('.windsurf/workflows'),
'Windsurf installer cleans legacy workflow output',
@ -196,8 +194,6 @@ async function runTests() {
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(
Array.isArray(kiroInstaller?.legacy_targets) && kiroInstaller.legacy_targets.includes('.kiro/steering'),
'Kiro installer cleans legacy steering output',
@ -244,8 +240,6 @@ async function runTests() {
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(
Array.isArray(antigravityInstaller?.legacy_targets) && antigravityInstaller.legacy_targets.includes('.agent/workflows'),
'Antigravity installer cleans legacy workflow output',
@ -292,8 +286,6 @@ async function runTests() {
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(
Array.isArray(auggieInstaller?.legacy_targets) && auggieInstaller.legacy_targets.includes('.augment/commands'),
'Auggie installer cleans legacy command output',
@ -345,8 +337,6 @@ async function runTests() {
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(
@ -411,8 +401,6 @@ async function runTests() {
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(
@ -504,8 +492,6 @@ async function runTests() {
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(
@ -594,8 +580,6 @@ async function runTests() {
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(
Array.isArray(cursorInstaller?.legacy_targets) && cursorInstaller.legacy_targets.includes('.cursor/commands'),
'Cursor installer cleans legacy command output',
@ -648,8 +632,6 @@ async function runTests() {
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(
Array.isArray(rooInstaller?.legacy_targets) && rooInstaller.legacy_targets.includes('.roo/commands'),
'Roo installer cleans legacy command output',
@ -756,8 +738,6 @@ async function runTests() {
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(
Array.isArray(copilotInstaller?.legacy_targets) && copilotInstaller.legacy_targets.includes('.github/agents'),
'GitHub Copilot installer cleans legacy agents output',
@ -838,8 +818,6 @@ async function runTests() {
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(
Array.isArray(clineInstaller?.legacy_targets) && clineInstaller.legacy_targets.includes('.clinerules/workflows'),
'Cline installer cleans legacy workflow output',
@ -900,8 +878,6 @@ async function runTests() {
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(
Array.isArray(codebuddyInstaller?.legacy_targets) && codebuddyInstaller.legacy_targets.includes('.codebuddy/commands'),
'CodeBuddy installer cleans legacy command output',
@ -960,8 +936,6 @@ async function runTests() {
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(
Array.isArray(crushInstaller?.legacy_targets) && crushInstaller.legacy_targets.includes('.crush/commands'),
'Crush installer cleans legacy command output',
@ -1020,8 +994,6 @@ async function runTests() {
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(
Array.isArray(traeInstaller?.legacy_targets) && traeInstaller.legacy_targets.includes('.trae/rules'),
'Trae installer cleans legacy rules output',
@ -1137,8 +1109,6 @@ async function runTests() {
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(
Array.isArray(geminiInstaller?.legacy_targets) && geminiInstaller.legacy_targets.includes('.gemini/commands'),
'Gemini installer cleans legacy commands output',
@ -1195,7 +1165,6 @@ async function runTests() {
const iflowInstaller = platformCodes24.platforms.iflow?.installer;
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(
Array.isArray(iflowInstaller?.legacy_targets) && iflowInstaller.legacy_targets.includes('.iflow/commands'),
'iFlow installer cleans legacy commands output',
@ -1245,7 +1214,6 @@ async function runTests() {
const qwenInstaller = platformCodes25.platforms.qwen?.installer;
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(
Array.isArray(qwenInstaller?.legacy_targets) && qwenInstaller.legacy_targets.includes('.qwen/commands'),
'QwenCoder installer cleans legacy commands output',
@ -1295,7 +1263,6 @@ async function runTests() {
const rovoInstaller = platformCodes26.platforms['rovo-dev']?.installer;
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(
Array.isArray(rovoInstaller?.legacy_targets) && rovoInstaller.legacy_targets.includes('.rovodev/workflows'),
'Rovo Dev installer cleans legacy workflows output',
@ -1431,8 +1398,6 @@ async function runTests() {
const piInstaller = platformCodes28.platforms.pi?.installer;
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-'));
installedBmadDir28 = await createTestBmadFixture();
@ -1773,8 +1738,6 @@ async function runTests() {
const onaInstaller = platformCodes32.platforms.ona?.installer;
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-'));
installedBmadDir32 = await createTestBmadFixture();

View File

@ -0,0 +1,52 @@
/**
* 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 };

View File

@ -48,10 +48,8 @@ class Installer {
await this.customModules.discoverPaths(config, paths);
if (existingInstall.installed && !config.force) {
if (!config.isQuickUpdate()) {
await this._removeDeselectedModules(existingInstall, config, paths);
}
if (existingInstall.installed) {
await this._removeDeselectedModules(existingInstall, config, paths);
await this._prepareUpdateState(paths, config, customConfig, existingInstall, officialModules);
}
@ -1730,12 +1728,12 @@ class Installer {
// Perform actual update
if (existingInstall.hasCore) {
await this.updateCore(bmadDir, config.force);
await this.updateCore(bmadDir);
}
const updateModules = new OfficialModules();
for (const module of existingInstall.modules) {
await updateModules.update(module.id, bmadDir, config.force, { installer: this });
await updateModules.update(module.id, bmadDir, { installer: this });
}
// Update manifest
@ -1753,18 +1751,10 @@ class Installer {
/**
* Private: Update core
*/
async updateCore(bmadDir, force = false) {
if (force) {
await new OfficialModules().install('core', bmadDir, (filePath) => this.installedFiles.add(filePath), {
skipModuleInstaller: true,
silent: true,
});
} else {
// Selective update - preserve user modifications
const sourcePath = getModulePath('core');
const targetPath = path.join(bmadDir, 'core');
await this.fileOps.syncDirectory(sourcePath, targetPath);
}
async updateCore(bmadDir) {
const sourcePath = getModulePath('core');
const targetPath = path.join(bmadDir, 'core');
await this.fileOps.syncDirectory(sourcePath, targetPath);
}
/**

View File

@ -1,657 +0,0 @@
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 };

View File

@ -2,9 +2,9 @@ const os = require('node:os');
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const { BaseIdeSetup } = require('./_base-ide');
const prompts = require('../../../lib/prompts');
const csv = require('csv-parse/sync');
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
/**
* Config-driven IDE setup handler
@ -15,43 +15,45 @@ const csv = require('csv-parse/sync');
*
* Features:
* - Config-driven from platform-codes.yaml
* - Template-based content generation
* - Multi-target installation support (e.g., GitHub Copilot)
* - Artifact type filtering (agents, workflows, tasks, tools)
* - Verbatim skill installation from skill-manifest.csv
* - Legacy directory cleanup and IDE-specific marker removal
*/
class ConfigDrivenIdeSetup extends BaseIdeSetup {
class ConfigDrivenIdeSetup {
constructor(platformCode, platformConfig) {
super(platformCode, platformConfig.name, platformConfig.preferred);
this.name = platformCode;
this.displayName = platformConfig.name || platformCode;
this.preferred = platformConfig.preferred || false;
this.platformConfig = platformConfig;
this.installerConfig = platformConfig.installer || null;
this.bmadFolderName = BMAD_FOLDER_NAME;
// Set configDir from target_dir so base-class detect() works
if (this.installerConfig?.target_dir) {
this.configDir = this.installerConfig.target_dir;
}
// Set configDir from target_dir so detect() works
this.configDir = this.installerConfig?.target_dir || null;
}
setBmadFolderName(bmadFolderName) {
this.bmadFolderName = bmadFolderName;
}
/**
* Detect whether this IDE already has configuration in the project.
* For skill_format platforms, checks for bmad-prefixed entries in target_dir
* (matching old codex.js behavior) instead of just checking directory existence.
* Checks for bmad-prefixed entries in target_dir.
* @param {string} projectDir - Project directory
* @returns {Promise<boolean>}
*/
async detect(projectDir) {
if (this.installerConfig?.skill_format && this.configDir) {
const dir = path.join(projectDir || process.cwd(), this.configDir);
if (await fs.pathExists(dir)) {
try {
const entries = await fs.readdir(dir);
return entries.some((e) => typeof e === 'string' && e.startsWith('bmad'));
} catch {
return false;
}
if (!this.configDir) return false;
const dir = path.join(projectDir || process.cwd(), this.configDir);
if (await fs.pathExists(dir)) {
try {
const entries = await fs.readdir(dir);
return entries.some((e) => typeof e === 'string' && e.startsWith('bmad'));
} catch {
return false;
}
return false;
}
return super.detect(projectDir);
return false;
}
/**
@ -90,12 +92,6 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
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) {
return this.installToTarget(projectDir, bmadDir, this.installerConfig, options);
}
@ -113,13 +109,8 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
*/
async installToTarget(projectDir, bmadDir, config, options) {
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);
await this.ensureDir(targetPath);
await fs.ensureDir(targetPath);
this.skillWriteTracker = new Set();
const results = { skills: 0 };
@ -132,351 +123,6 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
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.
* Copies the entire source directory as-is into the IDE skill directory.
@ -598,22 +244,8 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
await this.cleanupRovoDevPrompts(projectDir, options);
}
// Clean all target directories
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) {
// Clean target directory
if (this.installerConfig?.target_dir) {
await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options);
}
}
@ -711,6 +343,7 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
}
}
}
/**
* Strip BMAD-owned content from .github/copilot-instructions.md.
* The old custom installer injected content between <!-- BMAD:START --> and <!-- BMAD:END --> markers.

View File

@ -226,23 +226,6 @@ class IdeManager {
return results;
}
/**
* Get list of supported IDEs
* @returns {Array} List of supported IDE names
*/
getSupportedIdes() {
return [...this.handlers.keys()];
}
/**
* Check if an IDE is supported
* @param {string} ideName - Name of the IDE
* @returns {boolean} True if IDE is supported
*/
isSupported(ideName) {
return this.handlers.has(ideName.toLowerCase());
}
/**
* Detect installed IDEs
* @param {string} projectDir - Project directory
@ -259,41 +242,6 @@ class IdeManager {
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 };

View File

@ -23,8 +23,6 @@ platforms:
legacy_targets:
- .agent/workflows
target_dir: .agent/skills
template_type: antigravity
skill_format: true
auggie:
name: "Auggie"
@ -35,8 +33,6 @@ platforms:
legacy_targets:
- .augment/commands
target_dir: .augment/skills
template_type: default
skill_format: true
claude-code:
name: "Claude Code"
@ -47,8 +43,6 @@ platforms:
legacy_targets:
- .claude/commands
target_dir: .claude/skills
template_type: default
skill_format: true
ancestor_conflict_check: true
cline:
@ -60,8 +54,6 @@ platforms:
legacy_targets:
- .clinerules/workflows
target_dir: .cline/skills
template_type: default
skill_format: true
codex:
name: "Codex"
@ -73,10 +65,7 @@ platforms:
- .codex/prompts
- ~/.codex/prompts
target_dir: .agents/skills
template_type: default
skill_format: true
ancestor_conflict_check: true
artifact_types: [agents, workflows, tasks]
codebuddy:
name: "CodeBuddy"
@ -87,8 +76,6 @@ platforms:
legacy_targets:
- .codebuddy/commands
target_dir: .codebuddy/skills
template_type: default
skill_format: true
crush:
name: "Crush"
@ -99,8 +86,6 @@ platforms:
legacy_targets:
- .crush/commands
target_dir: .crush/skills
template_type: default
skill_format: true
cursor:
name: "Cursor"
@ -111,8 +96,6 @@ platforms:
legacy_targets:
- .cursor/commands
target_dir: .cursor/skills
template_type: default
skill_format: true
gemini:
name: "Gemini CLI"
@ -123,8 +106,6 @@ platforms:
legacy_targets:
- .gemini/commands
target_dir: .gemini/skills
template_type: default
skill_format: true
github-copilot:
name: "GitHub Copilot"
@ -136,8 +117,6 @@ platforms:
- .github/agents
- .github/prompts
target_dir: .github/skills
template_type: default
skill_format: true
iflow:
name: "iFlow"
@ -148,8 +127,6 @@ platforms:
legacy_targets:
- .iflow/commands
target_dir: .iflow/skills
template_type: default
skill_format: true
kilo:
name: "KiloCoder"
@ -161,8 +138,6 @@ platforms:
legacy_targets:
- .kilocode/workflows
target_dir: .kilocode/skills
template_type: default
skill_format: true
kiro:
name: "Kiro"
@ -173,8 +148,6 @@ platforms:
legacy_targets:
- .kiro/steering
target_dir: .kiro/skills
template_type: kiro
skill_format: true
ona:
name: "Ona"
@ -183,8 +156,6 @@ platforms:
description: "Ona AI development environment"
installer:
target_dir: .ona/skills
template_type: default
skill_format: true
opencode:
name: "OpenCode"
@ -198,8 +169,6 @@ platforms:
- .opencode/agent
- .opencode/command
target_dir: .opencode/skills
template_type: opencode
skill_format: true
ancestor_conflict_check: true
pi:
@ -209,8 +178,6 @@ platforms:
description: "Provider-agnostic terminal-native AI coding agent"
installer:
target_dir: .pi/skills
template_type: default
skill_format: true
qoder:
name: "Qoder"
@ -219,8 +186,6 @@ platforms:
description: "Qoder AI coding assistant"
installer:
target_dir: .qoder/skills
template_type: default
skill_format: true
qwen:
name: "QwenCoder"
@ -231,8 +196,6 @@ platforms:
legacy_targets:
- .qwen/commands
target_dir: .qwen/skills
template_type: default
skill_format: true
roo:
name: "Roo Code"
@ -243,8 +206,6 @@ platforms:
legacy_targets:
- .roo/commands
target_dir: .roo/skills
template_type: default
skill_format: true
rovo-dev:
name: "Rovo Dev"
@ -255,8 +216,6 @@ platforms:
legacy_targets:
- .rovodev/workflows
target_dir: .rovodev/skills
template_type: default
skill_format: true
trae:
name: "Trae"
@ -267,8 +226,6 @@ platforms:
legacy_targets:
- .trae/rules
target_dir: .trae/skills
template_type: default
skill_format: true
windsurf:
name: "Windsurf"
@ -279,31 +236,18 @@ platforms:
legacy_targets:
- .windsurf/workflows
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)
# target_dir: string # Directory where skill directories are installed
# legacy_targets: array (optional) # Old target dirs to clean up on reinstall (migration)
# - string # Relative path, e.g. .opencode/agent
# 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)
# # in the same target_dir (for IDEs that inherit
# # skills from parent directories)
# ============================================================================
# Platform Categories

View File

@ -30,41 +30,37 @@ class OfficialModules {
}
/**
* Load module configurations. If pre-collected configs are provided (from UI),
* stores them directly. Otherwise falls back to headless collection for
* programmatic callers (quick-update, tests).
* @param {Object} config - Clean install config
* Build a configured OfficialModules instance from install config.
* @param {Object} config - Clean install config (from Config.build)
* @param {Object} paths - InstallPaths instance
* @returns {Object} Module configurations (also available as this.moduleConfigs)
* @returns {OfficialModules}
*/
async collectConfigs(config, paths) {
// Pre-collected by UI — just store them
static async build(config, paths) {
const instance = new OfficialModules();
// Pre-collected by UI or quickUpdate — store and load existing for path-change detection
if (config.moduleConfigs) {
this.collectedConfig = config.moduleConfigs;
// Load existing config for path-change detection in createModuleDirectories
await this.loadExistingConfig(paths.projectRoot);
return this.moduleConfigs;
instance.collectedConfig = config.moduleConfigs;
await instance.loadExistingConfig(paths.projectRoot);
return instance;
}
// Quick update already collected everything via quickUpdate()
if (config.isQuickUpdate()) {
return this.moduleConfigs;
}
// Fallback: headless collection (--yes flag from CLI without UI, tests)
// Headless collection (--yes flag from CLI without UI, tests)
if (config.hasCoreConfig()) {
this.collectedConfig.core = config.coreConfig;
this.allAnswers = {};
instance.collectedConfig.core = config.coreConfig;
instance.allAnswers = {};
for (const [key, value] of Object.entries(config.coreConfig)) {
this.allAnswers[`core_${key}`] = value;
instance.allAnswers[`core_${key}`] = value;
}
}
const toCollect = config.hasCoreConfig() ? config.modules.filter((m) => m !== 'core') : [...config.modules];
return await this.collectAllConfigurations(toCollect, paths.projectRoot, {
await instance.collectAllConfigurations(toCollect, paths.projectRoot, {
skipPrompts: config.skipPrompts,
});
return instance;
}
/**
@ -304,30 +300,20 @@ class OfficialModules {
* Update an existing module
* @param {string} moduleName - Name of the module to update
* @param {string} bmadDir - Target bmad directory
* @param {boolean} force - Force update (overwrite modifications)
*/
async update(moduleName, bmadDir, force = false, options = {}) {
async update(moduleName, bmadDir) {
const sourcePath = await this.findModuleSource(moduleName);
const targetPath = path.join(bmadDir, moduleName);
// Check if source module exists
if (!sourcePath) {
throw new Error(`Module '${moduleName}' not found in any source location`);
}
// Check if module is installed
if (!(await fs.pathExists(targetPath))) {
throw new Error(`Module '${moduleName}' is not installed`);
}
if (force) {
// Force update - remove and reinstall
await fs.remove(targetPath);
return await this.install(moduleName, bmadDir, null, { installer: options.installer });
} else {
// Selective update - preserve user modifications
await this.syncModule(sourcePath, targetPath);
}
await this.syncModule(sourcePath, targetPath);
return {
success: true,

View File

@ -1,213 +0,0 @@
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 };