Compare commits

...

4 Commits

Author SHA1 Message Date
Alex Verkhovsky ad9cb7a177
refactor(installer): remove dead .agent.yaml/.xml fallback logic (#2084) 2026-03-21 01:52:39 -06:00
Alex Verkhovsky 93a1e1dc46
refactor(installer): remove dead task/tool/workflow manifest code (#2083)
* refactor(installer): discover skills by SKILL.md instead of manifest YAML

Switch skill discovery gate from requiring bmad-skill-manifest.yaml with
type: skill to detecting any directory with a valid SKILL.md (frontmatter
name + description, name matches directory name). Delete 34 stub manifests
that carried no data beyond type: skill. Agent manifests (9) are retained
for persona metadata consumed by agent-manifest.csv.

* refactor(installer): remove dead task/tool/workflow manifest code

The remove-skill-manifest-yaml branch deleted the scanners that
discover tasks, tools, and workflows but left behind the code that
writes their manifest CSVs. Remove collectTasks/Tools/Workflows,
writeTaskManifest/ToolManifest/WorkflowManifest, their helpers, and
the now-unreachable getPreservedCsvRows/upgradeRowToSchema methods.
Update installer pre-registration and test assertions accordingly.
2026-03-21 00:12:40 -06:00
Alex Verkhovsky 31ae226bb4
refactor(installer): discover skills by SKILL.md instead of manifest YAML (#2082)
Switch skill discovery gate from requiring bmad-skill-manifest.yaml with
type: skill to detecting any directory with a valid SKILL.md (frontmatter
name + description, name matches directory name). Delete 34 stub manifests
that carried no data beyond type: skill. Agent manifests (9) are retained
for persona metadata consumed by agent-manifest.csv.
2026-03-21 00:11:23 -06:00
Alex Verkhovsky c28206dca4
refactor(installer): remove dead agent compilation pipeline (#2080)
* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>
2026-03-20 22:52:02 -06:00
58 changed files with 93 additions and 4525 deletions

View File

@ -127,7 +127,7 @@ prompts:
### 3. Appliquer vos modifications ### 3. Appliquer vos modifications
Après modification, recompilez l'agent pour appliquer les changements : Après modification, réinstallez pour appliquer les changements :
```bash ```bash
npx bmad-method install npx bmad-method install
@ -137,17 +137,16 @@ L'installateur détecte l'installation existante et propose ces options :
| Option | Ce qu'elle fait | | Option | Ce qu'elle fait |
| ----------------------------------- | ---------------------------------------------------------------------- | | ----------------------------------- | ---------------------------------------------------------------------- |
| **Quick Update** | Met à jour tous les modules vers la dernière version et recompile tous les agents | | **Quick Update** | Met à jour tous les modules vers la dernière version et applique les personnalisations |
| **Recompile Agents** | Applique uniquement les personnalisations, sans mettre à jour les fichiers de modules |
| **Modify BMad Installation** | Flux d'installation complet pour ajouter ou supprimer des modules | | **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 ## Résolution des problèmes
**Les modifications n'apparaissent pas ?** **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) - Vérifiez que votre syntaxe YAML est valide (l'indentation compte)
- Assurez-vous d'avoir modifié le bon fichier `.customize.yaml` pour l'agent - 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 ?** **Besoin de réinitialiser un agent ?**
- Effacez ou supprimez le fichier `.customize.yaml` de l'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 ## Personnalisation des workflows

View File

@ -28,7 +28,7 @@ Nécessite [Node.js](https://nodejs.org) v20+ et `npx` (inclus avec npm).
| `--modules <modules>` | IDs de modules séparés par des virgules | `--modules bmm,bmb` | | `--modules <modules>` | IDs de modules séparés par des virgules | `--modules bmm,bmb` |
| `--tools <outils>` | IDs d'outils/IDE séparés par des virgules (utilisez `none` pour ignorer) | `--tools claude-code,cursor` ou `--tools none` | | `--tools <outils>` | IDs d'outils/IDE séparés par des virgules (utilisez `none` pour ignorer) | `--tools claude-code,cursor` ou `--tools none` |
| `--custom-content <chemins>` | Chemins vers des modules personnalisés séparés par des virgules | `--custom-content ~/my-module,~/another-module` | | `--custom-content <chemins>` | Chemins vers des modules personnalisés séparés par des virgules | `--custom-content ~/my-module,~/another-module` |
| `--action <type>` | Action pour les installations existantes : `install` (par défaut), `update`, `quick-update`, ou `compile-agents` | `--action quick-update` | | `--action <type>` | Action pour les installations existantes : `install` (par défaut), `update`, ou `quick-update` | `--action quick-update` |
### Configuration principale ### Configuration principale
@ -121,7 +121,7 @@ npx bmad-method install \
## Ce que vous obtenez ## Ce que vous obtenez
- Un répertoire `_bmad/` entièrement configuré dans votre projet - 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 - Un dossier `_bmad-output/` pour les artefacts générés
## Validation et gestion des erreurs ## 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) - **Modules** — Avertit des IDs de modules invalides (mais n'échoue pas)
- **Tools** — Avertit des IDs d'outils 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 - **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 : Les valeurs invalides entraîneront soit :
1. Laffichage dun message d'erreur suivi dun exit (pour les options critiques comme le répertoire) 1. Laffichage dun message d'erreur suivi dun exit (pour les options critiques comme le répertoire)

View File

@ -128,7 +128,7 @@ prompts:
### 3. Apply Your Changes ### 3. Apply Your Changes
After editing, recompile the agent to apply changes: After editing, reinstall to apply changes:
```bash ```bash
npx bmad-method install npx bmad-method install
@ -138,17 +138,16 @@ The installer detects the existing installation and offers these options:
| Option | What It Does | | Option | What It Does |
| ---------------------------- | ------------------------------------------------------------------- | | ---------------------------- | ------------------------------------------------------------------- |
| **Quick Update** | Updates all modules to the latest version and recompiles all agents | | **Quick Update** | Updates all modules to the latest version and applies customizations |
| **Recompile Agents** | Applies customizations only, without updating module files |
| **Modify BMad Installation** | Full installation flow for adding or removing modules | | **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 ## Troubleshooting
**Changes not appearing?** **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) - Check that your YAML syntax is valid (indentation matters)
- Verify you edited the correct `.customize.yaml` file for the agent - 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?** **Need to reset an agent?**
- Clear or delete the agent's `.customize.yaml` file - 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 ## Workflow Customization

View File

@ -28,7 +28,7 @@ Requires [Node.js](https://nodejs.org) v20+ and `npx` (included with npm).
| `--modules <modules>` | Comma-separated module IDs | `--modules bmm,bmb` | | `--modules <modules>` | Comma-separated module IDs | `--modules bmm,bmb` |
| `--tools <tools>` | Comma-separated tool/IDE IDs (use `none` to skip) | `--tools claude-code,cursor` or `--tools none` | | `--tools <tools>` | Comma-separated tool/IDE IDs (use `none` to skip) | `--tools claude-code,cursor` or `--tools none` |
| `--custom-content <paths>` | Comma-separated paths to custom modules | `--custom-content ~/my-module,~/another-module` | | `--custom-content <paths>` | Comma-separated paths to custom modules | `--custom-content ~/my-module,~/another-module` |
| `--action <type>` | Action for existing installations: `install` (default), `update`, `quick-update`, or `compile-agents` | `--action quick-update` | | `--action <type>` | Action for existing installations: `install` (default), `update`, or `quick-update` | `--action quick-update` |
### Core Configuration ### Core Configuration
@ -121,7 +121,7 @@ npx bmad-method install \
## What You Get ## What You Get
- A fully configured `_bmad/` directory in your project - 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 - A `_bmad-output/` folder for generated artifacts
## Validation and Error Handling ## Validation and Error Handling
@ -132,7 +132,7 @@ BMad validates all provided flags:
- **Modules** — Warns about invalid module IDs (but won't fail) - **Modules** — Warns about invalid module IDs (but won't fail)
- **Tools** — Warns about invalid tool 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 - **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: Invalid values will either:
1. Show an error and exit (for critical options like directory) 1. Show an error and exit (for critical options like directory)

View File

@ -128,7 +128,7 @@ prompts:
### 3. 应用您的更改 ### 3. 应用您的更改
编辑后,重新编译智能体以应用更改: 编辑后,重新安装以应用更改:
```bash ```bash
npx bmad-method install npx bmad-method install
@ -138,17 +138,16 @@ npx bmad-method install
| Option | What It Does | | Option | What It Does |
| ---------------------------- | ------------------------------------------------------------------- | | ---------------------------- | ------------------------------------------------------------------- |
| **Quick Update** | 将所有模块更新到最新版本并重新编译所有智能体 | | **Quick Update** | 将所有模块更新到最新版本并应用自定义配置 |
| **Recompile Agents** | 仅应用自定义配置,不更新模块文件 |
| **Modify BMad Installation** | 用于添加或删除模块的完整安装流程 | | **Modify BMad Installation** | 用于添加或删除模块的完整安装流程 |
对于仅自定义配置的更改,**Recompile Agents** 是最快的选项。 对于仅自定义配置的更改,**Quick Update** 是最快的选项。
## 故障排除 ## 故障排除
**更改未生效?** **更改未生效?**
- 运行 `npx bmad-method install` 并选择 **Recompile Agents** 以应用更改 - 运行 `npx bmad-method install` 并选择 **Quick Update** 以应用更改
- 检查您的 YAML 语法是否有效(缩进很重要) - 检查您的 YAML 语法是否有效(缩进很重要)
- 验证您编辑的是该智能体正确的 `.customize.yaml` 文件 - 验证您编辑的是该智能体正确的 `.customize.yaml` 文件
@ -161,7 +160,7 @@ npx bmad-method install
**需要重置智能体?** **需要重置智能体?**
- 清空或删除智能体的 `.customize.yaml` 文件 - 清空或删除智能体的 `.customize.yaml` 文件
- 运行 `npx bmad-method install` 并选择 **Recompile Agents** 以恢复默认设置 - 运行 `npx bmad-method install` 并选择 **Quick Update** 以恢复默认设置
## 工作流自定义 ## 工作流自定义

View File

@ -28,7 +28,7 @@ sidebar:
| `--modules <modules>` | 逗号分隔的模块 ID | `--modules bmm,bmb` | | `--modules <modules>` | 逗号分隔的模块 ID | `--modules bmm,bmb` |
| `--tools <tools>` | 逗号分隔的工具/IDE ID使用 `none` 跳过) | `--tools claude-code,cursor``--tools none` | | `--tools <tools>` | 逗号分隔的工具/IDE ID使用 `none` 跳过) | `--tools claude-code,cursor``--tools none` |
| `--custom-content <paths>` | 逗号分隔的自定义模块路径 | `--custom-content ~/my-module,~/another-module` | | `--custom-content <paths>` | 逗号分隔的自定义模块路径 | `--custom-content ~/my-module,~/another-module` |
| `--action <type>` | 对现有安装的操作:`install`(默认)、`update`、`quick-update` 或 `compile-agents` | `--action quick-update` | | `--action <type>` | 对现有安装的操作:`install`(默认)、`update``quick-update` | `--action quick-update` |
### 核心配置 ### 核心配置
@ -121,7 +121,7 @@ npx bmad-method install \
## 安装结果 ## 安装结果
- 项目中完全配置的 `_bmad/` 目录 - 项目中完全配置的 `_bmad/` 目录
- 为所选模块和工具编译的智能体和工作流 - 为所选模块和工具配置的智能体和工作流
- 用于生成产物的 `_bmad-output/` 文件夹 - 用于生成产物的 `_bmad-output/` 文件夹
## 验证和错误处理 ## 验证和错误处理
@ -132,7 +132,7 @@ BMad 会验证所有提供的标志:
- **模块** — 对无效的模块 ID 发出警告(但不会失败) - **模块** — 对无效的模块 ID 发出警告(但不会失败)
- **工具** — 对无效的工具 ID 发出警告(但不会失败) - **工具** — 对无效的工具 ID 发出警告(但不会失败)
- **自定义内容** — 每个路径必须包含有效的 `module.yaml` 文件 - **自定义内容** — 每个路径必须包含有效的 `module.yaml` 文件
- **操作** — 必须是以下之一:`install`、`update`、`quick-update`、`compile-agents` - **操作** — 必须是以下之一:`install`、`update`、`quick-update`
无效值将: 无效值将:
1. 显示错误并退出(对于目录等关键选项) 1. 显示错误并退出(对于目录等关键选项)

View File

@ -1 +0,0 @@
type: skill

View File

@ -1,15 +0,0 @@
type: skill
module: core
capabilities:
- name: bmad-distillator
menu-code: DSTL
description: "Produces lossless LLM-optimized distillate from source documents. Use after producing large human presentable documents that will be consumed later by LLMs"
supports-headless: true
input: source documents
args: output, validate
output: single distillate or folder of distillates next to source input
config-vars-used: null
phase: anytime
before: []
after: []
is-required: false

View File

@ -1 +0,0 @@
type: skill

View File

@ -1 +0,0 @@
type: skill

View File

@ -1 +0,0 @@
type: skill

View File

@ -1 +0,0 @@
type: skill

View File

@ -1 +0,0 @@
type: skill

View File

@ -14,7 +14,6 @@
const path = require('node:path'); const path = require('node:path');
const os = require('node:os'); const os = require('node:os');
const fs = require('fs-extra'); 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 { ManifestGenerator } = require('../tools/cli/installers/lib/core/manifest-generator');
const { IdeManager } = require('../tools/cli/installers/lib/ide/manager'); const { IdeManager } = require('../tools/cli/installers/lib/ide/manager');
const { clearCache, loadPlatformCodes } = require('../tools/cli/installers/lib/ide/platform-codes'); const { clearCache, loadPlatformCodes } = require('../tools/cli/installers/lib/ide/platform-codes');
@ -79,7 +78,6 @@ async function createTestBmadFixture() {
'You are a test agent.', 'You are a test agent.',
].join('\n'), ].join('\n'),
); );
await fs.writeFile(path.join(skillDir, 'bmad-skill-manifest.yaml'), 'SKILL.md:\n type: skill\n');
await fs.writeFile(path.join(skillDir, 'workflow.md'), '# Test Workflow\nStep 1: Do the thing.\n'); await fs.writeFile(path.join(skillDir, 'workflow.md'), '# Test Workflow\nStep 1: Do the thing.\n');
return fixtureDir; return fixtureDir;
@ -100,17 +98,6 @@ async function createSkillCollisionFixture() {
].join('\n'), ].join('\n'),
); );
await fs.writeFile(
path.join(configDir, 'workflow-manifest.csv'),
[
'name,description,module,path,canonicalId',
'"help","Workflow help","core","_bmad/core/workflows/help/workflow.md","bmad-help"',
'',
].join('\n'),
);
await fs.writeFile(path.join(configDir, 'task-manifest.csv'), 'name,displayName,description,module,path,standalone,canonicalId\n');
await fs.writeFile(path.join(configDir, 'tool-manifest.csv'), 'name,displayName,description,module,path,standalone,canonicalId\n');
await fs.writeFile( await fs.writeFile(
path.join(configDir, 'skill-manifest.csv'), path.join(configDir, 'skill-manifest.csv'),
[ [
@ -149,77 +136,10 @@ async function runTests() {
const projectRoot = path.join(__dirname, '..'); 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`); console.log(`${colors.yellow}Test Suite 1: Windsurf Native Skills${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`);
try { try {
clearCache(); clearCache();
@ -1603,7 +1523,6 @@ async function runTests() {
// --- Skill at unusual path: core/custom-area/my-skill/ --- // --- Skill at unusual path: core/custom-area/my-skill/ ---
const skillDir29 = path.join(tempFixture29, 'core', 'custom-area', 'my-skill'); const skillDir29 = path.join(tempFixture29, 'core', 'custom-area', 'my-skill');
await fs.ensureDir(skillDir29); await fs.ensureDir(skillDir29);
await fs.writeFile(path.join(skillDir29, 'bmad-skill-manifest.yaml'), 'type: skill\n');
await fs.writeFile( await fs.writeFile(
path.join(skillDir29, 'SKILL.md'), path.join(skillDir29, 'SKILL.md'),
'---\nname: my-skill\ndescription: A skill at an unusual path\n---\n\nFollow the instructions in [workflow.md](workflow.md).\n', '---\nname: my-skill\ndescription: A skill at an unusual path\n---\n\nFollow the instructions in [workflow.md](workflow.md).\n',
@ -1619,10 +1538,9 @@ async function runTests() {
'---\nname: Regular Workflow\ndescription: A regular workflow not a skill\n---\n\nWorkflow body\n', '---\nname: Regular Workflow\ndescription: A regular workflow not a skill\n---\n\nWorkflow body\n',
); );
// --- Skill inside workflows/ dir: core/workflows/wf-skill/ (exercises findWorkflows skip logic) --- // --- Skill inside workflows/ dir: core/workflows/wf-skill/ ---
const wfSkillDir29 = path.join(tempFixture29, 'core', 'workflows', 'wf-skill'); const wfSkillDir29 = path.join(tempFixture29, 'core', 'workflows', 'wf-skill');
await fs.ensureDir(wfSkillDir29); await fs.ensureDir(wfSkillDir29);
await fs.writeFile(path.join(wfSkillDir29, 'bmad-skill-manifest.yaml'), 'type: skill\n');
await fs.writeFile( await fs.writeFile(
path.join(wfSkillDir29, 'SKILL.md'), path.join(wfSkillDir29, 'SKILL.md'),
'---\nname: wf-skill\ndescription: A skill inside workflows dir\n---\n\nFollow the instructions in [workflow.md](workflow.md).\n', '---\nname: wf-skill\ndescription: A skill inside workflows dir\n---\n\nFollow the instructions in [workflow.md](workflow.md).\n',
@ -1632,7 +1550,6 @@ async function runTests() {
// --- Skill inside tasks/ dir: core/tasks/task-skill/ --- // --- Skill inside tasks/ dir: core/tasks/task-skill/ ---
const taskSkillDir29 = path.join(tempFixture29, 'core', 'tasks', 'task-skill'); const taskSkillDir29 = path.join(tempFixture29, 'core', 'tasks', 'task-skill');
await fs.ensureDir(taskSkillDir29); await fs.ensureDir(taskSkillDir29);
await fs.writeFile(path.join(taskSkillDir29, 'bmad-skill-manifest.yaml'), 'type: skill\n');
await fs.writeFile( await fs.writeFile(
path.join(taskSkillDir29, 'SKILL.md'), path.join(taskSkillDir29, 'SKILL.md'),
'---\nname: task-skill\ndescription: A skill inside tasks dir\n---\n\nFollow the instructions in [workflow.md](workflow.md).\n', '---\nname: task-skill\ndescription: A skill inside tasks dir\n---\n\nFollow the instructions in [workflow.md](workflow.md).\n',
@ -1665,18 +1582,10 @@ async function runTests() {
'Skill path includes relative path from module root', 'Skill path includes relative path from module root',
); );
// Skill should NOT be in workflows
const inWorkflows29 = generator29.workflows.find((w) => w.name === 'my-skill');
assert(inWorkflows29 === undefined, 'Skill at unusual path does NOT appear in workflows[]');
// Skill in tasks/ dir should be in skills // Skill in tasks/ dir should be in skills
const taskSkillEntry29 = generator29.skills.find((s) => s.canonicalId === 'task-skill'); const taskSkillEntry29 = generator29.skills.find((s) => s.canonicalId === 'task-skill');
assert(taskSkillEntry29 !== undefined, 'Skill in tasks/ dir appears in skills[]'); assert(taskSkillEntry29 !== undefined, 'Skill in tasks/ dir appears in skills[]');
// Skill in tasks/ should NOT appear in tasks[]
const inTasks29 = generator29.tasks.find((t) => t.name === 'task-skill');
assert(inTasks29 === undefined, 'Skill in tasks/ dir does NOT appear in tasks[]');
// Native agent entrypoint should be installed as a verbatim skill and also // Native agent entrypoint should be installed as a verbatim skill and also
// remain visible to the agent manifest pipeline. // remain visible to the agent manifest pipeline.
const nativeAgentEntry29 = generator29.skills.find((s) => s.canonicalId === 'bmad-tea'); const nativeAgentEntry29 = generator29.skills.find((s) => s.canonicalId === 'bmad-tea');
@ -1688,23 +1597,17 @@ async function runTests() {
const nativeAgentManifest29 = generator29.agents.find((a) => a.name === 'bmad-tea'); const nativeAgentManifest29 = generator29.agents.find((a) => a.name === 'bmad-tea');
assert(nativeAgentManifest29 !== undefined, 'Native type:agent SKILL.md dir appears in agents[] for agent metadata'); assert(nativeAgentManifest29 !== undefined, 'Native type:agent SKILL.md dir appears in agents[] for agent metadata');
// Regular workflow should be in workflows, NOT in skills // Regular type:workflow should NOT appear in skills[]
const regularWf29 = generator29.workflows.find((w) => w.name === 'Regular Workflow');
assert(regularWf29 !== undefined, 'Regular type:workflow appears in workflows[]');
const regularInSkills29 = generator29.skills.find((s) => s.canonicalId === 'regular-wf'); const regularInSkills29 = generator29.skills.find((s) => s.canonicalId === 'regular-wf');
assert(regularInSkills29 === undefined, 'Regular type:workflow does NOT appear in skills[]'); assert(regularInSkills29 === undefined, 'Regular type:workflow does NOT appear in skills[]');
// Skill inside workflows/ should be in skills[], NOT in workflows[] (exercises findWorkflows skip at lines 311/322) // Skill inside workflows/ should be in skills[]
const wfSkill29 = generator29.skills.find((s) => s.canonicalId === 'wf-skill'); const wfSkill29 = generator29.skills.find((s) => s.canonicalId === 'wf-skill');
assert(wfSkill29 !== undefined, 'Skill in workflows/ dir appears in skills[]'); assert(wfSkill29 !== undefined, 'Skill in workflows/ dir appears in skills[]');
const wfSkillInWorkflows29 = generator29.workflows.find((w) => w.name === 'wf-skill');
assert(wfSkillInWorkflows29 === undefined, 'Skill in workflows/ dir does NOT appear in workflows[]');
// Test scanInstalledModules recognizes skill-only modules // Test scanInstalledModules recognizes skill-only modules
const skillOnlyModDir29 = path.join(tempFixture29, 'skill-only-mod'); const skillOnlyModDir29 = path.join(tempFixture29, 'skill-only-mod');
await fs.ensureDir(path.join(skillOnlyModDir29, 'deep', 'nested', 'my-skill')); await fs.ensureDir(path.join(skillOnlyModDir29, 'deep', 'nested', 'my-skill'));
await fs.writeFile(path.join(skillOnlyModDir29, 'deep', 'nested', 'my-skill', 'bmad-skill-manifest.yaml'), 'type: skill\n');
await fs.writeFile( await fs.writeFile(
path.join(skillOnlyModDir29, 'deep', 'nested', 'my-skill', 'SKILL.md'), path.join(skillOnlyModDir29, 'deep', 'nested', 'my-skill', 'SKILL.md'),
'---\nname: my-skill\ndescription: desc\n---\n\nFollow the instructions in [workflow.md](workflow.md).\n', '---\nname: my-skill\ndescription: desc\n---\n\nFollow the instructions in [workflow.md](workflow.md).\n',

View File

@ -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.', 'Comma-separated list of tool/IDE IDs to configure (e.g., "claude-code,cursor"). Use "none" to skip tool configuration.',
], ],
['--custom-content <paths>', 'Comma-separated list of paths to custom modules/agents/workflows'], ['--custom-content <paths>', 'Comma-separated list of paths to custom modules/agents/workflows'],
['--action <type>', 'Action type for existing installations: install, update, quick-update, or compile-agents'], ['--action <type>', 'Action type for existing installations: install, update, or quick-update'],
['--user-name <name>', 'Name for agents to use (default: system username)'], ['--user-name <name>', 'Name for agents to use (default: system username)'],
['--communication-language <lang>', 'Language for agent communication (default: English)'], ['--communication-language <lang>', 'Language for agent communication (default: English)'],
['--document-output-language <lang>', 'Language for document output (default: English)'], ['--document-output-language <lang>', 'Language for document output (default: English)'],
@ -49,13 +49,6 @@ module.exports = {
process.exit(0); 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 // Regular install/update flow
const result = await installer.install(config); const result = await installer.install(config);

View File

@ -6,7 +6,6 @@ const { ModuleManager } = require('../modules/manager');
const { IdeManager } = require('../ide/manager'); const { IdeManager } = require('../ide/manager');
const { FileOps } = require('../../../lib/file-ops'); const { FileOps } = require('../../../lib/file-ops');
const { Config } = require('../../../lib/config'); const { Config } = require('../../../lib/config');
const { XmlHandler } = require('../../../lib/xml-handler');
const { DependencyResolver } = require('./dependency-resolver'); const { DependencyResolver } = require('./dependency-resolver');
const { ConfigCollector } = require('./config-collector'); const { ConfigCollector } = require('./config-collector');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
@ -25,7 +24,6 @@ class Installer {
this.ideManager = new IdeManager(); this.ideManager = new IdeManager();
this.fileOps = new FileOps(); this.fileOps = new FileOps();
this.config = new Config(); this.config = new Config();
this.xmlHandler = new XmlHandler();
this.dependencyResolver = new DependencyResolver(); this.dependencyResolver = new DependencyResolver();
this.configCollector = new ConfigCollector(); this.configCollector = new ConfigCollector();
this.ideConfigManager = new IdeConfigManager(); this.ideConfigManager = new IdeConfigManager();
@ -1126,11 +1124,9 @@ class Installer {
// Pre-register manifest files // Pre-register manifest files
const cfgDir = path.join(bmadDir, '_config'); const cfgDir = path.join(bmadDir, '_config');
this.installedFiles.add(path.join(cfgDir, 'manifest.yaml')); this.installedFiles.add(path.join(cfgDir, 'manifest.yaml'));
this.installedFiles.add(path.join(cfgDir, 'workflow-manifest.csv'));
this.installedFiles.add(path.join(cfgDir, 'agent-manifest.csv')); this.installedFiles.add(path.join(cfgDir, 'agent-manifest.csv'));
this.installedFiles.add(path.join(cfgDir, 'task-manifest.csv'));
// Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes // Generate CSV manifests for agents, skills AND ALL FILES with hashes
// This must happen BEFORE mergeModuleHelpCatalogs because it depends on agent-manifest.csv // This must happen BEFORE mergeModuleHelpCatalogs because it depends on agent-manifest.csv
message('Generating manifests...'); message('Generating manifests...');
const manifestGen = new ManifestGenerator(); const manifestGen = new ManifestGenerator();
@ -2114,10 +2110,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 // Dependencies are already included in full module install
} }
@ -2227,16 +2219,8 @@ class Installer {
const sourcePath = getModulePath('core'); const sourcePath = getModulePath('core');
const targetPath = path.join(bmadDir, '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); 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 +2238,6 @@ class Installer {
continue; 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 // Skip module.yaml at root - it's only needed at install time
if (file === 'module.yaml') { if (file === 'module.yaml') {
continue; continue;
@ -2274,27 +2248,9 @@ class Installer {
continue; continue;
} }
// Skip .agent.yaml files - they will be compiled separately
if (file.endsWith('.agent.yaml')) {
continue;
}
const sourceFile = path.join(sourcePath, file); const sourceFile = path.join(sourcePath, file);
const targetFile = path.join(targetPath, 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(/<agent[^>]*\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 // Copy the file with placeholder replacement
await fs.ensureDir(path.dirname(targetFile)); await fs.ensureDir(path.dirname(targetFile));
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile); await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
@ -2328,58 +2284,6 @@ class Installer {
return files; 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 * Private: Update core
*/ */
@ -2393,12 +2297,6 @@ class Installer {
} else { } else {
// Selective update - preserve user modifications // Selective update - preserve user modifications
await this.fileOps.syncDirectory(sourcePath, targetPath); 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 +2541,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 * Private: Prompt for update action
*/ */

View File

@ -16,15 +16,12 @@ const {
const packageJson = require('../../../../../package.json'); const packageJson = require('../../../../../package.json');
/** /**
* Generates manifest files for installed workflows, agents, and tasks * Generates manifest files for installed skills and agents
*/ */
class ManifestGenerator { class ManifestGenerator {
constructor() { constructor() {
this.workflows = [];
this.skills = []; this.skills = [];
this.agents = []; this.agents = [];
this.tasks = [];
this.tools = [];
this.modules = []; this.modules = [];
this.files = []; this.files = [];
this.selectedIdes = []; this.selectedIdes = [];
@ -50,29 +47,6 @@ class ManifestGenerator {
return getInstallToBmadShared(manifest, filename); return getInstallToBmadShared(manifest, filename);
} }
/**
* Native SKILL.md entrypoints can be packaged as either skills or agents.
* Both need verbatim installation for skill-format IDEs.
* @param {string|null} artifactType - Manifest type resolved for SKILL.md
* @returns {boolean} True when the directory should be installed verbatim
*/
isNativeSkillDirType(artifactType) {
return artifactType === 'skill' || artifactType === 'agent';
}
/**
* Check whether a loaded bmad-skill-manifest.yaml declares a native
* SKILL.md entrypoint, either as a single-entry manifest or a multi-entry map.
* @param {Object|null} manifest - Loaded manifest
* @returns {boolean} True when the manifest contains a native skill/agent entrypoint
*/
hasNativeSkillManifest(manifest) {
if (!manifest) return false;
if (manifest.__single) return this.isNativeSkillDirType(manifest.__single.type);
return Object.values(manifest).some((entry) => this.isNativeSkillDirType(entry?.type));
}
/** /**
* Clean text for CSV output by normalizing whitespace. * Clean text for CSV output by normalizing whitespace.
* Note: Quote escaping is handled by escapeCsv() at write time. * Note: Quote escaping is handled by escapeCsv() at write time.
@ -108,10 +82,6 @@ class ManifestGenerator {
this.modules = allModules; this.modules = allModules;
this.updatedModules = allModules; // Include ALL modules (including custom) for scanning this.updatedModules = allModules; // Include ALL modules (including custom) for scanning
// For CSV manifests, we need to include ALL modules that are installed
// preservedModules controls which modules stay as-is in the CSV (don't get rescanned)
// But all modules should be included in the final manifest
this.preservedModules = allModules; // Include ALL modules (including custom)
this.bmadDir = bmadDir; this.bmadDir = bmadDir;
this.bmadFolderName = path.basename(bmadDir); // Get the actual folder name (e.g., '_bmad' or 'bmad') this.bmadFolderName = path.basename(bmadDir); // Get the actual folder name (e.g., '_bmad' or 'bmad')
this.allInstalledFiles = installedFiles; this.allInstalledFiles = installedFiles;
@ -134,35 +104,20 @@ class ManifestGenerator {
// Collect skills first (populates skillClaimedDirs before legacy collectors run) // Collect skills first (populates skillClaimedDirs before legacy collectors run)
await this.collectSkills(); await this.collectSkills();
// Collect workflow data
await this.collectWorkflows(selectedModules);
// Collect agent data - use updatedModules which includes all installed modules // Collect agent data - use updatedModules which includes all installed modules
await this.collectAgents(this.updatedModules); await this.collectAgents(this.updatedModules);
// Collect task data
await this.collectTasks(this.updatedModules);
// Collect tool data
await this.collectTools(this.updatedModules);
// Write manifest files and collect their paths // Write manifest files and collect their paths
const manifestFiles = [ const manifestFiles = [
await this.writeMainManifest(cfgDir), await this.writeMainManifest(cfgDir),
await this.writeWorkflowManifest(cfgDir),
await this.writeSkillManifest(cfgDir), await this.writeSkillManifest(cfgDir),
await this.writeAgentManifest(cfgDir), await this.writeAgentManifest(cfgDir),
await this.writeTaskManifest(cfgDir),
await this.writeToolManifest(cfgDir),
await this.writeFilesManifest(cfgDir), await this.writeFilesManifest(cfgDir),
]; ];
return { return {
skills: this.skills.length, skills: this.skills.length,
workflows: this.workflows.length,
agents: this.agents.length, agents: this.agents.length,
tasks: this.tasks.length,
tools: this.tools.length,
files: this.files.length, files: this.files.length,
manifestFiles: manifestFiles, manifestFiles: manifestFiles,
}; };
@ -170,9 +125,9 @@ class ManifestGenerator {
/** /**
* Recursively walk a module directory tree, collecting native SKILL.md entrypoints. * Recursively walk a module directory tree, collecting native SKILL.md entrypoints.
* A native entrypoint directory is one that contains both a * A directory is discovered as a skill when it contains a SKILL.md file with
* bmad-skill-manifest.yaml with type: skill or type: agent AND a SKILL.md file * valid name/description frontmatter (name must match directory name).
* with name/description frontmatter. * Manifest YAML is loaded only when present for install_to_bmad and agent metadata.
* Populates this.skills[] and this.skillClaimedDirs (Set of absolute paths). * Populates this.skills[] and this.skillClaimedDirs (Set of absolute paths).
*/ */
async collectSkills() { async collectSkills() {
@ -193,77 +148,55 @@ class ManifestGenerator {
return; return;
} }
// Check this directory for skill manifest // SKILL.md with valid frontmatter is the primary discovery gate
const manifest = await this.loadSkillManifest(dir);
// Determine if this directory is a native SKILL.md entrypoint
const skillFile = 'SKILL.md'; const skillFile = 'SKILL.md';
const artifactType = this.getArtifactType(manifest, skillFile); const skillMdPath = path.join(dir, skillFile);
const dirName = path.basename(dir);
if (this.isNativeSkillDirType(artifactType)) { const skillMeta = await this.parseSkillMd(skillMdPath, dir, dirName, debug);
const skillMdPath = path.join(dir, 'SKILL.md');
const dirName = path.basename(dir);
// Validate and parse SKILL.md if (skillMeta) {
const skillMeta = await this.parseSkillMd(skillMdPath, dir, dirName, debug); // Load manifest when present (for install_to_bmad and agent metadata)
const manifest = await this.loadSkillManifest(dir);
const artifactType = this.getArtifactType(manifest, skillFile);
if (skillMeta) { // Build path relative from module root (points to SKILL.md — the permanent entrypoint)
// Build path relative from module root (points to SKILL.md — the permanent entrypoint) const relativePath = path.relative(modulePath, dir).split(path.sep).join('/');
const relativePath = path.relative(modulePath, dir).split(path.sep).join('/'); const installPath = relativePath
const installPath = relativePath ? `${this.bmadFolderName}/${moduleName}/${relativePath}/${skillFile}`
? `${this.bmadFolderName}/${moduleName}/${relativePath}/${skillFile}` : `${this.bmadFolderName}/${moduleName}/${skillFile}`;
: `${this.bmadFolderName}/${moduleName}/${skillFile}`;
// Native SKILL.md entrypoints derive canonicalId from directory name. // Native SKILL.md entrypoints derive canonicalId from directory name.
// Agent entrypoints may keep canonicalId metadata for compatibility, so // Agent entrypoints may keep canonicalId metadata for compatibility, so
// only warn for non-agent SKILL.md directories. // only warn for non-agent SKILL.md directories.
if (manifest && manifest.__single && manifest.__single.canonicalId && artifactType !== 'agent') { if (manifest && manifest.__single && manifest.__single.canonicalId && artifactType !== 'agent') {
console.warn( console.warn(
`Warning: Native entrypoint manifest at ${dir}/bmad-skill-manifest.yaml contains canonicalId — this field is ignored for SKILL.md directories (directory name is the canonical ID)`, `Warning: Native entrypoint manifest at ${dir}/bmad-skill-manifest.yaml contains canonicalId — this field is ignored for SKILL.md directories (directory name is the canonical ID)`,
); );
}
const canonicalId = dirName;
this.skills.push({
name: skillMeta.name,
description: this.cleanForCSV(skillMeta.description),
module: moduleName,
path: installPath,
canonicalId,
install_to_bmad: this.getInstallToBmad(manifest, skillFile),
});
// Add to files list
this.files.push({
type: 'skill',
name: skillMeta.name,
module: moduleName,
path: installPath,
});
this.skillClaimedDirs.add(dir);
if (debug) {
console.log(`[DEBUG] collectSkills: claimed skill "${skillMeta.name}" as ${canonicalId} at ${dir}`);
}
} }
} const canonicalId = dirName;
// Warn if manifest says this is a native entrypoint but the directory was not claimed this.skills.push({
if (manifest && !this.skillClaimedDirs.has(dir)) { name: skillMeta.name,
let hasNativeSkillType = false; description: this.cleanForCSV(skillMeta.description),
if (manifest.__single) { module: moduleName,
hasNativeSkillType = this.isNativeSkillDirType(manifest.__single.type); path: installPath,
} else { canonicalId,
for (const key of Object.keys(manifest)) { install_to_bmad: this.getInstallToBmad(manifest, skillFile),
if (this.isNativeSkillDirType(manifest[key]?.type)) { });
hasNativeSkillType = true;
break; // Add to files list
} this.files.push({
} type: 'skill',
} name: skillMeta.name,
if (hasNativeSkillType && debug) { module: moduleName,
console.log(`[DEBUG] collectSkills: dir has native SKILL.md manifest but failed validation: ${dir}`); path: installPath,
});
this.skillClaimedDirs.add(dir);
if (debug) {
console.log(`[DEBUG] collectSkills: claimed skill "${skillMeta.name}" as ${canonicalId} at ${dir}`);
} }
} }
@ -334,153 +267,6 @@ class ManifestGenerator {
} }
} }
/**
* Collect all workflows from core and selected modules
* Scans the INSTALLED bmad directory, not the source
*/
async collectWorkflows(selectedModules) {
this.workflows = [];
// Use updatedModules which already includes deduplicated 'core' + selectedModules
for (const moduleName of this.updatedModules) {
const modulePath = path.join(this.bmadDir, moduleName);
if (await fs.pathExists(modulePath)) {
const moduleWorkflows = await this.getWorkflowsFromPath(modulePath, moduleName);
this.workflows.push(...moduleWorkflows);
// Also scan tasks/ for type:skill entries (skills can live anywhere)
const tasksSkills = await this.getWorkflowsFromPath(modulePath, moduleName, 'tasks');
this.workflows.push(...tasksSkills);
}
}
}
/**
* Recursively find and parse workflow.md files
*/
async getWorkflowsFromPath(basePath, moduleName, subDir = 'workflows') {
const workflows = [];
const workflowsPath = path.join(basePath, subDir);
const debug = process.env.BMAD_DEBUG_MANIFEST === 'true';
if (debug) {
console.log(`[DEBUG] Scanning workflows in: ${workflowsPath}`);
}
if (!(await fs.pathExists(workflowsPath))) {
if (debug) {
console.log(`[DEBUG] Workflows path does not exist: ${workflowsPath}`);
}
return workflows;
}
// Recursively find workflow.md files
const findWorkflows = async (dir, relativePath = '') => {
// Skip directories already claimed as skills
if (this.skillClaimedDirs && this.skillClaimedDirs.has(dir)) return;
const entries = await fs.readdir(dir, { withFileTypes: true });
// Load skill manifest for this directory (if present)
const skillManifest = await this.loadSkillManifest(dir);
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Skip directories claimed by collectSkills
if (this.skillClaimedDirs && this.skillClaimedDirs.has(fullPath)) continue;
// Recurse into subdirectories
const newRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
await findWorkflows(fullPath, newRelativePath);
} else if (entry.name === 'workflow.md' || (entry.name.startsWith('workflow-') && entry.name.endsWith('.md'))) {
// Parse workflow file (both YAML and MD formats)
if (debug) {
console.log(`[DEBUG] Found workflow file: ${fullPath}`);
}
try {
// Read and normalize line endings (fix Windows CRLF issues)
const rawContent = await fs.readFile(fullPath, 'utf8');
const content = rawContent.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
// Parse MD workflow with YAML frontmatter
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch) {
if (debug) {
console.log(`[DEBUG] Skipped (no frontmatter): ${fullPath}`);
}
continue; // Skip MD files without frontmatter
}
const workflow = yaml.parse(frontmatterMatch[1]);
if (debug) {
console.log(`[DEBUG] Parsed: name="${workflow.name}", description=${workflow.description ? 'OK' : 'MISSING'}`);
}
// Skip template workflows (those with placeholder values)
if (workflow.name && workflow.name.includes('{') && workflow.name.includes('}')) {
if (debug) {
console.log(`[DEBUG] Skipped (template placeholder): ${workflow.name}`);
}
continue;
}
// Skip workflows marked as non-standalone (reference/example workflows)
if (workflow.standalone === false) {
if (debug) {
console.log(`[DEBUG] Skipped (standalone=false): ${workflow.name}`);
}
continue;
}
if (workflow.name && workflow.description) {
// Build relative path for installation
const installPath =
moduleName === 'core'
? `${this.bmadFolderName}/core/${subDir}/${relativePath}/${entry.name}`
: `${this.bmadFolderName}/${moduleName}/${subDir}/${relativePath}/${entry.name}`;
// Workflows with standalone: false are filtered out above
workflows.push({
name: workflow.name,
description: this.cleanForCSV(workflow.description),
module: moduleName,
path: installPath,
canonicalId: this.getCanonicalId(skillManifest, entry.name),
});
// Add to files list
this.files.push({
type: 'workflow',
name: workflow.name,
module: moduleName,
path: installPath,
});
if (debug) {
console.log(`[DEBUG] ✓ Added workflow: ${workflow.name} (${moduleName})`);
}
} else {
if (debug) {
console.log(`[DEBUG] Skipped (missing name or description): ${fullPath}`);
}
}
} catch (error) {
await prompts.log.warn(`Failed to parse workflow at ${fullPath}: ${error.message}`);
}
}
}
};
await findWorkflows(workflowsPath);
if (debug) {
console.log(`[DEBUG] Total workflows found in ${moduleName}: ${workflows.length}`);
}
return workflows;
}
/** /**
* Collect all agents from core and selected modules * Collect all agents from core and selected modules
* Scans the INSTALLED bmad directory, not the source * Scans the INSTALLED bmad directory, not the source
@ -515,7 +301,7 @@ class ManifestGenerator {
/** /**
* Get agents from a directory recursively * 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 = '') { async getAgentsFromDir(dirPath, moduleName, relativePath = '') {
// Skip directories claimed by collectSkills // Skip directories claimed by collectSkills
@ -572,7 +358,7 @@ class ManifestGenerator {
const newRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name; const newRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
const subDirAgents = await this.getAgentsFromDir(fullPath, moduleName, newRelativePath); const subDirAgents = await this.getAgentsFromDir(fullPath, moduleName, newRelativePath);
agents.push(...subDirAgents); 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'); const content = await fs.readFile(fullPath, 'utf8');
// Skip files that don't contain <agent> tag (e.g., README files) // Skip files that don't contain <agent> tag (e.g., README files)
@ -634,212 +420,6 @@ class ManifestGenerator {
return agents; return agents;
} }
/**
* Collect all tasks from core and selected modules
* Scans the INSTALLED bmad directory, not the source
*/
async collectTasks(selectedModules) {
this.tasks = [];
// Use updatedModules which already includes deduplicated 'core' + selectedModules
for (const moduleName of this.updatedModules) {
const tasksPath = path.join(this.bmadDir, moduleName, 'tasks');
if (await fs.pathExists(tasksPath)) {
const moduleTasks = await this.getTasksFromDir(tasksPath, moduleName);
this.tasks.push(...moduleTasks);
}
}
}
/**
* Get tasks from a directory
*/
async getTasksFromDir(dirPath, moduleName) {
// Skip directories claimed by collectSkills
if (this.skillClaimedDirs && this.skillClaimedDirs.has(dirPath)) return [];
const tasks = [];
const files = await fs.readdir(dirPath);
// Load skill manifest for this directory (if present)
const skillManifest = await this.loadSkillManifest(dirPath);
for (const file of files) {
// Check for both .xml and .md files
if (file.endsWith('.xml') || file.endsWith('.md')) {
const filePath = path.join(dirPath, file);
const content = await fs.readFile(filePath, 'utf8');
// Skip internal/engine files (not user-facing tasks)
if (content.includes('internal="true"')) {
continue;
}
let name = file.replace(/\.(xml|md)$/, '');
let displayName = name;
let description = '';
let standalone = false;
if (file.endsWith('.md')) {
// Parse YAML frontmatter for .md tasks
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (frontmatterMatch) {
try {
const frontmatter = yaml.parse(frontmatterMatch[1]);
name = frontmatter.name || name;
displayName = frontmatter.displayName || frontmatter.name || name;
description = this.cleanForCSV(frontmatter.description || '');
// Tasks are standalone by default unless explicitly false (internal=true is already filtered above)
standalone = frontmatter.standalone !== false && frontmatter.standalone !== 'false';
} catch {
// If YAML parsing fails, use defaults
standalone = true; // Default to standalone
}
} else {
standalone = true; // No frontmatter means standalone
}
} else {
// For .xml tasks, extract from tag attributes
const nameMatch = content.match(/name="([^"]+)"/);
displayName = nameMatch ? nameMatch[1] : name;
const descMatch = content.match(/description="([^"]+)"/);
const objMatch = content.match(/<objective>([^<]+)<\/objective>/);
description = this.cleanForCSV(descMatch ? descMatch[1] : objMatch ? objMatch[1].trim() : '');
const standaloneFalseMatch = content.match(/<task[^>]+standalone="false"/);
standalone = !standaloneFalseMatch;
}
// Build relative path for installation
const installPath =
moduleName === 'core' ? `${this.bmadFolderName}/core/tasks/${file}` : `${this.bmadFolderName}/${moduleName}/tasks/${file}`;
tasks.push({
name: name,
displayName: displayName,
description: description,
module: moduleName,
path: installPath,
standalone: standalone,
canonicalId: this.getCanonicalId(skillManifest, file),
});
// Add to files list
this.files.push({
type: 'task',
name: name,
module: moduleName,
path: installPath,
});
}
}
return tasks;
}
/**
* Collect all tools from core and selected modules
* Scans the INSTALLED bmad directory, not the source
*/
async collectTools(selectedModules) {
this.tools = [];
// Use updatedModules which already includes deduplicated 'core' + selectedModules
for (const moduleName of this.updatedModules) {
const toolsPath = path.join(this.bmadDir, moduleName, 'tools');
if (await fs.pathExists(toolsPath)) {
const moduleTools = await this.getToolsFromDir(toolsPath, moduleName);
this.tools.push(...moduleTools);
}
}
}
/**
* Get tools from a directory
*/
async getToolsFromDir(dirPath, moduleName) {
// Skip directories claimed by collectSkills
if (this.skillClaimedDirs && this.skillClaimedDirs.has(dirPath)) return [];
const tools = [];
const files = await fs.readdir(dirPath);
// Load skill manifest for this directory (if present)
const skillManifest = await this.loadSkillManifest(dirPath);
for (const file of files) {
// Check for both .xml and .md files
if (file.endsWith('.xml') || file.endsWith('.md')) {
const filePath = path.join(dirPath, file);
const content = await fs.readFile(filePath, 'utf8');
// Skip internal tools (same as tasks)
if (content.includes('internal="true"')) {
continue;
}
let name = file.replace(/\.(xml|md)$/, '');
let displayName = name;
let description = '';
let standalone = false;
if (file.endsWith('.md')) {
// Parse YAML frontmatter for .md tools
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (frontmatterMatch) {
try {
const frontmatter = yaml.parse(frontmatterMatch[1]);
name = frontmatter.name || name;
displayName = frontmatter.displayName || frontmatter.name || name;
description = this.cleanForCSV(frontmatter.description || '');
// Tools are standalone by default unless explicitly false (internal=true is already filtered above)
standalone = frontmatter.standalone !== false && frontmatter.standalone !== 'false';
} catch {
// If YAML parsing fails, use defaults
standalone = true; // Default to standalone
}
} else {
standalone = true; // No frontmatter means standalone
}
} else {
// For .xml tools, extract from tag attributes
const nameMatch = content.match(/name="([^"]+)"/);
displayName = nameMatch ? nameMatch[1] : name;
const descMatch = content.match(/description="([^"]+)"/);
const objMatch = content.match(/<objective>([^<]+)<\/objective>/);
description = this.cleanForCSV(descMatch ? descMatch[1] : objMatch ? objMatch[1].trim() : '');
const standaloneFalseMatch = content.match(/<tool[^>]+standalone="false"/);
standalone = !standaloneFalseMatch;
}
// Build relative path for installation
const installPath =
moduleName === 'core' ? `${this.bmadFolderName}/core/tools/${file}` : `${this.bmadFolderName}/${moduleName}/tools/${file}`;
tools.push({
name: name,
displayName: displayName,
description: description,
module: moduleName,
path: installPath,
standalone: standalone,
canonicalId: this.getCanonicalId(skillManifest, file),
});
// Add to files list
this.files.push({
type: 'tool',
name: name,
module: moduleName,
path: installPath,
});
}
}
return tools;
}
/** /**
* Write main manifest as YAML with installation info only * Write main manifest as YAML with installation info only
* Fetches fresh version info for all modules * Fetches fresh version info for all modules
@ -925,131 +505,6 @@ class ManifestGenerator {
return manifestPath; return manifestPath;
} }
/**
* Read existing CSV and preserve rows for modules NOT being updated
* @param {string} csvPath - Path to existing CSV file
* @param {number} moduleColumnIndex - Which column contains the module name (0-indexed)
* @param {Array<string>} expectedColumns - Expected column names in order
* @param {Object} defaultValues - Default values for missing columns
* @returns {Array} Preserved CSV rows (without header), upgraded to match expected columns
*/
async getPreservedCsvRows(csvPath, moduleColumnIndex, expectedColumns, defaultValues = {}) {
if (!(await fs.pathExists(csvPath)) || this.preservedModules.length === 0) {
return [];
}
try {
const content = await fs.readFile(csvPath, 'utf8');
const lines = content.trim().split('\n');
if (lines.length < 2) {
return []; // No data rows
}
// Parse header to understand old schema
const header = lines[0];
const headerColumns = header.match(/(".*?"|[^",\s]+)(?=\s*,|\s*$)/g) || [];
const oldColumns = headerColumns.map((c) => c.replaceAll(/^"|"$/g, ''));
// Skip header row for data
const dataRows = lines.slice(1);
const preservedRows = [];
for (const row of dataRows) {
// Simple CSV parsing (handles quoted values)
const columns = row.match(/(".*?"|[^",\s]+)(?=\s*,|\s*$)/g) || [];
const cleanColumns = columns.map((c) => c.replaceAll(/^"|"$/g, ''));
const moduleValue = cleanColumns[moduleColumnIndex];
// Keep this row if it belongs to a preserved module
if (this.preservedModules.includes(moduleValue)) {
// Upgrade row to match expected schema
const upgradedRow = this.upgradeRowToSchema(cleanColumns, oldColumns, expectedColumns, defaultValues);
preservedRows.push(upgradedRow);
}
}
return preservedRows;
} catch (error) {
await prompts.log.warn(`Failed to read existing CSV ${csvPath}: ${error.message}`);
return [];
}
}
/**
* Upgrade a CSV row from old schema to new schema
* @param {Array<string>} rowValues - Values from old row
* @param {Array<string>} oldColumns - Old column names
* @param {Array<string>} newColumns - New column names
* @param {Object} defaultValues - Default values for missing columns
* @returns {string} Upgraded CSV row
*/
upgradeRowToSchema(rowValues, oldColumns, newColumns, defaultValues) {
const upgradedValues = [];
for (const newCol of newColumns) {
const oldIndex = oldColumns.indexOf(newCol);
if (oldIndex !== -1 && oldIndex < rowValues.length) {
// Column exists in old schema, use its value
upgradedValues.push(rowValues[oldIndex]);
} else if (defaultValues[newCol] === undefined) {
// Column missing, no default provided
upgradedValues.push('');
} else {
// Column missing, use default value
upgradedValues.push(defaultValues[newCol]);
}
}
// Properly quote values and join
return upgradedValues.map((v) => `"${v}"`).join(',');
}
/**
* Write workflow manifest CSV
* @returns {string} Path to the manifest file
*/
async writeWorkflowManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'workflow-manifest.csv');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
// Create CSV header - standalone column removed, canonicalId added as optional column
let csv = 'name,description,module,path,canonicalId\n';
// Build workflows map from discovered workflows only
// Old entries are NOT preserved - the manifest reflects what actually exists on disk
const allWorkflows = new Map();
// Only add workflows that were actually discovered in this scan
for (const workflow of this.workflows) {
const key = `${workflow.module}:${workflow.name}`;
allWorkflows.set(key, {
name: workflow.name,
description: workflow.description,
module: workflow.module,
path: workflow.path,
canonicalId: workflow.canonicalId || '',
});
}
// Write all workflows
for (const [, value] of allWorkflows) {
const row = [
escapeCsv(value.name),
escapeCsv(value.description),
escapeCsv(value.module),
escapeCsv(value.path),
escapeCsv(value.canonicalId),
].join(',');
csv += row + '\n';
}
await fs.writeFile(csvPath, csv);
return csvPath;
}
/** /**
* Write skill manifest CSV * Write skill manifest CSV
* @returns {string} Path to the manifest file * @returns {string} Path to the manifest file
@ -1150,134 +605,6 @@ class ManifestGenerator {
return csvPath; return csvPath;
} }
/**
* Write task manifest CSV
* @returns {string} Path to the manifest file
*/
async writeTaskManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'task-manifest.csv');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
// Read existing manifest to preserve entries
const existingEntries = new Map();
if (await fs.pathExists(csvPath)) {
const content = await fs.readFile(csvPath, 'utf8');
const records = csv.parse(content, {
columns: true,
skip_empty_lines: true,
});
for (const record of records) {
existingEntries.set(`${record.module}:${record.name}`, record);
}
}
// Create CSV header with standalone and canonicalId columns
let csvContent = 'name,displayName,description,module,path,standalone,canonicalId\n';
// Combine existing and new tasks
const allTasks = new Map();
// Add existing entries
for (const [key, value] of existingEntries) {
allTasks.set(key, value);
}
// Add/update new tasks
for (const task of this.tasks) {
const key = `${task.module}:${task.name}`;
allTasks.set(key, {
name: task.name,
displayName: task.displayName,
description: task.description,
module: task.module,
path: task.path,
standalone: task.standalone,
canonicalId: task.canonicalId || '',
});
}
// Write all tasks
for (const [, record] of allTasks) {
const row = [
escapeCsv(record.name),
escapeCsv(record.displayName),
escapeCsv(record.description),
escapeCsv(record.module),
escapeCsv(record.path),
escapeCsv(record.standalone),
escapeCsv(record.canonicalId),
].join(',');
csvContent += row + '\n';
}
await fs.writeFile(csvPath, csvContent);
return csvPath;
}
/**
* Write tool manifest CSV
* @returns {string} Path to the manifest file
*/
async writeToolManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'tool-manifest.csv');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
// Read existing manifest to preserve entries
const existingEntries = new Map();
if (await fs.pathExists(csvPath)) {
const content = await fs.readFile(csvPath, 'utf8');
const records = csv.parse(content, {
columns: true,
skip_empty_lines: true,
});
for (const record of records) {
existingEntries.set(`${record.module}:${record.name}`, record);
}
}
// Create CSV header with standalone and canonicalId columns
let csvContent = 'name,displayName,description,module,path,standalone,canonicalId\n';
// Combine existing and new tools
const allTools = new Map();
// Add existing entries
for (const [key, value] of existingEntries) {
allTools.set(key, value);
}
// Add/update new tools
for (const tool of this.tools) {
const key = `${tool.module}:${tool.name}`;
allTools.set(key, {
name: tool.name,
displayName: tool.displayName,
description: tool.description,
module: tool.module,
path: tool.path,
standalone: tool.standalone,
canonicalId: tool.canonicalId || '',
});
}
// Write all tools
for (const [, record] of allTools) {
const row = [
escapeCsv(record.name),
escapeCsv(record.displayName),
escapeCsv(record.description),
escapeCsv(record.module),
escapeCsv(record.path),
escapeCsv(record.standalone),
escapeCsv(record.canonicalId),
].join(',');
csvContent += row + '\n';
}
await fs.writeFile(csvPath, csvContent);
return csvPath;
}
/** /**
* Write files manifest CSV * Write files manifest CSV
*/ */
@ -1377,22 +704,12 @@ class ManifestGenerator {
continue; continue;
} }
// Check if this looks like a module (has agents, workflows, or tasks directory) // Check if this looks like a module (has agents directory or skill manifests)
const modulePath = path.join(bmadDir, entry.name); const modulePath = path.join(bmadDir, entry.name);
const hasAgents = await fs.pathExists(path.join(modulePath, 'agents')); const hasAgents = await fs.pathExists(path.join(modulePath, 'agents'));
const hasWorkflows = await fs.pathExists(path.join(modulePath, 'workflows')); const hasSkills = await this._hasSkillMdRecursive(modulePath);
const hasTasks = await fs.pathExists(path.join(modulePath, 'tasks'));
const hasTools = await fs.pathExists(path.join(modulePath, 'tools'));
// Check for native-entrypoint-only modules: recursive scan for if (hasAgents || hasSkills) {
// bmad-skill-manifest.yaml with type: skill or type: agent
let hasSkills = false;
if (!hasAgents && !hasWorkflows && !hasTasks && !hasTools) {
hasSkills = await this._hasSkillManifestRecursive(modulePath);
}
// If it has any of these directories or skill manifests, it's likely a module
if (hasAgents || hasWorkflows || hasTasks || hasTools || hasSkills) {
modules.push(entry.name); modules.push(entry.name);
} }
} }
@ -1404,13 +721,12 @@ class ManifestGenerator {
} }
/** /**
* Recursively check if a directory tree contains a bmad-skill-manifest.yaml that * Recursively check if a directory tree contains a SKILL.md file.
* declares a native SKILL.md entrypoint (type: skill or type: agent).
* Skips directories starting with . or _. * Skips directories starting with . or _.
* @param {string} dir - Directory to search * @param {string} dir - Directory to search
* @returns {boolean} True if a skill manifest is found * @returns {boolean} True if a SKILL.md is found
*/ */
async _hasSkillManifestRecursive(dir) { async _hasSkillMdRecursive(dir) {
let entries; let entries;
try { try {
entries = await fs.readdir(dir, { withFileTypes: true }); entries = await fs.readdir(dir, { withFileTypes: true });
@ -1418,15 +734,14 @@ class ManifestGenerator {
return false; return false;
} }
// Check for manifest in this directory // Check for SKILL.md in this directory
const manifest = await this.loadSkillManifest(dir); if (entries.some((e) => !e.isDirectory() && e.name === 'SKILL.md')) return true;
if (this.hasNativeSkillManifest(manifest)) return true;
// Recurse into subdirectories // Recurse into subdirectories
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory()) continue; if (!entry.isDirectory()) continue;
if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue; if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
if (await this._hasSkillManifestRecursive(path.join(dir, entry.name))) return true; if (await this._hasSkillMdRecursive(path.join(dir, entry.name))) return true;
} }
return false; return false;

View File

@ -2,19 +2,11 @@ const path = require('node:path');
const fs = require('fs-extra'); const fs = require('fs-extra');
const yaml = require('yaml'); const yaml = require('yaml');
const prompts = require('../../../lib/prompts'); const prompts = require('../../../lib/prompts');
const { FileOps } = require('../../../lib/file-ops');
const { XmlHandler } = require('../../../lib/xml-handler');
/** /**
* Handler for custom content (custom.yaml) * 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 { class CustomHandler {
constructor() {
this.fileOps = new FileOps();
this.xmlHandler = new XmlHandler();
}
/** /**
* Find all custom.yaml files in the project * Find all custom.yaml files in the project
* @param {string} projectRoot - Project root directory * @param {string} projectRoot - Project root directory
@ -115,244 +107,6 @@ class CustomHandler {
return null; 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 }; module.exports = { CustomHandler };

View File

@ -1,6 +1,5 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { XmlHandler } = require('../../../lib/xml-handler');
const prompts = require('../../../lib/prompts'); const prompts = require('../../../lib/prompts');
const { getSourcePath } = require('../../../lib/project-root'); const { getSourcePath } = require('../../../lib/project-root');
const { BMAD_FOLDER_NAME } = require('./shared/path-utils'); const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
@ -18,7 +17,6 @@ class BaseIdeSetup {
this.rulesDir = null; // Override in subclasses this.rulesDir = null; // Override in subclasses
this.configFile = null; // Override in subclasses when detection is file-based this.configFile = null; // Override in subclasses when detection is file-based
this.detectionPaths = []; // Additional paths that indicate the IDE is configured this.detectionPaths = []; // Additional paths that indicate the IDE is configured
this.xmlHandler = new XmlHandler();
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
} }
@ -30,15 +28,6 @@ class BaseIdeSetup {
this.bmadFolderName = bmadFolderName; 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 * Main setup method - must be implemented by subclasses
* @param {string} projectDir - Project directory * @param {string} projectDir - Project directory
@ -511,11 +500,6 @@ class BaseIdeSetup {
// Replace placeholders // Replace placeholders
let processed = content; let processed = content;
// Inject activation block for agent files FIRST (before replacements)
if (metadata.name && content.includes('<agent')) {
processed = this.xmlHandler.injectActivationSimple(processed, metadata);
}
// Only replace {project-root} if a specific projectDir is provided // Only replace {project-root} if a specific projectDir is provided
// Otherwise leave the placeholder intact // Otherwise leave the placeholder intact
// Note: Don't add trailing slash - paths in source include leading slash // Note: Don't add trailing slash - paths in source include leading slash

View File

@ -27,7 +27,7 @@ async function loadSkillManifest(dirPath) {
/** /**
* Get the canonicalId for a specific file from a loaded skill manifest. * Get the canonicalId for a specific file from a loaded skill manifest.
* @param {Object|null} manifest - Loaded manifest (from loadSkillManifest) * @param {Object|null} manifest - Loaded manifest (from loadSkillManifest)
* @param {string} filename - Source filename to look up (e.g., 'pm.md', 'help.md', 'pm.agent.yaml') * @param {string} filename - Source filename to look up (e.g., 'pm.md', 'help.md')
* @returns {string} canonicalId or empty string * @returns {string} canonicalId or empty string
*/ */
function getCanonicalId(manifest, filename) { function getCanonicalId(manifest, filename) {
@ -36,12 +36,6 @@ function getCanonicalId(manifest, filename) {
if (manifest.__single) return manifest.__single.canonicalId || ''; if (manifest.__single) return manifest.__single.canonicalId || '';
// Multi-entry: look up by filename directly // Multi-entry: look up by filename directly
if (manifest[filename]) return manifest[filename].canonicalId || ''; if (manifest[filename]) return manifest[filename].canonicalId || '';
// Fallback: try alternate extensions for compiled files
const baseName = filename.replace(/\.(md|xml)$/i, '');
const agentKey = `${baseName}.agent.yaml`;
if (manifest[agentKey]) return manifest[agentKey].canonicalId || '';
const xmlKey = `${baseName}.xml`;
if (manifest[xmlKey]) return manifest[xmlKey].canonicalId || '';
return ''; return '';
} }
@ -57,12 +51,6 @@ function getArtifactType(manifest, filename) {
if (manifest.__single) return manifest.__single.type || null; if (manifest.__single) return manifest.__single.type || null;
// Multi-entry: look up by filename directly // Multi-entry: look up by filename directly
if (manifest[filename]) return manifest[filename].type || null; if (manifest[filename]) return manifest[filename].type || null;
// Fallback: try alternate extensions for compiled files
const baseName = filename.replace(/\.(md|xml)$/i, '');
const agentKey = `${baseName}.agent.yaml`;
if (manifest[agentKey]) return manifest[agentKey].type || null;
const xmlKey = `${baseName}.xml`;
if (manifest[xmlKey]) return manifest[xmlKey].type || null;
return null; return null;
} }
@ -78,12 +66,6 @@ function getInstallToBmad(manifest, filename) {
if (manifest.__single) return manifest.__single.install_to_bmad !== false; if (manifest.__single) return manifest.__single.install_to_bmad !== false;
// Multi-entry: look up by filename directly // Multi-entry: look up by filename directly
if (manifest[filename]) return manifest[filename].install_to_bmad !== false; if (manifest[filename]) return manifest[filename].install_to_bmad !== false;
// Fallback: try alternate extensions for compiled files
const baseName = filename.replace(/\.(md|xml)$/i, '');
const agentKey = `${baseName}.agent.yaml`;
if (manifest[agentKey]) return manifest[agentKey].install_to_bmad !== false;
const xmlKey = `${baseName}.xml`;
if (manifest[xmlKey]) return manifest[xmlKey].install_to_bmad !== false;
return true; return true;
} }

View File

@ -2,22 +2,18 @@ const path = require('node:path');
const fs = require('fs-extra'); const fs = require('fs-extra');
const yaml = require('yaml'); const yaml = require('yaml');
const prompts = require('../../../lib/prompts'); const prompts = require('../../../lib/prompts');
const { XmlHandler } = require('../../../lib/xml-handler');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
const { filterCustomizationData } = require('../../../lib/agent/compiler');
const { ExternalModuleManager } = require('./external-manager'); const { ExternalModuleManager } = require('./external-manager');
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils'); const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
/** /**
* Manages the installation, updating, and removal of BMAD modules. * Manages the installation, updating, and removal of BMAD modules.
* Handles module discovery, dependency resolution, configuration processing, * Handles module discovery, dependency resolution, and configuration processing.
* and agent file management including XML activation block injection.
* *
* @class ModuleManager * @class ModuleManager
* @requires fs-extra * @requires fs-extra
* @requires yaml * @requires yaml
* @requires prompts * @requires prompts
* @requires XmlHandler
* *
* @example * @example
* const manager = new ModuleManager(); * const manager = new ModuleManager();
@ -26,7 +22,6 @@ const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
*/ */
class ModuleManager { class ModuleManager {
constructor(options = {}) { constructor(options = {}) {
this.xmlHandler = new XmlHandler();
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
this.customModulePaths = new Map(); // Initialize custom module paths this.customModulePaths = new Map(); // Initialize custom module paths
this.externalModuleManager = new ExternalModuleManager(); // For external official modules this.externalModuleManager = new ExternalModuleManager(); // For external official modules
@ -88,103 +83,6 @@ class ModuleManager {
} }
} }
/**
* Copy sidecar directory to _bmad/_memory location with update-safe handling
* @param {string} sourceSidecarPath - Source sidecar directory path
* @param {string} agentName - Name of the agent (for naming)
* @param {string} bmadMemoryPath - This should ALWAYS be _bmad/_memory
* @param {boolean} isUpdate - Whether this is an update (default: false)
* @param {string} bmadDir - BMAD installation directory
* @param {Object} installer - Installer instance for file tracking
*/
async copySidecarToMemory(sourceSidecarPath, agentName, bmadMemoryPath, isUpdate = false, bmadDir = null, installer = null) {
const crypto = require('node:crypto');
const sidecarTargetDir = path.join(bmadMemoryPath, `${agentName}-sidecar`);
// Ensure target directory exists
await fs.ensureDir(bmadMemoryPath);
await fs.ensureDir(sidecarTargetDir);
// Get existing files manifest for update checking
let existingFilesManifest = [];
if (isUpdate && installer) {
existingFilesManifest = await installer.readFilesManifest(bmadDir);
}
// Build map of existing sidecar files with their hashes
const existingSidecarFiles = new Map();
for (const fileEntry of existingFilesManifest) {
if (fileEntry.path && fileEntry.path.includes(`${agentName}-sidecar/`)) {
existingSidecarFiles.set(fileEntry.path, fileEntry.hash);
}
}
// Get all files in source sidecar
const sourceFiles = await this.getFileList(sourceSidecarPath);
for (const file of sourceFiles) {
const sourceFilePath = path.join(sourceSidecarPath, file);
const targetFilePath = path.join(sidecarTargetDir, file);
// Calculate current source file hash
const sourceHash = crypto
.createHash('sha256')
.update(await fs.readFile(sourceFilePath))
.digest('hex');
// Path relative to bmad directory
const relativeToBmad = path.join('_memory', `${agentName}-sidecar`, file);
if (isUpdate && (await fs.pathExists(targetFilePath))) {
// Calculate current target file hash
const currentTargetHash = crypto
.createHash('sha256')
.update(await fs.readFile(targetFilePath))
.digest('hex');
// Get the last known hash from files-manifest
const lastKnownHash = existingSidecarFiles.get(relativeToBmad);
if (lastKnownHash) {
// We have a record of this file
if (currentTargetHash === lastKnownHash) {
// File hasn't been modified by user, safe to update
await this.copyFileWithPlaceholderReplacement(sourceFilePath, targetFilePath, true);
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
await prompts.log.message(` Updated sidecar file: ${relativeToBmad}`);
}
} else {
// User has modified the file, preserve it
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
await prompts.log.message(` Preserving user-modified file: ${relativeToBmad}`);
}
}
} else {
// First time seeing this file in manifest, copy it
await this.copyFileWithPlaceholderReplacement(sourceFilePath, targetFilePath, true);
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
await prompts.log.message(` Added new sidecar file: ${relativeToBmad}`);
}
}
} else {
// New installation
await this.copyFileWithPlaceholderReplacement(sourceFilePath, targetFilePath, true);
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
await prompts.log.message(` Copied sidecar file: ${relativeToBmad}`);
}
}
// Track the file in the installer's file tracking system
if (installer && installer.installedFiles) {
installer.installedFiles.add(targetFilePath);
}
}
// Return list of files that were processed
const processedFiles = sourceFiles.map((file) => path.join('_memory', `${agentName}-sidecar`, file));
return processedFiles;
}
/** /**
* List all available modules (excluding core which is always installed) * List all available modules (excluding core which is always installed)
* bmm is the only built-in module, directly under src/bmm-skills * bmm is the only built-in module, directly under src/bmm-skills
@ -559,19 +457,9 @@ class ModuleManager {
await fs.remove(targetPath); 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 // Copy module files with filtering
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig); 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) // Create directories declared in module.yaml (unless explicitly skipped)
if (!options.skipModuleInstaller) { if (!options.skipModuleInstaller) {
await this.createModuleDirectories(moduleName, bmadDir, options); await this.createModuleDirectories(moduleName, bmadDir, options);
@ -624,10 +512,6 @@ class ModuleManager {
} else { } else {
// Selective update - preserve user modifications // Selective update - preserve user modifications
await this.syncModule(sourcePath, targetPath); await this.syncModule(sourcePath, targetPath);
// Recompile agents (#1133)
await this.compileModuleAgents(sourcePath, targetPath, moduleName, bmadDir, options.installer);
await this.processAgentFiles(targetPath, moduleName);
} }
return { return {
@ -718,9 +602,7 @@ class ModuleManager {
continue; continue;
} }
// Only skip sidecar directories - they are handled separately during agent compilation // Skip sidecar directories - these contain agent-specific assets not needed at install time
// But still allow other files in agent directories
const isInAgentDirectory = file.startsWith('agents/');
const isInSidecarDirectory = path const isInSidecarDirectory = path
.dirname(file) .dirname(file)
.split('/') .split('/')
@ -742,11 +624,6 @@ class ModuleManager {
continue; continue;
} }
// Skip .agent.yaml files - they will be compiled separately
if (file.endsWith('.agent.yaml')) {
continue;
}
const sourceFile = path.join(sourcePath, file); const sourceFile = path.join(sourcePath, file);
const targetFile = path.join(targetPath, 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('<agent') && !content.includes('<activation')) {
// // Inject the activation block using XML handler
// content = this.xmlHandler.injectActivationSimple(content);
// await fs.writeFile(agentFile, content, 'utf8');
// }
// }
}
/** /**
* Find all .md agent files recursively in a directory * Find all .md agent files recursively in a directory
* @param {string} dir - Directory to search * @param {string} dir - Directory to search
@ -1029,101 +676,6 @@ class ModuleManager {
return agentFiles; return agentFiles;
} }
/**
* Vendor cross-module workflows referenced in agent files
* Scans SOURCE agent.yaml files for workflow-install and copies workflows to destination
* @param {string} sourcePath - Source module path
* @param {string} targetPath - Target module path (destination)
* @param {string} moduleName - Module name being installed
*/
async vendorCrossModuleWorkflows(sourcePath, targetPath, moduleName) {
const sourceAgentsPath = path.join(sourcePath, 'agents');
// Check if source agents directory exists
if (!(await fs.pathExists(sourceAgentsPath))) {
return; // No agents to process
}
// Get all agent YAML files from source
const agentFiles = await fs.readdir(sourceAgentsPath);
const yamlFiles = agentFiles.filter((f) => 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 * Create directories declared in module.yaml's `directories` key
* This replaces the security-risky module installer pattern with declarative config * This replaces the security-risky module installer pattern with declarative config

View File

@ -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 = '<activation critical="MANDATORY">\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('<extract>{DYNAMIC_EXTRACT_LIST}</extract>\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 += '</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 = `<step n="${currentStepNum}">${action}</step>`;
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 };

View File

@ -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 };

View File

@ -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 = `<!-- Powered by BMAD-CORE™ -->
<!-- Agent Manifest - Generated during BMAD ${forWeb ? 'bundling' : 'installation'} -->
<!-- This file contains a summary of all ${forWeb ? 'bundled' : 'installed'} agents for quick reference -->
<manifest id="bmad/_config/agent-manifest.csv" version="1.0" generated="${new Date().toISOString()}">
<description>
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.
</description>
`;
// 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 <!-- ${moduleTitle} Agents -->\n`;
for (const agent of agents) {
xmlContent += ` <agent id="${agent.id}" name="${agent.name}" title="${agent.title || ''}" icon="${agent.icon || ''}">
<persona>
<role>${escapeXml(agent.role || '')}</role>
<identity>${escapeXml(agent.identity || '')}</identity>
<communication_style>${escapeXml(agent.communicationStyle || '')}</communication_style>
<principles>${agent.principles || ''}</principles>
</persona>
</agent>\n`;
}
}
// Add statistics
const totalAgents = agentDetails.length;
const moduleList = Object.keys(agentsByModule)
.filter((m) => agentsByModule[m].length > 0)
.join(', ');
xmlContent += `\n <statistics>
<total_agents>${totalAgents}</total_agents>
<modules>${moduleList}</modules>
<last_updated>${new Date().toISOString()}</last_updated>
</statistics>
</manifest>`;
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(/<agent[^>]*>([\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(/<role>([\s\S]*?)<\/role>/);
const identityMatch = agentXml.match(/<identity>([\s\S]*?)<\/identity>/);
const styleMatch = agentXml.match(/<communication_style>([\s\S]*?)<\/communication_style>/);
const principlesMatch = agentXml.match(/<principles>([\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(/<agent-config>([\s\S]*?)<\/agent-config>/);
if (!configMatch) return details;
const configXml = configMatch[0];
// Extract override values
const nameMatch = configXml.match(/<name>([\s\S]*?)<\/name>/);
const titleMatch = configXml.match(/<title>([\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 };

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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 // Common actions
choices.push({ name: 'Modify BMAD Installation', value: 'update' }); 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 // If actionType === 'update', handle it with the new flow
// Return early with modify configuration // Return early with modify configuration
if (actionType === 'update') { if (actionType === 'update') {

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };