diff --git a/test/test-installation-components.js b/test/test-installation-components.js index c5b04a1ee..1b17b7cf9 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -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(); diff --git a/tools/cli/installers/lib/core/config.js b/tools/cli/installers/lib/core/config.js new file mode 100644 index 000000000..c844e2d00 --- /dev/null +++ b/tools/cli/installers/lib/core/config.js @@ -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 }; diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 58fcab38c..7fc524322 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -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); } /** diff --git a/tools/cli/installers/lib/ide/_base-ide.js b/tools/cli/installers/lib/ide/_base-ide.js deleted file mode 100644 index 8c970d130..000000000 --- a/tools/cli/installers/lib/ide/_base-ide.js +++ /dev/null @@ -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} 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} 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 }; diff --git a/tools/cli/installers/lib/ide/_config-driven.js b/tools/cli/installers/lib/ide/_config-driven.js index 5fb4c595a..c23938a40 100644 --- a/tools/cli/installers/lib/ide/_config-driven.js +++ b/tools/cli/installers/lib/ide/_config-driven.js @@ -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} */ 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} 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} 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. - - -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 section precisely - -`; - } - 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 /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 and markers. diff --git a/tools/cli/installers/lib/ide/manager.js b/tools/cli/installers/lib/ide/manager.js index 0d7f91209..d6ea07039 100644 --- a/tools/cli/installers/lib/ide/manager.js +++ b/tools/cli/installers/lib/ide/manager.js @@ -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 }; diff --git a/tools/cli/installers/lib/ide/platform-codes.yaml b/tools/cli/installers/lib/ide/platform-codes.yaml index 2c4d2e920..662b5d171 100644 --- a/tools/cli/installers/lib/ide/platform-codes.yaml +++ b/tools/cli/installers/lib/ide/platform-codes.yaml @@ -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: /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 diff --git a/tools/cli/installers/lib/modules/official-modules.js b/tools/cli/installers/lib/modules/official-modules.js index d57f3e692..cdc97aa2d 100644 --- a/tools/cli/installers/lib/modules/official-modules.js +++ b/tools/cli/installers/lib/modules/official-modules.js @@ -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, diff --git a/tools/cli/lib/config.js b/tools/cli/lib/config.js deleted file mode 100644 index a78250305..000000000 --- a/tools/cli/lib/config.js +++ /dev/null @@ -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 };