Compare commits
No commits in common. "ad9cb7a1777f2258818d65b5b3390454d0cd4ca4" and "1a6f8d52bcd7fb9cc840ba50dfc4e57dc71600e1" have entirely different histories.
ad9cb7a177
...
1a6f8d52bc
|
|
@ -127,7 +127,7 @@ prompts:
|
||||||
|
|
||||||
### 3. Appliquer vos modifications
|
### 3. Appliquer vos modifications
|
||||||
|
|
||||||
Après modification, réinstallez pour appliquer les changements :
|
Après modification, recompilez l'agent pour appliquer les changements :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx bmad-method install
|
npx bmad-method install
|
||||||
|
|
@ -137,16 +137,17 @@ 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 applique les personnalisations |
|
| **Quick Update** | Met à jour tous les modules vers la dernière version et recompile tous les agents |
|
||||||
|
| **Recompile Agents** | Applique uniquement les personnalisations, sans mettre à jour les fichiers de modules |
|
||||||
| **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, **Quick Update** est l'option la plus rapide.
|
Pour des modifications de personnalisation uniquement, **Recompile Agents** 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 **Quick Update** pour appliquer les modifications
|
- Exécutez `npx bmad-method install` et sélectionnez **Recompile Agents** 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
|
||||||
|
|
||||||
|
|
@ -159,7 +160,7 @@ Pour des modifications de personnalisation uniquement, **Quick Update** est l'op
|
||||||
**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 **Quick Update** pour restaurer les valeurs par défaut
|
- Exécutez `npx bmad-method install` et sélectionnez **Recompile Agents** pour restaurer les valeurs par défaut
|
||||||
|
|
||||||
## Personnalisation des workflows
|
## Personnalisation des workflows
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`, ou `quick-update` | `--action quick-update` |
|
| `--action <type>` | Action pour les installations existantes : `install` (par défaut), `update`, `quick-update`, ou `compile-agents` | `--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 configurés pour vos modules et outils sélectionnés
|
- Des agents et des flux de travail compilé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`
|
- **Action** — Doit être l'une des suivantes : `install`, `update`, `quick-update`, `compile-agents`
|
||||||
|
|
||||||
Les valeurs invalides entraîneront soit :
|
Les valeurs invalides entraîneront soit :
|
||||||
1. L’affichage d’un message d'erreur suivi d’un exit (pour les options critiques comme le répertoire)
|
1. L’affichage d’un message d'erreur suivi d’un exit (pour les options critiques comme le répertoire)
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ prompts:
|
||||||
|
|
||||||
### 3. Apply Your Changes
|
### 3. Apply Your Changes
|
||||||
|
|
||||||
After editing, reinstall to apply changes:
|
After editing, recompile the agent to apply changes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx bmad-method install
|
npx bmad-method install
|
||||||
|
|
@ -138,16 +138,17 @@ 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 applies customizations |
|
| **Quick Update** | Updates all modules to the latest version and recompiles all agents |
|
||||||
|
| **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, **Quick Update** is the fastest option.
|
For customization-only changes, **Recompile Agents** is the fastest option.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
**Changes not appearing?**
|
**Changes not appearing?**
|
||||||
|
|
||||||
- Run `npx bmad-method install` and select **Quick Update** to apply changes
|
- Run `npx bmad-method install` and select **Recompile Agents** 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
|
||||||
|
|
||||||
|
|
@ -160,7 +161,7 @@ For customization-only changes, **Quick Update** 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 **Quick Update** to restore defaults
|
- Run `npx bmad-method install` and select **Recompile Agents** to restore defaults
|
||||||
|
|
||||||
## Workflow Customization
|
## Workflow Customization
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`, or `quick-update` | `--action quick-update` |
|
| `--action <type>` | Action for existing installations: `install` (default), `update`, `quick-update`, or `compile-agents` | `--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
|
||||||
- Agents and workflows configured for your selected modules and tools
|
- Compiled agents and workflows 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`
|
- **Action** — Must be one of: `install`, `update`, `quick-update`, `compile-agents`
|
||||||
|
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ prompts:
|
||||||
|
|
||||||
### 3. 应用您的更改
|
### 3. 应用您的更改
|
||||||
|
|
||||||
编辑后,重新安装以应用更改:
|
编辑后,重新编译智能体以应用更改:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx bmad-method install
|
npx bmad-method install
|
||||||
|
|
@ -138,16 +138,17 @@ npx bmad-method install
|
||||||
|
|
||||||
| Option | What It Does |
|
| Option | What It Does |
|
||||||
| ---------------------------- | ------------------------------------------------------------------- |
|
| ---------------------------- | ------------------------------------------------------------------- |
|
||||||
| **Quick Update** | 将所有模块更新到最新版本并应用自定义配置 |
|
| **Quick Update** | 将所有模块更新到最新版本并重新编译所有智能体 |
|
||||||
|
| **Recompile Agents** | 仅应用自定义配置,不更新模块文件 |
|
||||||
| **Modify BMad Installation** | 用于添加或删除模块的完整安装流程 |
|
| **Modify BMad Installation** | 用于添加或删除模块的完整安装流程 |
|
||||||
|
|
||||||
对于仅自定义配置的更改,**Quick Update** 是最快的选项。
|
对于仅自定义配置的更改,**Recompile Agents** 是最快的选项。
|
||||||
|
|
||||||
## 故障排除
|
## 故障排除
|
||||||
|
|
||||||
**更改未生效?**
|
**更改未生效?**
|
||||||
|
|
||||||
- 运行 `npx bmad-method install` 并选择 **Quick Update** 以应用更改
|
- 运行 `npx bmad-method install` 并选择 **Recompile Agents** 以应用更改
|
||||||
- 检查您的 YAML 语法是否有效(缩进很重要)
|
- 检查您的 YAML 语法是否有效(缩进很重要)
|
||||||
- 验证您编辑的是该智能体正确的 `.customize.yaml` 文件
|
- 验证您编辑的是该智能体正确的 `.customize.yaml` 文件
|
||||||
|
|
||||||
|
|
@ -160,7 +161,7 @@ npx bmad-method install
|
||||||
**需要重置智能体?**
|
**需要重置智能体?**
|
||||||
|
|
||||||
- 清空或删除智能体的 `.customize.yaml` 文件
|
- 清空或删除智能体的 `.customize.yaml` 文件
|
||||||
- 运行 `npx bmad-method install` 并选择 **Quick Update** 以恢复默认设置
|
- 运行 `npx bmad-method install` 并选择 **Recompile Agents** 以恢复默认设置
|
||||||
|
|
||||||
## 工作流自定义
|
## 工作流自定义
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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` | `--action quick-update` |
|
| `--action <type>` | 对现有安装的操作:`install`(默认)、`update`、`quick-update` 或 `compile-agents` | `--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`
|
- **操作** — 必须是以下之一:`install`、`update`、`quick-update`、`compile-agents`
|
||||||
|
|
||||||
无效值将:
|
无效值将:
|
||||||
1. 显示错误并退出(对于目录等关键选项)
|
1. 显示错误并退出(对于目录等关键选项)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
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');
|
||||||
|
|
@ -78,6 +79,7 @@ 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;
|
||||||
|
|
@ -98,6 +100,17 @@ 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'),
|
||||||
[
|
[
|
||||||
|
|
@ -136,10 +149,77 @@ 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 1: Windsurf Native Skills Install
|
// Test 2: Customization Merging
|
||||||
// ============================================================
|
// ============================================================
|
||||||
console.log(`${colors.yellow}Test Suite 1: Windsurf Native Skills${colors.reset}\n`);
|
console.log(`${colors.yellow}Test Suite 2: Customization Merging${colors.reset}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const builder = new YamlXmlBuilder();
|
||||||
|
|
||||||
|
// Test deepMerge function
|
||||||
|
const base = {
|
||||||
|
agent: {
|
||||||
|
metadata: { name: 'John', title: 'PM' },
|
||||||
|
persona: { role: 'Product Manager', style: 'Analytical' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const customize = {
|
||||||
|
agent: {
|
||||||
|
metadata: { name: 'Sarah' }, // Override name only
|
||||||
|
persona: { style: 'Concise' }, // Override style only
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const merged = builder.deepMerge(base, customize);
|
||||||
|
|
||||||
|
assert(merged.agent.metadata.name === 'Sarah', 'Deep merge overrides customized name');
|
||||||
|
|
||||||
|
assert(merged.agent.metadata.title === 'PM', 'Deep merge preserves non-overridden title');
|
||||||
|
|
||||||
|
assert(merged.agent.persona.role === 'Product Manager', 'Deep merge preserves non-overridden role');
|
||||||
|
|
||||||
|
assert(merged.agent.persona.style === 'Concise', 'Deep merge overrides customized style');
|
||||||
|
} catch (error) {
|
||||||
|
assert(false, 'Customization merging works', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test 3: Path Resolution
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 3: Path Variable Resolution${colors.reset}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const builder = new YamlXmlBuilder();
|
||||||
|
|
||||||
|
// Test path resolution logic (if exposed)
|
||||||
|
// This would test {project-root}, {installed_path}, {config_source} resolution
|
||||||
|
|
||||||
|
const testPath = '{project-root}/bmad/bmm/config.yaml';
|
||||||
|
const expectedPattern = /\/bmad\/bmm\/config\.yaml$/;
|
||||||
|
|
||||||
|
assert(
|
||||||
|
true, // Placeholder - would test actual resolution
|
||||||
|
'Path variable resolution pattern matches expected format',
|
||||||
|
'Note: This test validates path resolution logic exists',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
assert(false, 'Path resolution works', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test 4: Windsurf Native Skills Install
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 4: Windsurf Native Skills${colors.reset}\n`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
clearCache();
|
clearCache();
|
||||||
|
|
@ -1523,6 +1603,7 @@ 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',
|
||||||
|
|
@ -1538,9 +1619,10 @@ 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/ ---
|
// --- Skill inside workflows/ dir: core/workflows/wf-skill/ (exercises findWorkflows skip logic) ---
|
||||||
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',
|
||||||
|
|
@ -1550,6 +1632,7 @@ 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',
|
||||||
|
|
@ -1582,10 +1665,18 @@ 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');
|
||||||
|
|
@ -1597,17 +1688,23 @@ 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 type:workflow should NOT appear in skills[]
|
// Regular workflow should be in workflows, NOT 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[]
|
// Skill inside workflows/ should be in skills[], NOT in workflows[] (exercises findWorkflows skip at lines 311/322)
|
||||||
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',
|
||||||
|
|
|
||||||
|
|
@ -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, or quick-update'],
|
['--action <type>', 'Action type for existing installations: install, update, quick-update, or compile-agents'],
|
||||||
['--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,6 +49,13 @@ 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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ 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');
|
||||||
|
|
@ -24,6 +25,7 @@ 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();
|
||||||
|
|
@ -1124,9 +1126,11 @@ 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 agents, skills AND ALL FILES with hashes
|
// Generate CSV manifests for workflows, agents, tasks 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();
|
||||||
|
|
@ -2110,6 +2114,10 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2219,8 +2227,16 @@ 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
|
// Copy core files (skip .agent.yaml files like modules do)
|
||||||
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -2238,6 +2254,16 @@ 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;
|
||||||
|
|
@ -2248,9 +2274,27 @@ 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);
|
||||||
|
|
@ -2284,6 +2328,58 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|
@ -2297,6 +2393,12 @@ 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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2541,6 +2643,114 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,15 @@ const {
|
||||||
const packageJson = require('../../../../../package.json');
|
const packageJson = require('../../../../../package.json');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates manifest files for installed skills and agents
|
* Generates manifest files for installed workflows, agents, and tasks
|
||||||
*/
|
*/
|
||||||
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 = [];
|
||||||
|
|
@ -47,6 +50,29 @@ 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.
|
||||||
|
|
@ -82,6 +108,10 @@ 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;
|
||||||
|
|
@ -104,20 +134,35 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
@ -125,9 +170,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 directory is discovered as a skill when it contains a SKILL.md file with
|
* A native entrypoint directory is one that contains both a
|
||||||
* valid name/description frontmatter (name must match directory name).
|
* bmad-skill-manifest.yaml with type: skill or type: agent AND a SKILL.md file
|
||||||
* Manifest YAML is loaded only when present — for install_to_bmad and agent metadata.
|
* with name/description frontmatter.
|
||||||
* Populates this.skills[] and this.skillClaimedDirs (Set of absolute paths).
|
* Populates this.skills[] and this.skillClaimedDirs (Set of absolute paths).
|
||||||
*/
|
*/
|
||||||
async collectSkills() {
|
async collectSkills() {
|
||||||
|
|
@ -148,55 +193,77 @@ class ManifestGenerator {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SKILL.md with valid frontmatter is the primary discovery gate
|
// Check this directory for skill manifest
|
||||||
|
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 skillMdPath = path.join(dir, skillFile);
|
const artifactType = this.getArtifactType(manifest, skillFile);
|
||||||
const dirName = path.basename(dir);
|
|
||||||
|
|
||||||
const skillMeta = await this.parseSkillMd(skillMdPath, dir, dirName, debug);
|
if (this.isNativeSkillDirType(artifactType)) {
|
||||||
|
const skillMdPath = path.join(dir, 'SKILL.md');
|
||||||
|
const dirName = path.basename(dir);
|
||||||
|
|
||||||
if (skillMeta) {
|
// Validate and parse SKILL.md
|
||||||
// Load manifest when present (for install_to_bmad and agent metadata)
|
const skillMeta = await this.parseSkillMd(skillMdPath, dir, dirName, debug);
|
||||||
const manifest = await this.loadSkillManifest(dir);
|
|
||||||
const artifactType = this.getArtifactType(manifest, skillFile);
|
|
||||||
|
|
||||||
// Build path relative from module root (points to SKILL.md — the permanent entrypoint)
|
if (skillMeta) {
|
||||||
const relativePath = path.relative(modulePath, dir).split(path.sep).join('/');
|
// Build path relative from module root (points to SKILL.md — the permanent entrypoint)
|
||||||
const installPath = relativePath
|
const relativePath = path.relative(modulePath, dir).split(path.sep).join('/');
|
||||||
? `${this.bmadFolderName}/${moduleName}/${relativePath}/${skillFile}`
|
const installPath = relativePath
|
||||||
: `${this.bmadFolderName}/${moduleName}/${skillFile}`;
|
? `${this.bmadFolderName}/${moduleName}/${relativePath}/${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;
|
}
|
||||||
|
|
||||||
this.skills.push({
|
// Warn if manifest says this is a native entrypoint but the directory was not claimed
|
||||||
name: skillMeta.name,
|
if (manifest && !this.skillClaimedDirs.has(dir)) {
|
||||||
description: this.cleanForCSV(skillMeta.description),
|
let hasNativeSkillType = false;
|
||||||
module: moduleName,
|
if (manifest.__single) {
|
||||||
path: installPath,
|
hasNativeSkillType = this.isNativeSkillDirType(manifest.__single.type);
|
||||||
canonicalId,
|
} else {
|
||||||
install_to_bmad: this.getInstallToBmad(manifest, skillFile),
|
for (const key of Object.keys(manifest)) {
|
||||||
});
|
if (this.isNativeSkillDirType(manifest[key]?.type)) {
|
||||||
|
hasNativeSkillType = true;
|
||||||
// Add to files list
|
break;
|
||||||
this.files.push({
|
}
|
||||||
type: 'skill',
|
}
|
||||||
name: skillMeta.name,
|
}
|
||||||
module: moduleName,
|
if (hasNativeSkillType && debug) {
|
||||||
path: installPath,
|
console.log(`[DEBUG] collectSkills: dir has native SKILL.md manifest but failed validation: ${dir}`);
|
||||||
});
|
|
||||||
|
|
||||||
this.skillClaimedDirs.add(dir);
|
|
||||||
|
|
||||||
if (debug) {
|
|
||||||
console.log(`[DEBUG] collectSkills: claimed skill "${skillMeta.name}" as ${canonicalId} at ${dir}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,6 +334,153 @@ 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
|
||||||
|
|
@ -301,7 +515,7 @@ class ManifestGenerator {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get agents from a directory recursively
|
* Get agents from a directory recursively
|
||||||
* Only includes .md files with agent content
|
* Only includes compiled .md files (not .agent.yaml source files)
|
||||||
*/
|
*/
|
||||||
async getAgentsFromDir(dirPath, moduleName, relativePath = '') {
|
async getAgentsFromDir(dirPath, moduleName, relativePath = '') {
|
||||||
// Skip directories claimed by collectSkills
|
// Skip directories claimed by collectSkills
|
||||||
|
|
@ -358,7 +572,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.toLowerCase() !== 'readme.md') {
|
} else if (entry.name.endsWith('.md') && !entry.name.endsWith('.agent.yaml') && 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)
|
||||||
|
|
@ -420,6 +634,212 @@ 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
|
||||||
|
|
@ -505,6 +925,131 @@ 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
|
||||||
|
|
@ -605,6 +1150,134 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|
@ -704,12 +1377,22 @@ class ManifestGenerator {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this looks like a module (has agents directory or skill manifests)
|
// Check if this looks like a module (has agents, workflows, or tasks directory)
|
||||||
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 hasSkills = await this._hasSkillMdRecursive(modulePath);
|
const hasWorkflows = await fs.pathExists(path.join(modulePath, 'workflows'));
|
||||||
|
const hasTasks = await fs.pathExists(path.join(modulePath, 'tasks'));
|
||||||
|
const hasTools = await fs.pathExists(path.join(modulePath, 'tools'));
|
||||||
|
|
||||||
if (hasAgents || hasSkills) {
|
// Check for native-entrypoint-only modules: recursive scan for
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -721,12 +1404,13 @@ class ManifestGenerator {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively check if a directory tree contains a SKILL.md file.
|
* Recursively check if a directory tree contains a bmad-skill-manifest.yaml that
|
||||||
|
* 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.md is found
|
* @returns {boolean} True if a skill manifest is found
|
||||||
*/
|
*/
|
||||||
async _hasSkillMdRecursive(dir) {
|
async _hasSkillManifestRecursive(dir) {
|
||||||
let entries;
|
let entries;
|
||||||
try {
|
try {
|
||||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
|
@ -734,14 +1418,15 @@ class ManifestGenerator {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for SKILL.md in this directory
|
// Check for manifest in this directory
|
||||||
if (entries.some((e) => !e.isDirectory() && e.name === 'SKILL.md')) return true;
|
const manifest = await this.loadSkillManifest(dir);
|
||||||
|
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._hasSkillMdRecursive(path.join(dir, entry.name))) return true;
|
if (await this._hasSkillManifestRecursive(path.join(dir, entry.name))) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,19 @@ 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)
|
||||||
* Discovers custom agents and workflows in the project
|
* Installs custom agents and workflows without requiring a full module structure
|
||||||
*/
|
*/
|
||||||
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
|
||||||
|
|
@ -107,6 +115,244 @@ 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 };
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
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');
|
||||||
|
|
@ -17,6 +18,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,6 +30,15 @@ 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
|
||||||
|
|
@ -500,6 +511,11 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -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')
|
* @param {string} filename - Source filename to look up (e.g., 'pm.md', 'help.md', 'pm.agent.yaml')
|
||||||
* @returns {string} canonicalId or empty string
|
* @returns {string} canonicalId or empty string
|
||||||
*/
|
*/
|
||||||
function getCanonicalId(manifest, filename) {
|
function getCanonicalId(manifest, filename) {
|
||||||
|
|
@ -36,6 +36,12 @@ 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 '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,6 +57,12 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,6 +78,12 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,22 @@ 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, and configuration processing.
|
* Handles module discovery, dependency resolution, 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();
|
||||||
|
|
@ -22,6 +26,7 @@ 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
|
||||||
|
|
@ -83,6 +88,103 @@ 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
|
||||||
|
|
@ -457,9 +559,19 @@ 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);
|
||||||
|
|
@ -512,6 +624,10 @@ 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 {
|
||||||
|
|
@ -602,7 +718,9 @@ class ModuleManager {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip sidecar directories - these contain agent-specific assets not needed at install time
|
// Only skip sidecar directories - they are handled separately during agent compilation
|
||||||
|
// 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('/')
|
||||||
|
|
@ -624,6 +742,11 @@ 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);
|
||||||
|
|
||||||
|
|
@ -650,6 +773,236 @@ 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
|
||||||
|
|
@ -676,6 +1029,101 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
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 };
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
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 };
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
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 };
|
||||||
|
|
@ -0,0 +1,516 @@
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,680 @@
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
|
@ -208,6 +208,14 @@ 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' });
|
||||||
|
|
||||||
|
|
@ -283,6 +291,17 @@ 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') {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
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 };
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
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 };
|
||||||
|
|
@ -0,0 +1,572 @@
|
||||||
|
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 };
|
||||||
Loading…
Reference in New Issue