From c28206dca43342b50c7a28b0fc7524b21d0ece33 Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Fri, 20 Mar 2026 22:52:02 -0600 Subject: [PATCH] refactor(installer): remove dead agent compilation pipeline (#2080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(installer): remove dead agent compilation pipeline Delete 9 files (~2,600 lines) that compiled .agent.yaml to .md. No .agent.yaml files exist in the source tree — agents now ship as pre-built SKILL.md. Clean up all references in installer, module manager, custom handler, base IDE, UI, and tests. * refactor(custom-handler): remove dead install/copy/find methods CustomHandler.install(), copyDirectory(), and findFilesRecursively() are never called — custom modules are installed via moduleManager.install() since Dec 2025. Also removes unused FileOps import and constructor. Verified with before/after clean-installer comparison (codex + custom modules with custom.yaml): output is identical. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(installer): remove dead compilation refs from docs and module manager Address review findings from PR #2080 triage: - Remove compile-agents from CLI action docs (en, fr, zh-cn) - Remove dead vendorCrossModuleWorkflows() and .agent.yaml skip logic - Clean stale compilation-era comments in manifest-generator --------- Co-authored-by: Claude Opus 4.6 (1M context) --- docs/fr/how-to/customize-bmad.md | 11 +- .../fr/how-to/non-interactive-installation.md | 6 +- docs/how-to/customize-bmad.md | 11 +- docs/how-to/non-interactive-installation.md | 6 +- docs/zh-cn/how-to/customize-bmad.md | 11 +- .../how-to/non-interactive-installation.md | 6 +- test/test-installation-components.js | 72 +- tools/cli/commands/install.js | 9 +- tools/cli/installers/lib/core/installer.js | 210 +----- .../installers/lib/core/manifest-generator.js | 4 +- tools/cli/installers/lib/custom/handler.js | 248 +------ tools/cli/installers/lib/ide/_base-ide.js | 16 - tools/cli/installers/lib/modules/manager.js | 452 +----------- tools/cli/lib/activation-builder.js | 165 ----- tools/cli/lib/agent-analyzer.js | 97 --- tools/cli/lib/agent-party-generator.js | 194 ----- tools/cli/lib/agent/compiler.js | 516 ------------- tools/cli/lib/agent/installer.js | 680 ------------------ tools/cli/lib/agent/template-engine.js | 152 ---- tools/cli/lib/ui.js | 19 - tools/cli/lib/xml-handler.js | 177 ----- tools/cli/lib/xml-to-markdown.js | 82 --- tools/cli/lib/yaml-xml-builder.js | 572 --------------- 23 files changed, 33 insertions(+), 3683 deletions(-) delete mode 100644 tools/cli/lib/activation-builder.js delete mode 100644 tools/cli/lib/agent-analyzer.js delete mode 100644 tools/cli/lib/agent-party-generator.js delete mode 100644 tools/cli/lib/agent/compiler.js delete mode 100644 tools/cli/lib/agent/installer.js delete mode 100644 tools/cli/lib/agent/template-engine.js delete mode 100644 tools/cli/lib/xml-handler.js delete mode 100644 tools/cli/lib/xml-to-markdown.js delete mode 100644 tools/cli/lib/yaml-xml-builder.js diff --git a/docs/fr/how-to/customize-bmad.md b/docs/fr/how-to/customize-bmad.md index 94abfffde..f6a481235 100644 --- a/docs/fr/how-to/customize-bmad.md +++ b/docs/fr/how-to/customize-bmad.md @@ -127,7 +127,7 @@ prompts: ### 3. Appliquer vos modifications -Après modification, recompilez l'agent pour appliquer les changements : +Après modification, réinstallez pour appliquer les changements : ```bash npx bmad-method install @@ -137,17 +137,16 @@ L'installateur détecte l'installation existante et propose ces options : | Option | Ce qu'elle fait | | ----------------------------------- | ---------------------------------------------------------------------- | -| **Quick Update** | Met à jour tous les modules vers la dernière version et recompile tous les agents | -| **Recompile Agents** | Applique uniquement les personnalisations, sans mettre à jour les fichiers de modules | +| **Quick Update** | Met à jour tous les modules vers la dernière version et applique les personnalisations | | **Modify BMad Installation** | Flux d'installation complet pour ajouter ou supprimer des modules | -Pour des modifications de personnalisation uniquement, **Recompile Agents** est l'option la plus rapide. +Pour des modifications de personnalisation uniquement, **Quick Update** est l'option la plus rapide. ## Résolution des problèmes **Les modifications n'apparaissent pas ?** -- Exécutez `npx bmad-method install` et sélectionnez **Recompile Agents** pour appliquer les modifications +- Exécutez `npx bmad-method install` et sélectionnez **Quick Update** pour appliquer les modifications - Vérifiez que votre syntaxe YAML est valide (l'indentation compte) - Assurez-vous d'avoir modifié le bon fichier `.customize.yaml` pour l'agent @@ -160,7 +159,7 @@ Pour des modifications de personnalisation uniquement, **Recompile Agents** est **Besoin de réinitialiser un agent ?** - Effacez ou supprimez le fichier `.customize.yaml` de l'agent -- Exécutez `npx bmad-method install` et sélectionnez **Recompile Agents** pour restaurer les valeurs par défaut +- Exécutez `npx bmad-method install` et sélectionnez **Quick Update** pour restaurer les valeurs par défaut ## Personnalisation des workflows diff --git a/docs/fr/how-to/non-interactive-installation.md b/docs/fr/how-to/non-interactive-installation.md index 90ee4574f..46e8ad4dc 100644 --- a/docs/fr/how-to/non-interactive-installation.md +++ b/docs/fr/how-to/non-interactive-installation.md @@ -28,7 +28,7 @@ Nécessite [Node.js](https://nodejs.org) v20+ et `npx` (inclus avec npm). | `--modules ` | IDs de modules séparés par des virgules | `--modules bmm,bmb` | | `--tools ` | IDs d'outils/IDE séparés par des virgules (utilisez `none` pour ignorer) | `--tools claude-code,cursor` ou `--tools none` | | `--custom-content ` | Chemins vers des modules personnalisés séparés par des virgules | `--custom-content ~/my-module,~/another-module` | -| `--action ` | Action pour les installations existantes : `install` (par défaut), `update`, `quick-update`, ou `compile-agents` | `--action quick-update` | +| `--action ` | Action pour les installations existantes : `install` (par défaut), `update`, ou `quick-update` | `--action quick-update` | ### Configuration principale @@ -121,7 +121,7 @@ npx bmad-method install \ ## Ce que vous obtenez - Un répertoire `_bmad/` entièrement configuré dans votre projet -- Des agents et des flux de travail compilés pour vos modules et outils sélectionnés +- Des agents et des flux de travail configurés pour vos modules et outils sélectionnés - Un dossier `_bmad-output/` pour les artefacts générés ## Validation et gestion des erreurs @@ -132,7 +132,7 @@ BMad valide toutes les options fournis : - **Modules** — Avertit des IDs de modules invalides (mais n'échoue pas) - **Tools** — Avertit des IDs d'outils invalides (mais n'échoue pas) - **Custom Content** — Chaque chemin doit contenir un fichier `module.yaml` valide -- **Action** — Doit être l'une des suivantes : `install`, `update`, `quick-update`, `compile-agents` +- **Action** — Doit être l'une des suivantes : `install`, `update`, `quick-update` Les valeurs invalides entraîneront soit : 1. L’affichage d’un message d'erreur suivi d’un exit (pour les options critiques comme le répertoire) diff --git a/docs/how-to/customize-bmad.md b/docs/how-to/customize-bmad.md index d478c349b..cfb75333c 100644 --- a/docs/how-to/customize-bmad.md +++ b/docs/how-to/customize-bmad.md @@ -128,7 +128,7 @@ prompts: ### 3. Apply Your Changes -After editing, recompile the agent to apply changes: +After editing, reinstall to apply changes: ```bash npx bmad-method install @@ -138,17 +138,16 @@ The installer detects the existing installation and offers these options: | Option | What It Does | | ---------------------------- | ------------------------------------------------------------------- | -| **Quick Update** | Updates all modules to the latest version and recompiles all agents | -| **Recompile Agents** | Applies customizations only, without updating module files | +| **Quick Update** | Updates all modules to the latest version and applies customizations | | **Modify BMad Installation** | Full installation flow for adding or removing modules | -For customization-only changes, **Recompile Agents** is the fastest option. +For customization-only changes, **Quick Update** is the fastest option. ## Troubleshooting **Changes not appearing?** -- Run `npx bmad-method install` and select **Recompile Agents** to apply changes +- Run `npx bmad-method install` and select **Quick Update** to apply changes - Check that your YAML syntax is valid (indentation matters) - Verify you edited the correct `.customize.yaml` file for the agent @@ -161,7 +160,7 @@ For customization-only changes, **Recompile Agents** is the fastest option. **Need to reset an agent?** - Clear or delete the agent's `.customize.yaml` file -- Run `npx bmad-method install` and select **Recompile Agents** to restore defaults +- Run `npx bmad-method install` and select **Quick Update** to restore defaults ## Workflow Customization diff --git a/docs/how-to/non-interactive-installation.md b/docs/how-to/non-interactive-installation.md index fa7a1e7b1..62b3090d8 100644 --- a/docs/how-to/non-interactive-installation.md +++ b/docs/how-to/non-interactive-installation.md @@ -28,7 +28,7 @@ Requires [Node.js](https://nodejs.org) v20+ and `npx` (included with npm). | `--modules ` | Comma-separated module IDs | `--modules bmm,bmb` | | `--tools ` | Comma-separated tool/IDE IDs (use `none` to skip) | `--tools claude-code,cursor` or `--tools none` | | `--custom-content ` | Comma-separated paths to custom modules | `--custom-content ~/my-module,~/another-module` | -| `--action ` | Action for existing installations: `install` (default), `update`, `quick-update`, or `compile-agents` | `--action quick-update` | +| `--action ` | Action for existing installations: `install` (default), `update`, or `quick-update` | `--action quick-update` | ### Core Configuration @@ -121,7 +121,7 @@ npx bmad-method install \ ## What You Get - A fully configured `_bmad/` directory in your project -- Compiled agents and workflows for your selected modules and tools +- Agents and workflows configured for your selected modules and tools - A `_bmad-output/` folder for generated artifacts ## Validation and Error Handling @@ -132,7 +132,7 @@ BMad validates all provided flags: - **Modules** — Warns about invalid module IDs (but won't fail) - **Tools** — Warns about invalid tool IDs (but won't fail) - **Custom Content** — Each path must contain a valid `module.yaml` file -- **Action** — Must be one of: `install`, `update`, `quick-update`, `compile-agents` +- **Action** — Must be one of: `install`, `update`, `quick-update` Invalid values will either: 1. Show an error and exit (for critical options like directory) diff --git a/docs/zh-cn/how-to/customize-bmad.md b/docs/zh-cn/how-to/customize-bmad.md index 55396ac6e..5f762ba20 100644 --- a/docs/zh-cn/how-to/customize-bmad.md +++ b/docs/zh-cn/how-to/customize-bmad.md @@ -128,7 +128,7 @@ prompts: ### 3. 应用您的更改 -编辑后,重新编译智能体以应用更改: +编辑后,重新安装以应用更改: ```bash npx bmad-method install @@ -138,17 +138,16 @@ npx bmad-method install | Option | What It Does | | ---------------------------- | ------------------------------------------------------------------- | -| **Quick Update** | 将所有模块更新到最新版本并重新编译所有智能体 | -| **Recompile Agents** | 仅应用自定义配置,不更新模块文件 | +| **Quick Update** | 将所有模块更新到最新版本并应用自定义配置 | | **Modify BMad Installation** | 用于添加或删除模块的完整安装流程 | -对于仅自定义配置的更改,**Recompile Agents** 是最快的选项。 +对于仅自定义配置的更改,**Quick Update** 是最快的选项。 ## 故障排除 **更改未生效?** -- 运行 `npx bmad-method install` 并选择 **Recompile Agents** 以应用更改 +- 运行 `npx bmad-method install` 并选择 **Quick Update** 以应用更改 - 检查您的 YAML 语法是否有效(缩进很重要) - 验证您编辑的是该智能体正确的 `.customize.yaml` 文件 @@ -161,7 +160,7 @@ npx bmad-method install **需要重置智能体?** - 清空或删除智能体的 `.customize.yaml` 文件 -- 运行 `npx bmad-method install` 并选择 **Recompile Agents** 以恢复默认设置 +- 运行 `npx bmad-method install` 并选择 **Quick Update** 以恢复默认设置 ## 工作流自定义 diff --git a/docs/zh-cn/how-to/non-interactive-installation.md b/docs/zh-cn/how-to/non-interactive-installation.md index 11d57a712..930bbe639 100644 --- a/docs/zh-cn/how-to/non-interactive-installation.md +++ b/docs/zh-cn/how-to/non-interactive-installation.md @@ -28,7 +28,7 @@ sidebar: | `--modules ` | 逗号分隔的模块 ID | `--modules bmm,bmb` | | `--tools ` | 逗号分隔的工具/IDE ID(使用 `none` 跳过) | `--tools claude-code,cursor` 或 `--tools none` | | `--custom-content ` | 逗号分隔的自定义模块路径 | `--custom-content ~/my-module,~/another-module` | -| `--action ` | 对现有安装的操作:`install`(默认)、`update`、`quick-update` 或 `compile-agents` | `--action quick-update` | +| `--action ` | 对现有安装的操作:`install`(默认)、`update` 或 `quick-update` | `--action quick-update` | ### 核心配置 @@ -121,7 +121,7 @@ npx bmad-method install \ ## 安装结果 - 项目中完全配置的 `_bmad/` 目录 -- 为所选模块和工具编译的智能体和工作流 +- 为所选模块和工具配置的智能体和工作流 - 用于生成产物的 `_bmad-output/` 文件夹 ## 验证和错误处理 @@ -132,7 +132,7 @@ BMad 会验证所有提供的标志: - **模块** — 对无效的模块 ID 发出警告(但不会失败) - **工具** — 对无效的工具 ID 发出警告(但不会失败) - **自定义内容** — 每个路径必须包含有效的 `module.yaml` 文件 -- **操作** — 必须是以下之一:`install`、`update`、`quick-update`、`compile-agents` +- **操作** — 必须是以下之一:`install`、`update`、`quick-update` 无效值将: 1. 显示错误并退出(对于目录等关键选项) diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 0442594e8..f7a8d325c 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -14,7 +14,6 @@ const path = require('node:path'); const os = require('node:os'); const fs = require('fs-extra'); -const { YamlXmlBuilder } = require('../tools/cli/lib/yaml-xml-builder'); const { ManifestGenerator } = require('../tools/cli/installers/lib/core/manifest-generator'); const { IdeManager } = require('../tools/cli/installers/lib/ide/manager'); const { clearCache, loadPlatformCodes } = require('../tools/cli/installers/lib/ide/platform-codes'); @@ -149,77 +148,10 @@ async function runTests() { const projectRoot = path.join(__dirname, '..'); - // Test 1: Removed — old YAML→XML agent compilation no longer applies (agents now use SKILL.md format) - - console.log(''); - // ============================================================ - // Test 2: Customization Merging + // Test 1: Windsurf Native Skills Install // ============================================================ - console.log(`${colors.yellow}Test Suite 2: Customization Merging${colors.reset}\n`); - - try { - const builder = new YamlXmlBuilder(); - - // Test deepMerge function - const base = { - agent: { - metadata: { name: 'John', title: 'PM' }, - persona: { role: 'Product Manager', style: 'Analytical' }, - }, - }; - - const customize = { - agent: { - metadata: { name: 'Sarah' }, // Override name only - persona: { style: 'Concise' }, // Override style only - }, - }; - - const merged = builder.deepMerge(base, customize); - - assert(merged.agent.metadata.name === 'Sarah', 'Deep merge overrides customized name'); - - assert(merged.agent.metadata.title === 'PM', 'Deep merge preserves non-overridden title'); - - assert(merged.agent.persona.role === 'Product Manager', 'Deep merge preserves non-overridden role'); - - assert(merged.agent.persona.style === 'Concise', 'Deep merge overrides customized style'); - } catch (error) { - assert(false, 'Customization merging works', error.message); - } - - console.log(''); - - // ============================================================ - // Test 3: Path Resolution - // ============================================================ - console.log(`${colors.yellow}Test Suite 3: Path Variable Resolution${colors.reset}\n`); - - try { - const builder = new YamlXmlBuilder(); - - // Test path resolution logic (if exposed) - // This would test {project-root}, {installed_path}, {config_source} resolution - - const testPath = '{project-root}/bmad/bmm/config.yaml'; - const expectedPattern = /\/bmad\/bmm\/config\.yaml$/; - - assert( - true, // Placeholder - would test actual resolution - 'Path variable resolution pattern matches expected format', - 'Note: This test validates path resolution logic exists', - ); - } catch (error) { - assert(false, 'Path resolution works', error.message); - } - - console.log(''); - - // ============================================================ - // Test 4: Windsurf Native Skills Install - // ============================================================ - console.log(`${colors.yellow}Test Suite 4: Windsurf Native Skills${colors.reset}\n`); + console.log(`${colors.yellow}Test Suite 1: Windsurf Native Skills${colors.reset}\n`); try { clearCache(); diff --git a/tools/cli/commands/install.js b/tools/cli/commands/install.js index d9d8332be..3577116d7 100644 --- a/tools/cli/commands/install.js +++ b/tools/cli/commands/install.js @@ -18,7 +18,7 @@ module.exports = { 'Comma-separated list of tool/IDE IDs to configure (e.g., "claude-code,cursor"). Use "none" to skip tool configuration.', ], ['--custom-content ', 'Comma-separated list of paths to custom modules/agents/workflows'], - ['--action ', 'Action type for existing installations: install, update, quick-update, or compile-agents'], + ['--action ', 'Action type for existing installations: install, update, or quick-update'], ['--user-name ', 'Name for agents to use (default: system username)'], ['--communication-language ', 'Language for agent communication (default: English)'], ['--document-output-language ', 'Language for document output (default: English)'], @@ -49,13 +49,6 @@ module.exports = { process.exit(0); } - // Handle compile agents separately - if (config.actionType === 'compile-agents') { - const result = await installer.compileAgents(config); - await prompts.log.info(`Recompiled ${result.agentCount} agents with customizations applied`); - process.exit(0); - } - // Regular install/update flow const result = await installer.install(config); diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 5022ab954..dd3902657 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -6,7 +6,6 @@ const { ModuleManager } = require('../modules/manager'); const { IdeManager } = require('../ide/manager'); const { FileOps } = require('../../../lib/file-ops'); const { Config } = require('../../../lib/config'); -const { XmlHandler } = require('../../../lib/xml-handler'); const { DependencyResolver } = require('./dependency-resolver'); const { ConfigCollector } = require('./config-collector'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); @@ -25,7 +24,6 @@ class Installer { this.ideManager = new IdeManager(); this.fileOps = new FileOps(); this.config = new Config(); - this.xmlHandler = new XmlHandler(); this.dependencyResolver = new DependencyResolver(); this.configCollector = new ConfigCollector(); this.ideConfigManager = new IdeConfigManager(); @@ -2114,10 +2112,6 @@ class Installer { }, ); - // Process agent files to build YAML agents and create customize templates - const modulePath = path.join(bmadDir, moduleName); - await this.processAgentFiles(modulePath, moduleName); - // Dependencies are already included in full module install } @@ -2227,16 +2221,8 @@ class Installer { const sourcePath = getModulePath('core'); const targetPath = path.join(bmadDir, 'core'); - // Copy core files (skip .agent.yaml files like modules do) + // Copy core files await this.copyCoreFiles(sourcePath, targetPath); - - // Compile agents using the same compiler as modules - const { ModuleManager } = require('../modules/manager'); - const moduleManager = new ModuleManager(); - await moduleManager.compileModuleAgents(sourcePath, targetPath, 'core', bmadDir, this); - - // Process agent files to inject activation block - await this.processAgentFiles(targetPath, 'core'); } /** @@ -2254,16 +2240,6 @@ class Installer { continue; } - // Skip sidecar directories - they are handled separately during agent compilation - if ( - path - .dirname(file) - .split('/') - .some((dir) => dir.toLowerCase().includes('sidecar')) - ) { - continue; - } - // Skip module.yaml at root - it's only needed at install time if (file === 'module.yaml') { continue; @@ -2274,27 +2250,9 @@ class Installer { continue; } - // Skip .agent.yaml files - they will be compiled separately - if (file.endsWith('.agent.yaml')) { - continue; - } - const sourceFile = path.join(sourcePath, file); const targetFile = path.join(targetPath, file); - // Check if this is an agent file - if (file.startsWith('agents/') && file.endsWith('.md')) { - // Read the file to check for localskip - const content = await fs.readFile(sourceFile, 'utf8'); - - // Check for localskip="true" in the agent tag - const agentMatch = content.match(/]*\slocalskip="true"[^>]*>/); - if (agentMatch) { - await prompts.log.message(` Skipping web-only agent: ${path.basename(file)}`); - continue; // Skip this agent - } - } - // Copy the file with placeholder replacement await fs.ensureDir(path.dirname(targetFile)); await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile); @@ -2328,58 +2286,6 @@ class Installer { return files; } - /** - * Process agent files to build YAML agents and inject activation blocks - * @param {string} modulePath - Path to module in bmad/ installation - * @param {string} moduleName - Module name - */ - async processAgentFiles(modulePath, moduleName) { - const agentsPath = path.join(modulePath, 'agents'); - - // Check if agents directory exists - if (!(await fs.pathExists(agentsPath))) { - return; // No agents to process - } - - // Determine project directory (parent of bmad/ directory) - const bmadDir = path.dirname(modulePath); - const cfgAgentsDir = path.join(bmadDir, '_config', 'agents'); - - // Ensure _config/agents directory exists - await fs.ensureDir(cfgAgentsDir); - - // Get all agent files - const agentFiles = await fs.readdir(agentsPath); - - for (const agentFile of agentFiles) { - // Skip .agent.yaml files - they should already be compiled by compileModuleAgents - if (agentFile.endsWith('.agent.yaml')) { - continue; - } - - // Only process .md files (already compiled from YAML) - if (!agentFile.endsWith('.md')) { - continue; - } - - const agentName = agentFile.replace('.md', ''); - const mdPath = path.join(agentsPath, agentFile); - const customizePath = path.join(cfgAgentsDir, `${moduleName}-${agentName}.customize.yaml`); - - // For .md files that are already compiled, we don't need to do much - // Just ensure the customize template exists - if (!(await fs.pathExists(customizePath))) { - const genericTemplatePath = getSourcePath('utility', 'agent-components', 'agent.customize.template.yaml'); - if (await fs.pathExists(genericTemplatePath)) { - await this.copyFileWithPlaceholderReplacement(genericTemplatePath, customizePath); - if (process.env.BMAD_VERBOSE_INSTALL === 'true') { - await prompts.log.message(` Created customize: ${moduleName}-${agentName}.customize.yaml`); - } - } - } - } - } - /** * Private: Update core */ @@ -2393,12 +2299,6 @@ class Installer { } else { // Selective update - preserve user modifications await this.fileOps.syncDirectory(sourcePath, targetPath); - - // Recompile agents (#1133) - const { ModuleManager } = require('../modules/manager'); - const moduleManager = new ModuleManager(); - await moduleManager.compileModuleAgents(sourcePath, targetPath, 'core', bmadDir, this); - await this.processAgentFiles(targetPath, 'core'); } } @@ -2643,114 +2543,6 @@ class Installer { } } - /** - * Compile agents with customizations only - * @param {Object} config - Configuration with directory - * @returns {Object} Compilation result - */ - async compileAgents(config) { - // Using @clack prompts - const { ModuleManager } = require('../modules/manager'); - const { getSourcePath } = require('../../../lib/project-root'); - - const spinner = await prompts.spinner(); - spinner.start('Recompiling agents with customizations...'); - - try { - const projectDir = path.resolve(config.directory); - const { bmadDir } = await this.findBmadDir(projectDir); - - // Check if bmad directory exists - if (!(await fs.pathExists(bmadDir))) { - spinner.stop('No BMAD installation found'); - throw new Error(`BMAD not installed at ${bmadDir}. Use regular install for first-time setup.`); - } - - // Detect existing installation - const existingInstall = await this.detector.detect(bmadDir); - const installedModules = existingInstall.modules.map((m) => m.id); - - // Initialize module manager - const moduleManager = new ModuleManager(); - moduleManager.setBmadFolderName(path.basename(bmadDir)); - - let totalAgentCount = 0; - - // Get custom module sources from cache - const customModuleSources = new Map(); - const cacheDir = path.join(bmadDir, '_config', 'custom'); - if (await fs.pathExists(cacheDir)) { - const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true }); - - for (const cachedModule of cachedModules) { - if (cachedModule.isDirectory()) { - const moduleId = cachedModule.name; - const cachedPath = path.join(cacheDir, moduleId); - const moduleYamlPath = path.join(cachedPath, 'module.yaml'); - - // Check if this is actually a custom module - if (await fs.pathExists(moduleYamlPath)) { - // Check if this is an external official module - skip cache for those - const isExternal = await this.moduleManager.isExternalModule(moduleId); - if (isExternal) { - // External modules are handled via cloneExternalModule, not from cache - continue; - } - customModuleSources.set(moduleId, cachedPath); - } - } - } - } - - // Process each installed module - for (const moduleId of installedModules) { - spinner.message(`Recompiling agents in ${moduleId}...`); - - // Get source path - let sourcePath; - if (moduleId === 'core') { - sourcePath = getSourcePath('core-skills'); - } else { - // First check if it's in the custom cache - if (customModuleSources.has(moduleId)) { - sourcePath = customModuleSources.get(moduleId); - } else { - sourcePath = await moduleManager.findModuleSource(moduleId); - } - } - - if (!sourcePath) { - await prompts.log.warn(`Source not found for module ${moduleId}, skipping...`); - continue; - } - - const targetPath = path.join(bmadDir, moduleId); - - // Compile agents for this module - await moduleManager.compileModuleAgents(sourcePath, targetPath, moduleId, bmadDir, this); - - // Count agents (rough estimate based on files) - const agentsPath = path.join(targetPath, 'agents'); - if (await fs.pathExists(agentsPath)) { - const agentFiles = await fs.readdir(agentsPath); - const agentCount = agentFiles.filter((f) => f.endsWith('.md')).length; - totalAgentCount += agentCount; - } - } - - spinner.stop('Agent recompilation complete!'); - - return { - success: true, - agentCount: totalAgentCount, - modules: installedModules, - }; - } catch (error) { - spinner.error('Agent recompilation failed'); - throw error; - } - } - /** * Private: Prompt for update action */ diff --git a/tools/cli/installers/lib/core/manifest-generator.js b/tools/cli/installers/lib/core/manifest-generator.js index c9b85db27..53f2e11c6 100644 --- a/tools/cli/installers/lib/core/manifest-generator.js +++ b/tools/cli/installers/lib/core/manifest-generator.js @@ -515,7 +515,7 @@ class ManifestGenerator { /** * Get agents from a directory recursively - * Only includes compiled .md files (not .agent.yaml source files) + * Only includes .md files with agent content */ async getAgentsFromDir(dirPath, moduleName, relativePath = '') { // Skip directories claimed by collectSkills @@ -572,7 +572,7 @@ class ManifestGenerator { const newRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name; const subDirAgents = await this.getAgentsFromDir(fullPath, moduleName, newRelativePath); agents.push(...subDirAgents); - } else if (entry.name.endsWith('.md') && !entry.name.endsWith('.agent.yaml') && entry.name.toLowerCase() !== 'readme.md') { + } else if (entry.name.endsWith('.md') && entry.name.toLowerCase() !== 'readme.md') { const content = await fs.readFile(fullPath, 'utf8'); // Skip files that don't contain tag (e.g., README files) diff --git a/tools/cli/installers/lib/custom/handler.js b/tools/cli/installers/lib/custom/handler.js index 52595e4ff..fbd6c728f 100644 --- a/tools/cli/installers/lib/custom/handler.js +++ b/tools/cli/installers/lib/custom/handler.js @@ -2,19 +2,11 @@ const path = require('node:path'); const fs = require('fs-extra'); const yaml = require('yaml'); const prompts = require('../../../lib/prompts'); -const { FileOps } = require('../../../lib/file-ops'); -const { XmlHandler } = require('../../../lib/xml-handler'); - /** * Handler for custom content (custom.yaml) - * Installs custom agents and workflows without requiring a full module structure + * Discovers custom agents and workflows in the project */ class CustomHandler { - constructor() { - this.fileOps = new FileOps(); - this.xmlHandler = new XmlHandler(); - } - /** * Find all custom.yaml files in the project * @param {string} projectRoot - Project root directory @@ -115,244 +107,6 @@ class CustomHandler { return null; } } - - /** - * Install custom content - * @param {string} customPath - Path to custom content directory - * @param {string} bmadDir - Target bmad directory - * @param {Object} config - Configuration from custom.yaml - * @param {Function} fileTrackingCallback - Optional callback to track installed files - * @returns {Object} Installation result - */ - async install(customPath, bmadDir, config, fileTrackingCallback = null) { - const results = { - agentsInstalled: 0, - workflowsInstalled: 0, - filesCopied: 0, - preserved: 0, - errors: [], - }; - - try { - // Create custom directories in bmad - const bmadCustomDir = path.join(bmadDir, 'custom'); - const bmadAgentsDir = path.join(bmadCustomDir, 'agents'); - const bmadWorkflowsDir = path.join(bmadCustomDir, 'workflows'); - - await fs.ensureDir(bmadCustomDir); - await fs.ensureDir(bmadAgentsDir); - await fs.ensureDir(bmadWorkflowsDir); - - // Process agents - compile and copy agents - const agentsDir = path.join(customPath, 'agents'); - if (await fs.pathExists(agentsDir)) { - await this.compileAndCopyAgents(agentsDir, bmadAgentsDir, bmadDir, config, fileTrackingCallback, results); - - // Count agent files - const agentFiles = await this.findFilesRecursively(agentsDir, ['.agent.yaml', '.md']); - results.agentsInstalled = agentFiles.length; - } - - // Process workflows - copy entire workflows directory structure - const workflowsDir = path.join(customPath, 'workflows'); - if (await fs.pathExists(workflowsDir)) { - await this.copyDirectory(workflowsDir, bmadWorkflowsDir, results, fileTrackingCallback, config); - - // Count workflow files - const workflowFiles = await this.findFilesRecursively(workflowsDir, ['.md']); - results.workflowsInstalled = workflowFiles.length; - } - - // Process any additional files at root - const entries = await fs.readdir(customPath, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isFile() && entry.name !== 'custom.yaml' && !entry.name.startsWith('.') && !entry.name.endsWith('.md')) { - // Skip .md files at root as they're likely docs - const sourcePath = path.join(customPath, entry.name); - const targetPath = path.join(bmadCustomDir, entry.name); - - try { - // Check if file already exists - if (await fs.pathExists(targetPath)) { - // File already exists, preserve it - results.preserved = (results.preserved || 0) + 1; - } else { - await fs.copy(sourcePath, targetPath); - results.filesCopied++; - - if (fileTrackingCallback) { - fileTrackingCallback(targetPath); - } - } - } catch (error) { - results.errors.push(`Failed to copy file ${entry.name}: ${error.message}`); - } - } - } - } catch (error) { - results.errors.push(`Installation failed: ${error.message}`); - } - - return results; - } - - /** - * Find all files with specific extensions recursively - * @param {string} dir - Directory to search - * @param {Array} extensions - File extensions to match - * @returns {Array} List of matching files - */ - async findFilesRecursively(dir, extensions) { - const files = []; - - async function search(currentDir) { - const entries = await fs.readdir(currentDir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(currentDir, entry.name); - - if (entry.isDirectory()) { - await search(fullPath); - } else if (extensions.some((ext) => entry.name.endsWith(ext))) { - files.push(fullPath); - } - } - } - - await search(dir); - return files; - } - - /** - * Recursively copy a directory - * @param {string} sourceDir - Source directory - * @param {string} targetDir - Target directory - * @param {Object} results - Results object to update - * @param {Function} fileTrackingCallback - Optional callback - * @param {Object} config - Configuration for placeholder replacement - */ - async copyDirectory(sourceDir, targetDir, results, fileTrackingCallback, config) { - await fs.ensureDir(targetDir); - const entries = await fs.readdir(sourceDir, { withFileTypes: true }); - - for (const entry of entries) { - const sourcePath = path.join(sourceDir, entry.name); - const targetPath = path.join(targetDir, entry.name); - - if (entry.isDirectory()) { - await this.copyDirectory(sourcePath, targetPath, results, fileTrackingCallback, config); - } else { - try { - // Check if file already exists - if (await fs.pathExists(targetPath)) { - // File already exists, preserve it - results.preserved = (results.preserved || 0) + 1; - } else { - // Copy with placeholder replacement for text files - const textExtensions = ['.md', '.yaml', '.yml', '.txt', '.json']; - if (textExtensions.some((ext) => entry.name.endsWith(ext))) { - // Read source content - let content = await fs.readFile(sourcePath, 'utf8'); - - // Replace placeholders - content = content.replaceAll('{user_name}', config.user_name || 'User'); - content = content.replaceAll('{communication_language}', config.communication_language || 'English'); - content = content.replaceAll('{output_folder}', config.output_folder || 'docs'); - - // Write to target - await fs.ensureDir(path.dirname(targetPath)); - await fs.writeFile(targetPath, content, 'utf8'); - } else { - // Copy binary files as-is - await fs.copy(sourcePath, targetPath); - } - - results.filesCopied++; - if (entry.name.endsWith('.md')) { - results.workflowsInstalled++; - } - if (fileTrackingCallback) { - fileTrackingCallback(targetPath); - } - } - } catch (error) { - results.errors.push(`Failed to copy ${entry.name}: ${error.message}`); - } - } - } - } - - /** - * Compile .agent.yaml files to .md format and handle sidecars - * @param {string} sourceAgentsPath - Source agents directory - * @param {string} targetAgentsPath - Target agents directory - * @param {string} bmadDir - BMAD installation directory - * @param {Object} config - Configuration for placeholder replacement - * @param {Function} fileTrackingCallback - Optional callback to track installed files - * @param {Object} results - Results object to update - */ - async compileAndCopyAgents(sourceAgentsPath, targetAgentsPath, bmadDir, config, fileTrackingCallback, results) { - // Get all .agent.yaml files recursively - const agentFiles = await this.findFilesRecursively(sourceAgentsPath, ['.agent.yaml']); - - for (const agentFile of agentFiles) { - const relativePath = path.relative(sourceAgentsPath, agentFile).split(path.sep).join('/'); - const targetDir = path.join(targetAgentsPath, path.dirname(relativePath)); - - await fs.ensureDir(targetDir); - - const agentName = path.basename(agentFile, '.agent.yaml'); - const targetMdPath = path.join(targetDir, `${agentName}.md`); - // Use the actual bmadDir if available (for when installing to temp dir) - const actualBmadDir = config._bmadDir || bmadDir; - const customizePath = path.join(actualBmadDir, '_config', 'agents', `custom-${agentName}.customize.yaml`); - - // Read and compile the YAML - try { - const yamlContent = await fs.readFile(agentFile, 'utf8'); - const { compileAgent } = require('../../../lib/agent/compiler'); - - // Create customize template if it doesn't exist - if (!(await fs.pathExists(customizePath))) { - const { getSourcePath } = require('../../../lib/project-root'); - const genericTemplatePath = getSourcePath('utility', 'agent-components', 'agent.customize.template.yaml'); - if (await fs.pathExists(genericTemplatePath)) { - let templateContent = await fs.readFile(genericTemplatePath, 'utf8'); - await fs.writeFile(customizePath, templateContent, 'utf8'); - // Only show customize creation in verbose mode - if (process.env.BMAD_VERBOSE_INSTALL === 'true') { - await prompts.log.message(' Created customize: custom-' + agentName + '.customize.yaml'); - } - } - } - - // Compile the agent - const { xml } = compileAgent(yamlContent, {}, agentName, relativePath, { config }); - - // Replace placeholders in the compiled content - let processedXml = xml; - processedXml = processedXml.replaceAll('{user_name}', config.user_name || 'User'); - processedXml = processedXml.replaceAll('{communication_language}', config.communication_language || 'English'); - processedXml = processedXml.replaceAll('{output_folder}', config.output_folder || 'docs'); - - // Write the compiled MD file - await fs.writeFile(targetMdPath, processedXml, 'utf8'); - - // Track the file - if (fileTrackingCallback) { - fileTrackingCallback(targetMdPath); - } - - // Only show compilation details in verbose mode - if (process.env.BMAD_VERBOSE_INSTALL === 'true') { - await prompts.log.message(' Compiled agent: ' + agentName + ' -> ' + path.relative(targetAgentsPath, targetMdPath)); - } - } catch (error) { - await prompts.log.warn(' Failed to compile agent ' + agentName + ': ' + error.message); - results.errors.push(`Failed to compile agent ${agentName}: ${error.message}`); - } - } - } } module.exports = { CustomHandler }; diff --git a/tools/cli/installers/lib/ide/_base-ide.js b/tools/cli/installers/lib/ide/_base-ide.js index ce1b0ceae..8c970d130 100644 --- a/tools/cli/installers/lib/ide/_base-ide.js +++ b/tools/cli/installers/lib/ide/_base-ide.js @@ -1,6 +1,5 @@ const path = require('node:path'); const fs = require('fs-extra'); -const { XmlHandler } = require('../../../lib/xml-handler'); const prompts = require('../../../lib/prompts'); const { getSourcePath } = require('../../../lib/project-root'); const { BMAD_FOLDER_NAME } = require('./shared/path-utils'); @@ -18,7 +17,6 @@ class BaseIdeSetup { 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.xmlHandler = new XmlHandler(); this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden } @@ -30,15 +28,6 @@ class BaseIdeSetup { this.bmadFolderName = bmadFolderName; } - /** - * Get the agent command activation header from the central template - * @returns {string} The activation header text - */ - async getAgentCommandHeader() { - const headerPath = getSourcePath('utility', 'agent-components', 'agent-command-header.md'); - return await fs.readFile(headerPath, 'utf8'); - } - /** * Main setup method - must be implemented by subclasses * @param {string} projectDir - Project directory @@ -511,11 +500,6 @@ class BaseIdeSetup { // Replace placeholders let processed = content; - // Inject activation block for agent files FIRST (before replacements) - if (metadata.name && content.includes(' path.join('_memory', `${agentName}-sidecar`, file)); - return processedFiles; - } - /** * List all available modules (excluding core which is always installed) * bmm is the only built-in module, directly under src/bmm-skills @@ -559,19 +457,9 @@ class ModuleManager { await fs.remove(targetPath); } - // Vendor cross-module workflows BEFORE copying - // This reads source agent.yaml files and copies referenced workflows - await this.vendorCrossModuleWorkflows(sourcePath, targetPath, moduleName); - // Copy module files with filtering await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig); - // Compile any .agent.yaml files to .md format - await this.compileModuleAgents(sourcePath, targetPath, moduleName, bmadDir, options.installer); - - // Process agent files to inject activation block - await this.processAgentFiles(targetPath, moduleName); - // Create directories declared in module.yaml (unless explicitly skipped) if (!options.skipModuleInstaller) { await this.createModuleDirectories(moduleName, bmadDir, options); @@ -624,10 +512,6 @@ class ModuleManager { } else { // Selective update - preserve user modifications await this.syncModule(sourcePath, targetPath); - - // Recompile agents (#1133) - await this.compileModuleAgents(sourcePath, targetPath, moduleName, bmadDir, options.installer); - await this.processAgentFiles(targetPath, moduleName); } return { @@ -718,9 +602,7 @@ class ModuleManager { continue; } - // Only skip sidecar directories - they are handled separately during agent compilation - // But still allow other files in agent directories - const isInAgentDirectory = file.startsWith('agents/'); + // Skip sidecar directories - these contain agent-specific assets not needed at install time const isInSidecarDirectory = path .dirname(file) .split('/') @@ -742,11 +624,6 @@ class ModuleManager { continue; } - // Skip .agent.yaml files - they will be compiled separately - if (file.endsWith('.agent.yaml')) { - continue; - } - const sourceFile = path.join(sourcePath, file); const targetFile = path.join(targetPath, file); @@ -773,236 +650,6 @@ class ModuleManager { } } - /** - * Compile .agent.yaml files to .md format in modules - * @param {string} sourcePath - Source module path - * @param {string} targetPath - Target module path - * @param {string} moduleName - Module name - * @param {string} bmadDir - BMAD installation directory - * @param {Object} installer - Installer instance for file tracking - */ - async compileModuleAgents(sourcePath, targetPath, moduleName, bmadDir, installer = null) { - const sourceAgentsPath = path.join(sourcePath, 'agents'); - const targetAgentsPath = path.join(targetPath, 'agents'); - const cfgAgentsDir = path.join(bmadDir, '_config', 'agents'); - - // Check if agents directory exists in source - if (!(await fs.pathExists(sourceAgentsPath))) { - return; // No agents to compile - } - - // Get all agent YAML files recursively - const agentFiles = await this.findAgentFiles(sourceAgentsPath); - - for (const agentFile of agentFiles) { - if (!agentFile.endsWith('.agent.yaml')) continue; - - const relativePath = path.relative(sourceAgentsPath, agentFile).split(path.sep).join('/'); - const targetDir = path.join(targetAgentsPath, path.dirname(relativePath)); - - await fs.ensureDir(targetDir); - - const agentName = path.basename(agentFile, '.agent.yaml'); - const sourceYamlPath = agentFile; - const targetMdPath = path.join(targetDir, `${agentName}.md`); - const customizePath = path.join(cfgAgentsDir, `${moduleName}-${agentName}.customize.yaml`); - - // Read and compile the YAML - try { - const yamlContent = await fs.readFile(sourceYamlPath, 'utf8'); - const { compileAgent } = require('../../../lib/agent/compiler'); - - // Create customize template if it doesn't exist - if (!(await fs.pathExists(customizePath))) { - const { getSourcePath } = require('../../../lib/project-root'); - const genericTemplatePath = getSourcePath('utility', 'agent-components', 'agent.customize.template.yaml'); - if (await fs.pathExists(genericTemplatePath)) { - await this.copyFileWithPlaceholderReplacement(genericTemplatePath, customizePath); - // Only show customize creation in verbose mode - if (process.env.BMAD_VERBOSE_INSTALL === 'true') { - await prompts.log.message(` Created customize: ${moduleName}-${agentName}.customize.yaml`); - } - - // Store original hash for modification detection - const crypto = require('node:crypto'); - const customizeContent = await fs.readFile(customizePath, 'utf8'); - const originalHash = crypto.createHash('sha256').update(customizeContent).digest('hex'); - - // Store in main manifest - const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml'); - let manifestData = {}; - if (await fs.pathExists(manifestPath)) { - const manifestContent = await fs.readFile(manifestPath, 'utf8'); - const yaml = require('yaml'); - manifestData = yaml.parse(manifestContent); - } - if (!manifestData.agentCustomizations) { - manifestData.agentCustomizations = {}; - } - manifestData.agentCustomizations[path.relative(bmadDir, customizePath)] = originalHash; - - // Write back to manifest - const yaml = require('yaml'); - // Clean the manifest data to remove any non-serializable values - const cleanManifestData = structuredClone(manifestData); - - const updatedContent = yaml.stringify(cleanManifestData, { - indent: 2, - lineWidth: 0, - }); - await fs.writeFile(manifestPath, updatedContent, 'utf8'); - } - } - - // Check for customizations and build answers object - let customizedFields = []; - let answers = {}; - if (await fs.pathExists(customizePath)) { - const customizeContent = await fs.readFile(customizePath, 'utf8'); - const customizeData = yaml.parse(customizeContent); - customizedFields = customizeData.customized_fields || []; - - // Build answers object from customizations - if (customizeData.persona) { - answers.persona = customizeData.persona; - } - if (customizeData.agent?.metadata) { - const filteredMetadata = filterCustomizationData(customizeData.agent.metadata); - if (Object.keys(filteredMetadata).length > 0) { - Object.assign(answers, { metadata: filteredMetadata }); - } - } - if (customizeData.critical_actions && customizeData.critical_actions.length > 0) { - answers.critical_actions = customizeData.critical_actions; - } - if (customizeData.memories && customizeData.memories.length > 0) { - answers.memories = customizeData.memories; - } - if (customizeData.menu && customizeData.menu.length > 0) { - answers.menu = customizeData.menu; - } - if (customizeData.prompts && customizeData.prompts.length > 0) { - answers.prompts = customizeData.prompts; - } - } - - // Check if agent has sidecar - let hasSidecar = false; - try { - const agentYaml = yaml.parse(yamlContent); - hasSidecar = agentYaml?.agent?.metadata?.hasSidecar === true; - } catch { - // Continue without sidecar processing - } - - // Compile with customizations if any - const { xml } = await compileAgent(yamlContent, answers, agentName, relativePath, { config: this.coreConfig || {} }); - - // Write the compiled agent - await fs.writeFile(targetMdPath, xml, 'utf8'); - - // Handle sidecar copying if present - if (hasSidecar) { - // Get the agent's directory to look for sidecar - const agentDir = path.dirname(agentFile); - const sidecarDirName = `${agentName}-sidecar`; - const sourceSidecarPath = path.join(agentDir, sidecarDirName); - - // Check if sidecar directory exists - if (await fs.pathExists(sourceSidecarPath)) { - // Memory is always in _bmad/_memory - const bmadMemoryPath = path.join(bmadDir, '_memory'); - - // Determine if this is an update (by checking if agent already exists) - const isUpdate = await fs.pathExists(targetMdPath); - - // Copy sidecar to memory location with update-safe handling - const copiedFiles = await this.copySidecarToMemory(sourceSidecarPath, agentName, bmadMemoryPath, isUpdate, bmadDir, installer); - - if (process.env.BMAD_VERBOSE_INSTALL === 'true' && copiedFiles.length > 0) { - await prompts.log.message(` Sidecar files processed: ${copiedFiles.length} files`); - } - } else if (process.env.BMAD_VERBOSE_INSTALL === 'true') { - await prompts.log.warn(` Agent marked as having sidecar but ${sidecarDirName} directory not found`); - } - } - - // Copy any non-sidecar files from agent directory (e.g., foo.md) - const agentDir = path.dirname(agentFile); - const agentEntries = await fs.readdir(agentDir, { withFileTypes: true }); - - for (const entry of agentEntries) { - if (entry.isFile() && !entry.name.endsWith('.agent.yaml') && !entry.name.endsWith('.md')) { - // Copy additional files (like foo.md) to the agent target directory - const sourceFile = path.join(agentDir, entry.name); - const targetFile = path.join(targetDir, entry.name); - await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile); - } - } - - // Only show compilation details in verbose mode - if (process.env.BMAD_VERBOSE_INSTALL === 'true') { - await prompts.log.message( - ` Compiled agent: ${agentName} -> ${path.relative(targetPath, targetMdPath)}${hasSidecar ? ' (with sidecar)' : ''}`, - ); - } - } catch (error) { - await prompts.log.warn(` Failed to compile agent ${agentName}: ${error.message}`); - } - } - } - - /** - * Find all .agent.yaml files recursively in a directory - * @param {string} dir - Directory to search - * @returns {Array} List of .agent.yaml file paths - */ - async findAgentFiles(dir) { - const agentFiles = []; - - async function searchDirectory(searchDir) { - const entries = await fs.readdir(searchDir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(searchDir, entry.name); - - if (entry.isFile() && entry.name.endsWith('.agent.yaml')) { - agentFiles.push(fullPath); - } else if (entry.isDirectory()) { - await searchDirectory(fullPath); - } - } - } - - await searchDirectory(dir); - return agentFiles; - } - - /** - * Process agent files to inject activation block - * @param {string} modulePath - Path to installed module - * @param {string} moduleName - Module name - */ - async processAgentFiles(modulePath, moduleName) { - // const agentsPath = path.join(modulePath, 'agents'); - // // Check if agents directory exists - // if (!(await fs.pathExists(agentsPath))) { - // return; // No agents to process - // } - // // Get all agent MD files recursively - // const agentFiles = await this.findAgentMdFiles(agentsPath); - // for (const agentFile of agentFiles) { - // if (!agentFile.endsWith('.md')) continue; - // let content = await fs.readFile(agentFile, 'utf8'); - // // Check if content has agent XML and no activation block - // if (content.includes(' f.endsWith('.agent.yaml') || f.endsWith('.yaml')); - - if (yamlFiles.length === 0) { - return; // No YAML agent files - } - - let workflowsVendored = false; - - for (const agentFile of yamlFiles) { - const agentPath = path.join(sourceAgentsPath, agentFile); - const agentYaml = yaml.parse(await fs.readFile(agentPath, 'utf8')); - - // Check if agent has menu items with workflow-install - const menuItems = agentYaml?.agent?.menu || []; - const workflowInstallItems = menuItems.filter((item) => item['workflow-install']); - - if (workflowInstallItems.length === 0) { - continue; // No workflow-install in this agent - } - - if (!workflowsVendored) { - await prompts.log.info(`\n Vendoring cross-module workflows for ${moduleName}...`); - workflowsVendored = true; - } - - await prompts.log.message(` Processing: ${agentFile}`); - - for (const item of workflowInstallItems) { - const sourceWorkflowPath = item.exec; // Where to copy FROM - const installWorkflowPath = item['workflow-install']; // Where to copy TO - - // Parse SOURCE workflow path - // Example: {project-root}/_bmad/bmm/workflows/4-implementation/bmad-create-story/workflow.md - const sourceMatch = sourceWorkflowPath.match(/\{project-root\}\/(?:_bmad)\/([^/]+)\/workflows\/(.+)/); - if (!sourceMatch) { - await prompts.log.warn(` Could not parse workflow path: ${sourceWorkflowPath}`); - continue; - } - - const [, sourceModule, sourceWorkflowSubPath] = sourceMatch; - - // Parse INSTALL workflow path - // Example: {project-root}/_bmad/bmgd/workflows/4-production/create-story/workflow.md - const installMatch = installWorkflowPath.match(/\{project-root\}\/(?:_bmad)\/([^/]+)\/workflows\/(.+)/); - if (!installMatch) { - await prompts.log.warn(` Could not parse workflow-install path: ${installWorkflowPath}`); - continue; - } - - const installWorkflowSubPath = installMatch[2]; - - const sourceModulePath = getModulePath(sourceModule); - const actualSourceWorkflowPath = path.join(sourceModulePath, 'workflows', sourceWorkflowSubPath.replace(/\/workflow\.md$/, '')); - - const actualDestWorkflowPath = path.join(targetPath, 'workflows', installWorkflowSubPath.replace(/\/workflow\.md$/, '')); - - // Check if source workflow exists - if (!(await fs.pathExists(actualSourceWorkflowPath))) { - await prompts.log.warn(` Source workflow not found: ${actualSourceWorkflowPath}`); - continue; - } - - // Copy the entire workflow folder - await prompts.log.message( - ` Vendoring: ${sourceModule}/workflows/${sourceWorkflowSubPath.replace(/\/workflow\.md$/, '')} → ${moduleName}/workflows/${installWorkflowSubPath.replace(/\/workflow\.md$/, '')}`, - ); - - await fs.ensureDir(path.dirname(actualDestWorkflowPath)); - // Copy the workflow directory recursively with placeholder replacement - await this.copyDirectoryWithPlaceholderReplacement(actualSourceWorkflowPath, actualDestWorkflowPath); - } - } - - if (workflowsVendored) { - await prompts.log.success(` Workflow vendoring complete\n`); - } - } - /** * Create directories declared in module.yaml's `directories` key * This replaces the security-risky module installer pattern with declarative config diff --git a/tools/cli/lib/activation-builder.js b/tools/cli/lib/activation-builder.js deleted file mode 100644 index 81e11158e..000000000 --- a/tools/cli/lib/activation-builder.js +++ /dev/null @@ -1,165 +0,0 @@ -const fs = require('fs-extra'); -const path = require('node:path'); -const { getSourcePath } = require('./project-root'); - -/** - * Builds activation blocks from fragments based on agent profile - */ -class ActivationBuilder { - constructor() { - this.agentComponents = getSourcePath('utility', 'agent-components'); - this.fragmentCache = new Map(); - } - - /** - * Load a fragment file - * @param {string} fragmentName - Name of fragment file (e.g., 'activation-init.txt') - * @returns {string} Fragment content - */ - async loadFragment(fragmentName) { - // Check cache first - if (this.fragmentCache.has(fragmentName)) { - return this.fragmentCache.get(fragmentName); - } - - const fragmentPath = path.join(this.agentComponents, fragmentName); - - if (!(await fs.pathExists(fragmentPath))) { - throw new Error(`Fragment not found: ${fragmentName}`); - } - - const content = await fs.readFile(fragmentPath, 'utf8'); - this.fragmentCache.set(fragmentName, content); - return content; - } - - /** - * Build complete activation block based on agent profile - * @param {Object} profile - Agent profile from AgentAnalyzer - * @param {Object} metadata - Agent metadata (module, name, etc.) - * @param {Array} agentSpecificActions - Optional agent-specific critical actions - * @param {boolean} forWebBundle - Whether this is for a web bundle - * @returns {string} Complete activation block XML - */ - async buildActivation(profile, metadata = {}, agentSpecificActions = [], forWebBundle = false) { - let activation = '\n'; - - // 1. Build sequential steps (use web-specific steps for web bundles) - const steps = await this.buildSteps(metadata, agentSpecificActions, forWebBundle); - activation += this.indent(steps, 2) + '\n'; - - // 2. Build menu handlers section with dynamic handlers - const menuHandlers = await this.loadFragment('menu-handlers.txt'); - - // Build handlers (load only needed handlers) - const handlers = await this.buildHandlers(profile); - - // Remove the extract line from the final output - it's just build metadata - // The extract list tells us which attributes to look for during processing - // but shouldn't appear in the final agent file - const processedHandlers = menuHandlers - .replace('{DYNAMIC_EXTRACT_LIST}\n', '') // Remove the entire extract line - .replace('{DYNAMIC_HANDLERS}', handlers); - - activation += '\n' + this.indent(processedHandlers, 2) + '\n'; - - const rules = await this.loadFragment('activation-rules.txt'); - activation += this.indent(rules, 2) + '\n'; - - activation += ''; - - return activation; - } - - /** - * Build handlers section based on profile - * @param {Object} profile - Agent profile - * @returns {string} Handlers XML - */ - async buildHandlers(profile) { - const handlerFragments = []; - - for (const attrType of profile.usedAttributes) { - const fragmentName = `handler-${attrType}.txt`; - try { - const handler = await this.loadFragment(fragmentName); - handlerFragments.push(handler); - } catch { - console.warn(`Warning: Handler fragment not found: ${fragmentName}`); - } - } - - return handlerFragments.join('\n'); - } - - /** - * Build sequential activation steps - * @param {Object} metadata - Agent metadata - * @param {Array} agentSpecificActions - Optional agent-specific actions - * @param {boolean} forWebBundle - Whether this is for a web bundle - * @returns {string} Steps XML - */ - async buildSteps(metadata = {}, agentSpecificActions = [], forWebBundle = false) { - const stepsTemplate = await this.loadFragment('activation-steps.txt'); - - // Extract basename from agent ID (e.g., "bmad/bmm/agents/pm.md" → "pm") - const agentBasename = metadata.id ? metadata.id.split('/').pop().replace('.md', '') : metadata.name || 'agent'; - - // Build agent-specific steps - let agentStepsXml = ''; - let currentStepNum = 4; // Steps 1-3 are standard - - if (agentSpecificActions && agentSpecificActions.length > 0) { - agentStepsXml = agentSpecificActions - .map((action) => { - const step = `${action}`; - currentStepNum++; - return step; - }) - .join('\n'); - } - - // Calculate final step numbers - const menuStep = currentStepNum; - const helpStep = currentStepNum + 1; - const haltStep = currentStepNum + 2; - const inputStep = currentStepNum + 3; - const executeStep = currentStepNum + 4; - - // Replace placeholders - const processed = stepsTemplate - .replace('{agent-file-basename}', agentBasename) - .replace('{{module}}', metadata.module || 'core') // Fixed to use {{module}} - .replace('{AGENT_SPECIFIC_STEPS}', agentStepsXml) - .replace('{MENU_STEP}', menuStep.toString()) - .replace('{HELP_STEP}', helpStep.toString()) - .replace('{HALT_STEP}', haltStep.toString()) - .replace('{INPUT_STEP}', inputStep.toString()) - .replace('{EXECUTE_STEP}', executeStep.toString()); - - return processed; - } - - /** - * Indent XML content - * @param {string} content - Content to indent - * @param {number} spaces - Number of spaces to indent - * @returns {string} Indented content - */ - indent(content, spaces) { - const indentation = ' '.repeat(spaces); - return content - .split('\n') - .map((line) => (line ? indentation + line : line)) - .join('\n'); - } - - /** - * Clear fragment cache (useful for testing or hot reload) - */ - clearCache() { - this.fragmentCache.clear(); - } -} - -module.exports = { ActivationBuilder }; diff --git a/tools/cli/lib/agent-analyzer.js b/tools/cli/lib/agent-analyzer.js deleted file mode 100644 index a62bdd7cf..000000000 --- a/tools/cli/lib/agent-analyzer.js +++ /dev/null @@ -1,97 +0,0 @@ -const yaml = require('yaml'); -const fs = require('fs-extra'); - -/** - * Analyzes agent YAML files to detect which handlers are needed - */ -class AgentAnalyzer { - /** - * Analyze an agent YAML structure to determine which handlers it needs - * @param {Object} agentYaml - Parsed agent YAML object - * @returns {Object} Profile of needed handlers - */ - analyzeAgentObject(agentYaml) { - const profile = { - usedAttributes: new Set(), - hasPrompts: false, - menuItems: [], - }; - - // Check if agent has prompts section - if (agentYaml.agent && agentYaml.agent.prompts) { - profile.hasPrompts = true; - } - - // Analyze menu items (support both 'menu' and legacy 'commands') - const menuItems = agentYaml.agent?.menu || agentYaml.agent?.commands || []; - - for (const item of menuItems) { - // Track the menu item - profile.menuItems.push(item); - - // Check for multi format items - if (item.multi && item.triggers) { - profile.usedAttributes.add('multi'); - - // Also check attributes in nested handlers - for (const triggerGroup of item.triggers) { - for (const [triggerName, execArray] of Object.entries(triggerGroup)) { - if (Array.isArray(execArray)) { - for (const exec of execArray) { - if (exec.route) { - profile.usedAttributes.add('exec'); - } - if (exec.action) profile.usedAttributes.add('action'); - if (exec.type && ['exec', 'action'].includes(exec.type)) { - profile.usedAttributes.add(exec.type); - } - } - } - } - } - } else { - // Check for each possible attribute in legacy items - if (item.exec) { - profile.usedAttributes.add('exec'); - } - if (item.tmpl) { - profile.usedAttributes.add('tmpl'); - } - if (item.data) { - profile.usedAttributes.add('data'); - } - if (item.action) { - profile.usedAttributes.add('action'); - } - } - } - - // Convert Set to Array for easier use - profile.usedAttributes = [...profile.usedAttributes]; - - return profile; - } - - /** - * Analyze an agent YAML file - * @param {string} filePath - Path to agent YAML file - * @returns {Object} Profile of needed handlers - */ - async analyzeAgentFile(filePath) { - const content = await fs.readFile(filePath, 'utf8'); - const agentYaml = yaml.parse(content); - return this.analyzeAgentObject(agentYaml); - } - - /** - * Check if an agent needs a specific handler - * @param {Object} profile - Agent profile from analyze - * @param {string} handlerType - Handler type to check - * @returns {boolean} True if handler is needed - */ - needsHandler(profile, handlerType) { - return profile.usedAttributes.includes(handlerType); - } -} - -module.exports = { AgentAnalyzer }; diff --git a/tools/cli/lib/agent-party-generator.js b/tools/cli/lib/agent-party-generator.js deleted file mode 100644 index efc783a87..000000000 --- a/tools/cli/lib/agent-party-generator.js +++ /dev/null @@ -1,194 +0,0 @@ -const path = require('node:path'); -const fs = require('fs-extra'); -const { escapeXml } = require('../../lib/xml-utils'); - -const AgentPartyGenerator = { - /** - * Generate agent-manifest.csv content - * @param {Array} agentDetails - Array of agent details - * @param {Object} options - Generation options - * @returns {string} XML content - */ - generateAgentParty(agentDetails, options = {}) { - const { forWeb = false } = options; - - // Group agents by module - const agentsByModule = { - bmm: [], - cis: [], - core: [], - custom: [], - }; - - for (const agent of agentDetails) { - const moduleKey = agentsByModule[agent.module] ? agent.module : 'custom'; - agentsByModule[moduleKey].push(agent); - } - - // Build XML content - let xmlContent = ` - - - - - Complete roster of ${forWeb ? 'bundled' : 'installed'} BMAD agents with summarized personas for efficient multi-agent orchestration. - Used by party-mode and other multi-agent coordination features. - -`; - - // Add agents by module - for (const [module, agents] of Object.entries(agentsByModule)) { - if (agents.length === 0) continue; - - const moduleTitle = - module === 'bmm' ? 'BMM Module' : module === 'cis' ? 'CIS Module' : module === 'core' ? 'Core Module' : 'Custom Module'; - - xmlContent += `\n \n`; - - for (const agent of agents) { - xmlContent += ` - - ${escapeXml(agent.role || '')} - ${escapeXml(agent.identity || '')} - ${escapeXml(agent.communicationStyle || '')} - ${agent.principles || ''} - - \n`; - } - } - - // Add statistics - const totalAgents = agentDetails.length; - const moduleList = Object.keys(agentsByModule) - .filter((m) => agentsByModule[m].length > 0) - .join(', '); - - xmlContent += `\n - ${totalAgents} - ${moduleList} - ${new Date().toISOString()} - -`; - - return xmlContent; - }, - - /** - * Extract agent details from XML content - * @param {string} content - Full agent file content (markdown with XML) - * @param {string} moduleName - Module name - * @param {string} agentName - Agent name - * @returns {Object} Agent details - */ - extractAgentDetails(content, moduleName, agentName) { - try { - // Extract agent XML block - const agentMatch = content.match(/]*>([\s\S]*?)<\/agent>/); - if (!agentMatch) return null; - - const agentXml = agentMatch[0]; - - // Extract attributes from opening tag - const nameMatch = agentXml.match(/name="([^"]*)"/); - const titleMatch = agentXml.match(/title="([^"]*)"/); - const iconMatch = agentXml.match(/icon="([^"]*)"/); - - // Extract persona elements - now we just copy them as-is - const roleMatch = agentXml.match(/([\s\S]*?)<\/role>/); - const identityMatch = agentXml.match(/([\s\S]*?)<\/identity>/); - const styleMatch = agentXml.match(/([\s\S]*?)<\/communication_style>/); - const principlesMatch = agentXml.match(/([\s\S]*?)<\/principles>/); - - return { - id: `bmad/${moduleName}/agents/${agentName}.md`, - name: nameMatch ? nameMatch[1] : agentName, - title: titleMatch ? titleMatch[1] : 'Agent', - icon: iconMatch ? iconMatch[1] : '🤖', - module: moduleName, - role: roleMatch ? roleMatch[1].trim() : '', - identity: identityMatch ? identityMatch[1].trim() : '', - communicationStyle: styleMatch ? styleMatch[1].trim() : '', - principles: principlesMatch ? principlesMatch[1].trim() : '', - }; - } catch (error) { - console.error(`Error extracting details for agent ${agentName}:`, error); - return null; - } - }, - - /** - * Extract attribute from XML tag - */ - extractAttribute(xml, tagName, attrName) { - const regex = new RegExp(`<${tagName}[^>]*\\s${attrName}="([^"]*)"`, 'i'); - const match = xml.match(regex); - return match ? match[1] : ''; - }, - - /** - * Apply config overrides to agent details - * @param {Object} details - Original agent details - * @param {string} configContent - Config file content - * @returns {Object} Agent details with overrides applied - */ - applyConfigOverrides(details, configContent) { - try { - // Extract agent-config XML block - const configMatch = configContent.match(/([\s\S]*?)<\/agent-config>/); - if (!configMatch) return details; - - const configXml = configMatch[0]; - - // Extract override values - const nameMatch = configXml.match(/([\s\S]*?)<\/name>/); - const titleMatch = configXml.match(/([\s\S]*?)<\/title>/); - const roleMatch = configXml.match(/<role>([\s\S]*?)<\/role>/); - const identityMatch = configXml.match(/<identity>([\s\S]*?)<\/identity>/); - const styleMatch = configXml.match(/<communication_style>([\s\S]*?)<\/communication_style>/); - const principlesMatch = configXml.match(/<principles>([\s\S]*?)<\/principles>/); - - // Apply overrides only if values are non-empty - if (nameMatch && nameMatch[1].trim()) { - details.name = nameMatch[1].trim(); - } - - if (titleMatch && titleMatch[1].trim()) { - details.title = titleMatch[1].trim(); - } - - if (roleMatch && roleMatch[1].trim()) { - details.role = roleMatch[1].trim(); - } - - if (identityMatch && identityMatch[1].trim()) { - details.identity = identityMatch[1].trim(); - } - - if (styleMatch && styleMatch[1].trim()) { - details.communicationStyle = styleMatch[1].trim(); - } - - if (principlesMatch && principlesMatch[1].trim()) { - // Principles are now just copied as-is (narrative paragraph) - details.principles = principlesMatch[1].trim(); - } - - return details; - } catch (error) { - console.error(`Error applying config overrides:`, error); - return details; - } - }, - - /** - * Write agent-manifest.csv to file - */ - async writeAgentParty(filePath, agentDetails, options = {}) { - const content = this.generateAgentParty(agentDetails, options); - await fs.ensureDir(path.dirname(filePath)); - await fs.writeFile(filePath, content, 'utf8'); - return content; - }, -}; - -module.exports = { AgentPartyGenerator }; diff --git a/tools/cli/lib/agent/compiler.js b/tools/cli/lib/agent/compiler.js deleted file mode 100644 index a557a69af..000000000 --- a/tools/cli/lib/agent/compiler.js +++ /dev/null @@ -1,516 +0,0 @@ -/** - * BMAD Agent Compiler - * Transforms agent YAML to compiled XML (.md) format - * Uses the existing BMAD builder infrastructure for proper formatting - */ - -const yaml = require('yaml'); -const fs = require('node:fs'); -const path = require('node:path'); -const { processAgentYaml, extractInstallConfig, stripInstallConfig, getDefaultValues } = require('./template-engine'); -const { escapeXml } = require('../../../lib/xml-utils'); -const { ActivationBuilder } = require('../activation-builder'); -const { AgentAnalyzer } = require('../agent-analyzer'); - -/** - * Build frontmatter for agent - * @param {Object} metadata - Agent metadata - * @param {string} agentName - Final agent name - * @returns {string} YAML frontmatter - */ -function buildFrontmatter(metadata, agentName) { - const nameFromFile = agentName.replaceAll('-', ' '); - const description = metadata.title || 'BMAD Agent'; - - return `--- -name: "${nameFromFile}" -description: "${description}" ---- - -You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. - -`; -} - -// buildSimpleActivation function removed - replaced by ActivationBuilder for proper fragment loading from src/utility/agent-components/ - -/** - * Build persona XML section - * @param {Object} persona - Persona object - * @returns {string} Persona XML - */ -function buildPersonaXml(persona) { - if (!persona) return ''; - - let xml = ' <persona>\n'; - - if (persona.role) { - const roleText = persona.role.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' '); - xml += ` <role>${escapeXml(roleText)}</role>\n`; - } - - if (persona.identity) { - const identityText = persona.identity.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' '); - xml += ` <identity>${escapeXml(identityText)}</identity>\n`; - } - - if (persona.communication_style) { - const styleText = persona.communication_style.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' '); - xml += ` <communication_style>${escapeXml(styleText)}</communication_style>\n`; - } - - if (persona.principles) { - let principlesText; - if (Array.isArray(persona.principles)) { - principlesText = persona.principles.join(' '); - } else { - principlesText = persona.principles.trim().replaceAll(/\n+/g, ' '); - } - xml += ` <principles>${escapeXml(principlesText)}</principles>\n`; - } - - xml += ' </persona>\n'; - - return xml; -} - -/** - * Build prompts XML section - * @param {Array} prompts - Prompts array - * @returns {string} Prompts XML - */ -function buildPromptsXml(prompts) { - if (!prompts || prompts.length === 0) return ''; - - let xml = ' <prompts>\n'; - - for (const prompt of prompts) { - xml += ` <prompt id="${prompt.id || ''}">\n`; - xml += ` <content>\n`; - // Don't escape prompt content - it's meant to be read as-is - xml += `${prompt.content || ''}\n`; - xml += ` </content>\n`; - xml += ` </prompt>\n`; - } - - xml += ' </prompts>\n'; - - return xml; -} - -/** - * Build memories XML section - * @param {Array} memories - Memories array - * @returns {string} Memories XML - */ -function buildMemoriesXml(memories) { - if (!memories || memories.length === 0) return ''; - - let xml = ' <memories>\n'; - - for (const memory of memories) { - xml += ` <memory>${escapeXml(String(memory))}</memory>\n`; - } - - xml += ' </memories>\n'; - - return xml; -} - -/** - * Build menu XML section - * Supports both legacy and multi format menu items - * Multi items display as a single menu item with nested handlers - * @param {Array} menuItems - Menu items - * @returns {string} Menu XML - */ -function buildMenuXml(menuItems) { - let xml = ' <menu>\n'; - - // Always inject menu display option first - xml += ` <item cmd="MH or fuzzy match on menu or help">[MH] Redisplay Menu Help</item>\n`; - xml += ` <item cmd="CH or fuzzy match on chat">[CH] Chat with the Agent about anything</item>\n`; - - // Add user-defined menu items - if (menuItems && menuItems.length > 0) { - for (const item of menuItems) { - // Handle multi format menu items with nested handlers - if (item.multi && item.triggers && Array.isArray(item.triggers)) { - xml += ` <item type="multi">${escapeXml(item.multi)}\n`; - xml += buildNestedHandlers(item.triggers); - xml += ` </item>\n`; - } - // Handle legacy format menu items - else if (item.trigger) { - let trigger = item.trigger || ''; - - const attrs = [`cmd="${trigger}"`]; - - // Add handler attributes - if (item.exec) attrs.push(`exec="${item.exec}"`); - if (item.tmpl) attrs.push(`tmpl="${item.tmpl}"`); - if (item.data) attrs.push(`data="${item.data}"`); - if (item.action) attrs.push(`action="${item.action}"`); - - xml += ` <item ${attrs.join(' ')}>${escapeXml(item.description || '')}</item>\n`; - } - } - } - - xml += ` <item cmd="PM or fuzzy match on party-mode" exec="skill:bmad-party-mode">[PM] Start Party Mode</item>\n`; - xml += ` <item cmd="DA or fuzzy match on exit, leave, goodbye or dismiss agent">[DA] Dismiss Agent</item>\n`; - - xml += ' </menu>\n'; - - return xml; -} - -/** - * Build nested handlers for multi format menu items - * @param {Array} triggers - Triggers array from multi format - * @returns {string} Handler XML - */ -function buildNestedHandlers(triggers) { - let xml = ''; - - for (const triggerGroup of triggers) { - for (const [triggerName, execArray] of Object.entries(triggerGroup)) { - // Build trigger with * prefix - let trigger = triggerName.startsWith('*') ? triggerName : '*' + triggerName; - - // Extract the relevant execution data - const execData = processExecArray(execArray); - - // For nested handlers in multi items, we use match attribute for fuzzy matching - const attrs = [`match="${escapeXml(execData.description || '')}"`]; - - // Add handler attributes based on exec data - if (execData.route) attrs.push(`exec="${execData.route}"`); - if (execData.action) attrs.push(`action="${execData.action}"`); - if (execData.data) attrs.push(`data="${execData.data}"`); - if (execData.tmpl) attrs.push(`tmpl="${execData.tmpl}"`); - // Only add type if it's not 'exec' (exec is already implied by the exec attribute) - if (execData.type && execData.type !== 'exec') attrs.push(`type="${execData.type}"`); - - xml += ` <handler ${attrs.join(' ')}></handler>\n`; - } - } - - return xml; -} - -/** - * Process the execution array from multi format triggers - * Extracts relevant data for XML attributes - * @param {Array} execArray - Array of execution objects - * @returns {Object} Processed execution data - */ -function processExecArray(execArray) { - const result = { - description: '', - route: null, - data: null, - action: null, - type: null, - }; - - if (!Array.isArray(execArray)) { - return result; - } - - for (const exec of execArray) { - if (exec.input) { - // Use input as description if no explicit description is provided - result.description = exec.input; - } - - if (exec.route) { - result.route = exec.route; - } - - if (exec.data !== null && exec.data !== undefined) { - result.data = exec.data; - } - - if (exec.action) { - result.action = exec.action; - } - - if (exec.type) { - result.type = exec.type; - } - } - - return result; -} - -/** - * Compile agent YAML to proper XML format - * @param {Object} agentYaml - Parsed and processed agent YAML - * @param {string} agentName - Final agent name (for ID and frontmatter) - * @param {string} targetPath - Target path for agent ID - * @returns {Promise<string>} Compiled XML string with frontmatter - */ -async function compileToXml(agentYaml, agentName = '', targetPath = '') { - const agent = agentYaml.agent; - const meta = agent.metadata; - - let xml = ''; - - // Build frontmatter - xml += buildFrontmatter(meta, agentName || meta.name || 'agent'); - - // Start code fence - xml += '```xml\n'; - - // Agent opening tag - const agentAttrs = [ - `id="${targetPath || meta.id || ''}"`, - `name="${meta.name || ''}"`, - `title="${meta.title || ''}"`, - `icon="${meta.icon || '🤖'}"`, - ]; - if (meta.capabilities) { - agentAttrs.push(`capabilities="${escapeXml(meta.capabilities)}"`); - } - - xml += `<agent ${agentAttrs.join(' ')}>\n`; - - // Activation block - use ActivationBuilder for proper fragment loading - const activationBuilder = new ActivationBuilder(); - const analyzer = new AgentAnalyzer(); - const profile = analyzer.analyzeAgentObject(agentYaml); - xml += await activationBuilder.buildActivation( - profile, - meta, - agent.critical_actions || [], - false, // forWebBundle - set to false for IDE deployment - ); - - // Persona section - xml += buildPersonaXml(agent.persona); - - // Prompts section (if present) - if (agent.prompts && agent.prompts.length > 0) { - xml += buildPromptsXml(agent.prompts); - } - - // Memories section (if present) - if (agent.memories && agent.memories.length > 0) { - xml += buildMemoriesXml(agent.memories); - } - - // Menu section - xml += buildMenuXml(agent.menu || []); - - // Closing agent tag - xml += '</agent>\n'; - - // Close code fence - xml += '```\n'; - - return xml; -} - -/** - * Full compilation pipeline - * @param {string} yamlContent - Raw YAML string - * @param {Object} answers - Answers from install_config questions (or defaults) - * @param {string} agentName - Optional final agent name (user's custom persona name) - * @param {string} targetPath - Optional target path for agent ID - * @param {Object} options - Additional options including config - * @returns {Promise<Object>} { xml: string, metadata: Object } - */ -async function compileAgent(yamlContent, answers = {}, agentName = '', targetPath = '', options = {}) { - // Parse YAML - let agentYaml = yaml.parse(yamlContent); - - // Apply customization merges before template processing - // Handle metadata overrides (like name) - if (answers.metadata) { - // Filter out empty values from metadata - const filteredMetadata = filterCustomizationData(answers.metadata); - if (Object.keys(filteredMetadata).length > 0) { - agentYaml.agent.metadata = { ...agentYaml.agent.metadata, ...filteredMetadata }; - } - // Remove from answers so it doesn't get processed as template variables - const { metadata, ...templateAnswers } = answers; - answers = templateAnswers; - } - - // Handle other customization properties - // These should be merged into the agent structure, not processed as template variables - const customizationKeys = ['persona', 'critical_actions', 'memories', 'menu', 'prompts']; - const customizations = {}; - const remainingAnswers = { ...answers }; - - for (const key of customizationKeys) { - if (answers[key]) { - let filtered; - - // Handle different data types - if (Array.isArray(answers[key])) { - // For arrays, filter out empty/null/undefined values - filtered = answers[key].filter((item) => item !== null && item !== undefined && item !== ''); - } else { - // For objects, use filterCustomizationData - filtered = filterCustomizationData(answers[key]); - } - - // Check if we have valid content - const hasContent = Array.isArray(filtered) ? filtered.length > 0 : Object.keys(filtered).length > 0; - - if (hasContent) { - customizations[key] = filtered; - } - delete remainingAnswers[key]; - } - } - - // Merge customizations into agentYaml - if (Object.keys(customizations).length > 0) { - // For persona: replace entire section - if (customizations.persona) { - agentYaml.agent.persona = customizations.persona; - } - - // For critical_actions: append to existing or create new - if (customizations.critical_actions) { - const existing = agentYaml.agent.critical_actions || []; - agentYaml.agent.critical_actions = [...existing, ...customizations.critical_actions]; - } - - // For memories: append to existing or create new - if (customizations.memories) { - const existing = agentYaml.agent.memories || []; - agentYaml.agent.memories = [...existing, ...customizations.memories]; - } - - // For menu: append to existing or create new - if (customizations.menu) { - const existing = agentYaml.agent.menu || []; - agentYaml.agent.menu = [...existing, ...customizations.menu]; - } - - // For prompts: append to existing or create new (by id) - if (customizations.prompts) { - const existing = agentYaml.agent.prompts || []; - // Merge by id, with customizations taking precedence - const mergedPrompts = [...existing]; - for (const customPrompt of customizations.prompts) { - const existingIndex = mergedPrompts.findIndex((p) => p.id === customPrompt.id); - if (existingIndex === -1) { - mergedPrompts.push(customPrompt); - } else { - mergedPrompts[existingIndex] = customPrompt; - } - } - agentYaml.agent.prompts = mergedPrompts; - } - } - - // Use remaining answers for template processing - answers = remainingAnswers; - - // Extract install_config - const installConfig = extractInstallConfig(agentYaml); - - // Merge defaults with provided answers - let finalAnswers = answers; - if (installConfig) { - const defaults = getDefaultValues(installConfig); - finalAnswers = { ...defaults, ...answers }; - } - - // Process templates with answers - const processedYaml = processAgentYaml(agentYaml, finalAnswers); - - // Strip install_config from output - const cleanYaml = stripInstallConfig(processedYaml); - - let xml = await compileToXml(cleanYaml, agentName, targetPath); - - // Ensure xml is a string before attempting replaceAll - if (typeof xml !== 'string') { - throw new TypeError('compileToXml did not return a string'); - } - - return { - xml, - metadata: cleanYaml.agent.metadata, - processedYaml: cleanYaml, - }; -} - -/** - * Filter customization data to remove empty/null values - * @param {Object} data - Raw customization data - * @returns {Object} Filtered customization data - */ -function filterCustomizationData(data) { - const filtered = {}; - - for (const [key, value] of Object.entries(data)) { - if (value === null || value === undefined || value === '') { - continue; // Skip null/undefined/empty values - } - - if (Array.isArray(value)) { - if (value.length > 0) { - filtered[key] = value; - } - } else if (typeof value === 'object') { - const nested = filterCustomizationData(value); - if (Object.keys(nested).length > 0) { - filtered[key] = nested; - } - } else { - filtered[key] = value; - } - } - - return filtered; -} - -/** - * Compile agent file to .md - * @param {string} yamlPath - Path to agent YAML file - * @param {Object} options - { answers: {}, outputPath: string } - * @returns {Object} Compilation result - */ -function compileAgentFile(yamlPath, options = {}) { - const yamlContent = fs.readFileSync(yamlPath, 'utf8'); - const result = compileAgent(yamlContent, options.answers || {}); - - // Determine output path - let outputPath = options.outputPath; - if (!outputPath) { - // Default: same directory, same name, .md extension - const dir = path.dirname(yamlPath); - const basename = path.basename(yamlPath, '.agent.yaml'); - outputPath = path.join(dir, `${basename}.md`); - } - - // Write compiled XML - fs.writeFileSync(outputPath, xml, 'utf8'); - - return { - ...result, - xml, - outputPath, - sourcePath: yamlPath, - }; -} - -module.exports = { - compileToXml, - compileAgent, - compileAgentFile, - escapeXml, - buildFrontmatter, - buildPersonaXml, - buildPromptsXml, - buildMemoriesXml, - buildMenuXml, - filterCustomizationData, -}; diff --git a/tools/cli/lib/agent/installer.js b/tools/cli/lib/agent/installer.js deleted file mode 100644 index c9e0dd916..000000000 --- a/tools/cli/lib/agent/installer.js +++ /dev/null @@ -1,680 +0,0 @@ -/** - * BMAD Agent Installer - * Discovers, prompts, compiles, and installs agents - */ - -const fs = require('node:fs'); -const path = require('node:path'); -const yaml = require('yaml'); -const prompts = require('../prompts'); -const { compileAgent, compileAgentFile } = require('./compiler'); -const { extractInstallConfig, getDefaultValues } = require('./template-engine'); - -/** - * Find BMAD config file in project - * @param {string} startPath - Starting directory to search from - * @returns {Object|null} Config data or null - */ -function findBmadConfig(startPath = process.cwd()) { - // Look for common BMAD folder names - const possibleNames = ['_bmad']; - - for (const name of possibleNames) { - const configPath = path.join(startPath, name, 'bmb', 'config.yaml'); - if (fs.existsSync(configPath)) { - const content = fs.readFileSync(configPath, 'utf8'); - const config = yaml.parse(content); - return { - ...config, - bmadFolder: path.join(startPath, name), - projectRoot: startPath, - }; - } - } - - return null; -} - -/** - * Resolve path variables like {project-root} and {bmad-folder} - * @param {string} pathStr - Path with variables - * @param {Object} context - Contains projectRoot, bmadFolder - * @returns {string} Resolved path - */ -function resolvePath(pathStr, context) { - return pathStr.replaceAll('{project-root}', context.projectRoot).replaceAll('{bmad-folder}', context.bmadFolder); -} - -/** - * Discover available agents in the custom agent location recursively - * @param {string} searchPath - Path to search for agents - * @returns {Array} List of agent info objects - */ -function discoverAgents(searchPath) { - if (!fs.existsSync(searchPath)) { - return []; - } - - const agents = []; - - // Helper function to recursively search - function searchDirectory(dir, relativePath = '') { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - const agentRelativePath = relativePath ? path.join(relativePath, entry.name) : entry.name; - - if (entry.isFile() && entry.name.endsWith('.agent.yaml')) { - // Simple agent (single file) - // The agent name is based on the filename - const agentName = entry.name.replace('.agent.yaml', ''); - agents.push({ - type: 'simple', - name: agentName, - path: fullPath, - yamlFile: fullPath, - relativePath: agentRelativePath.replace('.agent.yaml', ''), - }); - } else if (entry.isDirectory()) { - // Check if this directory contains an .agent.yaml file - try { - const dirContents = fs.readdirSync(fullPath); - const yamlFiles = dirContents.filter((f) => f.endsWith('.agent.yaml')); - - if (yamlFiles.length > 0) { - // Found .agent.yaml files in this directory - for (const yamlFile of yamlFiles) { - const agentYamlPath = path.join(fullPath, yamlFile); - const agentName = path.basename(yamlFile, '.agent.yaml'); - - agents.push({ - type: 'expert', - name: agentName, - path: fullPath, - yamlFile: agentYamlPath, - relativePath: agentRelativePath, - }); - } - } else { - // No .agent.yaml in this directory, recurse deeper - searchDirectory(fullPath, agentRelativePath); - } - } catch { - // Skip directories we can't read - } - } - } - } - - searchDirectory(searchPath); - return agents; -} - -/** - * Load agent YAML and extract install_config - * @param {string} yamlPath - Path to agent YAML file - * @returns {Object} Agent YAML and install config - */ -function loadAgentConfig(yamlPath) { - const content = fs.readFileSync(yamlPath, 'utf8'); - const agentYaml = yaml.parse(content); - const installConfig = extractInstallConfig(agentYaml); - const defaults = installConfig ? getDefaultValues(installConfig) : {}; - - // Check for saved_answers (from previously installed custom agents) - // These take precedence over defaults - const savedAnswers = agentYaml?.saved_answers || {}; - - const metadata = agentYaml?.agent?.metadata || {}; - - return { - yamlContent: content, - agentYaml, - installConfig, - defaults: { ...defaults, ...savedAnswers }, // saved_answers override defaults - metadata, - hasSidecar: metadata.hasSidecar === true, - }; -} - -/** - * Interactive prompt for install_config questions - * @param {Object} installConfig - Install configuration with questions - * @param {Object} defaults - Default values - * @returns {Promise<Object>} User answers - */ -async function promptInstallQuestions(installConfig, defaults, presetAnswers = {}) { - if (!installConfig || !installConfig.questions || installConfig.questions.length === 0) { - return { ...defaults, ...presetAnswers }; - } - - const answers = { ...defaults, ...presetAnswers }; - - await prompts.note(installConfig.description || '', 'Agent Configuration'); - - for (const q of installConfig.questions) { - // Skip questions for variables that are already set (e.g., custom_name set upfront) - if (answers[q.var] !== undefined && answers[q.var] !== defaults[q.var]) { - await prompts.log.message(` ${q.var}: ${answers[q.var]} (already set)`); - continue; - } - - switch (q.type) { - case 'text': { - const response = await prompts.text({ - message: q.prompt, - default: q.default ?? '', - }); - answers[q.var] = response ?? q.default ?? ''; - break; - } - case 'boolean': { - const response = await prompts.confirm({ - message: q.prompt, - default: q.default, - }); - answers[q.var] = response; - break; - } - case 'choice': { - const response = await prompts.select({ - message: q.prompt, - options: q.options.map((o) => ({ value: o.value, label: o.label })), - initialValue: q.default, - }); - answers[q.var] = response; - break; - } - // No default - } - } - - return answers; -} - -/** - * Install a compiled agent to target location - * @param {Object} agentInfo - Agent discovery info - * @param {Object} answers - User answers for install_config - * @param {string} targetPath - Target installation directory - * @param {Object} options - Additional options including config - * @returns {Object} Installation result - */ -function installAgent(agentInfo, answers, targetPath, options = {}) { - // Compile the agent - const { xml, metadata, processedYaml } = compileAgent(fs.readFileSync(agentInfo.yamlFile, 'utf8'), answers); - - // Determine target agent folder name - // Use the folder name from agentInfo, NOT the persona name from metadata - const agentFolderName = agentInfo.name; - - const agentTargetDir = path.join(targetPath, agentFolderName); - - // Create target directory - if (!fs.existsSync(agentTargetDir)) { - fs.mkdirSync(agentTargetDir, { recursive: true }); - } - - // Write compiled XML (.md) - const compiledFileName = `${agentFolderName}.md`; - const compiledPath = path.join(agentTargetDir, compiledFileName); - fs.writeFileSync(compiledPath, xml, 'utf8'); - - const result = { - success: true, - agentName: metadata.name || agentInfo.name, - targetDir: agentTargetDir, - compiledFile: compiledPath, - }; - - return result; -} - -/** - * Update agent metadata ID to reflect installed location - * @param {string} compiledContent - Compiled XML content - * @param {string} targetPath - Target installation path relative to project - * @returns {string} Updated content - */ -function updateAgentId(compiledContent, targetPath) { - // Update the id attribute in the opening agent tag - return compiledContent.replace(/(<agent\s+id=")[^"]*(")/, `$1${targetPath}$2`); -} - -/** - * Detect if a path is within a BMAD project - * @param {string} targetPath - Path to check - * @returns {Object|null} Project info with bmadFolder and cfgFolder - */ -function detectBmadProject(targetPath) { - let checkPath = path.resolve(targetPath); - const root = path.parse(checkPath).root; - - // Walk up directory tree looking for BMAD installation - while (checkPath !== root) { - const possibleNames = ['_bmad']; - for (const name of possibleNames) { - const bmadFolder = path.join(checkPath, name); - const cfgFolder = path.join(bmadFolder, '_config'); - const manifestFile = path.join(cfgFolder, 'agent-manifest.csv'); - - if (fs.existsSync(manifestFile)) { - return { - projectRoot: checkPath, - bmadFolder, - cfgFolder, - manifestFile, - }; - } - } - checkPath = path.dirname(checkPath); - } - - return null; -} - -/** - * Escape CSV field value - * @param {string} value - Value to escape - * @returns {string} Escaped value - */ -function escapeCsvField(value) { - if (typeof value !== 'string') value = String(value); - // If contains comma, quote, or newline, wrap in quotes and escape internal quotes - if (value.includes(',') || value.includes('"') || value.includes('\n')) { - return '"' + value.replaceAll('"', '""') + '"'; - } - return value; -} - -/** - * Parse CSV line respecting quoted fields - * @param {string} line - CSV line - * @returns {Array} Parsed fields - */ -function parseCsvLine(line) { - const fields = []; - let current = ''; - let inQuotes = false; - - for (let i = 0; i < line.length; i++) { - const char = line[i]; - const nextChar = line[i + 1]; - - if (char === '"' && !inQuotes) { - inQuotes = true; - } else if (char === '"' && inQuotes) { - if (nextChar === '"') { - current += '"'; - i++; // Skip escaped quote - } else { - inQuotes = false; - } - } else if (char === ',' && !inQuotes) { - fields.push(current); - current = ''; - } else { - current += char; - } - } - fields.push(current); - return fields; -} - -/** - * Check if agent name exists in manifest - * @param {string} manifestFile - Path to agent-manifest.csv - * @param {string} agentName - Agent name to check - * @returns {Object|null} Existing entry or null - */ -function checkManifestForAgent(manifestFile, agentName) { - const content = fs.readFileSync(manifestFile, 'utf8'); - const lines = content.trim().split('\n'); - - if (lines.length < 2) return null; - - const header = parseCsvLine(lines[0]); - const nameIndex = header.indexOf('name'); - - if (nameIndex === -1) return null; - - for (let i = 1; i < lines.length; i++) { - const fields = parseCsvLine(lines[i]); - if (fields[nameIndex] === agentName) { - const entry = {}; - for (const [idx, col] of header.entries()) { - entry[col] = fields[idx] || ''; - } - entry._lineNumber = i; - return entry; - } - } - - return null; -} - -/** - * Check if agent path exists in manifest - * @param {string} manifestFile - Path to agent-manifest.csv - * @param {string} agentPath - Agent path to check - * @returns {Object|null} Existing entry or null - */ -function checkManifestForPath(manifestFile, agentPath) { - const content = fs.readFileSync(manifestFile, 'utf8'); - const lines = content.trim().split('\n'); - - if (lines.length < 2) return null; - - const header = parseCsvLine(lines[0]); - const pathIndex = header.indexOf('path'); - - if (pathIndex === -1) return null; - - for (let i = 1; i < lines.length; i++) { - const fields = parseCsvLine(lines[i]); - if (fields[pathIndex] === agentPath) { - const entry = {}; - for (const [idx, col] of header.entries()) { - entry[col] = fields[idx] || ''; - } - entry._lineNumber = i; - return entry; - } - } - - return null; -} - -/** - * Update existing entry in manifest - * @param {string} manifestFile - Path to agent-manifest.csv - * @param {Object} agentData - New agent data - * @param {number} lineNumber - Line number to replace (1-indexed, excluding header) - * @returns {boolean} Success - */ -function updateManifestEntry(manifestFile, agentData, lineNumber) { - const content = fs.readFileSync(manifestFile, 'utf8'); - const lines = content.trim().split('\n'); - - const header = lines[0]; - const columns = header.split(','); - - // Build the new row - const row = columns.map((col) => { - const value = agentData[col] || ''; - return escapeCsvField(value); - }); - - // Replace the line - lines[lineNumber] = row.join(','); - - fs.writeFileSync(manifestFile, lines.join('\n') + '\n', 'utf8'); - return true; -} - -/** - * Add agent to manifest CSV - * @param {string} manifestFile - Path to agent-manifest.csv - * @param {Object} agentData - Agent metadata and path info - * @returns {boolean} Success - */ -function addToManifest(manifestFile, agentData) { - const content = fs.readFileSync(manifestFile, 'utf8'); - const lines = content.trim().split('\n'); - - // Parse header to understand column order - const header = lines[0]; - const columns = header.split(','); - - // Build the new row based on header columns - const row = columns.map((col) => { - const value = agentData[col] || ''; - return escapeCsvField(value); - }); - - // Append new row - const newLine = row.join(','); - const updatedContent = content.trim() + '\n' + newLine + '\n'; - - fs.writeFileSync(manifestFile, updatedContent, 'utf8'); - return true; -} - -/** - * Save agent source YAML to _config/custom/agents/ for reinstallation - * Stores user answers in a top-level saved_answers section (cleaner than overwriting defaults) - * @param {Object} agentInfo - Agent info (path, type, etc.) - * @param {string} cfgFolder - Path to _config folder - * @param {string} agentName - Final agent name (e.g., "fred-commit-poet") - * @param {Object} answers - User answers to save for reinstallation - * @returns {Object} Info about saved source - */ -function saveAgentSource(agentInfo, cfgFolder, agentName, answers = {}) { - // Save to _config/custom/agents/ instead of _config/agents/ - const customAgentsCfgDir = path.join(cfgFolder, 'custom', 'agents'); - - if (!fs.existsSync(customAgentsCfgDir)) { - fs.mkdirSync(customAgentsCfgDir, { recursive: true }); - } - - const yamlLib = require('yaml'); - - /** - * Add saved_answers section to store user's actual answers - */ - function addSavedAnswers(agentYaml, answers) { - // Store answers in a clear, separate section - agentYaml.saved_answers = answers; - return agentYaml; - } - - if (agentInfo.type === 'simple') { - // Simple agent: copy YAML with saved_answers section - const targetYaml = path.join(customAgentsCfgDir, `${agentName}.agent.yaml`); - const originalContent = fs.readFileSync(agentInfo.yamlFile, 'utf8'); - const agentYaml = yamlLib.parse(originalContent); - - // Add saved_answers section with user's choices - addSavedAnswers(agentYaml, answers); - - fs.writeFileSync(targetYaml, yamlLib.stringify(agentYaml), 'utf8'); - return { type: 'simple', path: targetYaml }; - } else { - // Expert agent with sidecar: copy entire folder with saved_answers - const targetFolder = path.join(customAgentsCfgDir, agentName); - if (!fs.existsSync(targetFolder)) { - fs.mkdirSync(targetFolder, { recursive: true }); - } - - // Copy YAML and entire sidecar structure - const sourceDir = agentInfo.path; - const copied = []; - - function copyDir(src, dest) { - if (!fs.existsSync(dest)) { - fs.mkdirSync(dest, { recursive: true }); - } - - const entries = fs.readdirSync(src, { withFileTypes: true }); - for (const entry of entries) { - const srcPath = path.join(src, entry.name); - const destPath = path.join(dest, entry.name); - - if (entry.isDirectory()) { - copyDir(srcPath, destPath); - } else if (entry.name.endsWith('.agent.yaml')) { - // For the agent YAML, add saved_answers section - const originalContent = fs.readFileSync(srcPath, 'utf8'); - const agentYaml = yamlLib.parse(originalContent); - addSavedAnswers(agentYaml, answers); - // Rename YAML to match final agent name - const newYamlPath = path.join(dest, `${agentName}.agent.yaml`); - fs.writeFileSync(newYamlPath, yamlLib.stringify(agentYaml), 'utf8'); - copied.push(newYamlPath); - } else { - fs.copyFileSync(srcPath, destPath); - copied.push(destPath); - } - } - } - - copyDir(sourceDir, targetFolder); - return { type: 'expert', path: targetFolder, files: copied }; - } -} - -/** - * Create IDE slash command wrapper for agent - * Leverages IdeManager to dispatch to IDE-specific handlers - * @param {string} projectRoot - Project root path - * @param {string} agentName - Agent name (e.g., "commit-poet") - * @param {string} agentPath - Path to compiled agent (relative to project root) - * @param {Object} metadata - Agent metadata - * @returns {Promise<Object>} Info about created slash commands - */ -async function createIdeSlashCommands(projectRoot, agentName, agentPath, metadata) { - // Read manifest.yaml to get installed IDEs - const manifestPath = path.join(projectRoot, '_bmad', '_config', 'manifest.yaml'); - let installedIdes = ['claude-code']; // Default to Claude Code if no manifest - - if (fs.existsSync(manifestPath)) { - const yamlLib = require('yaml'); - const manifestContent = fs.readFileSync(manifestPath, 'utf8'); - const manifest = yamlLib.parse(manifestContent); - if (manifest.ides && Array.isArray(manifest.ides)) { - installedIdes = manifest.ides; - } - } - - // Use IdeManager to install custom agent launchers for all configured IDEs - const { IdeManager } = require('../../installers/lib/ide/manager'); - const ideManager = new IdeManager(); - - const results = await ideManager.installCustomAgentLaunchers(installedIdes, projectRoot, agentName, agentPath, metadata); - - return results; -} - -/** - * Update manifest.yaml to track custom agent - * @param {string} manifestPath - Path to manifest.yaml - * @param {string} agentName - Agent name - * @param {string} agentType - Agent type (source name) - * @returns {boolean} Success - */ -function updateManifestYaml(manifestPath, agentName, agentType) { - if (!fs.existsSync(manifestPath)) { - return false; - } - - const yamlLib = require('yaml'); - const content = fs.readFileSync(manifestPath, 'utf8'); - const manifest = yamlLib.parse(content); - - // Initialize custom_agents array if not exists - if (!manifest.custom_agents) { - manifest.custom_agents = []; - } - - // Check if this agent is already registered - const existingIndex = manifest.custom_agents.findIndex((a) => a.name === agentName || (typeof a === 'string' && a === agentName)); - - const agentEntry = { - name: agentName, - type: agentType, - installed: new Date().toISOString(), - }; - - if (existingIndex === -1) { - // Add new entry - manifest.custom_agents.push(agentEntry); - } else { - // Update existing entry - manifest.custom_agents[existingIndex] = agentEntry; - } - - // Update lastUpdated timestamp - if (manifest.installation) { - manifest.installation.lastUpdated = new Date().toISOString(); - } - - // Write back - const newContent = yamlLib.stringify(manifest); - fs.writeFileSync(manifestPath, newContent, 'utf8'); - - return true; -} - -/** - * Extract manifest data from compiled agent XML - * @param {string} xmlContent - Compiled agent XML - * @param {Object} metadata - Agent metadata from YAML - * @param {string} agentPath - Relative path to agent file - * @param {string} moduleName - Module name (default: 'custom') - * @returns {Object} Manifest row data - */ -function extractManifestData(xmlContent, metadata, agentPath, moduleName = 'custom') { - // Extract data from XML using regex (simple parsing) - const extractTag = (tag) => { - const match = xmlContent.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`)); - if (!match) return ''; - // Collapse multiple lines into single line, normalize whitespace - return match[1].trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ').trim(); - }; - - // Extract attributes from agent tag - const extractAgentAttribute = (attr) => { - const match = xmlContent.match(new RegExp(`<agent[^>]*\\s${attr}=["']([^"']+)["']`)); - return match ? match[1] : ''; - }; - - const extractPrinciples = () => { - const match = xmlContent.match(/<principles>([\s\S]*?)<\/principles>/); - if (!match) return ''; - // Extract individual principle lines - const principles = match[1] - .split('\n') - .map((l) => l.trim()) - .filter((l) => l.length > 0) - .join(' '); - return principles; - }; - - // Prioritize XML extraction over metadata for agent persona info - const xmlTitle = extractAgentAttribute('title') || extractTag('name'); - const xmlIcon = extractAgentAttribute('icon'); - - return { - name: metadata.id ? path.basename(metadata.id, '.md') : metadata.name.toLowerCase().replaceAll(/\s+/g, '-'), - displayName: xmlTitle || metadata.name || '', - title: xmlTitle || metadata.title || '', - icon: xmlIcon || metadata.icon || '', - role: extractTag('role'), - identity: extractTag('identity'), - communicationStyle: extractTag('communication_style'), - principles: extractPrinciples(), - module: moduleName, - path: agentPath, - }; -} - -module.exports = { - findBmadConfig, - resolvePath, - discoverAgents, - loadAgentConfig, - promptInstallQuestions, - installAgent, - updateAgentId, - detectBmadProject, - addToManifest, - extractManifestData, - escapeCsvField, - checkManifestForAgent, - checkManifestForPath, - updateManifestEntry, - saveAgentSource, - createIdeSlashCommands, - updateManifestYaml, -}; diff --git a/tools/cli/lib/agent/template-engine.js b/tools/cli/lib/agent/template-engine.js deleted file mode 100644 index 01281fb17..000000000 --- a/tools/cli/lib/agent/template-engine.js +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Template Engine for BMAD Agent Install Configuration - * Processes {{variable}}, {{#if}}, {{#unless}}, and {{/if}} blocks - */ - -/** - * Process all template syntax in a string - * @param {string} content - Content with template syntax - * @param {Object} variables - Key-value pairs from install_config answers - * @returns {string} Processed content - */ -function processTemplate(content, variables = {}) { - let result = content; - - // Process conditionals first (they may contain variables) - result = processConditionals(result, variables); - - // Then process simple variable replacements - result = processVariables(result, variables); - - // Clean up any empty lines left by removed conditionals - result = cleanupEmptyLines(result); - - return result; -} - -/** - * Process {{#if}}, {{#unless}}, {{/if}}, {{/unless}} blocks - */ -function processConditionals(content, variables) { - let result = content; - - // Process {{#if variable == "value"}} blocks - // Handle both regular quotes and JSON-escaped quotes (\") - const ifEqualsPattern = /\{\{#if\s+(\w+)\s*==\s*\\?"([^"\\]+)\\?"\s*\}\}([\s\S]*?)\{\{\/if\}\}/g; - result = result.replaceAll(ifEqualsPattern, (match, varName, value, block) => { - return variables[varName] === value ? block : ''; - }); - - // Process {{#if variable}} blocks (boolean or truthy check) - const ifBoolPattern = /\{\{#if\s+(\w+)\s*\}\}([\s\S]*?)\{\{\/if\}\}/g; - result = result.replaceAll(ifBoolPattern, (match, varName, block) => { - const val = variables[varName]; - // Treat as truthy: true, non-empty string, non-zero number - const isTruthy = val === true || (typeof val === 'string' && val.length > 0) || (typeof val === 'number' && val !== 0); - return isTruthy ? block : ''; - }); - - // Process {{#unless variable}} blocks (inverse of if) - const unlessPattern = /\{\{#unless\s+(\w+)\s*\}\}([\s\S]*?)\{\{\/unless\}\}/g; - result = result.replaceAll(unlessPattern, (match, varName, block) => { - const val = variables[varName]; - const isFalsy = val === false || val === '' || val === null || val === undefined || val === 0; - return isFalsy ? block : ''; - }); - - return result; -} - -/** - * Process {{variable}} replacements - */ -function processVariables(content, variables) { - let result = content; - - // Replace {{variable}} with value - const varPattern = /\{\{(\w+)\}\}/g; - result = result.replaceAll(varPattern, (match, varName) => { - if (Object.hasOwn(variables, varName)) { - return String(variables[varName]); - } - // If variable not found, leave as-is (might be runtime variable like {user_name}) - return match; - }); - - return result; -} - -/** - * Clean up excessive empty lines left after removing conditional blocks - */ -function cleanupEmptyLines(content) { - // Replace 3+ consecutive newlines with 2 - return content.replaceAll(/\n{3,}/g, '\n\n'); -} - -/** - * Extract install_config from agent YAML object - * @param {Object} agentYaml - Parsed agent YAML - * @returns {Object|null} install_config section or null - */ -function extractInstallConfig(agentYaml) { - return agentYaml?.agent?.install_config || null; -} - -/** - * Remove install_config from agent YAML (after processing) - * @param {Object} agentYaml - Parsed agent YAML - * @returns {Object} Agent YAML without install_config - */ -function stripInstallConfig(agentYaml) { - const result = structuredClone(agentYaml); - if (result.agent) { - delete result.agent.install_config; - } - return result; -} - -/** - * Process entire agent YAML object with template variables - * @param {Object} agentYaml - Parsed agent YAML - * @param {Object} variables - Answers from install_config questions - * @returns {Object} Processed agent YAML - */ -function processAgentYaml(agentYaml, variables) { - // Convert to JSON string, process templates, parse back - const jsonString = JSON.stringify(agentYaml, null, 2); - const processed = processTemplate(jsonString, variables); - return JSON.parse(processed); -} - -/** - * Get default values from install_config questions - * @param {Object} installConfig - install_config section - * @returns {Object} Default values keyed by variable name - */ -function getDefaultValues(installConfig) { - const defaults = {}; - - if (!installConfig?.questions) { - return defaults; - } - - for (const question of installConfig.questions) { - if (question.var && question.default !== undefined) { - defaults[question.var] = question.default; - } - } - - return defaults; -} - -module.exports = { - processTemplate, - processConditionals, - processVariables, - extractInstallConfig, - stripInstallConfig, - processAgentYaml, - getDefaultValues, - cleanupEmptyLines, -}; diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index 1338c1f17..3f25dae03 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -208,14 +208,6 @@ class UI { }); } - // Add custom agent compilation option - if (installedVersion !== 'unknown') { - choices.push({ - name: 'Recompile Agents (apply customizations only)', - value: 'compile-agents', - }); - } - // Common actions choices.push({ name: 'Modify BMAD Installation', value: 'update' }); @@ -291,17 +283,6 @@ class UI { }; } - // Handle compile agents separately - if (actionType === 'compile-agents') { - // Only recompile agents with customizations, don't update any files - return { - actionType: 'compile-agents', - directory: confirmedDirectory, - customContent: { hasCustomContent: false }, - skipPrompts: options.yes || false, - }; - } - // If actionType === 'update', handle it with the new flow // Return early with modify configuration if (actionType === 'update') { diff --git a/tools/cli/lib/xml-handler.js b/tools/cli/lib/xml-handler.js deleted file mode 100644 index a6111b1a7..000000000 --- a/tools/cli/lib/xml-handler.js +++ /dev/null @@ -1,177 +0,0 @@ -const xml2js = require('xml2js'); -const fs = require('fs-extra'); -const path = require('node:path'); -const { getProjectRoot, getSourcePath } = require('./project-root'); -const { YamlXmlBuilder } = require('./yaml-xml-builder'); - -/** - * XML utility functions for BMAD installer - * Now supports both legacy XML agents and new YAML-based agents - */ -class XmlHandler { - constructor() { - this.parser = new xml2js.Parser({ - preserveChildrenOrder: true, - explicitChildren: true, - explicitArray: false, - trim: false, - normalizeTags: false, - attrkey: '$', - charkey: '_', - }); - - this.builder = new xml2js.Builder({ - renderOpts: { - pretty: true, - indent: ' ', - newline: '\n', - }, - xmldec: { - version: '1.0', - encoding: 'utf8', - standalone: false, - }, - headless: true, // Don't add XML declaration - attrkey: '$', - charkey: '_', - }); - - this.yamlBuilder = new YamlXmlBuilder(); - } - - /** - * Load and parse the activation template - * @returns {Object} Parsed activation block - */ - async loadActivationTemplate() { - console.error('Failed to load activation template:', error); - } - - /** - * Inject activation block into agent XML content - * @param {string} agentContent - The agent file content - * @param {Object} metadata - Metadata containing module and name - * @returns {string} Modified content with activation block - */ - async injectActivation(agentContent, metadata = {}) { - try { - // Check if already has activation - if (agentContent.includes('<activation')) { - return agentContent; - } - - // Extract the XML portion from markdown if needed - let xmlContent = agentContent; - let beforeXml = ''; - let afterXml = ''; - - const xmlBlockMatch = agentContent.match(/([\s\S]*?)```xml\n([\s\S]*?)\n```([\s\S]*)/); - if (xmlBlockMatch) { - beforeXml = xmlBlockMatch[1] + '```xml\n'; - xmlContent = xmlBlockMatch[2]; - afterXml = '\n```' + xmlBlockMatch[3]; - } - - // Parse the agent XML - const parsed = await this.parser.parseStringPromise(xmlContent); - - // Get the activation template - const activationBlock = await this.loadActivationTemplate(); - if (!activationBlock) { - console.warn('Could not load activation template'); - return agentContent; - } - - // Find the agent node - if ( - parsed.agent && // Insert activation as the first child - !parsed.agent.activation - ) { - // Ensure proper structure - if (!parsed.agent.$$) { - parsed.agent.$$ = []; - } - - // Create the activation node with proper structure - const activationNode = { - '#name': 'activation', - $: { critical: '1' }, - $$: activationBlock.$$, - }; - - // Insert at the beginning - parsed.agent.$$.unshift(activationNode); - } - - // Convert back to XML - let modifiedXml = this.builder.buildObject(parsed); - - // Fix indentation - xml2js doesn't maintain our exact formatting - // Add 2-space base indentation to match our style - const lines = modifiedXml.split('\n'); - const indentedLines = lines.map((line) => { - if (line.trim() === '') return line; - if (line.startsWith('<agent')) return line; // Keep agent at column 0 - return ' ' + line; // Indent everything else - }); - modifiedXml = indentedLines.join('\n'); - - // Reconstruct the full content - return beforeXml + modifiedXml + afterXml; - } catch (error) { - console.error('Error injecting activation:', error); - return agentContent; - } - } - - /** - * TODO: DELETE THIS METHOD - */ - injectActivationSimple(agentContent, metadata = {}) { - console.error('Error in simple injection:', error); - } - - /** - * Build agent from YAML source - * @param {string} yamlPath - Path to .agent.yaml file - * @param {string} customizePath - Path to .customize.yaml file (optional) - * @param {Object} metadata - Build metadata - * @returns {string} Generated XML content - */ - async buildFromYaml(yamlPath, customizePath = null, metadata = {}) { - try { - // Use YamlXmlBuilder to convert YAML to XML - const mergedAgent = await this.yamlBuilder.loadAndMergeAgent(yamlPath, customizePath); - - // Build metadata - const buildMetadata = { - sourceFile: path.basename(yamlPath), - sourceHash: await this.yamlBuilder.calculateFileHash(yamlPath), - customizeFile: customizePath ? path.basename(customizePath) : null, - customizeHash: customizePath ? await this.yamlBuilder.calculateFileHash(customizePath) : null, - builderVersion: '1.0.0', - includeMetadata: metadata.includeMetadata !== false, - forWebBundle: metadata.forWebBundle || false, // Pass through forWebBundle flag - }; - - // Convert to XML - const xml = await this.yamlBuilder.convertToXml(mergedAgent, buildMetadata); - - return xml; - } catch (error) { - console.error('Error building agent from YAML:', error); - throw error; - } - } - - /** - * Check if a path is a YAML agent file - * @param {string} filePath - Path to check - * @returns {boolean} True if it's a YAML agent file - */ - isYamlAgent(filePath) { - return filePath.endsWith('.agent.yaml'); - } -} - -module.exports = { XmlHandler }; diff --git a/tools/cli/lib/xml-to-markdown.js b/tools/cli/lib/xml-to-markdown.js deleted file mode 100644 index d5787b11f..000000000 --- a/tools/cli/lib/xml-to-markdown.js +++ /dev/null @@ -1,82 +0,0 @@ -const fs = require('node:fs'); -const path = require('node:path'); - -function convertXmlToMarkdown(xmlFilePath) { - if (!xmlFilePath.endsWith('.xml')) { - throw new Error('Input file must be an XML file'); - } - - const xmlContent = fs.readFileSync(xmlFilePath, 'utf8'); - - const basename = path.basename(xmlFilePath, '.xml'); - const dirname = path.dirname(xmlFilePath); - const mdFilePath = path.join(dirname, `${basename}.md`); - - // Extract version and name/title from root element attributes - let title = basename; - let version = ''; - - // Match the root element and its attributes - const rootMatch = xmlContent.match( - /<[^>\s]+[^>]*?\sv="([^"]+)"[^>]*?(?:\sname="([^"]+)")?|<[^>\s]+[^>]*?(?:\sname="([^"]+)")?[^>]*?\sv="([^"]+)"/, - ); - - if (rootMatch) { - // Handle both v="x" name="y" and name="y" v="x" orders - version = rootMatch[1] || rootMatch[4] || ''; - const nameAttr = rootMatch[2] || rootMatch[3] || ''; - - if (nameAttr) { - title = nameAttr; - } else { - // Try to find name in a <name> element if not in attributes - const nameElementMatch = xmlContent.match(/<name>([^<]+)<\/name>/); - if (nameElementMatch) { - title = nameElementMatch[1]; - } - } - } - - const heading = version ? `# ${title} v${version}` : `# ${title}`; - - const markdownContent = `${heading} - -\`\`\`xml -${xmlContent} -\`\`\` -`; - - fs.writeFileSync(mdFilePath, markdownContent, 'utf8'); - - return mdFilePath; -} - -function main() { - const args = process.argv.slice(2); - - if (args.length === 0) { - console.error('Usage: node xml-to-markdown.js <xml-file-path>'); - process.exit(1); - } - - const xmlFilePath = path.resolve(args[0]); - - if (!fs.existsSync(xmlFilePath)) { - console.error(`Error: File not found: ${xmlFilePath}`); - process.exit(1); - } - - try { - const mdFilePath = convertXmlToMarkdown(xmlFilePath); - console.log(`Successfully converted: ${xmlFilePath} -> ${mdFilePath}`); - } catch (error) { - console.error(`Error converting file: ${error.message}`); - process.exit(1); - } -} - -if (require.main === module) { - main(); -} - -module.exports = { convertXmlToMarkdown }; diff --git a/tools/cli/lib/yaml-xml-builder.js b/tools/cli/lib/yaml-xml-builder.js deleted file mode 100644 index 995483c5c..000000000 --- a/tools/cli/lib/yaml-xml-builder.js +++ /dev/null @@ -1,572 +0,0 @@ -const yaml = require('yaml'); -const fs = require('fs-extra'); -const path = require('node:path'); -const crypto = require('node:crypto'); -const { AgentAnalyzer } = require('./agent-analyzer'); -const { ActivationBuilder } = require('./activation-builder'); -const { escapeXml } = require('../../lib/xml-utils'); - -/** - * Converts agent YAML files to XML format with smart activation injection - */ -class YamlXmlBuilder { - constructor() { - this.analyzer = new AgentAnalyzer(); - this.activationBuilder = new ActivationBuilder(); - } - - /** - * Deep merge two objects (for customize.yaml + agent.yaml) - * @param {Object} target - Target object - * @param {Object} source - Source object to merge in - * @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 if (Array.isArray(source[key])) { - // For arrays, append rather than replace (for commands) - if (Array.isArray(target[key])) { - output[key] = [...target[key], ...source[key]]; - } else { - output[key] = source[key]; - } - } else { - output[key] = source[key]; - } - } - } - - return output; - } - - /** - * Check if value is an object - */ - isObject(item) { - return item && typeof item === 'object' && !Array.isArray(item); - } - - /** - * Load and merge agent YAML with customization - * @param {string} agentYamlPath - Path to base agent YAML - * @param {string} customizeYamlPath - Path to customize YAML (optional) - * @returns {Object} Merged agent configuration - */ - async loadAndMergeAgent(agentYamlPath, customizeYamlPath = null) { - // Load base agent - const agentContent = await fs.readFile(agentYamlPath, 'utf8'); - const agentYaml = yaml.parse(agentContent); - - // Load customization if exists - let merged = agentYaml; - if (customizeYamlPath && (await fs.pathExists(customizeYamlPath))) { - const customizeContent = await fs.readFile(customizeYamlPath, 'utf8'); - const customizeYaml = yaml.parse(customizeContent); - - if (customizeYaml) { - // Special handling: persona fields are merged, but only non-empty values override - if (customizeYaml.persona) { - const basePersona = merged.agent.persona || {}; - const customPersona = {}; - - // Only copy non-empty customize values - for (const [key, value] of Object.entries(customizeYaml.persona)) { - if (value !== '' && value !== null && !(Array.isArray(value) && value.length === 0)) { - customPersona[key] = value; - } - } - - // Merge non-empty customize values over base - if (Object.keys(customPersona).length > 0) { - merged.agent.persona = { ...basePersona, ...customPersona }; - } - } - - // Merge metadata (only non-empty values) - if (customizeYaml.agent && customizeYaml.agent.metadata) { - const nonEmptyMetadata = {}; - for (const [key, value] of Object.entries(customizeYaml.agent.metadata)) { - if (value !== '' && value !== null) { - nonEmptyMetadata[key] = value; - } - } - merged.agent.metadata = { ...merged.agent.metadata, ...nonEmptyMetadata }; - } - - // Append menu items (support both 'menu' and legacy 'commands') - const customMenuItems = customizeYaml.menu || customizeYaml.commands; - if (customMenuItems) { - // Determine if base uses 'menu' or 'commands' - if (merged.agent.menu) { - merged.agent.menu = [...merged.agent.menu, ...customMenuItems]; - } else if (merged.agent.commands) { - merged.agent.commands = [...merged.agent.commands, ...customMenuItems]; - } else { - // Default to 'menu' for new agents - merged.agent.menu = customMenuItems; - } - } - - // Append critical actions - if (customizeYaml.critical_actions) { - merged.agent.critical_actions = [...(merged.agent.critical_actions || []), ...customizeYaml.critical_actions]; - } - - // Append prompts - if (customizeYaml.prompts) { - merged.agent.prompts = [...(merged.agent.prompts || []), ...customizeYaml.prompts]; - } - - // Append memories - if (customizeYaml.memories) { - merged.agent.memories = [...(merged.agent.memories || []), ...customizeYaml.memories]; - } - } - } - - return merged; - } - - /** - * Convert agent YAML to XML - * @param {Object} agentYaml - Parsed agent YAML object - * @param {Object} buildMetadata - Metadata about the build (file paths, hashes, etc.) - * @returns {string} XML content - */ - async convertToXml(agentYaml, buildMetadata = {}) { - const agent = agentYaml.agent; - const metadata = agent.metadata || {}; - - // Add module from buildMetadata if available - if (buildMetadata.module) { - metadata.module = buildMetadata.module; - } - - // Analyze agent to determine needed handlers - const profile = this.analyzer.analyzeAgentObject(agentYaml); - - // Build activation block only if not skipped - let activationBlock = ''; - if (!buildMetadata.skipActivation) { - activationBlock = await this.activationBuilder.buildActivation( - profile, - metadata, - agent.critical_actions || [], - buildMetadata.forWebBundle || false, // Pass web bundle flag - ); - } - - // Start building XML - let xml = ''; - - if (buildMetadata.forWebBundle) { - // Web bundle: keep existing format - xml += '<!-- Powered by BMAD-CORE™ -->\n\n'; - xml += `# ${metadata.title || 'Agent'}\n\n`; - } else { - // Installation: use YAML frontmatter + instruction - // Extract name from filename: "cli-chief.yaml" or "pm.agent.yaml" -> "cli chief" or "pm" - const filename = buildMetadata.sourceFile || 'agent.yaml'; - let nameFromFile = path.basename(filename, path.extname(filename)); // Remove .yaml/.md extension - nameFromFile = nameFromFile.replace(/\.agent$/, ''); // Remove .agent suffix if present - nameFromFile = nameFromFile.replaceAll('-', ' '); // Replace dashes with spaces - - xml += '---\n'; - xml += `name: "${nameFromFile}"\n`; - xml += `description: "${metadata.title || 'BMAD Agent'}"\n`; - xml += '---\n\n'; - xml += - "You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.\n\n"; - } - - xml += '```xml\n'; - - // Agent opening tag - const agentAttrs = [ - `id="${metadata.id || ''}"`, - `name="${metadata.name || ''}"`, - `title="${metadata.title || ''}"`, - `icon="${metadata.icon || '🤖'}"`, - ]; - - // Add localskip attribute if present - if (metadata.localskip === true) { - agentAttrs.push('localskip="true"'); - } - - xml += `<agent ${agentAttrs.join(' ')}>\n`; - - // Activation block (only if not skipped) - if (activationBlock) { - xml += activationBlock + '\n'; - } - - // Persona section - xml += this.buildPersonaXml(agent.persona); - - // Memories section (if exists) - if (agent.memories) { - xml += this.buildMemoriesXml(agent.memories); - } - - // Prompts section (if exists) - if (agent.prompts) { - xml += this.buildPromptsXml(agent.prompts); - } - - // Menu section (support both 'menu' and legacy 'commands') - const menuItems = agent.menu || agent.commands || []; - xml += this.buildCommandsXml(menuItems, buildMetadata.forWebBundle); - - xml += '</agent>\n'; - xml += '```\n'; - - return xml; - } - - /** - * Build persona XML section - */ - buildPersonaXml(persona) { - if (!persona) return ''; - - let xml = ' <persona>\n'; - - if (persona.role) { - xml += ` <role>${escapeXml(persona.role)}</role>\n`; - } - - if (persona.identity) { - xml += ` <identity>${escapeXml(persona.identity)}</identity>\n`; - } - - if (persona.communication_style) { - xml += ` <communication_style>${escapeXml(persona.communication_style)}</communication_style>\n`; - } - - if (persona.principles) { - // Principles can be array or string - let principlesText; - if (Array.isArray(persona.principles)) { - principlesText = persona.principles.join(' '); - } else { - principlesText = persona.principles; - } - xml += ` <principles>${escapeXml(principlesText)}</principles>\n`; - } - - xml += ' </persona>\n'; - - return xml; - } - - /** - * Build memories XML section - */ - buildMemoriesXml(memories) { - if (!memories || memories.length === 0) return ''; - - let xml = ' <memories>\n'; - - for (const memory of memories) { - xml += ` <memory>${escapeXml(memory)}</memory>\n`; - } - - xml += ' </memories>\n'; - - return xml; - } - - /** - * Build prompts XML section - * Handles both array format and object/dictionary format - */ - buildPromptsXml(prompts) { - if (!prompts) return ''; - - // Handle object/dictionary format: { promptId: 'content', ... } - // Convert to array format for processing - let promptsArray = prompts; - if (!Array.isArray(prompts)) { - // Check if it's an object with no length property (dictionary format) - if (typeof prompts === 'object' && prompts.length === undefined) { - promptsArray = Object.entries(prompts).map(([id, content]) => ({ - id: id, - content: content, - })); - } else { - return ''; // Not a valid prompts format - } - } - - if (promptsArray.length === 0) return ''; - - let xml = ' <prompts>\n'; - - for (const prompt of promptsArray) { - xml += ` <prompt id="${prompt.id || ''}">\n`; - xml += ` <content>\n`; - xml += `${escapeXml(prompt.content || '')}\n`; - xml += ` </content>\n`; - xml += ` </prompt>\n`; - } - - xml += ' </prompts>\n'; - - return xml; - } - - /** - * Build menu XML section (renamed from commands for clarity) - * Auto-injects *help and *exit, adds * prefix to all triggers - * Supports both legacy format and new multi format with nested handlers - * @param {Array} menuItems - Menu items from YAML - * @param {boolean} forWebBundle - Whether building for web bundle - */ - buildCommandsXml(menuItems, forWebBundle = false) { - let xml = ' <menu>\n'; - - // Always inject menu display option first - xml += ` <item cmd="*menu">[M] Redisplay Menu Options</item>\n`; - - // Add user-defined menu items with * prefix - if (menuItems && menuItems.length > 0) { - for (const item of menuItems) { - // Skip ide-only items when building for web bundles - if (forWebBundle && item['ide-only'] === true) { - continue; - } - // Skip web-only items when NOT building for web bundles (i.e., IDE/local installation) - if (!forWebBundle && item['web-only'] === true) { - continue; - } - - // Handle multi format menu items with nested handlers - if (item.multi && item.triggers && Array.isArray(item.triggers)) { - xml += ` <item type="multi">${escapeXml(item.multi)}\n`; - xml += this.buildNestedHandlers(item.triggers); - xml += ` </item>\n`; - } - // Handle legacy format menu items - else if (item.trigger) { - // For legacy items, keep using cmd with *<trigger> format - let trigger = item.trigger || ''; - if (!trigger.startsWith('*')) { - trigger = '*' + trigger; - } - - const attrs = [`cmd="${trigger}"`]; - - // Add handler attributes - if (item['validate-workflow']) attrs.push(`validate-workflow="${item['validate-workflow']}"`); - if (item.exec) attrs.push(`exec="${item.exec}"`); - if (item.tmpl) attrs.push(`tmpl="${item.tmpl}"`); - if (item.data) attrs.push(`data="${item.data}"`); - if (item.action) attrs.push(`action="${item.action}"`); - - xml += ` <item ${attrs.join(' ')}>${escapeXml(item.description || '')}</item>\n`; - } - } - } - - // Always inject dismiss last - xml += ` <item cmd="*dismiss">[D] Dismiss Agent</item>\n`; - - xml += ' </menu>\n'; - - return xml; - } - - /** - * Build nested handlers for multi format menu items - * @param {Array} triggers - Triggers array from multi format - * @returns {string} Handler XML - */ - buildNestedHandlers(triggers) { - let xml = ''; - - for (const triggerGroup of triggers) { - for (const [triggerName, execArray] of Object.entries(triggerGroup)) { - // Build trigger with * prefix - let trigger = triggerName.startsWith('*') ? triggerName : '*' + triggerName; - - // Extract the relevant execution data - const execData = this.processExecArray(execArray); - - // For nested handlers in multi items, we don't need cmd attribute - // The match attribute will handle fuzzy matching - const attrs = [`match="${escapeXml(execData.description || '')}"`]; - - // Add handler attributes based on exec data - if (execData.route) attrs.push(`exec="${execData.route}"`); - if (execData.action) attrs.push(`action="${execData.action}"`); - if (execData.data) attrs.push(`data="${execData.data}"`); - if (execData.tmpl) attrs.push(`tmpl="${execData.tmpl}"`); - // Only add type if it's not 'exec' (exec is already implied by the exec attribute) - if (execData.type && execData.type !== 'exec') attrs.push(`type="${execData.type}"`); - - xml += ` <handler ${attrs.join(' ')}></handler>\n`; - } - } - - return xml; - } - - /** - * Process the execution array from multi format triggers - * Extracts relevant data for XML attributes - * @param {Array} execArray - Array of execution objects - * @returns {Object} Processed execution data - */ - processExecArray(execArray) { - const result = { - description: '', - route: null, - data: null, - action: null, - type: null, - }; - - if (!Array.isArray(execArray)) { - return result; - } - - for (const exec of execArray) { - if (exec.input) { - // Use input as description if no explicit description is provided - result.description = exec.input; - } - - if (exec.route) { - result.route = exec.route; - } - - if (exec.data !== null && exec.data !== undefined) { - result.data = exec.data; - } - - if (exec.action) { - result.action = exec.action; - } - - if (exec.type) { - result.type = exec.type; - } - } - - return result; - } - - /** - * Calculate file hash for build tracking - */ - async calculateFileHash(filePath) { - if (!(await fs.pathExists(filePath))) { - return null; - } - - const content = await fs.readFile(filePath, 'utf8'); - return crypto.createHash('md5').update(content).digest('hex').slice(0, 8); - } - - /** - * Build agent XML from YAML files and return as string (for in-memory use) - * @param {string} agentYamlPath - Path to agent YAML - * @param {string} customizeYamlPath - Path to customize YAML (optional) - * @param {Object} options - Build options - * @returns {Promise<string>} XML content as string - */ - async buildFromYaml(agentYamlPath, customizeYamlPath = null, options = {}) { - // Load and merge YAML files - const mergedAgent = await this.loadAndMergeAgent(agentYamlPath, customizeYamlPath); - - // Calculate hashes for build tracking - const sourceHash = await this.calculateFileHash(agentYamlPath); - const customizeHash = customizeYamlPath ? await this.calculateFileHash(customizeYamlPath) : null; - - // Extract module from path (e.g., /path/to/modules/bmm/agents/pm.yaml -> bmm) - // or /path/to/bmad/bmm/agents/pm.yaml -> bmm - // or /path/to/src/bmm-skills/agents/pm.yaml -> bmm - let module = 'core'; // default to core - const pathParts = agentYamlPath.split(path.sep); - - // Look for module indicators in the path - const modulesIndex = pathParts.indexOf('modules'); - const bmadIndex = pathParts.indexOf('bmad'); - const srcIndex = pathParts.indexOf('src'); - - if (modulesIndex !== -1 && pathParts[modulesIndex + 1]) { - // Path contains /modules/{module}/ - module = pathParts[modulesIndex + 1]; - } else if (bmadIndex !== -1 && pathParts[bmadIndex + 1]) { - // Path contains /bmad/{module}/ - const potentialModule = pathParts[bmadIndex + 1]; - // Check if it's a known module, not 'agents' or '_config' - if (['bmm', 'bmb', 'cis', 'core'].includes(potentialModule)) { - module = potentialModule; - } - } else if (srcIndex !== -1 && pathParts[srcIndex + 1]) { - // Path contains /src/{module}/ (bmm-skills and core-skills are directly under src/) - const potentialModule = pathParts[srcIndex + 1]; - if (potentialModule === 'bmm-skills') { - module = 'bmm'; - } else if (potentialModule === 'core-skills') { - module = 'core'; - } - } - - // Build metadata - const buildMetadata = { - sourceFile: path.basename(agentYamlPath), - sourceHash, - customizeFile: customizeYamlPath ? path.basename(customizeYamlPath) : null, - customizeHash, - builderVersion: '1.0.0', - includeMetadata: options.includeMetadata !== false, - skipActivation: options.skipActivation === true, - forWebBundle: options.forWebBundle === true, - module: module, // Add module to buildMetadata - }; - - // Convert to XML and return - return await this.convertToXml(mergedAgent, buildMetadata); - } - - /** - * Build agent XML from YAML files - * @param {string} agentYamlPath - Path to agent YAML - * @param {string} customizeYamlPath - Path to customize YAML (optional) - * @param {string} outputPath - Path to write XML file - * @param {Object} options - Build options - */ - async buildAgent(agentYamlPath, customizeYamlPath, outputPath, options = {}) { - // Use buildFromYaml to get XML content - const xml = await this.buildFromYaml(agentYamlPath, customizeYamlPath, options); - - // Write output file - await fs.ensureDir(path.dirname(outputPath)); - await fs.writeFile(outputPath, xml, 'utf8'); - - // Calculate hashes for return value - const sourceHash = await this.calculateFileHash(agentYamlPath); - const customizeHash = customizeYamlPath ? await this.calculateFileHash(customizeYamlPath) : null; - - return { - success: true, - outputPath, - sourceHash, - customizeHash, - }; - } -} - -module.exports = { YamlXmlBuilder };