Compare commits
4 Commits
1a6f8d52bc
...
ad9cb7a177
| Author | SHA1 | Date |
|---|---|---|
|
|
ad9cb7a177 | |
|
|
93a1e1dc46 | |
|
|
31ae226bb4 | |
|
|
c28206dca4 |
|
|
@ -127,7 +127,7 @@ prompts:
|
|||
|
||||
### 3. Appliquer vos modifications
|
||||
|
||||
Après modification, recompilez l'agent pour appliquer les changements :
|
||||
Après modification, réinstallez pour appliquer les changements :
|
||||
|
||||
```bash
|
||||
npx bmad-method install
|
||||
|
|
@ -137,17 +137,16 @@ L'installateur détecte l'installation existante et propose ces options :
|
|||
|
||||
| Option | Ce qu'elle fait |
|
||||
| ----------------------------------- | ---------------------------------------------------------------------- |
|
||||
| **Quick Update** | Met à jour tous les modules vers la dernière version et recompile tous les agents |
|
||||
| **Recompile Agents** | Applique uniquement les personnalisations, sans mettre à jour les fichiers de modules |
|
||||
| **Quick Update** | Met à jour tous les modules vers la dernière version et applique les personnalisations |
|
||||
| **Modify BMad Installation** | Flux d'installation complet pour ajouter ou supprimer des modules |
|
||||
|
||||
Pour des modifications de personnalisation uniquement, **Recompile Agents** est l'option la plus rapide.
|
||||
Pour des modifications de personnalisation uniquement, **Quick Update** est l'option la plus rapide.
|
||||
|
||||
## Résolution des problèmes
|
||||
|
||||
**Les modifications n'apparaissent pas ?**
|
||||
|
||||
- Exécutez `npx bmad-method install` et sélectionnez **Recompile Agents** pour appliquer les modifications
|
||||
- Exécutez `npx bmad-method install` et sélectionnez **Quick Update** pour appliquer les modifications
|
||||
- Vérifiez que votre syntaxe YAML est valide (l'indentation compte)
|
||||
- Assurez-vous d'avoir modifié le bon fichier `.customize.yaml` pour l'agent
|
||||
|
||||
|
|
@ -160,7 +159,7 @@ Pour des modifications de personnalisation uniquement, **Recompile Agents** est
|
|||
**Besoin de réinitialiser un agent ?**
|
||||
|
||||
- Effacez ou supprimez le fichier `.customize.yaml` de l'agent
|
||||
- Exécutez `npx bmad-method install` et sélectionnez **Recompile Agents** pour restaurer les valeurs par défaut
|
||||
- Exécutez `npx bmad-method install` et sélectionnez **Quick Update** pour restaurer les valeurs par défaut
|
||||
|
||||
## Personnalisation des workflows
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
| `--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` |
|
||||
| `--action <type>` | Action pour les installations existantes : `install` (par défaut), `update`, `quick-update`, ou `compile-agents` | `--action quick-update` |
|
||||
| `--action <type>` | Action pour les installations existantes : `install` (par défaut), `update`, ou `quick-update` | `--action quick-update` |
|
||||
|
||||
### Configuration principale
|
||||
|
||||
|
|
@ -121,7 +121,7 @@ npx bmad-method install \
|
|||
## Ce que vous obtenez
|
||||
|
||||
- Un répertoire `_bmad/` entièrement configuré dans votre projet
|
||||
- Des agents et des flux de travail compilés pour vos modules et outils sélectionnés
|
||||
- Des agents et des flux de travail configurés pour vos modules et outils sélectionnés
|
||||
- Un dossier `_bmad-output/` pour les artefacts générés
|
||||
|
||||
## Validation et gestion des erreurs
|
||||
|
|
@ -132,7 +132,7 @@ BMad valide toutes les options fournis :
|
|||
- **Modules** — Avertit des IDs de modules invalides (mais n'échoue pas)
|
||||
- **Tools** — Avertit des IDs d'outils invalides (mais n'échoue pas)
|
||||
- **Custom Content** — Chaque chemin doit contenir un fichier `module.yaml` valide
|
||||
- **Action** — Doit être l'une des suivantes : `install`, `update`, `quick-update`, `compile-agents`
|
||||
- **Action** — Doit être l'une des suivantes : `install`, `update`, `quick-update`
|
||||
|
||||
Les valeurs invalides entraîneront soit :
|
||||
1. L’affichage d’un message d'erreur suivi d’un exit (pour les options critiques comme le répertoire)
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ prompts:
|
|||
|
||||
### 3. Apply Your Changes
|
||||
|
||||
After editing, recompile the agent to apply changes:
|
||||
After editing, reinstall to apply changes:
|
||||
|
||||
```bash
|
||||
npx bmad-method install
|
||||
|
|
@ -138,17 +138,16 @@ The installer detects the existing installation and offers these options:
|
|||
|
||||
| Option | What It Does |
|
||||
| ---------------------------- | ------------------------------------------------------------------- |
|
||||
| **Quick Update** | Updates all modules to the latest version and recompiles all agents |
|
||||
| **Recompile Agents** | Applies customizations only, without updating module files |
|
||||
| **Quick Update** | Updates all modules to the latest version and applies customizations |
|
||||
| **Modify BMad Installation** | Full installation flow for adding or removing modules |
|
||||
|
||||
For customization-only changes, **Recompile Agents** is the fastest option.
|
||||
For customization-only changes, **Quick Update** is the fastest option.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Changes not appearing?**
|
||||
|
||||
- Run `npx bmad-method install` and select **Recompile Agents** to apply changes
|
||||
- Run `npx bmad-method install` and select **Quick Update** to apply changes
|
||||
- Check that your YAML syntax is valid (indentation matters)
|
||||
- Verify you edited the correct `.customize.yaml` file for the agent
|
||||
|
||||
|
|
@ -161,7 +160,7 @@ For customization-only changes, **Recompile Agents** is the fastest option.
|
|||
**Need to reset an agent?**
|
||||
|
||||
- Clear or delete the agent's `.customize.yaml` file
|
||||
- Run `npx bmad-method install` and select **Recompile Agents** to restore defaults
|
||||
- Run `npx bmad-method install` and select **Quick Update** to restore defaults
|
||||
|
||||
## Workflow Customization
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
| `--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` |
|
||||
| `--action <type>` | Action for existing installations: `install` (default), `update`, `quick-update`, or `compile-agents` | `--action quick-update` |
|
||||
| `--action <type>` | Action for existing installations: `install` (default), `update`, or `quick-update` | `--action quick-update` |
|
||||
|
||||
### Core Configuration
|
||||
|
||||
|
|
@ -121,7 +121,7 @@ npx bmad-method install \
|
|||
## What You Get
|
||||
|
||||
- A fully configured `_bmad/` directory in your project
|
||||
- Compiled agents and workflows for your selected modules and tools
|
||||
- Agents and workflows configured for your selected modules and tools
|
||||
- A `_bmad-output/` folder for generated artifacts
|
||||
|
||||
## Validation and Error Handling
|
||||
|
|
@ -132,7 +132,7 @@ BMad validates all provided flags:
|
|||
- **Modules** — Warns about invalid module IDs (but won't fail)
|
||||
- **Tools** — Warns about invalid tool IDs (but won't fail)
|
||||
- **Custom Content** — Each path must contain a valid `module.yaml` file
|
||||
- **Action** — Must be one of: `install`, `update`, `quick-update`, `compile-agents`
|
||||
- **Action** — Must be one of: `install`, `update`, `quick-update`
|
||||
|
||||
Invalid values will either:
|
||||
1. Show an error and exit (for critical options like directory)
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ prompts:
|
|||
|
||||
### 3. 应用您的更改
|
||||
|
||||
编辑后,重新编译智能体以应用更改:
|
||||
编辑后,重新安装以应用更改:
|
||||
|
||||
```bash
|
||||
npx bmad-method install
|
||||
|
|
@ -138,17 +138,16 @@ npx bmad-method install
|
|||
|
||||
| Option | What It Does |
|
||||
| ---------------------------- | ------------------------------------------------------------------- |
|
||||
| **Quick Update** | 将所有模块更新到最新版本并重新编译所有智能体 |
|
||||
| **Recompile Agents** | 仅应用自定义配置,不更新模块文件 |
|
||||
| **Quick Update** | 将所有模块更新到最新版本并应用自定义配置 |
|
||||
| **Modify BMad Installation** | 用于添加或删除模块的完整安装流程 |
|
||||
|
||||
对于仅自定义配置的更改,**Recompile Agents** 是最快的选项。
|
||||
对于仅自定义配置的更改,**Quick Update** 是最快的选项。
|
||||
|
||||
## 故障排除
|
||||
|
||||
**更改未生效?**
|
||||
|
||||
- 运行 `npx bmad-method install` 并选择 **Recompile Agents** 以应用更改
|
||||
- 运行 `npx bmad-method install` 并选择 **Quick Update** 以应用更改
|
||||
- 检查您的 YAML 语法是否有效(缩进很重要)
|
||||
- 验证您编辑的是该智能体正确的 `.customize.yaml` 文件
|
||||
|
||||
|
|
@ -161,7 +160,7 @@ npx bmad-method install
|
|||
**需要重置智能体?**
|
||||
|
||||
- 清空或删除智能体的 `.customize.yaml` 文件
|
||||
- 运行 `npx bmad-method install` 并选择 **Recompile Agents** 以恢复默认设置
|
||||
- 运行 `npx bmad-method install` 并选择 **Quick Update** 以恢复默认设置
|
||||
|
||||
## 工作流自定义
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ sidebar:
|
|||
| `--modules <modules>` | 逗号分隔的模块 ID | `--modules bmm,bmb` |
|
||||
| `--tools <tools>` | 逗号分隔的工具/IDE ID(使用 `none` 跳过) | `--tools claude-code,cursor` 或 `--tools none` |
|
||||
| `--custom-content <paths>` | 逗号分隔的自定义模块路径 | `--custom-content ~/my-module,~/another-module` |
|
||||
| `--action <type>` | 对现有安装的操作:`install`(默认)、`update`、`quick-update` 或 `compile-agents` | `--action quick-update` |
|
||||
| `--action <type>` | 对现有安装的操作:`install`(默认)、`update` 或 `quick-update` | `--action quick-update` |
|
||||
|
||||
### 核心配置
|
||||
|
||||
|
|
@ -121,7 +121,7 @@ npx bmad-method install \
|
|||
## 安装结果
|
||||
|
||||
- 项目中完全配置的 `_bmad/` 目录
|
||||
- 为所选模块和工具编译的智能体和工作流
|
||||
- 为所选模块和工具配置的智能体和工作流
|
||||
- 用于生成产物的 `_bmad-output/` 文件夹
|
||||
|
||||
## 验证和错误处理
|
||||
|
|
@ -132,7 +132,7 @@ BMad 会验证所有提供的标志:
|
|||
- **模块** — 对无效的模块 ID 发出警告(但不会失败)
|
||||
- **工具** — 对无效的工具 ID 发出警告(但不会失败)
|
||||
- **自定义内容** — 每个路径必须包含有效的 `module.yaml` 文件
|
||||
- **操作** — 必须是以下之一:`install`、`update`、`quick-update`、`compile-agents`
|
||||
- **操作** — 必须是以下之一:`install`、`update`、`quick-update`
|
||||
|
||||
无效值将:
|
||||
1. 显示错误并退出(对于目录等关键选项)
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
type: skill
|
||||
module: core
|
||||
capabilities:
|
||||
- name: bmad-distillator
|
||||
menu-code: DSTL
|
||||
description: "Produces lossless LLM-optimized distillate from source documents. Use after producing large human presentable documents that will be consumed later by LLMs"
|
||||
supports-headless: true
|
||||
input: source documents
|
||||
args: output, validate
|
||||
output: single distillate or folder of distillates next to source input
|
||||
config-vars-used: null
|
||||
phase: anytime
|
||||
before: []
|
||||
after: []
|
||||
is-required: false
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -1 +0,0 @@
|
|||
type: skill
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const fs = require('fs-extra');
|
||||
const { YamlXmlBuilder } = require('../tools/cli/lib/yaml-xml-builder');
|
||||
const { ManifestGenerator } = require('../tools/cli/installers/lib/core/manifest-generator');
|
||||
const { IdeManager } = require('../tools/cli/installers/lib/ide/manager');
|
||||
const { clearCache, loadPlatformCodes } = require('../tools/cli/installers/lib/ide/platform-codes');
|
||||
|
|
@ -79,7 +78,6 @@ async function createTestBmadFixture() {
|
|||
'You are a test agent.',
|
||||
].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');
|
||||
|
||||
return fixtureDir;
|
||||
|
|
@ -100,17 +98,6 @@ async function createSkillCollisionFixture() {
|
|||
].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(
|
||||
path.join(configDir, 'skill-manifest.csv'),
|
||||
[
|
||||
|
|
@ -149,77 +136,10 @@ async function runTests() {
|
|||
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
|
||||
// Test 1: Removed — old YAML→XML agent compilation no longer applies (agents now use SKILL.md format)
|
||||
|
||||
console.log('');
|
||||
|
||||
// ============================================================
|
||||
// Test 2: Customization Merging
|
||||
// Test 1: Windsurf Native Skills Install
|
||||
// ============================================================
|
||||
console.log(`${colors.yellow}Test Suite 2: Customization Merging${colors.reset}\n`);
|
||||
|
||||
try {
|
||||
const builder = new YamlXmlBuilder();
|
||||
|
||||
// Test deepMerge function
|
||||
const base = {
|
||||
agent: {
|
||||
metadata: { name: 'John', title: 'PM' },
|
||||
persona: { role: 'Product Manager', style: 'Analytical' },
|
||||
},
|
||||
};
|
||||
|
||||
const customize = {
|
||||
agent: {
|
||||
metadata: { name: 'Sarah' }, // Override name only
|
||||
persona: { style: 'Concise' }, // Override style only
|
||||
},
|
||||
};
|
||||
|
||||
const merged = builder.deepMerge(base, customize);
|
||||
|
||||
assert(merged.agent.metadata.name === 'Sarah', 'Deep merge overrides customized name');
|
||||
|
||||
assert(merged.agent.metadata.title === 'PM', 'Deep merge preserves non-overridden title');
|
||||
|
||||
assert(merged.agent.persona.role === 'Product Manager', 'Deep merge preserves non-overridden role');
|
||||
|
||||
assert(merged.agent.persona.style === 'Concise', 'Deep merge overrides customized style');
|
||||
} catch (error) {
|
||||
assert(false, 'Customization merging works', error.message);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// ============================================================
|
||||
// Test 3: Path Resolution
|
||||
// ============================================================
|
||||
console.log(`${colors.yellow}Test Suite 3: Path Variable Resolution${colors.reset}\n`);
|
||||
|
||||
try {
|
||||
const builder = new YamlXmlBuilder();
|
||||
|
||||
// Test path resolution logic (if exposed)
|
||||
// This would test {project-root}, {installed_path}, {config_source} resolution
|
||||
|
||||
const testPath = '{project-root}/bmad/bmm/config.yaml';
|
||||
const expectedPattern = /\/bmad\/bmm\/config\.yaml$/;
|
||||
|
||||
assert(
|
||||
true, // Placeholder - would test actual resolution
|
||||
'Path variable resolution pattern matches expected format',
|
||||
'Note: This test validates path resolution logic exists',
|
||||
);
|
||||
} catch (error) {
|
||||
assert(false, 'Path resolution works', error.message);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// ============================================================
|
||||
// Test 4: Windsurf Native Skills Install
|
||||
// ============================================================
|
||||
console.log(`${colors.yellow}Test Suite 4: Windsurf Native Skills${colors.reset}\n`);
|
||||
console.log(`${colors.yellow}Test Suite 1: Windsurf Native Skills${colors.reset}\n`);
|
||||
|
||||
try {
|
||||
clearCache();
|
||||
|
|
@ -1603,7 +1523,6 @@ async function runTests() {
|
|||
// --- Skill at unusual path: core/custom-area/my-skill/ ---
|
||||
const skillDir29 = path.join(tempFixture29, 'core', 'custom-area', 'my-skill');
|
||||
await fs.ensureDir(skillDir29);
|
||||
await fs.writeFile(path.join(skillDir29, 'bmad-skill-manifest.yaml'), 'type: skill\n');
|
||||
await fs.writeFile(
|
||||
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',
|
||||
|
|
@ -1619,10 +1538,9 @@ async function runTests() {
|
|||
'---\nname: Regular Workflow\ndescription: A regular workflow not a skill\n---\n\nWorkflow body\n',
|
||||
);
|
||||
|
||||
// --- Skill inside workflows/ dir: core/workflows/wf-skill/ (exercises findWorkflows skip logic) ---
|
||||
// --- Skill inside workflows/ dir: core/workflows/wf-skill/ ---
|
||||
const wfSkillDir29 = path.join(tempFixture29, 'core', 'workflows', 'wf-skill');
|
||||
await fs.ensureDir(wfSkillDir29);
|
||||
await fs.writeFile(path.join(wfSkillDir29, 'bmad-skill-manifest.yaml'), 'type: skill\n');
|
||||
await fs.writeFile(
|
||||
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',
|
||||
|
|
@ -1632,7 +1550,6 @@ async function runTests() {
|
|||
// --- Skill inside tasks/ dir: core/tasks/task-skill/ ---
|
||||
const taskSkillDir29 = path.join(tempFixture29, 'core', 'tasks', 'task-skill');
|
||||
await fs.ensureDir(taskSkillDir29);
|
||||
await fs.writeFile(path.join(taskSkillDir29, 'bmad-skill-manifest.yaml'), 'type: skill\n');
|
||||
await fs.writeFile(
|
||||
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',
|
||||
|
|
@ -1665,18 +1582,10 @@ async function runTests() {
|
|||
'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
|
||||
const taskSkillEntry29 = generator29.skills.find((s) => s.canonicalId === 'task-skill');
|
||||
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
|
||||
// remain visible to the agent manifest pipeline.
|
||||
const nativeAgentEntry29 = generator29.skills.find((s) => s.canonicalId === 'bmad-tea');
|
||||
|
|
@ -1688,23 +1597,17 @@ async function runTests() {
|
|||
const nativeAgentManifest29 = generator29.agents.find((a) => a.name === 'bmad-tea');
|
||||
assert(nativeAgentManifest29 !== undefined, 'Native type:agent SKILL.md dir appears in agents[] for agent metadata');
|
||||
|
||||
// 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[]');
|
||||
|
||||
// Regular type:workflow should NOT appear in skills[]
|
||||
const regularInSkills29 = generator29.skills.find((s) => s.canonicalId === 'regular-wf');
|
||||
assert(regularInSkills29 === undefined, 'Regular type:workflow does NOT appear in skills[]');
|
||||
|
||||
// Skill inside workflows/ should be in skills[], NOT in workflows[] (exercises findWorkflows skip at lines 311/322)
|
||||
// Skill inside workflows/ should be in skills[]
|
||||
const wfSkill29 = generator29.skills.find((s) => s.canonicalId === 'wf-skill');
|
||||
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
|
||||
const skillOnlyModDir29 = path.join(tempFixture29, 'skill-only-mod');
|
||||
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(
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ module.exports = {
|
|||
'Comma-separated list of tool/IDE IDs to configure (e.g., "claude-code,cursor"). Use "none" to skip tool configuration.',
|
||||
],
|
||||
['--custom-content <paths>', 'Comma-separated list of paths to custom modules/agents/workflows'],
|
||||
['--action <type>', 'Action type for existing installations: install, update, quick-update, or compile-agents'],
|
||||
['--action <type>', 'Action type for existing installations: install, update, or quick-update'],
|
||||
['--user-name <name>', 'Name for agents to use (default: system username)'],
|
||||
['--communication-language <lang>', 'Language for agent communication (default: English)'],
|
||||
['--document-output-language <lang>', 'Language for document output (default: English)'],
|
||||
|
|
@ -49,13 +49,6 @@ module.exports = {
|
|||
process.exit(0);
|
||||
}
|
||||
|
||||
// Handle compile agents separately
|
||||
if (config.actionType === 'compile-agents') {
|
||||
const result = await installer.compileAgents(config);
|
||||
await prompts.log.info(`Recompiled ${result.agentCount} agents with customizations applied`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Regular install/update flow
|
||||
const result = await installer.install(config);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ const { ModuleManager } = require('../modules/manager');
|
|||
const { IdeManager } = require('../ide/manager');
|
||||
const { FileOps } = require('../../../lib/file-ops');
|
||||
const { Config } = require('../../../lib/config');
|
||||
const { XmlHandler } = require('../../../lib/xml-handler');
|
||||
const { DependencyResolver } = require('./dependency-resolver');
|
||||
const { ConfigCollector } = require('./config-collector');
|
||||
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
|
||||
|
|
@ -25,7 +24,6 @@ class Installer {
|
|||
this.ideManager = new IdeManager();
|
||||
this.fileOps = new FileOps();
|
||||
this.config = new Config();
|
||||
this.xmlHandler = new XmlHandler();
|
||||
this.dependencyResolver = new DependencyResolver();
|
||||
this.configCollector = new ConfigCollector();
|
||||
this.ideConfigManager = new IdeConfigManager();
|
||||
|
|
@ -1126,11 +1124,9 @@ class Installer {
|
|||
// Pre-register manifest files
|
||||
const cfgDir = path.join(bmadDir, '_config');
|
||||
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, 'task-manifest.csv'));
|
||||
|
||||
// Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes
|
||||
// Generate CSV manifests for agents, skills AND ALL FILES with hashes
|
||||
// This must happen BEFORE mergeModuleHelpCatalogs because it depends on agent-manifest.csv
|
||||
message('Generating manifests...');
|
||||
const manifestGen = new ManifestGenerator();
|
||||
|
|
@ -2114,10 +2110,6 @@ class Installer {
|
|||
},
|
||||
);
|
||||
|
||||
// Process agent files to build YAML agents and create customize templates
|
||||
const modulePath = path.join(bmadDir, moduleName);
|
||||
await this.processAgentFiles(modulePath, moduleName);
|
||||
|
||||
// Dependencies are already included in full module install
|
||||
}
|
||||
|
||||
|
|
@ -2227,16 +2219,8 @@ class Installer {
|
|||
const sourcePath = getModulePath('core');
|
||||
const targetPath = path.join(bmadDir, 'core');
|
||||
|
||||
// Copy core files (skip .agent.yaml files like modules do)
|
||||
// Copy core files
|
||||
await this.copyCoreFiles(sourcePath, targetPath);
|
||||
|
||||
// Compile agents using the same compiler as modules
|
||||
const { ModuleManager } = require('../modules/manager');
|
||||
const moduleManager = new ModuleManager();
|
||||
await moduleManager.compileModuleAgents(sourcePath, targetPath, 'core', bmadDir, this);
|
||||
|
||||
// Process agent files to inject activation block
|
||||
await this.processAgentFiles(targetPath, 'core');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -2254,16 +2238,6 @@ class Installer {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Skip sidecar directories - they are handled separately during agent compilation
|
||||
if (
|
||||
path
|
||||
.dirname(file)
|
||||
.split('/')
|
||||
.some((dir) => dir.toLowerCase().includes('sidecar'))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip module.yaml at root - it's only needed at install time
|
||||
if (file === 'module.yaml') {
|
||||
continue;
|
||||
|
|
@ -2274,27 +2248,9 @@ class Installer {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Skip .agent.yaml files - they will be compiled separately
|
||||
if (file.endsWith('.agent.yaml')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceFile = path.join(sourcePath, file);
|
||||
const targetFile = path.join(targetPath, file);
|
||||
|
||||
// Check if this is an agent file
|
||||
if (file.startsWith('agents/') && file.endsWith('.md')) {
|
||||
// Read the file to check for localskip
|
||||
const content = await fs.readFile(sourceFile, 'utf8');
|
||||
|
||||
// Check for localskip="true" in the agent tag
|
||||
const agentMatch = content.match(/<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
|
||||
await fs.ensureDir(path.dirname(targetFile));
|
||||
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
|
||||
|
|
@ -2328,58 +2284,6 @@ class Installer {
|
|||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process agent files to build YAML agents and inject activation blocks
|
||||
* @param {string} modulePath - Path to module in bmad/ installation
|
||||
* @param {string} moduleName - Module name
|
||||
*/
|
||||
async processAgentFiles(modulePath, moduleName) {
|
||||
const agentsPath = path.join(modulePath, 'agents');
|
||||
|
||||
// Check if agents directory exists
|
||||
if (!(await fs.pathExists(agentsPath))) {
|
||||
return; // No agents to process
|
||||
}
|
||||
|
||||
// Determine project directory (parent of bmad/ directory)
|
||||
const bmadDir = path.dirname(modulePath);
|
||||
const cfgAgentsDir = path.join(bmadDir, '_config', 'agents');
|
||||
|
||||
// Ensure _config/agents directory exists
|
||||
await fs.ensureDir(cfgAgentsDir);
|
||||
|
||||
// Get all agent files
|
||||
const agentFiles = await fs.readdir(agentsPath);
|
||||
|
||||
for (const agentFile of agentFiles) {
|
||||
// Skip .agent.yaml files - they should already be compiled by compileModuleAgents
|
||||
if (agentFile.endsWith('.agent.yaml')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only process .md files (already compiled from YAML)
|
||||
if (!agentFile.endsWith('.md')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const agentName = agentFile.replace('.md', '');
|
||||
const mdPath = path.join(agentsPath, agentFile);
|
||||
const customizePath = path.join(cfgAgentsDir, `${moduleName}-${agentName}.customize.yaml`);
|
||||
|
||||
// For .md files that are already compiled, we don't need to do much
|
||||
// Just ensure the customize template exists
|
||||
if (!(await fs.pathExists(customizePath))) {
|
||||
const genericTemplatePath = getSourcePath('utility', 'agent-components', 'agent.customize.template.yaml');
|
||||
if (await fs.pathExists(genericTemplatePath)) {
|
||||
await this.copyFileWithPlaceholderReplacement(genericTemplatePath, customizePath);
|
||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
||||
await prompts.log.message(` Created customize: ${moduleName}-${agentName}.customize.yaml`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Private: Update core
|
||||
*/
|
||||
|
|
@ -2393,12 +2297,6 @@ class Installer {
|
|||
} else {
|
||||
// Selective update - preserve user modifications
|
||||
await this.fileOps.syncDirectory(sourcePath, targetPath);
|
||||
|
||||
// Recompile agents (#1133)
|
||||
const { ModuleManager } = require('../modules/manager');
|
||||
const moduleManager = new ModuleManager();
|
||||
await moduleManager.compileModuleAgents(sourcePath, targetPath, 'core', bmadDir, this);
|
||||
await this.processAgentFiles(targetPath, 'core');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2643,114 +2541,6 @@ class Installer {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile agents with customizations only
|
||||
* @param {Object} config - Configuration with directory
|
||||
* @returns {Object} Compilation result
|
||||
*/
|
||||
async compileAgents(config) {
|
||||
// Using @clack prompts
|
||||
const { ModuleManager } = require('../modules/manager');
|
||||
const { getSourcePath } = require('../../../lib/project-root');
|
||||
|
||||
const spinner = await prompts.spinner();
|
||||
spinner.start('Recompiling agents with customizations...');
|
||||
|
||||
try {
|
||||
const projectDir = path.resolve(config.directory);
|
||||
const { bmadDir } = await this.findBmadDir(projectDir);
|
||||
|
||||
// Check if bmad directory exists
|
||||
if (!(await fs.pathExists(bmadDir))) {
|
||||
spinner.stop('No BMAD installation found');
|
||||
throw new Error(`BMAD not installed at ${bmadDir}. Use regular install for first-time setup.`);
|
||||
}
|
||||
|
||||
// Detect existing installation
|
||||
const existingInstall = await this.detector.detect(bmadDir);
|
||||
const installedModules = existingInstall.modules.map((m) => m.id);
|
||||
|
||||
// Initialize module manager
|
||||
const moduleManager = new ModuleManager();
|
||||
moduleManager.setBmadFolderName(path.basename(bmadDir));
|
||||
|
||||
let totalAgentCount = 0;
|
||||
|
||||
// Get custom module sources from cache
|
||||
const customModuleSources = new Map();
|
||||
const cacheDir = path.join(bmadDir, '_config', 'custom');
|
||||
if (await fs.pathExists(cacheDir)) {
|
||||
const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
|
||||
|
||||
for (const cachedModule of cachedModules) {
|
||||
if (cachedModule.isDirectory()) {
|
||||
const moduleId = cachedModule.name;
|
||||
const cachedPath = path.join(cacheDir, moduleId);
|
||||
const moduleYamlPath = path.join(cachedPath, 'module.yaml');
|
||||
|
||||
// Check if this is actually a custom module
|
||||
if (await fs.pathExists(moduleYamlPath)) {
|
||||
// Check if this is an external official module - skip cache for those
|
||||
const isExternal = await this.moduleManager.isExternalModule(moduleId);
|
||||
if (isExternal) {
|
||||
// External modules are handled via cloneExternalModule, not from cache
|
||||
continue;
|
||||
}
|
||||
customModuleSources.set(moduleId, cachedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process each installed module
|
||||
for (const moduleId of installedModules) {
|
||||
spinner.message(`Recompiling agents in ${moduleId}...`);
|
||||
|
||||
// Get source path
|
||||
let sourcePath;
|
||||
if (moduleId === 'core') {
|
||||
sourcePath = getSourcePath('core-skills');
|
||||
} else {
|
||||
// First check if it's in the custom cache
|
||||
if (customModuleSources.has(moduleId)) {
|
||||
sourcePath = customModuleSources.get(moduleId);
|
||||
} else {
|
||||
sourcePath = await moduleManager.findModuleSource(moduleId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourcePath) {
|
||||
await prompts.log.warn(`Source not found for module ${moduleId}, skipping...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetPath = path.join(bmadDir, moduleId);
|
||||
|
||||
// Compile agents for this module
|
||||
await moduleManager.compileModuleAgents(sourcePath, targetPath, moduleId, bmadDir, this);
|
||||
|
||||
// Count agents (rough estimate based on files)
|
||||
const agentsPath = path.join(targetPath, 'agents');
|
||||
if (await fs.pathExists(agentsPath)) {
|
||||
const agentFiles = await fs.readdir(agentsPath);
|
||||
const agentCount = agentFiles.filter((f) => f.endsWith('.md')).length;
|
||||
totalAgentCount += agentCount;
|
||||
}
|
||||
}
|
||||
|
||||
spinner.stop('Agent recompilation complete!');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
agentCount: totalAgentCount,
|
||||
modules: installedModules,
|
||||
};
|
||||
} catch (error) {
|
||||
spinner.error('Agent recompilation failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Private: Prompt for update action
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -16,15 +16,12 @@ const {
|
|||
const packageJson = require('../../../../../package.json');
|
||||
|
||||
/**
|
||||
* Generates manifest files for installed workflows, agents, and tasks
|
||||
* Generates manifest files for installed skills and agents
|
||||
*/
|
||||
class ManifestGenerator {
|
||||
constructor() {
|
||||
this.workflows = [];
|
||||
this.skills = [];
|
||||
this.agents = [];
|
||||
this.tasks = [];
|
||||
this.tools = [];
|
||||
this.modules = [];
|
||||
this.files = [];
|
||||
this.selectedIdes = [];
|
||||
|
|
@ -50,29 +47,6 @@ class ManifestGenerator {
|
|||
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.
|
||||
* Note: Quote escaping is handled by escapeCsv() at write time.
|
||||
|
|
@ -108,10 +82,6 @@ class ManifestGenerator {
|
|||
this.modules = allModules;
|
||||
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.bmadFolderName = path.basename(bmadDir); // Get the actual folder name (e.g., '_bmad' or 'bmad')
|
||||
this.allInstalledFiles = installedFiles;
|
||||
|
|
@ -134,35 +104,20 @@ class ManifestGenerator {
|
|||
// Collect skills first (populates skillClaimedDirs before legacy collectors run)
|
||||
await this.collectSkills();
|
||||
|
||||
// Collect workflow data
|
||||
await this.collectWorkflows(selectedModules);
|
||||
|
||||
// Collect agent data - use updatedModules which includes all installed modules
|
||||
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
|
||||
const manifestFiles = [
|
||||
await this.writeMainManifest(cfgDir),
|
||||
await this.writeWorkflowManifest(cfgDir),
|
||||
await this.writeSkillManifest(cfgDir),
|
||||
await this.writeAgentManifest(cfgDir),
|
||||
await this.writeTaskManifest(cfgDir),
|
||||
await this.writeToolManifest(cfgDir),
|
||||
await this.writeFilesManifest(cfgDir),
|
||||
];
|
||||
|
||||
return {
|
||||
skills: this.skills.length,
|
||||
workflows: this.workflows.length,
|
||||
agents: this.agents.length,
|
||||
tasks: this.tasks.length,
|
||||
tools: this.tools.length,
|
||||
files: this.files.length,
|
||||
manifestFiles: manifestFiles,
|
||||
};
|
||||
|
|
@ -170,9 +125,9 @@ class ManifestGenerator {
|
|||
|
||||
/**
|
||||
* Recursively walk a module directory tree, collecting native SKILL.md entrypoints.
|
||||
* A native entrypoint directory is one that contains both a
|
||||
* bmad-skill-manifest.yaml with type: skill or type: agent AND a SKILL.md file
|
||||
* with name/description frontmatter.
|
||||
* A directory is discovered as a skill when it contains a SKILL.md file with
|
||||
* valid name/description frontmatter (name must match directory name).
|
||||
* Manifest YAML is loaded only when present — for install_to_bmad and agent metadata.
|
||||
* Populates this.skills[] and this.skillClaimedDirs (Set of absolute paths).
|
||||
*/
|
||||
async collectSkills() {
|
||||
|
|
@ -193,21 +148,18 @@ class ManifestGenerator {
|
|||
return;
|
||||
}
|
||||
|
||||
// Check this directory for skill manifest
|
||||
const manifest = await this.loadSkillManifest(dir);
|
||||
|
||||
// Determine if this directory is a native SKILL.md entrypoint
|
||||
// SKILL.md with valid frontmatter is the primary discovery gate
|
||||
const skillFile = 'SKILL.md';
|
||||
const artifactType = this.getArtifactType(manifest, skillFile);
|
||||
|
||||
if (this.isNativeSkillDirType(artifactType)) {
|
||||
const skillMdPath = path.join(dir, 'SKILL.md');
|
||||
const skillMdPath = path.join(dir, skillFile);
|
||||
const dirName = path.basename(dir);
|
||||
|
||||
// Validate and parse SKILL.md
|
||||
const skillMeta = await this.parseSkillMd(skillMdPath, dir, dirName, debug);
|
||||
|
||||
if (skillMeta) {
|
||||
// Load manifest when present (for install_to_bmad and agent metadata)
|
||||
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)
|
||||
const relativePath = path.relative(modulePath, dir).split(path.sep).join('/');
|
||||
const installPath = relativePath
|
||||
|
|
@ -247,25 +199,6 @@ class ManifestGenerator {
|
|||
console.log(`[DEBUG] collectSkills: claimed skill "${skillMeta.name}" as ${canonicalId} at ${dir}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Warn if manifest says this is a native entrypoint but the directory was not claimed
|
||||
if (manifest && !this.skillClaimedDirs.has(dir)) {
|
||||
let hasNativeSkillType = false;
|
||||
if (manifest.__single) {
|
||||
hasNativeSkillType = this.isNativeSkillDirType(manifest.__single.type);
|
||||
} else {
|
||||
for (const key of Object.keys(manifest)) {
|
||||
if (this.isNativeSkillDirType(manifest[key]?.type)) {
|
||||
hasNativeSkillType = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasNativeSkillType && debug) {
|
||||
console.log(`[DEBUG] collectSkills: dir has native SKILL.md manifest but failed validation: ${dir}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into subdirectories
|
||||
for (const entry of entries) {
|
||||
|
|
@ -334,153 +267,6 @@ class ManifestGenerator {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all workflows from core and selected modules
|
||||
* Scans the INSTALLED bmad directory, not the source
|
||||
*/
|
||||
async collectWorkflows(selectedModules) {
|
||||
this.workflows = [];
|
||||
|
||||
// Use updatedModules which already includes deduplicated 'core' + selectedModules
|
||||
for (const moduleName of this.updatedModules) {
|
||||
const modulePath = path.join(this.bmadDir, moduleName);
|
||||
|
||||
if (await fs.pathExists(modulePath)) {
|
||||
const moduleWorkflows = await this.getWorkflowsFromPath(modulePath, moduleName);
|
||||
this.workflows.push(...moduleWorkflows);
|
||||
|
||||
// Also scan tasks/ for type:skill entries (skills can live anywhere)
|
||||
const tasksSkills = await this.getWorkflowsFromPath(modulePath, moduleName, 'tasks');
|
||||
this.workflows.push(...tasksSkills);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find and parse workflow.md files
|
||||
*/
|
||||
async getWorkflowsFromPath(basePath, moduleName, subDir = 'workflows') {
|
||||
const workflows = [];
|
||||
const workflowsPath = path.join(basePath, subDir);
|
||||
const debug = process.env.BMAD_DEBUG_MANIFEST === 'true';
|
||||
|
||||
if (debug) {
|
||||
console.log(`[DEBUG] Scanning workflows in: ${workflowsPath}`);
|
||||
}
|
||||
|
||||
if (!(await fs.pathExists(workflowsPath))) {
|
||||
if (debug) {
|
||||
console.log(`[DEBUG] Workflows path does not exist: ${workflowsPath}`);
|
||||
}
|
||||
return workflows;
|
||||
}
|
||||
|
||||
// Recursively find workflow.md files
|
||||
const findWorkflows = async (dir, relativePath = '') => {
|
||||
// Skip directories already claimed as skills
|
||||
if (this.skillClaimedDirs && this.skillClaimedDirs.has(dir)) return;
|
||||
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
// Load skill manifest for this directory (if present)
|
||||
const skillManifest = await this.loadSkillManifest(dir);
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Skip directories claimed by collectSkills
|
||||
if (this.skillClaimedDirs && this.skillClaimedDirs.has(fullPath)) continue;
|
||||
// Recurse into subdirectories
|
||||
const newRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
||||
await findWorkflows(fullPath, newRelativePath);
|
||||
} else if (entry.name === 'workflow.md' || (entry.name.startsWith('workflow-') && entry.name.endsWith('.md'))) {
|
||||
// Parse workflow file (both YAML and MD formats)
|
||||
if (debug) {
|
||||
console.log(`[DEBUG] Found workflow file: ${fullPath}`);
|
||||
}
|
||||
try {
|
||||
// Read and normalize line endings (fix Windows CRLF issues)
|
||||
const rawContent = await fs.readFile(fullPath, 'utf8');
|
||||
const content = rawContent.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
||||
|
||||
// Parse MD workflow with YAML frontmatter
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!frontmatterMatch) {
|
||||
if (debug) {
|
||||
console.log(`[DEBUG] Skipped (no frontmatter): ${fullPath}`);
|
||||
}
|
||||
continue; // Skip MD files without frontmatter
|
||||
}
|
||||
const workflow = yaml.parse(frontmatterMatch[1]);
|
||||
|
||||
if (debug) {
|
||||
console.log(`[DEBUG] Parsed: name="${workflow.name}", description=${workflow.description ? 'OK' : 'MISSING'}`);
|
||||
}
|
||||
|
||||
// Skip template workflows (those with placeholder values)
|
||||
if (workflow.name && workflow.name.includes('{') && workflow.name.includes('}')) {
|
||||
if (debug) {
|
||||
console.log(`[DEBUG] Skipped (template placeholder): ${workflow.name}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip workflows marked as non-standalone (reference/example workflows)
|
||||
if (workflow.standalone === false) {
|
||||
if (debug) {
|
||||
console.log(`[DEBUG] Skipped (standalone=false): ${workflow.name}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (workflow.name && workflow.description) {
|
||||
// Build relative path for installation
|
||||
const installPath =
|
||||
moduleName === 'core'
|
||||
? `${this.bmadFolderName}/core/${subDir}/${relativePath}/${entry.name}`
|
||||
: `${this.bmadFolderName}/${moduleName}/${subDir}/${relativePath}/${entry.name}`;
|
||||
|
||||
// Workflows with standalone: false are filtered out above
|
||||
workflows.push({
|
||||
name: workflow.name,
|
||||
description: this.cleanForCSV(workflow.description),
|
||||
module: moduleName,
|
||||
path: installPath,
|
||||
canonicalId: this.getCanonicalId(skillManifest, entry.name),
|
||||
});
|
||||
|
||||
// Add to files list
|
||||
this.files.push({
|
||||
type: 'workflow',
|
||||
name: workflow.name,
|
||||
module: moduleName,
|
||||
path: installPath,
|
||||
});
|
||||
|
||||
if (debug) {
|
||||
console.log(`[DEBUG] ✓ Added workflow: ${workflow.name} (${moduleName})`);
|
||||
}
|
||||
} else {
|
||||
if (debug) {
|
||||
console.log(`[DEBUG] Skipped (missing name or description): ${fullPath}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await prompts.log.warn(`Failed to parse workflow at ${fullPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await findWorkflows(workflowsPath);
|
||||
|
||||
if (debug) {
|
||||
console.log(`[DEBUG] Total workflows found in ${moduleName}: ${workflows.length}`);
|
||||
}
|
||||
|
||||
return workflows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all agents from core and selected modules
|
||||
* Scans the INSTALLED bmad directory, not the source
|
||||
|
|
@ -515,7 +301,7 @@ class ManifestGenerator {
|
|||
|
||||
/**
|
||||
* Get agents from a directory recursively
|
||||
* Only includes compiled .md files (not .agent.yaml source files)
|
||||
* Only includes .md files with agent content
|
||||
*/
|
||||
async getAgentsFromDir(dirPath, moduleName, relativePath = '') {
|
||||
// Skip directories claimed by collectSkills
|
||||
|
|
@ -572,7 +358,7 @@ class ManifestGenerator {
|
|||
const newRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
||||
const subDirAgents = await this.getAgentsFromDir(fullPath, moduleName, newRelativePath);
|
||||
agents.push(...subDirAgents);
|
||||
} else if (entry.name.endsWith('.md') && !entry.name.endsWith('.agent.yaml') && entry.name.toLowerCase() !== 'readme.md') {
|
||||
} else if (entry.name.endsWith('.md') && entry.name.toLowerCase() !== 'readme.md') {
|
||||
const content = await fs.readFile(fullPath, 'utf8');
|
||||
|
||||
// Skip files that don't contain <agent> tag (e.g., README files)
|
||||
|
|
@ -634,212 +420,6 @@ class ManifestGenerator {
|
|||
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
|
||||
* Fetches fresh version info for all modules
|
||||
|
|
@ -925,131 +505,6 @@ class ManifestGenerator {
|
|||
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
|
||||
* @returns {string} Path to the manifest file
|
||||
|
|
@ -1150,134 +605,6 @@ class ManifestGenerator {
|
|||
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
|
||||
*/
|
||||
|
|
@ -1377,22 +704,12 @@ class ManifestGenerator {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Check if this looks like a module (has agents, workflows, or tasks directory)
|
||||
// Check if this looks like a module (has agents directory or skill manifests)
|
||||
const modulePath = path.join(bmadDir, entry.name);
|
||||
const hasAgents = await fs.pathExists(path.join(modulePath, 'agents'));
|
||||
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'));
|
||||
const hasSkills = await this._hasSkillMdRecursive(modulePath);
|
||||
|
||||
// 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) {
|
||||
if (hasAgents || hasSkills) {
|
||||
modules.push(entry.name);
|
||||
}
|
||||
}
|
||||
|
|
@ -1404,13 +721,12 @@ class ManifestGenerator {
|
|||
}
|
||||
|
||||
/**
|
||||
* Recursively check if a directory tree contains a bmad-skill-manifest.yaml that
|
||||
* declares a native SKILL.md entrypoint (type: skill or type: agent).
|
||||
* Recursively check if a directory tree contains a SKILL.md file.
|
||||
* Skips directories starting with . or _.
|
||||
* @param {string} dir - Directory to search
|
||||
* @returns {boolean} True if a skill manifest is found
|
||||
* @returns {boolean} True if a SKILL.md is found
|
||||
*/
|
||||
async _hasSkillManifestRecursive(dir) {
|
||||
async _hasSkillMdRecursive(dir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
|
@ -1418,15 +734,14 @@ class ManifestGenerator {
|
|||
return false;
|
||||
}
|
||||
|
||||
// Check for manifest in this directory
|
||||
const manifest = await this.loadSkillManifest(dir);
|
||||
if (this.hasNativeSkillManifest(manifest)) return true;
|
||||
// Check for SKILL.md in this directory
|
||||
if (entries.some((e) => !e.isDirectory() && e.name === 'SKILL.md')) return true;
|
||||
|
||||
// Recurse into subdirectories
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
|
||||
if (await this._hasSkillManifestRecursive(path.join(dir, entry.name))) return true;
|
||||
if (await this._hasSkillMdRecursive(path.join(dir, entry.name))) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -2,19 +2,11 @@ const path = require('node:path');
|
|||
const fs = require('fs-extra');
|
||||
const yaml = require('yaml');
|
||||
const prompts = require('../../../lib/prompts');
|
||||
const { FileOps } = require('../../../lib/file-ops');
|
||||
const { XmlHandler } = require('../../../lib/xml-handler');
|
||||
|
||||
/**
|
||||
* Handler for custom content (custom.yaml)
|
||||
* Installs custom agents and workflows without requiring a full module structure
|
||||
* Discovers custom agents and workflows in the project
|
||||
*/
|
||||
class CustomHandler {
|
||||
constructor() {
|
||||
this.fileOps = new FileOps();
|
||||
this.xmlHandler = new XmlHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all custom.yaml files in the project
|
||||
* @param {string} projectRoot - Project root directory
|
||||
|
|
@ -115,244 +107,6 @@ class CustomHandler {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install custom content
|
||||
* @param {string} customPath - Path to custom content directory
|
||||
* @param {string} bmadDir - Target bmad directory
|
||||
* @param {Object} config - Configuration from custom.yaml
|
||||
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
||||
* @returns {Object} Installation result
|
||||
*/
|
||||
async install(customPath, bmadDir, config, fileTrackingCallback = null) {
|
||||
const results = {
|
||||
agentsInstalled: 0,
|
||||
workflowsInstalled: 0,
|
||||
filesCopied: 0,
|
||||
preserved: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
// Create custom directories in bmad
|
||||
const bmadCustomDir = path.join(bmadDir, 'custom');
|
||||
const bmadAgentsDir = path.join(bmadCustomDir, 'agents');
|
||||
const bmadWorkflowsDir = path.join(bmadCustomDir, 'workflows');
|
||||
|
||||
await fs.ensureDir(bmadCustomDir);
|
||||
await fs.ensureDir(bmadAgentsDir);
|
||||
await fs.ensureDir(bmadWorkflowsDir);
|
||||
|
||||
// Process agents - compile and copy agents
|
||||
const agentsDir = path.join(customPath, 'agents');
|
||||
if (await fs.pathExists(agentsDir)) {
|
||||
await this.compileAndCopyAgents(agentsDir, bmadAgentsDir, bmadDir, config, fileTrackingCallback, results);
|
||||
|
||||
// Count agent files
|
||||
const agentFiles = await this.findFilesRecursively(agentsDir, ['.agent.yaml', '.md']);
|
||||
results.agentsInstalled = agentFiles.length;
|
||||
}
|
||||
|
||||
// Process workflows - copy entire workflows directory structure
|
||||
const workflowsDir = path.join(customPath, 'workflows');
|
||||
if (await fs.pathExists(workflowsDir)) {
|
||||
await this.copyDirectory(workflowsDir, bmadWorkflowsDir, results, fileTrackingCallback, config);
|
||||
|
||||
// Count workflow files
|
||||
const workflowFiles = await this.findFilesRecursively(workflowsDir, ['.md']);
|
||||
results.workflowsInstalled = workflowFiles.length;
|
||||
}
|
||||
|
||||
// Process any additional files at root
|
||||
const entries = await fs.readdir(customPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name !== 'custom.yaml' && !entry.name.startsWith('.') && !entry.name.endsWith('.md')) {
|
||||
// Skip .md files at root as they're likely docs
|
||||
const sourcePath = path.join(customPath, entry.name);
|
||||
const targetPath = path.join(bmadCustomDir, entry.name);
|
||||
|
||||
try {
|
||||
// Check if file already exists
|
||||
if (await fs.pathExists(targetPath)) {
|
||||
// File already exists, preserve it
|
||||
results.preserved = (results.preserved || 0) + 1;
|
||||
} else {
|
||||
await fs.copy(sourcePath, targetPath);
|
||||
results.filesCopied++;
|
||||
|
||||
if (fileTrackingCallback) {
|
||||
fileTrackingCallback(targetPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
results.errors.push(`Failed to copy file ${entry.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
results.errors.push(`Installation failed: ${error.message}`);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all files with specific extensions recursively
|
||||
* @param {string} dir - Directory to search
|
||||
* @param {Array} extensions - File extensions to match
|
||||
* @returns {Array} List of matching files
|
||||
*/
|
||||
async findFilesRecursively(dir, extensions) {
|
||||
const files = [];
|
||||
|
||||
async function search(currentDir) {
|
||||
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await search(fullPath);
|
||||
} else if (extensions.some((ext) => entry.name.endsWith(ext))) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await search(dir);
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively copy a directory
|
||||
* @param {string} sourceDir - Source directory
|
||||
* @param {string} targetDir - Target directory
|
||||
* @param {Object} results - Results object to update
|
||||
* @param {Function} fileTrackingCallback - Optional callback
|
||||
* @param {Object} config - Configuration for placeholder replacement
|
||||
*/
|
||||
async copyDirectory(sourceDir, targetDir, results, fileTrackingCallback, config) {
|
||||
await fs.ensureDir(targetDir);
|
||||
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.join(sourceDir, entry.name);
|
||||
const targetPath = path.join(targetDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await this.copyDirectory(sourcePath, targetPath, results, fileTrackingCallback, config);
|
||||
} else {
|
||||
try {
|
||||
// Check if file already exists
|
||||
if (await fs.pathExists(targetPath)) {
|
||||
// File already exists, preserve it
|
||||
results.preserved = (results.preserved || 0) + 1;
|
||||
} else {
|
||||
// Copy with placeholder replacement for text files
|
||||
const textExtensions = ['.md', '.yaml', '.yml', '.txt', '.json'];
|
||||
if (textExtensions.some((ext) => entry.name.endsWith(ext))) {
|
||||
// Read source content
|
||||
let content = await fs.readFile(sourcePath, 'utf8');
|
||||
|
||||
// Replace placeholders
|
||||
content = content.replaceAll('{user_name}', config.user_name || 'User');
|
||||
content = content.replaceAll('{communication_language}', config.communication_language || 'English');
|
||||
content = content.replaceAll('{output_folder}', config.output_folder || 'docs');
|
||||
|
||||
// Write to target
|
||||
await fs.ensureDir(path.dirname(targetPath));
|
||||
await fs.writeFile(targetPath, content, 'utf8');
|
||||
} else {
|
||||
// Copy binary files as-is
|
||||
await fs.copy(sourcePath, targetPath);
|
||||
}
|
||||
|
||||
results.filesCopied++;
|
||||
if (entry.name.endsWith('.md')) {
|
||||
results.workflowsInstalled++;
|
||||
}
|
||||
if (fileTrackingCallback) {
|
||||
fileTrackingCallback(targetPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
results.errors.push(`Failed to copy ${entry.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile .agent.yaml files to .md format and handle sidecars
|
||||
* @param {string} sourceAgentsPath - Source agents directory
|
||||
* @param {string} targetAgentsPath - Target agents directory
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} config - Configuration for placeholder replacement
|
||||
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
||||
* @param {Object} results - Results object to update
|
||||
*/
|
||||
async compileAndCopyAgents(sourceAgentsPath, targetAgentsPath, bmadDir, config, fileTrackingCallback, results) {
|
||||
// Get all .agent.yaml files recursively
|
||||
const agentFiles = await this.findFilesRecursively(sourceAgentsPath, ['.agent.yaml']);
|
||||
|
||||
for (const agentFile of agentFiles) {
|
||||
const relativePath = path.relative(sourceAgentsPath, agentFile).split(path.sep).join('/');
|
||||
const targetDir = path.join(targetAgentsPath, path.dirname(relativePath));
|
||||
|
||||
await fs.ensureDir(targetDir);
|
||||
|
||||
const agentName = path.basename(agentFile, '.agent.yaml');
|
||||
const targetMdPath = path.join(targetDir, `${agentName}.md`);
|
||||
// Use the actual bmadDir if available (for when installing to temp dir)
|
||||
const actualBmadDir = config._bmadDir || bmadDir;
|
||||
const customizePath = path.join(actualBmadDir, '_config', 'agents', `custom-${agentName}.customize.yaml`);
|
||||
|
||||
// Read and compile the YAML
|
||||
try {
|
||||
const yamlContent = await fs.readFile(agentFile, 'utf8');
|
||||
const { compileAgent } = require('../../../lib/agent/compiler');
|
||||
|
||||
// Create customize template if it doesn't exist
|
||||
if (!(await fs.pathExists(customizePath))) {
|
||||
const { getSourcePath } = require('../../../lib/project-root');
|
||||
const genericTemplatePath = getSourcePath('utility', 'agent-components', 'agent.customize.template.yaml');
|
||||
if (await fs.pathExists(genericTemplatePath)) {
|
||||
let templateContent = await fs.readFile(genericTemplatePath, 'utf8');
|
||||
await fs.writeFile(customizePath, templateContent, 'utf8');
|
||||
// Only show customize creation in verbose mode
|
||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
||||
await prompts.log.message(' Created customize: custom-' + agentName + '.customize.yaml');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compile the agent
|
||||
const { xml } = compileAgent(yamlContent, {}, agentName, relativePath, { config });
|
||||
|
||||
// Replace placeholders in the compiled content
|
||||
let processedXml = xml;
|
||||
processedXml = processedXml.replaceAll('{user_name}', config.user_name || 'User');
|
||||
processedXml = processedXml.replaceAll('{communication_language}', config.communication_language || 'English');
|
||||
processedXml = processedXml.replaceAll('{output_folder}', config.output_folder || 'docs');
|
||||
|
||||
// Write the compiled MD file
|
||||
await fs.writeFile(targetMdPath, processedXml, 'utf8');
|
||||
|
||||
// Track the file
|
||||
if (fileTrackingCallback) {
|
||||
fileTrackingCallback(targetMdPath);
|
||||
}
|
||||
|
||||
// Only show compilation details in verbose mode
|
||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
||||
await prompts.log.message(' Compiled agent: ' + agentName + ' -> ' + path.relative(targetAgentsPath, targetMdPath));
|
||||
}
|
||||
} catch (error) {
|
||||
await prompts.log.warn(' Failed to compile agent ' + agentName + ': ' + error.message);
|
||||
results.errors.push(`Failed to compile agent ${agentName}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { CustomHandler };
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
const path = require('node:path');
|
||||
const fs = require('fs-extra');
|
||||
const { XmlHandler } = require('../../../lib/xml-handler');
|
||||
const prompts = require('../../../lib/prompts');
|
||||
const { getSourcePath } = require('../../../lib/project-root');
|
||||
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
|
||||
|
|
@ -18,7 +17,6 @@ class BaseIdeSetup {
|
|||
this.rulesDir = null; // Override in subclasses
|
||||
this.configFile = null; // Override in subclasses when detection is file-based
|
||||
this.detectionPaths = []; // Additional paths that indicate the IDE is configured
|
||||
this.xmlHandler = new XmlHandler();
|
||||
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
|
||||
}
|
||||
|
||||
|
|
@ -30,15 +28,6 @@ class BaseIdeSetup {
|
|||
this.bmadFolderName = bmadFolderName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the agent command activation header from the central template
|
||||
* @returns {string} The activation header text
|
||||
*/
|
||||
async getAgentCommandHeader() {
|
||||
const headerPath = getSourcePath('utility', 'agent-components', 'agent-command-header.md');
|
||||
return await fs.readFile(headerPath, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Main setup method - must be implemented by subclasses
|
||||
* @param {string} projectDir - Project directory
|
||||
|
|
@ -511,11 +500,6 @@ class BaseIdeSetup {
|
|||
// Replace placeholders
|
||||
let processed = content;
|
||||
|
||||
// Inject activation block for agent files FIRST (before replacements)
|
||||
if (metadata.name && content.includes('<agent')) {
|
||||
processed = this.xmlHandler.injectActivationSimple(processed, metadata);
|
||||
}
|
||||
|
||||
// Only replace {project-root} if a specific projectDir is provided
|
||||
// Otherwise leave the placeholder intact
|
||||
// Note: Don't add trailing slash - paths in source include leading slash
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ async function loadSkillManifest(dirPath) {
|
|||
/**
|
||||
* Get the canonicalId for a specific file from a loaded skill manifest.
|
||||
* @param {Object|null} manifest - Loaded manifest (from loadSkillManifest)
|
||||
* @param {string} filename - Source filename to look up (e.g., 'pm.md', 'help.md', 'pm.agent.yaml')
|
||||
* @param {string} filename - Source filename to look up (e.g., 'pm.md', 'help.md')
|
||||
* @returns {string} canonicalId or empty string
|
||||
*/
|
||||
function getCanonicalId(manifest, filename) {
|
||||
|
|
@ -36,12 +36,6 @@ function getCanonicalId(manifest, filename) {
|
|||
if (manifest.__single) return manifest.__single.canonicalId || '';
|
||||
// Multi-entry: look up by filename directly
|
||||
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 '';
|
||||
}
|
||||
|
||||
|
|
@ -57,12 +51,6 @@ function getArtifactType(manifest, filename) {
|
|||
if (manifest.__single) return manifest.__single.type || null;
|
||||
// Multi-entry: look up by filename directly
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -78,12 +66,6 @@ function getInstallToBmad(manifest, filename) {
|
|||
if (manifest.__single) return manifest.__single.install_to_bmad !== false;
|
||||
// Multi-entry: look up by filename directly
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,22 +2,18 @@ const path = require('node:path');
|
|||
const fs = require('fs-extra');
|
||||
const yaml = require('yaml');
|
||||
const prompts = require('../../../lib/prompts');
|
||||
const { XmlHandler } = require('../../../lib/xml-handler');
|
||||
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
|
||||
const { filterCustomizationData } = require('../../../lib/agent/compiler');
|
||||
const { ExternalModuleManager } = require('./external-manager');
|
||||
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
|
||||
|
||||
/**
|
||||
* Manages the installation, updating, and removal of BMAD modules.
|
||||
* Handles module discovery, dependency resolution, configuration processing,
|
||||
* and agent file management including XML activation block injection.
|
||||
* Handles module discovery, dependency resolution, and configuration processing.
|
||||
*
|
||||
* @class ModuleManager
|
||||
* @requires fs-extra
|
||||
* @requires yaml
|
||||
* @requires prompts
|
||||
* @requires XmlHandler
|
||||
*
|
||||
* @example
|
||||
* const manager = new ModuleManager();
|
||||
|
|
@ -26,7 +22,6 @@ const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
|
|||
*/
|
||||
class ModuleManager {
|
||||
constructor(options = {}) {
|
||||
this.xmlHandler = new XmlHandler();
|
||||
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
|
||||
this.customModulePaths = new Map(); // Initialize custom module paths
|
||||
this.externalModuleManager = new ExternalModuleManager(); // For external official modules
|
||||
|
|
@ -88,103 +83,6 @@ class ModuleManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy sidecar directory to _bmad/_memory location with update-safe handling
|
||||
* @param {string} sourceSidecarPath - Source sidecar directory path
|
||||
* @param {string} agentName - Name of the agent (for naming)
|
||||
* @param {string} bmadMemoryPath - This should ALWAYS be _bmad/_memory
|
||||
* @param {boolean} isUpdate - Whether this is an update (default: false)
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} installer - Installer instance for file tracking
|
||||
*/
|
||||
async copySidecarToMemory(sourceSidecarPath, agentName, bmadMemoryPath, isUpdate = false, bmadDir = null, installer = null) {
|
||||
const crypto = require('node:crypto');
|
||||
const sidecarTargetDir = path.join(bmadMemoryPath, `${agentName}-sidecar`);
|
||||
|
||||
// Ensure target directory exists
|
||||
await fs.ensureDir(bmadMemoryPath);
|
||||
await fs.ensureDir(sidecarTargetDir);
|
||||
|
||||
// Get existing files manifest for update checking
|
||||
let existingFilesManifest = [];
|
||||
if (isUpdate && installer) {
|
||||
existingFilesManifest = await installer.readFilesManifest(bmadDir);
|
||||
}
|
||||
|
||||
// Build map of existing sidecar files with their hashes
|
||||
const existingSidecarFiles = new Map();
|
||||
for (const fileEntry of existingFilesManifest) {
|
||||
if (fileEntry.path && fileEntry.path.includes(`${agentName}-sidecar/`)) {
|
||||
existingSidecarFiles.set(fileEntry.path, fileEntry.hash);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all files in source sidecar
|
||||
const sourceFiles = await this.getFileList(sourceSidecarPath);
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
const sourceFilePath = path.join(sourceSidecarPath, file);
|
||||
const targetFilePath = path.join(sidecarTargetDir, file);
|
||||
|
||||
// Calculate current source file hash
|
||||
const sourceHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(await fs.readFile(sourceFilePath))
|
||||
.digest('hex');
|
||||
|
||||
// Path relative to bmad directory
|
||||
const relativeToBmad = path.join('_memory', `${agentName}-sidecar`, file);
|
||||
|
||||
if (isUpdate && (await fs.pathExists(targetFilePath))) {
|
||||
// Calculate current target file hash
|
||||
const currentTargetHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(await fs.readFile(targetFilePath))
|
||||
.digest('hex');
|
||||
|
||||
// Get the last known hash from files-manifest
|
||||
const lastKnownHash = existingSidecarFiles.get(relativeToBmad);
|
||||
|
||||
if (lastKnownHash) {
|
||||
// We have a record of this file
|
||||
if (currentTargetHash === lastKnownHash) {
|
||||
// File hasn't been modified by user, safe to update
|
||||
await this.copyFileWithPlaceholderReplacement(sourceFilePath, targetFilePath, true);
|
||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
||||
await prompts.log.message(` Updated sidecar file: ${relativeToBmad}`);
|
||||
}
|
||||
} else {
|
||||
// User has modified the file, preserve it
|
||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
||||
await prompts.log.message(` Preserving user-modified file: ${relativeToBmad}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// First time seeing this file in manifest, copy it
|
||||
await this.copyFileWithPlaceholderReplacement(sourceFilePath, targetFilePath, true);
|
||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
||||
await prompts.log.message(` Added new sidecar file: ${relativeToBmad}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// New installation
|
||||
await this.copyFileWithPlaceholderReplacement(sourceFilePath, targetFilePath, true);
|
||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
||||
await prompts.log.message(` Copied sidecar file: ${relativeToBmad}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Track the file in the installer's file tracking system
|
||||
if (installer && installer.installedFiles) {
|
||||
installer.installedFiles.add(targetFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Return list of files that were processed
|
||||
const processedFiles = sourceFiles.map((file) => path.join('_memory', `${agentName}-sidecar`, file));
|
||||
return processedFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available modules (excluding core which is always installed)
|
||||
* bmm is the only built-in module, directly under src/bmm-skills
|
||||
|
|
@ -559,19 +457,9 @@ class ModuleManager {
|
|||
await fs.remove(targetPath);
|
||||
}
|
||||
|
||||
// Vendor cross-module workflows BEFORE copying
|
||||
// This reads source agent.yaml files and copies referenced workflows
|
||||
await this.vendorCrossModuleWorkflows(sourcePath, targetPath, moduleName);
|
||||
|
||||
// Copy module files with filtering
|
||||
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig);
|
||||
|
||||
// Compile any .agent.yaml files to .md format
|
||||
await this.compileModuleAgents(sourcePath, targetPath, moduleName, bmadDir, options.installer);
|
||||
|
||||
// Process agent files to inject activation block
|
||||
await this.processAgentFiles(targetPath, moduleName);
|
||||
|
||||
// Create directories declared in module.yaml (unless explicitly skipped)
|
||||
if (!options.skipModuleInstaller) {
|
||||
await this.createModuleDirectories(moduleName, bmadDir, options);
|
||||
|
|
@ -624,10 +512,6 @@ class ModuleManager {
|
|||
} else {
|
||||
// Selective update - preserve user modifications
|
||||
await this.syncModule(sourcePath, targetPath);
|
||||
|
||||
// Recompile agents (#1133)
|
||||
await this.compileModuleAgents(sourcePath, targetPath, moduleName, bmadDir, options.installer);
|
||||
await this.processAgentFiles(targetPath, moduleName);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -718,9 +602,7 @@ class ModuleManager {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Only skip sidecar directories - they are handled separately during agent compilation
|
||||
// But still allow other files in agent directories
|
||||
const isInAgentDirectory = file.startsWith('agents/');
|
||||
// Skip sidecar directories - these contain agent-specific assets not needed at install time
|
||||
const isInSidecarDirectory = path
|
||||
.dirname(file)
|
||||
.split('/')
|
||||
|
|
@ -742,11 +624,6 @@ class ModuleManager {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Skip .agent.yaml files - they will be compiled separately
|
||||
if (file.endsWith('.agent.yaml')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceFile = path.join(sourcePath, file);
|
||||
const targetFile = path.join(targetPath, file);
|
||||
|
||||
|
|
@ -773,236 +650,6 @@ class ModuleManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile .agent.yaml files to .md format in modules
|
||||
* @param {string} sourcePath - Source module path
|
||||
* @param {string} targetPath - Target module path
|
||||
* @param {string} moduleName - Module name
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
* @param {Object} installer - Installer instance for file tracking
|
||||
*/
|
||||
async compileModuleAgents(sourcePath, targetPath, moduleName, bmadDir, installer = null) {
|
||||
const sourceAgentsPath = path.join(sourcePath, 'agents');
|
||||
const targetAgentsPath = path.join(targetPath, 'agents');
|
||||
const cfgAgentsDir = path.join(bmadDir, '_config', 'agents');
|
||||
|
||||
// Check if agents directory exists in source
|
||||
if (!(await fs.pathExists(sourceAgentsPath))) {
|
||||
return; // No agents to compile
|
||||
}
|
||||
|
||||
// Get all agent YAML files recursively
|
||||
const agentFiles = await this.findAgentFiles(sourceAgentsPath);
|
||||
|
||||
for (const agentFile of agentFiles) {
|
||||
if (!agentFile.endsWith('.agent.yaml')) continue;
|
||||
|
||||
const relativePath = path.relative(sourceAgentsPath, agentFile).split(path.sep).join('/');
|
||||
const targetDir = path.join(targetAgentsPath, path.dirname(relativePath));
|
||||
|
||||
await fs.ensureDir(targetDir);
|
||||
|
||||
const agentName = path.basename(agentFile, '.agent.yaml');
|
||||
const sourceYamlPath = agentFile;
|
||||
const targetMdPath = path.join(targetDir, `${agentName}.md`);
|
||||
const customizePath = path.join(cfgAgentsDir, `${moduleName}-${agentName}.customize.yaml`);
|
||||
|
||||
// Read and compile the YAML
|
||||
try {
|
||||
const yamlContent = await fs.readFile(sourceYamlPath, 'utf8');
|
||||
const { compileAgent } = require('../../../lib/agent/compiler');
|
||||
|
||||
// Create customize template if it doesn't exist
|
||||
if (!(await fs.pathExists(customizePath))) {
|
||||
const { getSourcePath } = require('../../../lib/project-root');
|
||||
const genericTemplatePath = getSourcePath('utility', 'agent-components', 'agent.customize.template.yaml');
|
||||
if (await fs.pathExists(genericTemplatePath)) {
|
||||
await this.copyFileWithPlaceholderReplacement(genericTemplatePath, customizePath);
|
||||
// Only show customize creation in verbose mode
|
||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
||||
await prompts.log.message(` Created customize: ${moduleName}-${agentName}.customize.yaml`);
|
||||
}
|
||||
|
||||
// Store original hash for modification detection
|
||||
const crypto = require('node:crypto');
|
||||
const customizeContent = await fs.readFile(customizePath, 'utf8');
|
||||
const originalHash = crypto.createHash('sha256').update(customizeContent).digest('hex');
|
||||
|
||||
// Store in main manifest
|
||||
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
||||
let manifestData = {};
|
||||
if (await fs.pathExists(manifestPath)) {
|
||||
const manifestContent = await fs.readFile(manifestPath, 'utf8');
|
||||
const yaml = require('yaml');
|
||||
manifestData = yaml.parse(manifestContent);
|
||||
}
|
||||
if (!manifestData.agentCustomizations) {
|
||||
manifestData.agentCustomizations = {};
|
||||
}
|
||||
manifestData.agentCustomizations[path.relative(bmadDir, customizePath)] = originalHash;
|
||||
|
||||
// Write back to manifest
|
||||
const yaml = require('yaml');
|
||||
// Clean the manifest data to remove any non-serializable values
|
||||
const cleanManifestData = structuredClone(manifestData);
|
||||
|
||||
const updatedContent = yaml.stringify(cleanManifestData, {
|
||||
indent: 2,
|
||||
lineWidth: 0,
|
||||
});
|
||||
await fs.writeFile(manifestPath, updatedContent, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for customizations and build answers object
|
||||
let customizedFields = [];
|
||||
let answers = {};
|
||||
if (await fs.pathExists(customizePath)) {
|
||||
const customizeContent = await fs.readFile(customizePath, 'utf8');
|
||||
const customizeData = yaml.parse(customizeContent);
|
||||
customizedFields = customizeData.customized_fields || [];
|
||||
|
||||
// Build answers object from customizations
|
||||
if (customizeData.persona) {
|
||||
answers.persona = customizeData.persona;
|
||||
}
|
||||
if (customizeData.agent?.metadata) {
|
||||
const filteredMetadata = filterCustomizationData(customizeData.agent.metadata);
|
||||
if (Object.keys(filteredMetadata).length > 0) {
|
||||
Object.assign(answers, { metadata: filteredMetadata });
|
||||
}
|
||||
}
|
||||
if (customizeData.critical_actions && customizeData.critical_actions.length > 0) {
|
||||
answers.critical_actions = customizeData.critical_actions;
|
||||
}
|
||||
if (customizeData.memories && customizeData.memories.length > 0) {
|
||||
answers.memories = customizeData.memories;
|
||||
}
|
||||
if (customizeData.menu && customizeData.menu.length > 0) {
|
||||
answers.menu = customizeData.menu;
|
||||
}
|
||||
if (customizeData.prompts && customizeData.prompts.length > 0) {
|
||||
answers.prompts = customizeData.prompts;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if agent has sidecar
|
||||
let hasSidecar = false;
|
||||
try {
|
||||
const agentYaml = yaml.parse(yamlContent);
|
||||
hasSidecar = agentYaml?.agent?.metadata?.hasSidecar === true;
|
||||
} catch {
|
||||
// Continue without sidecar processing
|
||||
}
|
||||
|
||||
// Compile with customizations if any
|
||||
const { xml } = await compileAgent(yamlContent, answers, agentName, relativePath, { config: this.coreConfig || {} });
|
||||
|
||||
// Write the compiled agent
|
||||
await fs.writeFile(targetMdPath, xml, 'utf8');
|
||||
|
||||
// Handle sidecar copying if present
|
||||
if (hasSidecar) {
|
||||
// Get the agent's directory to look for sidecar
|
||||
const agentDir = path.dirname(agentFile);
|
||||
const sidecarDirName = `${agentName}-sidecar`;
|
||||
const sourceSidecarPath = path.join(agentDir, sidecarDirName);
|
||||
|
||||
// Check if sidecar directory exists
|
||||
if (await fs.pathExists(sourceSidecarPath)) {
|
||||
// Memory is always in _bmad/_memory
|
||||
const bmadMemoryPath = path.join(bmadDir, '_memory');
|
||||
|
||||
// Determine if this is an update (by checking if agent already exists)
|
||||
const isUpdate = await fs.pathExists(targetMdPath);
|
||||
|
||||
// Copy sidecar to memory location with update-safe handling
|
||||
const copiedFiles = await this.copySidecarToMemory(sourceSidecarPath, agentName, bmadMemoryPath, isUpdate, bmadDir, installer);
|
||||
|
||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true' && copiedFiles.length > 0) {
|
||||
await prompts.log.message(` Sidecar files processed: ${copiedFiles.length} files`);
|
||||
}
|
||||
} else if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
||||
await prompts.log.warn(` Agent marked as having sidecar but ${sidecarDirName} directory not found`);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy any non-sidecar files from agent directory (e.g., foo.md)
|
||||
const agentDir = path.dirname(agentFile);
|
||||
const agentEntries = await fs.readdir(agentDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of agentEntries) {
|
||||
if (entry.isFile() && !entry.name.endsWith('.agent.yaml') && !entry.name.endsWith('.md')) {
|
||||
// Copy additional files (like foo.md) to the agent target directory
|
||||
const sourceFile = path.join(agentDir, entry.name);
|
||||
const targetFile = path.join(targetDir, entry.name);
|
||||
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Only show compilation details in verbose mode
|
||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
||||
await prompts.log.message(
|
||||
` Compiled agent: ${agentName} -> ${path.relative(targetPath, targetMdPath)}${hasSidecar ? ' (with sidecar)' : ''}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
await prompts.log.warn(` Failed to compile agent ${agentName}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all .agent.yaml files recursively in a directory
|
||||
* @param {string} dir - Directory to search
|
||||
* @returns {Array} List of .agent.yaml file paths
|
||||
*/
|
||||
async findAgentFiles(dir) {
|
||||
const agentFiles = [];
|
||||
|
||||
async function searchDirectory(searchDir) {
|
||||
const entries = await fs.readdir(searchDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(searchDir, entry.name);
|
||||
|
||||
if (entry.isFile() && entry.name.endsWith('.agent.yaml')) {
|
||||
agentFiles.push(fullPath);
|
||||
} else if (entry.isDirectory()) {
|
||||
await searchDirectory(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await searchDirectory(dir);
|
||||
return agentFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process agent files to inject activation block
|
||||
* @param {string} modulePath - Path to installed module
|
||||
* @param {string} moduleName - Module name
|
||||
*/
|
||||
async processAgentFiles(modulePath, moduleName) {
|
||||
// const agentsPath = path.join(modulePath, 'agents');
|
||||
// // Check if agents directory exists
|
||||
// if (!(await fs.pathExists(agentsPath))) {
|
||||
// return; // No agents to process
|
||||
// }
|
||||
// // Get all agent MD files recursively
|
||||
// const agentFiles = await this.findAgentMdFiles(agentsPath);
|
||||
// for (const agentFile of agentFiles) {
|
||||
// if (!agentFile.endsWith('.md')) continue;
|
||||
// let content = await fs.readFile(agentFile, 'utf8');
|
||||
// // Check if content has agent XML and no activation block
|
||||
// if (content.includes('<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
|
||||
* @param {string} dir - Directory to search
|
||||
|
|
@ -1029,101 +676,6 @@ class ModuleManager {
|
|||
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
|
||||
* This replaces the security-risky module installer pattern with declarative config
|
||||
|
|
|
|||
|
|
@ -1,165 +0,0 @@
|
|||
const fs = require('fs-extra');
|
||||
const path = require('node:path');
|
||||
const { getSourcePath } = require('./project-root');
|
||||
|
||||
/**
|
||||
* Builds activation blocks from fragments based on agent profile
|
||||
*/
|
||||
class ActivationBuilder {
|
||||
constructor() {
|
||||
this.agentComponents = getSourcePath('utility', 'agent-components');
|
||||
this.fragmentCache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a fragment file
|
||||
* @param {string} fragmentName - Name of fragment file (e.g., 'activation-init.txt')
|
||||
* @returns {string} Fragment content
|
||||
*/
|
||||
async loadFragment(fragmentName) {
|
||||
// Check cache first
|
||||
if (this.fragmentCache.has(fragmentName)) {
|
||||
return this.fragmentCache.get(fragmentName);
|
||||
}
|
||||
|
||||
const fragmentPath = path.join(this.agentComponents, fragmentName);
|
||||
|
||||
if (!(await fs.pathExists(fragmentPath))) {
|
||||
throw new Error(`Fragment not found: ${fragmentName}`);
|
||||
}
|
||||
|
||||
const content = await fs.readFile(fragmentPath, 'utf8');
|
||||
this.fragmentCache.set(fragmentName, content);
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build complete activation block based on agent profile
|
||||
* @param {Object} profile - Agent profile from AgentAnalyzer
|
||||
* @param {Object} metadata - Agent metadata (module, name, etc.)
|
||||
* @param {Array} agentSpecificActions - Optional agent-specific critical actions
|
||||
* @param {boolean} forWebBundle - Whether this is for a web bundle
|
||||
* @returns {string} Complete activation block XML
|
||||
*/
|
||||
async buildActivation(profile, metadata = {}, agentSpecificActions = [], forWebBundle = false) {
|
||||
let activation = '<activation critical="MANDATORY">\n';
|
||||
|
||||
// 1. Build sequential steps (use web-specific steps for web bundles)
|
||||
const steps = await this.buildSteps(metadata, agentSpecificActions, forWebBundle);
|
||||
activation += this.indent(steps, 2) + '\n';
|
||||
|
||||
// 2. Build menu handlers section with dynamic handlers
|
||||
const menuHandlers = await this.loadFragment('menu-handlers.txt');
|
||||
|
||||
// Build handlers (load only needed handlers)
|
||||
const handlers = await this.buildHandlers(profile);
|
||||
|
||||
// Remove the extract line from the final output - it's just build metadata
|
||||
// The extract list tells us which attributes to look for during processing
|
||||
// but shouldn't appear in the final agent file
|
||||
const processedHandlers = menuHandlers
|
||||
.replace('<extract>{DYNAMIC_EXTRACT_LIST}</extract>\n', '') // Remove the entire extract line
|
||||
.replace('{DYNAMIC_HANDLERS}', handlers);
|
||||
|
||||
activation += '\n' + this.indent(processedHandlers, 2) + '\n';
|
||||
|
||||
const rules = await this.loadFragment('activation-rules.txt');
|
||||
activation += this.indent(rules, 2) + '\n';
|
||||
|
||||
activation += '</activation>';
|
||||
|
||||
return activation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build handlers section based on profile
|
||||
* @param {Object} profile - Agent profile
|
||||
* @returns {string} Handlers XML
|
||||
*/
|
||||
async buildHandlers(profile) {
|
||||
const handlerFragments = [];
|
||||
|
||||
for (const attrType of profile.usedAttributes) {
|
||||
const fragmentName = `handler-${attrType}.txt`;
|
||||
try {
|
||||
const handler = await this.loadFragment(fragmentName);
|
||||
handlerFragments.push(handler);
|
||||
} catch {
|
||||
console.warn(`Warning: Handler fragment not found: ${fragmentName}`);
|
||||
}
|
||||
}
|
||||
|
||||
return handlerFragments.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build sequential activation steps
|
||||
* @param {Object} metadata - Agent metadata
|
||||
* @param {Array} agentSpecificActions - Optional agent-specific actions
|
||||
* @param {boolean} forWebBundle - Whether this is for a web bundle
|
||||
* @returns {string} Steps XML
|
||||
*/
|
||||
async buildSteps(metadata = {}, agentSpecificActions = [], forWebBundle = false) {
|
||||
const stepsTemplate = await this.loadFragment('activation-steps.txt');
|
||||
|
||||
// Extract basename from agent ID (e.g., "bmad/bmm/agents/pm.md" → "pm")
|
||||
const agentBasename = metadata.id ? metadata.id.split('/').pop().replace('.md', '') : metadata.name || 'agent';
|
||||
|
||||
// Build agent-specific steps
|
||||
let agentStepsXml = '';
|
||||
let currentStepNum = 4; // Steps 1-3 are standard
|
||||
|
||||
if (agentSpecificActions && agentSpecificActions.length > 0) {
|
||||
agentStepsXml = agentSpecificActions
|
||||
.map((action) => {
|
||||
const step = `<step n="${currentStepNum}">${action}</step>`;
|
||||
currentStepNum++;
|
||||
return step;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// Calculate final step numbers
|
||||
const menuStep = currentStepNum;
|
||||
const helpStep = currentStepNum + 1;
|
||||
const haltStep = currentStepNum + 2;
|
||||
const inputStep = currentStepNum + 3;
|
||||
const executeStep = currentStepNum + 4;
|
||||
|
||||
// Replace placeholders
|
||||
const processed = stepsTemplate
|
||||
.replace('{agent-file-basename}', agentBasename)
|
||||
.replace('{{module}}', metadata.module || 'core') // Fixed to use {{module}}
|
||||
.replace('{AGENT_SPECIFIC_STEPS}', agentStepsXml)
|
||||
.replace('{MENU_STEP}', menuStep.toString())
|
||||
.replace('{HELP_STEP}', helpStep.toString())
|
||||
.replace('{HALT_STEP}', haltStep.toString())
|
||||
.replace('{INPUT_STEP}', inputStep.toString())
|
||||
.replace('{EXECUTE_STEP}', executeStep.toString());
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indent XML content
|
||||
* @param {string} content - Content to indent
|
||||
* @param {number} spaces - Number of spaces to indent
|
||||
* @returns {string} Indented content
|
||||
*/
|
||||
indent(content, spaces) {
|
||||
const indentation = ' '.repeat(spaces);
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => (line ? indentation + line : line))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear fragment cache (useful for testing or hot reload)
|
||||
*/
|
||||
clearCache() {
|
||||
this.fragmentCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ActivationBuilder };
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
const yaml = require('yaml');
|
||||
const fs = require('fs-extra');
|
||||
|
||||
/**
|
||||
* Analyzes agent YAML files to detect which handlers are needed
|
||||
*/
|
||||
class AgentAnalyzer {
|
||||
/**
|
||||
* Analyze an agent YAML structure to determine which handlers it needs
|
||||
* @param {Object} agentYaml - Parsed agent YAML object
|
||||
* @returns {Object} Profile of needed handlers
|
||||
*/
|
||||
analyzeAgentObject(agentYaml) {
|
||||
const profile = {
|
||||
usedAttributes: new Set(),
|
||||
hasPrompts: false,
|
||||
menuItems: [],
|
||||
};
|
||||
|
||||
// Check if agent has prompts section
|
||||
if (agentYaml.agent && agentYaml.agent.prompts) {
|
||||
profile.hasPrompts = true;
|
||||
}
|
||||
|
||||
// Analyze menu items (support both 'menu' and legacy 'commands')
|
||||
const menuItems = agentYaml.agent?.menu || agentYaml.agent?.commands || [];
|
||||
|
||||
for (const item of menuItems) {
|
||||
// Track the menu item
|
||||
profile.menuItems.push(item);
|
||||
|
||||
// Check for multi format items
|
||||
if (item.multi && item.triggers) {
|
||||
profile.usedAttributes.add('multi');
|
||||
|
||||
// Also check attributes in nested handlers
|
||||
for (const triggerGroup of item.triggers) {
|
||||
for (const [triggerName, execArray] of Object.entries(triggerGroup)) {
|
||||
if (Array.isArray(execArray)) {
|
||||
for (const exec of execArray) {
|
||||
if (exec.route) {
|
||||
profile.usedAttributes.add('exec');
|
||||
}
|
||||
if (exec.action) profile.usedAttributes.add('action');
|
||||
if (exec.type && ['exec', 'action'].includes(exec.type)) {
|
||||
profile.usedAttributes.add(exec.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check for each possible attribute in legacy items
|
||||
if (item.exec) {
|
||||
profile.usedAttributes.add('exec');
|
||||
}
|
||||
if (item.tmpl) {
|
||||
profile.usedAttributes.add('tmpl');
|
||||
}
|
||||
if (item.data) {
|
||||
profile.usedAttributes.add('data');
|
||||
}
|
||||
if (item.action) {
|
||||
profile.usedAttributes.add('action');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Set to Array for easier use
|
||||
profile.usedAttributes = [...profile.usedAttributes];
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze an agent YAML file
|
||||
* @param {string} filePath - Path to agent YAML file
|
||||
* @returns {Object} Profile of needed handlers
|
||||
*/
|
||||
async analyzeAgentFile(filePath) {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
const agentYaml = yaml.parse(content);
|
||||
return this.analyzeAgentObject(agentYaml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an agent needs a specific handler
|
||||
* @param {Object} profile - Agent profile from analyze
|
||||
* @param {string} handlerType - Handler type to check
|
||||
* @returns {boolean} True if handler is needed
|
||||
*/
|
||||
needsHandler(profile, handlerType) {
|
||||
return profile.usedAttributes.includes(handlerType);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { AgentAnalyzer };
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
const path = require('node:path');
|
||||
const fs = require('fs-extra');
|
||||
const { escapeXml } = require('../../lib/xml-utils');
|
||||
|
||||
const AgentPartyGenerator = {
|
||||
/**
|
||||
* Generate agent-manifest.csv content
|
||||
* @param {Array} agentDetails - Array of agent details
|
||||
* @param {Object} options - Generation options
|
||||
* @returns {string} XML content
|
||||
*/
|
||||
generateAgentParty(agentDetails, options = {}) {
|
||||
const { forWeb = false } = options;
|
||||
|
||||
// Group agents by module
|
||||
const agentsByModule = {
|
||||
bmm: [],
|
||||
cis: [],
|
||||
core: [],
|
||||
custom: [],
|
||||
};
|
||||
|
||||
for (const agent of agentDetails) {
|
||||
const moduleKey = agentsByModule[agent.module] ? agent.module : 'custom';
|
||||
agentsByModule[moduleKey].push(agent);
|
||||
}
|
||||
|
||||
// Build XML content
|
||||
let xmlContent = `<!-- Powered by BMAD-CORE™ -->
|
||||
<!-- Agent Manifest - Generated during BMAD ${forWeb ? 'bundling' : 'installation'} -->
|
||||
<!-- This file contains a summary of all ${forWeb ? 'bundled' : 'installed'} agents for quick reference -->
|
||||
<manifest id="bmad/_config/agent-manifest.csv" version="1.0" generated="${new Date().toISOString()}">
|
||||
<description>
|
||||
Complete roster of ${forWeb ? 'bundled' : 'installed'} BMAD agents with summarized personas for efficient multi-agent orchestration.
|
||||
Used by party-mode and other multi-agent coordination features.
|
||||
</description>
|
||||
`;
|
||||
|
||||
// Add agents by module
|
||||
for (const [module, agents] of Object.entries(agentsByModule)) {
|
||||
if (agents.length === 0) continue;
|
||||
|
||||
const moduleTitle =
|
||||
module === 'bmm' ? 'BMM Module' : module === 'cis' ? 'CIS Module' : module === 'core' ? 'Core Module' : 'Custom Module';
|
||||
|
||||
xmlContent += `\n <!-- ${moduleTitle} Agents -->\n`;
|
||||
|
||||
for (const agent of agents) {
|
||||
xmlContent += ` <agent id="${agent.id}" name="${agent.name}" title="${agent.title || ''}" icon="${agent.icon || ''}">
|
||||
<persona>
|
||||
<role>${escapeXml(agent.role || '')}</role>
|
||||
<identity>${escapeXml(agent.identity || '')}</identity>
|
||||
<communication_style>${escapeXml(agent.communicationStyle || '')}</communication_style>
|
||||
<principles>${agent.principles || ''}</principles>
|
||||
</persona>
|
||||
</agent>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add statistics
|
||||
const totalAgents = agentDetails.length;
|
||||
const moduleList = Object.keys(agentsByModule)
|
||||
.filter((m) => agentsByModule[m].length > 0)
|
||||
.join(', ');
|
||||
|
||||
xmlContent += `\n <statistics>
|
||||
<total_agents>${totalAgents}</total_agents>
|
||||
<modules>${moduleList}</modules>
|
||||
<last_updated>${new Date().toISOString()}</last_updated>
|
||||
</statistics>
|
||||
</manifest>`;
|
||||
|
||||
return xmlContent;
|
||||
},
|
||||
|
||||
/**
|
||||
* Extract agent details from XML content
|
||||
* @param {string} content - Full agent file content (markdown with XML)
|
||||
* @param {string} moduleName - Module name
|
||||
* @param {string} agentName - Agent name
|
||||
* @returns {Object} Agent details
|
||||
*/
|
||||
extractAgentDetails(content, moduleName, agentName) {
|
||||
try {
|
||||
// Extract agent XML block
|
||||
const agentMatch = content.match(/<agent[^>]*>([\s\S]*?)<\/agent>/);
|
||||
if (!agentMatch) return null;
|
||||
|
||||
const agentXml = agentMatch[0];
|
||||
|
||||
// Extract attributes from opening tag
|
||||
const nameMatch = agentXml.match(/name="([^"]*)"/);
|
||||
const titleMatch = agentXml.match(/title="([^"]*)"/);
|
||||
const iconMatch = agentXml.match(/icon="([^"]*)"/);
|
||||
|
||||
// Extract persona elements - now we just copy them as-is
|
||||
const roleMatch = agentXml.match(/<role>([\s\S]*?)<\/role>/);
|
||||
const identityMatch = agentXml.match(/<identity>([\s\S]*?)<\/identity>/);
|
||||
const styleMatch = agentXml.match(/<communication_style>([\s\S]*?)<\/communication_style>/);
|
||||
const principlesMatch = agentXml.match(/<principles>([\s\S]*?)<\/principles>/);
|
||||
|
||||
return {
|
||||
id: `bmad/${moduleName}/agents/${agentName}.md`,
|
||||
name: nameMatch ? nameMatch[1] : agentName,
|
||||
title: titleMatch ? titleMatch[1] : 'Agent',
|
||||
icon: iconMatch ? iconMatch[1] : '🤖',
|
||||
module: moduleName,
|
||||
role: roleMatch ? roleMatch[1].trim() : '',
|
||||
identity: identityMatch ? identityMatch[1].trim() : '',
|
||||
communicationStyle: styleMatch ? styleMatch[1].trim() : '',
|
||||
principles: principlesMatch ? principlesMatch[1].trim() : '',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error extracting details for agent ${agentName}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Extract attribute from XML tag
|
||||
*/
|
||||
extractAttribute(xml, tagName, attrName) {
|
||||
const regex = new RegExp(`<${tagName}[^>]*\\s${attrName}="([^"]*)"`, 'i');
|
||||
const match = xml.match(regex);
|
||||
return match ? match[1] : '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply config overrides to agent details
|
||||
* @param {Object} details - Original agent details
|
||||
* @param {string} configContent - Config file content
|
||||
* @returns {Object} Agent details with overrides applied
|
||||
*/
|
||||
applyConfigOverrides(details, configContent) {
|
||||
try {
|
||||
// Extract agent-config XML block
|
||||
const configMatch = configContent.match(/<agent-config>([\s\S]*?)<\/agent-config>/);
|
||||
if (!configMatch) return details;
|
||||
|
||||
const configXml = configMatch[0];
|
||||
|
||||
// Extract override values
|
||||
const nameMatch = configXml.match(/<name>([\s\S]*?)<\/name>/);
|
||||
const titleMatch = configXml.match(/<title>([\s\S]*?)<\/title>/);
|
||||
const roleMatch = configXml.match(/<role>([\s\S]*?)<\/role>/);
|
||||
const identityMatch = configXml.match(/<identity>([\s\S]*?)<\/identity>/);
|
||||
const styleMatch = configXml.match(/<communication_style>([\s\S]*?)<\/communication_style>/);
|
||||
const principlesMatch = configXml.match(/<principles>([\s\S]*?)<\/principles>/);
|
||||
|
||||
// Apply overrides only if values are non-empty
|
||||
if (nameMatch && nameMatch[1].trim()) {
|
||||
details.name = nameMatch[1].trim();
|
||||
}
|
||||
|
||||
if (titleMatch && titleMatch[1].trim()) {
|
||||
details.title = titleMatch[1].trim();
|
||||
}
|
||||
|
||||
if (roleMatch && roleMatch[1].trim()) {
|
||||
details.role = roleMatch[1].trim();
|
||||
}
|
||||
|
||||
if (identityMatch && identityMatch[1].trim()) {
|
||||
details.identity = identityMatch[1].trim();
|
||||
}
|
||||
|
||||
if (styleMatch && styleMatch[1].trim()) {
|
||||
details.communicationStyle = styleMatch[1].trim();
|
||||
}
|
||||
|
||||
if (principlesMatch && principlesMatch[1].trim()) {
|
||||
// Principles are now just copied as-is (narrative paragraph)
|
||||
details.principles = principlesMatch[1].trim();
|
||||
}
|
||||
|
||||
return details;
|
||||
} catch (error) {
|
||||
console.error(`Error applying config overrides:`, error);
|
||||
return details;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Write agent-manifest.csv to file
|
||||
*/
|
||||
async writeAgentParty(filePath, agentDetails, options = {}) {
|
||||
const content = this.generateAgentParty(agentDetails, options);
|
||||
await fs.ensureDir(path.dirname(filePath));
|
||||
await fs.writeFile(filePath, content, 'utf8');
|
||||
return content;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { AgentPartyGenerator };
|
||||
|
|
@ -1,516 +0,0 @@
|
|||
/**
|
||||
* BMAD Agent Compiler
|
||||
* Transforms agent YAML to compiled XML (.md) format
|
||||
* Uses the existing BMAD builder infrastructure for proper formatting
|
||||
*/
|
||||
|
||||
const yaml = require('yaml');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { processAgentYaml, extractInstallConfig, stripInstallConfig, getDefaultValues } = require('./template-engine');
|
||||
const { escapeXml } = require('../../../lib/xml-utils');
|
||||
const { ActivationBuilder } = require('../activation-builder');
|
||||
const { AgentAnalyzer } = require('../agent-analyzer');
|
||||
|
||||
/**
|
||||
* Build frontmatter for agent
|
||||
* @param {Object} metadata - Agent metadata
|
||||
* @param {string} agentName - Final agent name
|
||||
* @returns {string} YAML frontmatter
|
||||
*/
|
||||
function buildFrontmatter(metadata, agentName) {
|
||||
const nameFromFile = agentName.replaceAll('-', ' ');
|
||||
const description = metadata.title || 'BMAD Agent';
|
||||
|
||||
return `---
|
||||
name: "${nameFromFile}"
|
||||
description: "${description}"
|
||||
---
|
||||
|
||||
You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
// buildSimpleActivation function removed - replaced by ActivationBuilder for proper fragment loading from src/utility/agent-components/
|
||||
|
||||
/**
|
||||
* Build persona XML section
|
||||
* @param {Object} persona - Persona object
|
||||
* @returns {string} Persona XML
|
||||
*/
|
||||
function buildPersonaXml(persona) {
|
||||
if (!persona) return '';
|
||||
|
||||
let xml = ' <persona>\n';
|
||||
|
||||
if (persona.role) {
|
||||
const roleText = persona.role.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ');
|
||||
xml += ` <role>${escapeXml(roleText)}</role>\n`;
|
||||
}
|
||||
|
||||
if (persona.identity) {
|
||||
const identityText = persona.identity.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ');
|
||||
xml += ` <identity>${escapeXml(identityText)}</identity>\n`;
|
||||
}
|
||||
|
||||
if (persona.communication_style) {
|
||||
const styleText = persona.communication_style.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ');
|
||||
xml += ` <communication_style>${escapeXml(styleText)}</communication_style>\n`;
|
||||
}
|
||||
|
||||
if (persona.principles) {
|
||||
let principlesText;
|
||||
if (Array.isArray(persona.principles)) {
|
||||
principlesText = persona.principles.join(' ');
|
||||
} else {
|
||||
principlesText = persona.principles.trim().replaceAll(/\n+/g, ' ');
|
||||
}
|
||||
xml += ` <principles>${escapeXml(principlesText)}</principles>\n`;
|
||||
}
|
||||
|
||||
xml += ' </persona>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build prompts XML section
|
||||
* @param {Array} prompts - Prompts array
|
||||
* @returns {string} Prompts XML
|
||||
*/
|
||||
function buildPromptsXml(prompts) {
|
||||
if (!prompts || prompts.length === 0) return '';
|
||||
|
||||
let xml = ' <prompts>\n';
|
||||
|
||||
for (const prompt of prompts) {
|
||||
xml += ` <prompt id="${prompt.id || ''}">\n`;
|
||||
xml += ` <content>\n`;
|
||||
// Don't escape prompt content - it's meant to be read as-is
|
||||
xml += `${prompt.content || ''}\n`;
|
||||
xml += ` </content>\n`;
|
||||
xml += ` </prompt>\n`;
|
||||
}
|
||||
|
||||
xml += ' </prompts>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build memories XML section
|
||||
* @param {Array} memories - Memories array
|
||||
* @returns {string} Memories XML
|
||||
*/
|
||||
function buildMemoriesXml(memories) {
|
||||
if (!memories || memories.length === 0) return '';
|
||||
|
||||
let xml = ' <memories>\n';
|
||||
|
||||
for (const memory of memories) {
|
||||
xml += ` <memory>${escapeXml(String(memory))}</memory>\n`;
|
||||
}
|
||||
|
||||
xml += ' </memories>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build menu XML section
|
||||
* Supports both legacy and multi format menu items
|
||||
* Multi items display as a single menu item with nested handlers
|
||||
* @param {Array} menuItems - Menu items
|
||||
* @returns {string} Menu XML
|
||||
*/
|
||||
function buildMenuXml(menuItems) {
|
||||
let xml = ' <menu>\n';
|
||||
|
||||
// Always inject menu display option first
|
||||
xml += ` <item cmd="MH or fuzzy match on menu or help">[MH] Redisplay Menu Help</item>\n`;
|
||||
xml += ` <item cmd="CH or fuzzy match on chat">[CH] Chat with the Agent about anything</item>\n`;
|
||||
|
||||
// Add user-defined menu items
|
||||
if (menuItems && menuItems.length > 0) {
|
||||
for (const item of menuItems) {
|
||||
// Handle multi format menu items with nested handlers
|
||||
if (item.multi && item.triggers && Array.isArray(item.triggers)) {
|
||||
xml += ` <item type="multi">${escapeXml(item.multi)}\n`;
|
||||
xml += buildNestedHandlers(item.triggers);
|
||||
xml += ` </item>\n`;
|
||||
}
|
||||
// Handle legacy format menu items
|
||||
else if (item.trigger) {
|
||||
let trigger = item.trigger || '';
|
||||
|
||||
const attrs = [`cmd="${trigger}"`];
|
||||
|
||||
// Add handler attributes
|
||||
if (item.exec) attrs.push(`exec="${item.exec}"`);
|
||||
if (item.tmpl) attrs.push(`tmpl="${item.tmpl}"`);
|
||||
if (item.data) attrs.push(`data="${item.data}"`);
|
||||
if (item.action) attrs.push(`action="${item.action}"`);
|
||||
|
||||
xml += ` <item ${attrs.join(' ')}>${escapeXml(item.description || '')}</item>\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xml += ` <item cmd="PM or fuzzy match on party-mode" exec="skill:bmad-party-mode">[PM] Start Party Mode</item>\n`;
|
||||
xml += ` <item cmd="DA or fuzzy match on exit, leave, goodbye or dismiss agent">[DA] Dismiss Agent</item>\n`;
|
||||
|
||||
xml += ' </menu>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build nested handlers for multi format menu items
|
||||
* @param {Array} triggers - Triggers array from multi format
|
||||
* @returns {string} Handler XML
|
||||
*/
|
||||
function buildNestedHandlers(triggers) {
|
||||
let xml = '';
|
||||
|
||||
for (const triggerGroup of triggers) {
|
||||
for (const [triggerName, execArray] of Object.entries(triggerGroup)) {
|
||||
// Build trigger with * prefix
|
||||
let trigger = triggerName.startsWith('*') ? triggerName : '*' + triggerName;
|
||||
|
||||
// Extract the relevant execution data
|
||||
const execData = processExecArray(execArray);
|
||||
|
||||
// For nested handlers in multi items, we use match attribute for fuzzy matching
|
||||
const attrs = [`match="${escapeXml(execData.description || '')}"`];
|
||||
|
||||
// Add handler attributes based on exec data
|
||||
if (execData.route) attrs.push(`exec="${execData.route}"`);
|
||||
if (execData.action) attrs.push(`action="${execData.action}"`);
|
||||
if (execData.data) attrs.push(`data="${execData.data}"`);
|
||||
if (execData.tmpl) attrs.push(`tmpl="${execData.tmpl}"`);
|
||||
// Only add type if it's not 'exec' (exec is already implied by the exec attribute)
|
||||
if (execData.type && execData.type !== 'exec') attrs.push(`type="${execData.type}"`);
|
||||
|
||||
xml += ` <handler ${attrs.join(' ')}></handler>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the execution array from multi format triggers
|
||||
* Extracts relevant data for XML attributes
|
||||
* @param {Array} execArray - Array of execution objects
|
||||
* @returns {Object} Processed execution data
|
||||
*/
|
||||
function processExecArray(execArray) {
|
||||
const result = {
|
||||
description: '',
|
||||
route: null,
|
||||
data: null,
|
||||
action: null,
|
||||
type: null,
|
||||
};
|
||||
|
||||
if (!Array.isArray(execArray)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const exec of execArray) {
|
||||
if (exec.input) {
|
||||
// Use input as description if no explicit description is provided
|
||||
result.description = exec.input;
|
||||
}
|
||||
|
||||
if (exec.route) {
|
||||
result.route = exec.route;
|
||||
}
|
||||
|
||||
if (exec.data !== null && exec.data !== undefined) {
|
||||
result.data = exec.data;
|
||||
}
|
||||
|
||||
if (exec.action) {
|
||||
result.action = exec.action;
|
||||
}
|
||||
|
||||
if (exec.type) {
|
||||
result.type = exec.type;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile agent YAML to proper XML format
|
||||
* @param {Object} agentYaml - Parsed and processed agent YAML
|
||||
* @param {string} agentName - Final agent name (for ID and frontmatter)
|
||||
* @param {string} targetPath - Target path for agent ID
|
||||
* @returns {Promise<string>} Compiled XML string with frontmatter
|
||||
*/
|
||||
async function compileToXml(agentYaml, agentName = '', targetPath = '') {
|
||||
const agent = agentYaml.agent;
|
||||
const meta = agent.metadata;
|
||||
|
||||
let xml = '';
|
||||
|
||||
// Build frontmatter
|
||||
xml += buildFrontmatter(meta, agentName || meta.name || 'agent');
|
||||
|
||||
// Start code fence
|
||||
xml += '```xml\n';
|
||||
|
||||
// Agent opening tag
|
||||
const agentAttrs = [
|
||||
`id="${targetPath || meta.id || ''}"`,
|
||||
`name="${meta.name || ''}"`,
|
||||
`title="${meta.title || ''}"`,
|
||||
`icon="${meta.icon || '🤖'}"`,
|
||||
];
|
||||
if (meta.capabilities) {
|
||||
agentAttrs.push(`capabilities="${escapeXml(meta.capabilities)}"`);
|
||||
}
|
||||
|
||||
xml += `<agent ${agentAttrs.join(' ')}>\n`;
|
||||
|
||||
// Activation block - use ActivationBuilder for proper fragment loading
|
||||
const activationBuilder = new ActivationBuilder();
|
||||
const analyzer = new AgentAnalyzer();
|
||||
const profile = analyzer.analyzeAgentObject(agentYaml);
|
||||
xml += await activationBuilder.buildActivation(
|
||||
profile,
|
||||
meta,
|
||||
agent.critical_actions || [],
|
||||
false, // forWebBundle - set to false for IDE deployment
|
||||
);
|
||||
|
||||
// Persona section
|
||||
xml += buildPersonaXml(agent.persona);
|
||||
|
||||
// Prompts section (if present)
|
||||
if (agent.prompts && agent.prompts.length > 0) {
|
||||
xml += buildPromptsXml(agent.prompts);
|
||||
}
|
||||
|
||||
// Memories section (if present)
|
||||
if (agent.memories && agent.memories.length > 0) {
|
||||
xml += buildMemoriesXml(agent.memories);
|
||||
}
|
||||
|
||||
// Menu section
|
||||
xml += buildMenuXml(agent.menu || []);
|
||||
|
||||
// Closing agent tag
|
||||
xml += '</agent>\n';
|
||||
|
||||
// Close code fence
|
||||
xml += '```\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full compilation pipeline
|
||||
* @param {string} yamlContent - Raw YAML string
|
||||
* @param {Object} answers - Answers from install_config questions (or defaults)
|
||||
* @param {string} agentName - Optional final agent name (user's custom persona name)
|
||||
* @param {string} targetPath - Optional target path for agent ID
|
||||
* @param {Object} options - Additional options including config
|
||||
* @returns {Promise<Object>} { xml: string, metadata: Object }
|
||||
*/
|
||||
async function compileAgent(yamlContent, answers = {}, agentName = '', targetPath = '', options = {}) {
|
||||
// Parse YAML
|
||||
let agentYaml = yaml.parse(yamlContent);
|
||||
|
||||
// Apply customization merges before template processing
|
||||
// Handle metadata overrides (like name)
|
||||
if (answers.metadata) {
|
||||
// Filter out empty values from metadata
|
||||
const filteredMetadata = filterCustomizationData(answers.metadata);
|
||||
if (Object.keys(filteredMetadata).length > 0) {
|
||||
agentYaml.agent.metadata = { ...agentYaml.agent.metadata, ...filteredMetadata };
|
||||
}
|
||||
// Remove from answers so it doesn't get processed as template variables
|
||||
const { metadata, ...templateAnswers } = answers;
|
||||
answers = templateAnswers;
|
||||
}
|
||||
|
||||
// Handle other customization properties
|
||||
// These should be merged into the agent structure, not processed as template variables
|
||||
const customizationKeys = ['persona', 'critical_actions', 'memories', 'menu', 'prompts'];
|
||||
const customizations = {};
|
||||
const remainingAnswers = { ...answers };
|
||||
|
||||
for (const key of customizationKeys) {
|
||||
if (answers[key]) {
|
||||
let filtered;
|
||||
|
||||
// Handle different data types
|
||||
if (Array.isArray(answers[key])) {
|
||||
// For arrays, filter out empty/null/undefined values
|
||||
filtered = answers[key].filter((item) => item !== null && item !== undefined && item !== '');
|
||||
} else {
|
||||
// For objects, use filterCustomizationData
|
||||
filtered = filterCustomizationData(answers[key]);
|
||||
}
|
||||
|
||||
// Check if we have valid content
|
||||
const hasContent = Array.isArray(filtered) ? filtered.length > 0 : Object.keys(filtered).length > 0;
|
||||
|
||||
if (hasContent) {
|
||||
customizations[key] = filtered;
|
||||
}
|
||||
delete remainingAnswers[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Merge customizations into agentYaml
|
||||
if (Object.keys(customizations).length > 0) {
|
||||
// For persona: replace entire section
|
||||
if (customizations.persona) {
|
||||
agentYaml.agent.persona = customizations.persona;
|
||||
}
|
||||
|
||||
// For critical_actions: append to existing or create new
|
||||
if (customizations.critical_actions) {
|
||||
const existing = agentYaml.agent.critical_actions || [];
|
||||
agentYaml.agent.critical_actions = [...existing, ...customizations.critical_actions];
|
||||
}
|
||||
|
||||
// For memories: append to existing or create new
|
||||
if (customizations.memories) {
|
||||
const existing = agentYaml.agent.memories || [];
|
||||
agentYaml.agent.memories = [...existing, ...customizations.memories];
|
||||
}
|
||||
|
||||
// For menu: append to existing or create new
|
||||
if (customizations.menu) {
|
||||
const existing = agentYaml.agent.menu || [];
|
||||
agentYaml.agent.menu = [...existing, ...customizations.menu];
|
||||
}
|
||||
|
||||
// For prompts: append to existing or create new (by id)
|
||||
if (customizations.prompts) {
|
||||
const existing = agentYaml.agent.prompts || [];
|
||||
// Merge by id, with customizations taking precedence
|
||||
const mergedPrompts = [...existing];
|
||||
for (const customPrompt of customizations.prompts) {
|
||||
const existingIndex = mergedPrompts.findIndex((p) => p.id === customPrompt.id);
|
||||
if (existingIndex === -1) {
|
||||
mergedPrompts.push(customPrompt);
|
||||
} else {
|
||||
mergedPrompts[existingIndex] = customPrompt;
|
||||
}
|
||||
}
|
||||
agentYaml.agent.prompts = mergedPrompts;
|
||||
}
|
||||
}
|
||||
|
||||
// Use remaining answers for template processing
|
||||
answers = remainingAnswers;
|
||||
|
||||
// Extract install_config
|
||||
const installConfig = extractInstallConfig(agentYaml);
|
||||
|
||||
// Merge defaults with provided answers
|
||||
let finalAnswers = answers;
|
||||
if (installConfig) {
|
||||
const defaults = getDefaultValues(installConfig);
|
||||
finalAnswers = { ...defaults, ...answers };
|
||||
}
|
||||
|
||||
// Process templates with answers
|
||||
const processedYaml = processAgentYaml(agentYaml, finalAnswers);
|
||||
|
||||
// Strip install_config from output
|
||||
const cleanYaml = stripInstallConfig(processedYaml);
|
||||
|
||||
let xml = await compileToXml(cleanYaml, agentName, targetPath);
|
||||
|
||||
// Ensure xml is a string before attempting replaceAll
|
||||
if (typeof xml !== 'string') {
|
||||
throw new TypeError('compileToXml did not return a string');
|
||||
}
|
||||
|
||||
return {
|
||||
xml,
|
||||
metadata: cleanYaml.agent.metadata,
|
||||
processedYaml: cleanYaml,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter customization data to remove empty/null values
|
||||
* @param {Object} data - Raw customization data
|
||||
* @returns {Object} Filtered customization data
|
||||
*/
|
||||
function filterCustomizationData(data) {
|
||||
const filtered = {};
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
continue; // Skip null/undefined/empty values
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) {
|
||||
filtered[key] = value;
|
||||
}
|
||||
} else if (typeof value === 'object') {
|
||||
const nested = filterCustomizationData(value);
|
||||
if (Object.keys(nested).length > 0) {
|
||||
filtered[key] = nested;
|
||||
}
|
||||
} else {
|
||||
filtered[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile agent file to .md
|
||||
* @param {string} yamlPath - Path to agent YAML file
|
||||
* @param {Object} options - { answers: {}, outputPath: string }
|
||||
* @returns {Object} Compilation result
|
||||
*/
|
||||
function compileAgentFile(yamlPath, options = {}) {
|
||||
const yamlContent = fs.readFileSync(yamlPath, 'utf8');
|
||||
const result = compileAgent(yamlContent, options.answers || {});
|
||||
|
||||
// Determine output path
|
||||
let outputPath = options.outputPath;
|
||||
if (!outputPath) {
|
||||
// Default: same directory, same name, .md extension
|
||||
const dir = path.dirname(yamlPath);
|
||||
const basename = path.basename(yamlPath, '.agent.yaml');
|
||||
outputPath = path.join(dir, `${basename}.md`);
|
||||
}
|
||||
|
||||
// Write compiled XML
|
||||
fs.writeFileSync(outputPath, xml, 'utf8');
|
||||
|
||||
return {
|
||||
...result,
|
||||
xml,
|
||||
outputPath,
|
||||
sourcePath: yamlPath,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compileToXml,
|
||||
compileAgent,
|
||||
compileAgentFile,
|
||||
escapeXml,
|
||||
buildFrontmatter,
|
||||
buildPersonaXml,
|
||||
buildPromptsXml,
|
||||
buildMemoriesXml,
|
||||
buildMenuXml,
|
||||
filterCustomizationData,
|
||||
};
|
||||
|
|
@ -1,680 +0,0 @@
|
|||
/**
|
||||
* BMAD Agent Installer
|
||||
* Discovers, prompts, compiles, and installs agents
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const yaml = require('yaml');
|
||||
const prompts = require('../prompts');
|
||||
const { compileAgent, compileAgentFile } = require('./compiler');
|
||||
const { extractInstallConfig, getDefaultValues } = require('./template-engine');
|
||||
|
||||
/**
|
||||
* Find BMAD config file in project
|
||||
* @param {string} startPath - Starting directory to search from
|
||||
* @returns {Object|null} Config data or null
|
||||
*/
|
||||
function findBmadConfig(startPath = process.cwd()) {
|
||||
// Look for common BMAD folder names
|
||||
const possibleNames = ['_bmad'];
|
||||
|
||||
for (const name of possibleNames) {
|
||||
const configPath = path.join(startPath, name, 'bmb', 'config.yaml');
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const config = yaml.parse(content);
|
||||
return {
|
||||
...config,
|
||||
bmadFolder: path.join(startPath, name),
|
||||
projectRoot: startPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve path variables like {project-root} and {bmad-folder}
|
||||
* @param {string} pathStr - Path with variables
|
||||
* @param {Object} context - Contains projectRoot, bmadFolder
|
||||
* @returns {string} Resolved path
|
||||
*/
|
||||
function resolvePath(pathStr, context) {
|
||||
return pathStr.replaceAll('{project-root}', context.projectRoot).replaceAll('{bmad-folder}', context.bmadFolder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover available agents in the custom agent location recursively
|
||||
* @param {string} searchPath - Path to search for agents
|
||||
* @returns {Array} List of agent info objects
|
||||
*/
|
||||
function discoverAgents(searchPath) {
|
||||
if (!fs.existsSync(searchPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const agents = [];
|
||||
|
||||
// Helper function to recursively search
|
||||
function searchDirectory(dir, relativePath = '') {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
const agentRelativePath = relativePath ? path.join(relativePath, entry.name) : entry.name;
|
||||
|
||||
if (entry.isFile() && entry.name.endsWith('.agent.yaml')) {
|
||||
// Simple agent (single file)
|
||||
// The agent name is based on the filename
|
||||
const agentName = entry.name.replace('.agent.yaml', '');
|
||||
agents.push({
|
||||
type: 'simple',
|
||||
name: agentName,
|
||||
path: fullPath,
|
||||
yamlFile: fullPath,
|
||||
relativePath: agentRelativePath.replace('.agent.yaml', ''),
|
||||
});
|
||||
} else if (entry.isDirectory()) {
|
||||
// Check if this directory contains an .agent.yaml file
|
||||
try {
|
||||
const dirContents = fs.readdirSync(fullPath);
|
||||
const yamlFiles = dirContents.filter((f) => f.endsWith('.agent.yaml'));
|
||||
|
||||
if (yamlFiles.length > 0) {
|
||||
// Found .agent.yaml files in this directory
|
||||
for (const yamlFile of yamlFiles) {
|
||||
const agentYamlPath = path.join(fullPath, yamlFile);
|
||||
const agentName = path.basename(yamlFile, '.agent.yaml');
|
||||
|
||||
agents.push({
|
||||
type: 'expert',
|
||||
name: agentName,
|
||||
path: fullPath,
|
||||
yamlFile: agentYamlPath,
|
||||
relativePath: agentRelativePath,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No .agent.yaml in this directory, recurse deeper
|
||||
searchDirectory(fullPath, agentRelativePath);
|
||||
}
|
||||
} catch {
|
||||
// Skip directories we can't read
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
searchDirectory(searchPath);
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load agent YAML and extract install_config
|
||||
* @param {string} yamlPath - Path to agent YAML file
|
||||
* @returns {Object} Agent YAML and install config
|
||||
*/
|
||||
function loadAgentConfig(yamlPath) {
|
||||
const content = fs.readFileSync(yamlPath, 'utf8');
|
||||
const agentYaml = yaml.parse(content);
|
||||
const installConfig = extractInstallConfig(agentYaml);
|
||||
const defaults = installConfig ? getDefaultValues(installConfig) : {};
|
||||
|
||||
// Check for saved_answers (from previously installed custom agents)
|
||||
// These take precedence over defaults
|
||||
const savedAnswers = agentYaml?.saved_answers || {};
|
||||
|
||||
const metadata = agentYaml?.agent?.metadata || {};
|
||||
|
||||
return {
|
||||
yamlContent: content,
|
||||
agentYaml,
|
||||
installConfig,
|
||||
defaults: { ...defaults, ...savedAnswers }, // saved_answers override defaults
|
||||
metadata,
|
||||
hasSidecar: metadata.hasSidecar === true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive prompt for install_config questions
|
||||
* @param {Object} installConfig - Install configuration with questions
|
||||
* @param {Object} defaults - Default values
|
||||
* @returns {Promise<Object>} User answers
|
||||
*/
|
||||
async function promptInstallQuestions(installConfig, defaults, presetAnswers = {}) {
|
||||
if (!installConfig || !installConfig.questions || installConfig.questions.length === 0) {
|
||||
return { ...defaults, ...presetAnswers };
|
||||
}
|
||||
|
||||
const answers = { ...defaults, ...presetAnswers };
|
||||
|
||||
await prompts.note(installConfig.description || '', 'Agent Configuration');
|
||||
|
||||
for (const q of installConfig.questions) {
|
||||
// Skip questions for variables that are already set (e.g., custom_name set upfront)
|
||||
if (answers[q.var] !== undefined && answers[q.var] !== defaults[q.var]) {
|
||||
await prompts.log.message(` ${q.var}: ${answers[q.var]} (already set)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (q.type) {
|
||||
case 'text': {
|
||||
const response = await prompts.text({
|
||||
message: q.prompt,
|
||||
default: q.default ?? '',
|
||||
});
|
||||
answers[q.var] = response ?? q.default ?? '';
|
||||
break;
|
||||
}
|
||||
case 'boolean': {
|
||||
const response = await prompts.confirm({
|
||||
message: q.prompt,
|
||||
default: q.default,
|
||||
});
|
||||
answers[q.var] = response;
|
||||
break;
|
||||
}
|
||||
case 'choice': {
|
||||
const response = await prompts.select({
|
||||
message: q.prompt,
|
||||
options: q.options.map((o) => ({ value: o.value, label: o.label })),
|
||||
initialValue: q.default,
|
||||
});
|
||||
answers[q.var] = response;
|
||||
break;
|
||||
}
|
||||
// No default
|
||||
}
|
||||
}
|
||||
|
||||
return answers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a compiled agent to target location
|
||||
* @param {Object} agentInfo - Agent discovery info
|
||||
* @param {Object} answers - User answers for install_config
|
||||
* @param {string} targetPath - Target installation directory
|
||||
* @param {Object} options - Additional options including config
|
||||
* @returns {Object} Installation result
|
||||
*/
|
||||
function installAgent(agentInfo, answers, targetPath, options = {}) {
|
||||
// Compile the agent
|
||||
const { xml, metadata, processedYaml } = compileAgent(fs.readFileSync(agentInfo.yamlFile, 'utf8'), answers);
|
||||
|
||||
// Determine target agent folder name
|
||||
// Use the folder name from agentInfo, NOT the persona name from metadata
|
||||
const agentFolderName = agentInfo.name;
|
||||
|
||||
const agentTargetDir = path.join(targetPath, agentFolderName);
|
||||
|
||||
// Create target directory
|
||||
if (!fs.existsSync(agentTargetDir)) {
|
||||
fs.mkdirSync(agentTargetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write compiled XML (.md)
|
||||
const compiledFileName = `${agentFolderName}.md`;
|
||||
const compiledPath = path.join(agentTargetDir, compiledFileName);
|
||||
fs.writeFileSync(compiledPath, xml, 'utf8');
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
agentName: metadata.name || agentInfo.name,
|
||||
targetDir: agentTargetDir,
|
||||
compiledFile: compiledPath,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update agent metadata ID to reflect installed location
|
||||
* @param {string} compiledContent - Compiled XML content
|
||||
* @param {string} targetPath - Target installation path relative to project
|
||||
* @returns {string} Updated content
|
||||
*/
|
||||
function updateAgentId(compiledContent, targetPath) {
|
||||
// Update the id attribute in the opening agent tag
|
||||
return compiledContent.replace(/(<agent\s+id=")[^"]*(")/, `$1${targetPath}$2`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a path is within a BMAD project
|
||||
* @param {string} targetPath - Path to check
|
||||
* @returns {Object|null} Project info with bmadFolder and cfgFolder
|
||||
*/
|
||||
function detectBmadProject(targetPath) {
|
||||
let checkPath = path.resolve(targetPath);
|
||||
const root = path.parse(checkPath).root;
|
||||
|
||||
// Walk up directory tree looking for BMAD installation
|
||||
while (checkPath !== root) {
|
||||
const possibleNames = ['_bmad'];
|
||||
for (const name of possibleNames) {
|
||||
const bmadFolder = path.join(checkPath, name);
|
||||
const cfgFolder = path.join(bmadFolder, '_config');
|
||||
const manifestFile = path.join(cfgFolder, 'agent-manifest.csv');
|
||||
|
||||
if (fs.existsSync(manifestFile)) {
|
||||
return {
|
||||
projectRoot: checkPath,
|
||||
bmadFolder,
|
||||
cfgFolder,
|
||||
manifestFile,
|
||||
};
|
||||
}
|
||||
}
|
||||
checkPath = path.dirname(checkPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape CSV field value
|
||||
* @param {string} value - Value to escape
|
||||
* @returns {string} Escaped value
|
||||
*/
|
||||
function escapeCsvField(value) {
|
||||
if (typeof value !== 'string') value = String(value);
|
||||
// If contains comma, quote, or newline, wrap in quotes and escape internal quotes
|
||||
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
||||
return '"' + value.replaceAll('"', '""') + '"';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CSV line respecting quoted fields
|
||||
* @param {string} line - CSV line
|
||||
* @returns {Array} Parsed fields
|
||||
*/
|
||||
function parseCsvLine(line) {
|
||||
const fields = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
const nextChar = line[i + 1];
|
||||
|
||||
if (char === '"' && !inQuotes) {
|
||||
inQuotes = true;
|
||||
} else if (char === '"' && inQuotes) {
|
||||
if (nextChar === '"') {
|
||||
current += '"';
|
||||
i++; // Skip escaped quote
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
fields.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
fields.push(current);
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if agent name exists in manifest
|
||||
* @param {string} manifestFile - Path to agent-manifest.csv
|
||||
* @param {string} agentName - Agent name to check
|
||||
* @returns {Object|null} Existing entry or null
|
||||
*/
|
||||
function checkManifestForAgent(manifestFile, agentName) {
|
||||
const content = fs.readFileSync(manifestFile, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
if (lines.length < 2) return null;
|
||||
|
||||
const header = parseCsvLine(lines[0]);
|
||||
const nameIndex = header.indexOf('name');
|
||||
|
||||
if (nameIndex === -1) return null;
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const fields = parseCsvLine(lines[i]);
|
||||
if (fields[nameIndex] === agentName) {
|
||||
const entry = {};
|
||||
for (const [idx, col] of header.entries()) {
|
||||
entry[col] = fields[idx] || '';
|
||||
}
|
||||
entry._lineNumber = i;
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if agent path exists in manifest
|
||||
* @param {string} manifestFile - Path to agent-manifest.csv
|
||||
* @param {string} agentPath - Agent path to check
|
||||
* @returns {Object|null} Existing entry or null
|
||||
*/
|
||||
function checkManifestForPath(manifestFile, agentPath) {
|
||||
const content = fs.readFileSync(manifestFile, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
if (lines.length < 2) return null;
|
||||
|
||||
const header = parseCsvLine(lines[0]);
|
||||
const pathIndex = header.indexOf('path');
|
||||
|
||||
if (pathIndex === -1) return null;
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const fields = parseCsvLine(lines[i]);
|
||||
if (fields[pathIndex] === agentPath) {
|
||||
const entry = {};
|
||||
for (const [idx, col] of header.entries()) {
|
||||
entry[col] = fields[idx] || '';
|
||||
}
|
||||
entry._lineNumber = i;
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing entry in manifest
|
||||
* @param {string} manifestFile - Path to agent-manifest.csv
|
||||
* @param {Object} agentData - New agent data
|
||||
* @param {number} lineNumber - Line number to replace (1-indexed, excluding header)
|
||||
* @returns {boolean} Success
|
||||
*/
|
||||
function updateManifestEntry(manifestFile, agentData, lineNumber) {
|
||||
const content = fs.readFileSync(manifestFile, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
const header = lines[0];
|
||||
const columns = header.split(',');
|
||||
|
||||
// Build the new row
|
||||
const row = columns.map((col) => {
|
||||
const value = agentData[col] || '';
|
||||
return escapeCsvField(value);
|
||||
});
|
||||
|
||||
// Replace the line
|
||||
lines[lineNumber] = row.join(',');
|
||||
|
||||
fs.writeFileSync(manifestFile, lines.join('\n') + '\n', 'utf8');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add agent to manifest CSV
|
||||
* @param {string} manifestFile - Path to agent-manifest.csv
|
||||
* @param {Object} agentData - Agent metadata and path info
|
||||
* @returns {boolean} Success
|
||||
*/
|
||||
function addToManifest(manifestFile, agentData) {
|
||||
const content = fs.readFileSync(manifestFile, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
// Parse header to understand column order
|
||||
const header = lines[0];
|
||||
const columns = header.split(',');
|
||||
|
||||
// Build the new row based on header columns
|
||||
const row = columns.map((col) => {
|
||||
const value = agentData[col] || '';
|
||||
return escapeCsvField(value);
|
||||
});
|
||||
|
||||
// Append new row
|
||||
const newLine = row.join(',');
|
||||
const updatedContent = content.trim() + '\n' + newLine + '\n';
|
||||
|
||||
fs.writeFileSync(manifestFile, updatedContent, 'utf8');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save agent source YAML to _config/custom/agents/ for reinstallation
|
||||
* Stores user answers in a top-level saved_answers section (cleaner than overwriting defaults)
|
||||
* @param {Object} agentInfo - Agent info (path, type, etc.)
|
||||
* @param {string} cfgFolder - Path to _config folder
|
||||
* @param {string} agentName - Final agent name (e.g., "fred-commit-poet")
|
||||
* @param {Object} answers - User answers to save for reinstallation
|
||||
* @returns {Object} Info about saved source
|
||||
*/
|
||||
function saveAgentSource(agentInfo, cfgFolder, agentName, answers = {}) {
|
||||
// Save to _config/custom/agents/ instead of _config/agents/
|
||||
const customAgentsCfgDir = path.join(cfgFolder, 'custom', 'agents');
|
||||
|
||||
if (!fs.existsSync(customAgentsCfgDir)) {
|
||||
fs.mkdirSync(customAgentsCfgDir, { recursive: true });
|
||||
}
|
||||
|
||||
const yamlLib = require('yaml');
|
||||
|
||||
/**
|
||||
* Add saved_answers section to store user's actual answers
|
||||
*/
|
||||
function addSavedAnswers(agentYaml, answers) {
|
||||
// Store answers in a clear, separate section
|
||||
agentYaml.saved_answers = answers;
|
||||
return agentYaml;
|
||||
}
|
||||
|
||||
if (agentInfo.type === 'simple') {
|
||||
// Simple agent: copy YAML with saved_answers section
|
||||
const targetYaml = path.join(customAgentsCfgDir, `${agentName}.agent.yaml`);
|
||||
const originalContent = fs.readFileSync(agentInfo.yamlFile, 'utf8');
|
||||
const agentYaml = yamlLib.parse(originalContent);
|
||||
|
||||
// Add saved_answers section with user's choices
|
||||
addSavedAnswers(agentYaml, answers);
|
||||
|
||||
fs.writeFileSync(targetYaml, yamlLib.stringify(agentYaml), 'utf8');
|
||||
return { type: 'simple', path: targetYaml };
|
||||
} else {
|
||||
// Expert agent with sidecar: copy entire folder with saved_answers
|
||||
const targetFolder = path.join(customAgentsCfgDir, agentName);
|
||||
if (!fs.existsSync(targetFolder)) {
|
||||
fs.mkdirSync(targetFolder, { recursive: true });
|
||||
}
|
||||
|
||||
// Copy YAML and entire sidecar structure
|
||||
const sourceDir = agentInfo.path;
|
||||
const copied = [];
|
||||
|
||||
function copyDir(src, dest) {
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(src, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
copyDir(srcPath, destPath);
|
||||
} else if (entry.name.endsWith('.agent.yaml')) {
|
||||
// For the agent YAML, add saved_answers section
|
||||
const originalContent = fs.readFileSync(srcPath, 'utf8');
|
||||
const agentYaml = yamlLib.parse(originalContent);
|
||||
addSavedAnswers(agentYaml, answers);
|
||||
// Rename YAML to match final agent name
|
||||
const newYamlPath = path.join(dest, `${agentName}.agent.yaml`);
|
||||
fs.writeFileSync(newYamlPath, yamlLib.stringify(agentYaml), 'utf8');
|
||||
copied.push(newYamlPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
copied.push(destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
copyDir(sourceDir, targetFolder);
|
||||
return { type: 'expert', path: targetFolder, files: copied };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create IDE slash command wrapper for agent
|
||||
* Leverages IdeManager to dispatch to IDE-specific handlers
|
||||
* @param {string} projectRoot - Project root path
|
||||
* @param {string} agentName - Agent name (e.g., "commit-poet")
|
||||
* @param {string} agentPath - Path to compiled agent (relative to project root)
|
||||
* @param {Object} metadata - Agent metadata
|
||||
* @returns {Promise<Object>} Info about created slash commands
|
||||
*/
|
||||
async function createIdeSlashCommands(projectRoot, agentName, agentPath, metadata) {
|
||||
// Read manifest.yaml to get installed IDEs
|
||||
const manifestPath = path.join(projectRoot, '_bmad', '_config', 'manifest.yaml');
|
||||
let installedIdes = ['claude-code']; // Default to Claude Code if no manifest
|
||||
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
const yamlLib = require('yaml');
|
||||
const manifestContent = fs.readFileSync(manifestPath, 'utf8');
|
||||
const manifest = yamlLib.parse(manifestContent);
|
||||
if (manifest.ides && Array.isArray(manifest.ides)) {
|
||||
installedIdes = manifest.ides;
|
||||
}
|
||||
}
|
||||
|
||||
// Use IdeManager to install custom agent launchers for all configured IDEs
|
||||
const { IdeManager } = require('../../installers/lib/ide/manager');
|
||||
const ideManager = new IdeManager();
|
||||
|
||||
const results = await ideManager.installCustomAgentLaunchers(installedIdes, projectRoot, agentName, agentPath, metadata);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update manifest.yaml to track custom agent
|
||||
* @param {string} manifestPath - Path to manifest.yaml
|
||||
* @param {string} agentName - Agent name
|
||||
* @param {string} agentType - Agent type (source name)
|
||||
* @returns {boolean} Success
|
||||
*/
|
||||
function updateManifestYaml(manifestPath, agentName, agentType) {
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const yamlLib = require('yaml');
|
||||
const content = fs.readFileSync(manifestPath, 'utf8');
|
||||
const manifest = yamlLib.parse(content);
|
||||
|
||||
// Initialize custom_agents array if not exists
|
||||
if (!manifest.custom_agents) {
|
||||
manifest.custom_agents = [];
|
||||
}
|
||||
|
||||
// Check if this agent is already registered
|
||||
const existingIndex = manifest.custom_agents.findIndex((a) => a.name === agentName || (typeof a === 'string' && a === agentName));
|
||||
|
||||
const agentEntry = {
|
||||
name: agentName,
|
||||
type: agentType,
|
||||
installed: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (existingIndex === -1) {
|
||||
// Add new entry
|
||||
manifest.custom_agents.push(agentEntry);
|
||||
} else {
|
||||
// Update existing entry
|
||||
manifest.custom_agents[existingIndex] = agentEntry;
|
||||
}
|
||||
|
||||
// Update lastUpdated timestamp
|
||||
if (manifest.installation) {
|
||||
manifest.installation.lastUpdated = new Date().toISOString();
|
||||
}
|
||||
|
||||
// Write back
|
||||
const newContent = yamlLib.stringify(manifest);
|
||||
fs.writeFileSync(manifestPath, newContent, 'utf8');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract manifest data from compiled agent XML
|
||||
* @param {string} xmlContent - Compiled agent XML
|
||||
* @param {Object} metadata - Agent metadata from YAML
|
||||
* @param {string} agentPath - Relative path to agent file
|
||||
* @param {string} moduleName - Module name (default: 'custom')
|
||||
* @returns {Object} Manifest row data
|
||||
*/
|
||||
function extractManifestData(xmlContent, metadata, agentPath, moduleName = 'custom') {
|
||||
// Extract data from XML using regex (simple parsing)
|
||||
const extractTag = (tag) => {
|
||||
const match = xmlContent.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`));
|
||||
if (!match) return '';
|
||||
// Collapse multiple lines into single line, normalize whitespace
|
||||
return match[1].trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ').trim();
|
||||
};
|
||||
|
||||
// Extract attributes from agent tag
|
||||
const extractAgentAttribute = (attr) => {
|
||||
const match = xmlContent.match(new RegExp(`<agent[^>]*\\s${attr}=["']([^"']+)["']`));
|
||||
return match ? match[1] : '';
|
||||
};
|
||||
|
||||
const extractPrinciples = () => {
|
||||
const match = xmlContent.match(/<principles>([\s\S]*?)<\/principles>/);
|
||||
if (!match) return '';
|
||||
// Extract individual principle lines
|
||||
const principles = match[1]
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0)
|
||||
.join(' ');
|
||||
return principles;
|
||||
};
|
||||
|
||||
// Prioritize XML extraction over metadata for agent persona info
|
||||
const xmlTitle = extractAgentAttribute('title') || extractTag('name');
|
||||
const xmlIcon = extractAgentAttribute('icon');
|
||||
|
||||
return {
|
||||
name: metadata.id ? path.basename(metadata.id, '.md') : metadata.name.toLowerCase().replaceAll(/\s+/g, '-'),
|
||||
displayName: xmlTitle || metadata.name || '',
|
||||
title: xmlTitle || metadata.title || '',
|
||||
icon: xmlIcon || metadata.icon || '',
|
||||
role: extractTag('role'),
|
||||
identity: extractTag('identity'),
|
||||
communicationStyle: extractTag('communication_style'),
|
||||
principles: extractPrinciples(),
|
||||
module: moduleName,
|
||||
path: agentPath,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findBmadConfig,
|
||||
resolvePath,
|
||||
discoverAgents,
|
||||
loadAgentConfig,
|
||||
promptInstallQuestions,
|
||||
installAgent,
|
||||
updateAgentId,
|
||||
detectBmadProject,
|
||||
addToManifest,
|
||||
extractManifestData,
|
||||
escapeCsvField,
|
||||
checkManifestForAgent,
|
||||
checkManifestForPath,
|
||||
updateManifestEntry,
|
||||
saveAgentSource,
|
||||
createIdeSlashCommands,
|
||||
updateManifestYaml,
|
||||
};
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
/**
|
||||
* Template Engine for BMAD Agent Install Configuration
|
||||
* Processes {{variable}}, {{#if}}, {{#unless}}, and {{/if}} blocks
|
||||
*/
|
||||
|
||||
/**
|
||||
* Process all template syntax in a string
|
||||
* @param {string} content - Content with template syntax
|
||||
* @param {Object} variables - Key-value pairs from install_config answers
|
||||
* @returns {string} Processed content
|
||||
*/
|
||||
function processTemplate(content, variables = {}) {
|
||||
let result = content;
|
||||
|
||||
// Process conditionals first (they may contain variables)
|
||||
result = processConditionals(result, variables);
|
||||
|
||||
// Then process simple variable replacements
|
||||
result = processVariables(result, variables);
|
||||
|
||||
// Clean up any empty lines left by removed conditionals
|
||||
result = cleanupEmptyLines(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process {{#if}}, {{#unless}}, {{/if}}, {{/unless}} blocks
|
||||
*/
|
||||
function processConditionals(content, variables) {
|
||||
let result = content;
|
||||
|
||||
// Process {{#if variable == "value"}} blocks
|
||||
// Handle both regular quotes and JSON-escaped quotes (\")
|
||||
const ifEqualsPattern = /\{\{#if\s+(\w+)\s*==\s*\\?"([^"\\]+)\\?"\s*\}\}([\s\S]*?)\{\{\/if\}\}/g;
|
||||
result = result.replaceAll(ifEqualsPattern, (match, varName, value, block) => {
|
||||
return variables[varName] === value ? block : '';
|
||||
});
|
||||
|
||||
// Process {{#if variable}} blocks (boolean or truthy check)
|
||||
const ifBoolPattern = /\{\{#if\s+(\w+)\s*\}\}([\s\S]*?)\{\{\/if\}\}/g;
|
||||
result = result.replaceAll(ifBoolPattern, (match, varName, block) => {
|
||||
const val = variables[varName];
|
||||
// Treat as truthy: true, non-empty string, non-zero number
|
||||
const isTruthy = val === true || (typeof val === 'string' && val.length > 0) || (typeof val === 'number' && val !== 0);
|
||||
return isTruthy ? block : '';
|
||||
});
|
||||
|
||||
// Process {{#unless variable}} blocks (inverse of if)
|
||||
const unlessPattern = /\{\{#unless\s+(\w+)\s*\}\}([\s\S]*?)\{\{\/unless\}\}/g;
|
||||
result = result.replaceAll(unlessPattern, (match, varName, block) => {
|
||||
const val = variables[varName];
|
||||
const isFalsy = val === false || val === '' || val === null || val === undefined || val === 0;
|
||||
return isFalsy ? block : '';
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process {{variable}} replacements
|
||||
*/
|
||||
function processVariables(content, variables) {
|
||||
let result = content;
|
||||
|
||||
// Replace {{variable}} with value
|
||||
const varPattern = /\{\{(\w+)\}\}/g;
|
||||
result = result.replaceAll(varPattern, (match, varName) => {
|
||||
if (Object.hasOwn(variables, varName)) {
|
||||
return String(variables[varName]);
|
||||
}
|
||||
// If variable not found, leave as-is (might be runtime variable like {user_name})
|
||||
return match;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up excessive empty lines left after removing conditional blocks
|
||||
*/
|
||||
function cleanupEmptyLines(content) {
|
||||
// Replace 3+ consecutive newlines with 2
|
||||
return content.replaceAll(/\n{3,}/g, '\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract install_config from agent YAML object
|
||||
* @param {Object} agentYaml - Parsed agent YAML
|
||||
* @returns {Object|null} install_config section or null
|
||||
*/
|
||||
function extractInstallConfig(agentYaml) {
|
||||
return agentYaml?.agent?.install_config || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove install_config from agent YAML (after processing)
|
||||
* @param {Object} agentYaml - Parsed agent YAML
|
||||
* @returns {Object} Agent YAML without install_config
|
||||
*/
|
||||
function stripInstallConfig(agentYaml) {
|
||||
const result = structuredClone(agentYaml);
|
||||
if (result.agent) {
|
||||
delete result.agent.install_config;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process entire agent YAML object with template variables
|
||||
* @param {Object} agentYaml - Parsed agent YAML
|
||||
* @param {Object} variables - Answers from install_config questions
|
||||
* @returns {Object} Processed agent YAML
|
||||
*/
|
||||
function processAgentYaml(agentYaml, variables) {
|
||||
// Convert to JSON string, process templates, parse back
|
||||
const jsonString = JSON.stringify(agentYaml, null, 2);
|
||||
const processed = processTemplate(jsonString, variables);
|
||||
return JSON.parse(processed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default values from install_config questions
|
||||
* @param {Object} installConfig - install_config section
|
||||
* @returns {Object} Default values keyed by variable name
|
||||
*/
|
||||
function getDefaultValues(installConfig) {
|
||||
const defaults = {};
|
||||
|
||||
if (!installConfig?.questions) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
for (const question of installConfig.questions) {
|
||||
if (question.var && question.default !== undefined) {
|
||||
defaults[question.var] = question.default;
|
||||
}
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
processTemplate,
|
||||
processConditionals,
|
||||
processVariables,
|
||||
extractInstallConfig,
|
||||
stripInstallConfig,
|
||||
processAgentYaml,
|
||||
getDefaultValues,
|
||||
cleanupEmptyLines,
|
||||
};
|
||||
|
|
@ -208,14 +208,6 @@ class UI {
|
|||
});
|
||||
}
|
||||
|
||||
// Add custom agent compilation option
|
||||
if (installedVersion !== 'unknown') {
|
||||
choices.push({
|
||||
name: 'Recompile Agents (apply customizations only)',
|
||||
value: 'compile-agents',
|
||||
});
|
||||
}
|
||||
|
||||
// Common actions
|
||||
choices.push({ name: 'Modify BMAD Installation', value: 'update' });
|
||||
|
||||
|
|
@ -291,17 +283,6 @@ class UI {
|
|||
};
|
||||
}
|
||||
|
||||
// Handle compile agents separately
|
||||
if (actionType === 'compile-agents') {
|
||||
// Only recompile agents with customizations, don't update any files
|
||||
return {
|
||||
actionType: 'compile-agents',
|
||||
directory: confirmedDirectory,
|
||||
customContent: { hasCustomContent: false },
|
||||
skipPrompts: options.yes || false,
|
||||
};
|
||||
}
|
||||
|
||||
// If actionType === 'update', handle it with the new flow
|
||||
// Return early with modify configuration
|
||||
if (actionType === 'update') {
|
||||
|
|
|
|||
|
|
@ -1,177 +0,0 @@
|
|||
const xml2js = require('xml2js');
|
||||
const fs = require('fs-extra');
|
||||
const path = require('node:path');
|
||||
const { getProjectRoot, getSourcePath } = require('./project-root');
|
||||
const { YamlXmlBuilder } = require('./yaml-xml-builder');
|
||||
|
||||
/**
|
||||
* XML utility functions for BMAD installer
|
||||
* Now supports both legacy XML agents and new YAML-based agents
|
||||
*/
|
||||
class XmlHandler {
|
||||
constructor() {
|
||||
this.parser = new xml2js.Parser({
|
||||
preserveChildrenOrder: true,
|
||||
explicitChildren: true,
|
||||
explicitArray: false,
|
||||
trim: false,
|
||||
normalizeTags: false,
|
||||
attrkey: '$',
|
||||
charkey: '_',
|
||||
});
|
||||
|
||||
this.builder = new xml2js.Builder({
|
||||
renderOpts: {
|
||||
pretty: true,
|
||||
indent: ' ',
|
||||
newline: '\n',
|
||||
},
|
||||
xmldec: {
|
||||
version: '1.0',
|
||||
encoding: 'utf8',
|
||||
standalone: false,
|
||||
},
|
||||
headless: true, // Don't add XML declaration
|
||||
attrkey: '$',
|
||||
charkey: '_',
|
||||
});
|
||||
|
||||
this.yamlBuilder = new YamlXmlBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and parse the activation template
|
||||
* @returns {Object} Parsed activation block
|
||||
*/
|
||||
async loadActivationTemplate() {
|
||||
console.error('Failed to load activation template:', error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject activation block into agent XML content
|
||||
* @param {string} agentContent - The agent file content
|
||||
* @param {Object} metadata - Metadata containing module and name
|
||||
* @returns {string} Modified content with activation block
|
||||
*/
|
||||
async injectActivation(agentContent, metadata = {}) {
|
||||
try {
|
||||
// Check if already has activation
|
||||
if (agentContent.includes('<activation')) {
|
||||
return agentContent;
|
||||
}
|
||||
|
||||
// Extract the XML portion from markdown if needed
|
||||
let xmlContent = agentContent;
|
||||
let beforeXml = '';
|
||||
let afterXml = '';
|
||||
|
||||
const xmlBlockMatch = agentContent.match(/([\s\S]*?)```xml\n([\s\S]*?)\n```([\s\S]*)/);
|
||||
if (xmlBlockMatch) {
|
||||
beforeXml = xmlBlockMatch[1] + '```xml\n';
|
||||
xmlContent = xmlBlockMatch[2];
|
||||
afterXml = '\n```' + xmlBlockMatch[3];
|
||||
}
|
||||
|
||||
// Parse the agent XML
|
||||
const parsed = await this.parser.parseStringPromise(xmlContent);
|
||||
|
||||
// Get the activation template
|
||||
const activationBlock = await this.loadActivationTemplate();
|
||||
if (!activationBlock) {
|
||||
console.warn('Could not load activation template');
|
||||
return agentContent;
|
||||
}
|
||||
|
||||
// Find the agent node
|
||||
if (
|
||||
parsed.agent && // Insert activation as the first child
|
||||
!parsed.agent.activation
|
||||
) {
|
||||
// Ensure proper structure
|
||||
if (!parsed.agent.$$) {
|
||||
parsed.agent.$$ = [];
|
||||
}
|
||||
|
||||
// Create the activation node with proper structure
|
||||
const activationNode = {
|
||||
'#name': 'activation',
|
||||
$: { critical: '1' },
|
||||
$$: activationBlock.$$,
|
||||
};
|
||||
|
||||
// Insert at the beginning
|
||||
parsed.agent.$$.unshift(activationNode);
|
||||
}
|
||||
|
||||
// Convert back to XML
|
||||
let modifiedXml = this.builder.buildObject(parsed);
|
||||
|
||||
// Fix indentation - xml2js doesn't maintain our exact formatting
|
||||
// Add 2-space base indentation to match our style
|
||||
const lines = modifiedXml.split('\n');
|
||||
const indentedLines = lines.map((line) => {
|
||||
if (line.trim() === '') return line;
|
||||
if (line.startsWith('<agent')) return line; // Keep agent at column 0
|
||||
return ' ' + line; // Indent everything else
|
||||
});
|
||||
modifiedXml = indentedLines.join('\n');
|
||||
|
||||
// Reconstruct the full content
|
||||
return beforeXml + modifiedXml + afterXml;
|
||||
} catch (error) {
|
||||
console.error('Error injecting activation:', error);
|
||||
return agentContent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: DELETE THIS METHOD
|
||||
*/
|
||||
injectActivationSimple(agentContent, metadata = {}) {
|
||||
console.error('Error in simple injection:', error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build agent from YAML source
|
||||
* @param {string} yamlPath - Path to .agent.yaml file
|
||||
* @param {string} customizePath - Path to .customize.yaml file (optional)
|
||||
* @param {Object} metadata - Build metadata
|
||||
* @returns {string} Generated XML content
|
||||
*/
|
||||
async buildFromYaml(yamlPath, customizePath = null, metadata = {}) {
|
||||
try {
|
||||
// Use YamlXmlBuilder to convert YAML to XML
|
||||
const mergedAgent = await this.yamlBuilder.loadAndMergeAgent(yamlPath, customizePath);
|
||||
|
||||
// Build metadata
|
||||
const buildMetadata = {
|
||||
sourceFile: path.basename(yamlPath),
|
||||
sourceHash: await this.yamlBuilder.calculateFileHash(yamlPath),
|
||||
customizeFile: customizePath ? path.basename(customizePath) : null,
|
||||
customizeHash: customizePath ? await this.yamlBuilder.calculateFileHash(customizePath) : null,
|
||||
builderVersion: '1.0.0',
|
||||
includeMetadata: metadata.includeMetadata !== false,
|
||||
forWebBundle: metadata.forWebBundle || false, // Pass through forWebBundle flag
|
||||
};
|
||||
|
||||
// Convert to XML
|
||||
const xml = await this.yamlBuilder.convertToXml(mergedAgent, buildMetadata);
|
||||
|
||||
return xml;
|
||||
} catch (error) {
|
||||
console.error('Error building agent from YAML:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is a YAML agent file
|
||||
* @param {string} filePath - Path to check
|
||||
* @returns {boolean} True if it's a YAML agent file
|
||||
*/
|
||||
isYamlAgent(filePath) {
|
||||
return filePath.endsWith('.agent.yaml');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { XmlHandler };
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
function convertXmlToMarkdown(xmlFilePath) {
|
||||
if (!xmlFilePath.endsWith('.xml')) {
|
||||
throw new Error('Input file must be an XML file');
|
||||
}
|
||||
|
||||
const xmlContent = fs.readFileSync(xmlFilePath, 'utf8');
|
||||
|
||||
const basename = path.basename(xmlFilePath, '.xml');
|
||||
const dirname = path.dirname(xmlFilePath);
|
||||
const mdFilePath = path.join(dirname, `${basename}.md`);
|
||||
|
||||
// Extract version and name/title from root element attributes
|
||||
let title = basename;
|
||||
let version = '';
|
||||
|
||||
// Match the root element and its attributes
|
||||
const rootMatch = xmlContent.match(
|
||||
/<[^>\s]+[^>]*?\sv="([^"]+)"[^>]*?(?:\sname="([^"]+)")?|<[^>\s]+[^>]*?(?:\sname="([^"]+)")?[^>]*?\sv="([^"]+)"/,
|
||||
);
|
||||
|
||||
if (rootMatch) {
|
||||
// Handle both v="x" name="y" and name="y" v="x" orders
|
||||
version = rootMatch[1] || rootMatch[4] || '';
|
||||
const nameAttr = rootMatch[2] || rootMatch[3] || '';
|
||||
|
||||
if (nameAttr) {
|
||||
title = nameAttr;
|
||||
} else {
|
||||
// Try to find name in a <name> element if not in attributes
|
||||
const nameElementMatch = xmlContent.match(/<name>([^<]+)<\/name>/);
|
||||
if (nameElementMatch) {
|
||||
title = nameElementMatch[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const heading = version ? `# ${title} v${version}` : `# ${title}`;
|
||||
|
||||
const markdownContent = `${heading}
|
||||
|
||||
\`\`\`xml
|
||||
${xmlContent}
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
fs.writeFileSync(mdFilePath, markdownContent, 'utf8');
|
||||
|
||||
return mdFilePath;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error('Usage: node xml-to-markdown.js <xml-file-path>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const xmlFilePath = path.resolve(args[0]);
|
||||
|
||||
if (!fs.existsSync(xmlFilePath)) {
|
||||
console.error(`Error: File not found: ${xmlFilePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const mdFilePath = convertXmlToMarkdown(xmlFilePath);
|
||||
console.log(`Successfully converted: ${xmlFilePath} -> ${mdFilePath}`);
|
||||
} catch (error) {
|
||||
console.error(`Error converting file: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { convertXmlToMarkdown };
|
||||
|
|
@ -1,572 +0,0 @@
|
|||
const yaml = require('yaml');
|
||||
const fs = require('fs-extra');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { AgentAnalyzer } = require('./agent-analyzer');
|
||||
const { ActivationBuilder } = require('./activation-builder');
|
||||
const { escapeXml } = require('../../lib/xml-utils');
|
||||
|
||||
/**
|
||||
* Converts agent YAML files to XML format with smart activation injection
|
||||
*/
|
||||
class YamlXmlBuilder {
|
||||
constructor() {
|
||||
this.analyzer = new AgentAnalyzer();
|
||||
this.activationBuilder = new ActivationBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge two objects (for customize.yaml + agent.yaml)
|
||||
* @param {Object} target - Target object
|
||||
* @param {Object} source - Source object to merge in
|
||||
* @returns {Object} Merged object
|
||||
*/
|
||||
deepMerge(target, source) {
|
||||
const output = { ...target };
|
||||
|
||||
if (this.isObject(target) && this.isObject(source)) {
|
||||
for (const key of Object.keys(source)) {
|
||||
if (this.isObject(source[key])) {
|
||||
if (key in target) {
|
||||
output[key] = this.deepMerge(target[key], source[key]);
|
||||
} else {
|
||||
output[key] = source[key];
|
||||
}
|
||||
} else if (Array.isArray(source[key])) {
|
||||
// For arrays, append rather than replace (for commands)
|
||||
if (Array.isArray(target[key])) {
|
||||
output[key] = [...target[key], ...source[key]];
|
||||
} else {
|
||||
output[key] = source[key];
|
||||
}
|
||||
} else {
|
||||
output[key] = source[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value is an object
|
||||
*/
|
||||
isObject(item) {
|
||||
return item && typeof item === 'object' && !Array.isArray(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and merge agent YAML with customization
|
||||
* @param {string} agentYamlPath - Path to base agent YAML
|
||||
* @param {string} customizeYamlPath - Path to customize YAML (optional)
|
||||
* @returns {Object} Merged agent configuration
|
||||
*/
|
||||
async loadAndMergeAgent(agentYamlPath, customizeYamlPath = null) {
|
||||
// Load base agent
|
||||
const agentContent = await fs.readFile(agentYamlPath, 'utf8');
|
||||
const agentYaml = yaml.parse(agentContent);
|
||||
|
||||
// Load customization if exists
|
||||
let merged = agentYaml;
|
||||
if (customizeYamlPath && (await fs.pathExists(customizeYamlPath))) {
|
||||
const customizeContent = await fs.readFile(customizeYamlPath, 'utf8');
|
||||
const customizeYaml = yaml.parse(customizeContent);
|
||||
|
||||
if (customizeYaml) {
|
||||
// Special handling: persona fields are merged, but only non-empty values override
|
||||
if (customizeYaml.persona) {
|
||||
const basePersona = merged.agent.persona || {};
|
||||
const customPersona = {};
|
||||
|
||||
// Only copy non-empty customize values
|
||||
for (const [key, value] of Object.entries(customizeYaml.persona)) {
|
||||
if (value !== '' && value !== null && !(Array.isArray(value) && value.length === 0)) {
|
||||
customPersona[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge non-empty customize values over base
|
||||
if (Object.keys(customPersona).length > 0) {
|
||||
merged.agent.persona = { ...basePersona, ...customPersona };
|
||||
}
|
||||
}
|
||||
|
||||
// Merge metadata (only non-empty values)
|
||||
if (customizeYaml.agent && customizeYaml.agent.metadata) {
|
||||
const nonEmptyMetadata = {};
|
||||
for (const [key, value] of Object.entries(customizeYaml.agent.metadata)) {
|
||||
if (value !== '' && value !== null) {
|
||||
nonEmptyMetadata[key] = value;
|
||||
}
|
||||
}
|
||||
merged.agent.metadata = { ...merged.agent.metadata, ...nonEmptyMetadata };
|
||||
}
|
||||
|
||||
// Append menu items (support both 'menu' and legacy 'commands')
|
||||
const customMenuItems = customizeYaml.menu || customizeYaml.commands;
|
||||
if (customMenuItems) {
|
||||
// Determine if base uses 'menu' or 'commands'
|
||||
if (merged.agent.menu) {
|
||||
merged.agent.menu = [...merged.agent.menu, ...customMenuItems];
|
||||
} else if (merged.agent.commands) {
|
||||
merged.agent.commands = [...merged.agent.commands, ...customMenuItems];
|
||||
} else {
|
||||
// Default to 'menu' for new agents
|
||||
merged.agent.menu = customMenuItems;
|
||||
}
|
||||
}
|
||||
|
||||
// Append critical actions
|
||||
if (customizeYaml.critical_actions) {
|
||||
merged.agent.critical_actions = [...(merged.agent.critical_actions || []), ...customizeYaml.critical_actions];
|
||||
}
|
||||
|
||||
// Append prompts
|
||||
if (customizeYaml.prompts) {
|
||||
merged.agent.prompts = [...(merged.agent.prompts || []), ...customizeYaml.prompts];
|
||||
}
|
||||
|
||||
// Append memories
|
||||
if (customizeYaml.memories) {
|
||||
merged.agent.memories = [...(merged.agent.memories || []), ...customizeYaml.memories];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert agent YAML to XML
|
||||
* @param {Object} agentYaml - Parsed agent YAML object
|
||||
* @param {Object} buildMetadata - Metadata about the build (file paths, hashes, etc.)
|
||||
* @returns {string} XML content
|
||||
*/
|
||||
async convertToXml(agentYaml, buildMetadata = {}) {
|
||||
const agent = agentYaml.agent;
|
||||
const metadata = agent.metadata || {};
|
||||
|
||||
// Add module from buildMetadata if available
|
||||
if (buildMetadata.module) {
|
||||
metadata.module = buildMetadata.module;
|
||||
}
|
||||
|
||||
// Analyze agent to determine needed handlers
|
||||
const profile = this.analyzer.analyzeAgentObject(agentYaml);
|
||||
|
||||
// Build activation block only if not skipped
|
||||
let activationBlock = '';
|
||||
if (!buildMetadata.skipActivation) {
|
||||
activationBlock = await this.activationBuilder.buildActivation(
|
||||
profile,
|
||||
metadata,
|
||||
agent.critical_actions || [],
|
||||
buildMetadata.forWebBundle || false, // Pass web bundle flag
|
||||
);
|
||||
}
|
||||
|
||||
// Start building XML
|
||||
let xml = '';
|
||||
|
||||
if (buildMetadata.forWebBundle) {
|
||||
// Web bundle: keep existing format
|
||||
xml += '<!-- Powered by BMAD-CORE™ -->\n\n';
|
||||
xml += `# ${metadata.title || 'Agent'}\n\n`;
|
||||
} else {
|
||||
// Installation: use YAML frontmatter + instruction
|
||||
// Extract name from filename: "cli-chief.yaml" or "pm.agent.yaml" -> "cli chief" or "pm"
|
||||
const filename = buildMetadata.sourceFile || 'agent.yaml';
|
||||
let nameFromFile = path.basename(filename, path.extname(filename)); // Remove .yaml/.md extension
|
||||
nameFromFile = nameFromFile.replace(/\.agent$/, ''); // Remove .agent suffix if present
|
||||
nameFromFile = nameFromFile.replaceAll('-', ' '); // Replace dashes with spaces
|
||||
|
||||
xml += '---\n';
|
||||
xml += `name: "${nameFromFile}"\n`;
|
||||
xml += `description: "${metadata.title || 'BMAD Agent'}"\n`;
|
||||
xml += '---\n\n';
|
||||
xml +=
|
||||
"You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.\n\n";
|
||||
}
|
||||
|
||||
xml += '```xml\n';
|
||||
|
||||
// Agent opening tag
|
||||
const agentAttrs = [
|
||||
`id="${metadata.id || ''}"`,
|
||||
`name="${metadata.name || ''}"`,
|
||||
`title="${metadata.title || ''}"`,
|
||||
`icon="${metadata.icon || '🤖'}"`,
|
||||
];
|
||||
|
||||
// Add localskip attribute if present
|
||||
if (metadata.localskip === true) {
|
||||
agentAttrs.push('localskip="true"');
|
||||
}
|
||||
|
||||
xml += `<agent ${agentAttrs.join(' ')}>\n`;
|
||||
|
||||
// Activation block (only if not skipped)
|
||||
if (activationBlock) {
|
||||
xml += activationBlock + '\n';
|
||||
}
|
||||
|
||||
// Persona section
|
||||
xml += this.buildPersonaXml(agent.persona);
|
||||
|
||||
// Memories section (if exists)
|
||||
if (agent.memories) {
|
||||
xml += this.buildMemoriesXml(agent.memories);
|
||||
}
|
||||
|
||||
// Prompts section (if exists)
|
||||
if (agent.prompts) {
|
||||
xml += this.buildPromptsXml(agent.prompts);
|
||||
}
|
||||
|
||||
// Menu section (support both 'menu' and legacy 'commands')
|
||||
const menuItems = agent.menu || agent.commands || [];
|
||||
xml += this.buildCommandsXml(menuItems, buildMetadata.forWebBundle);
|
||||
|
||||
xml += '</agent>\n';
|
||||
xml += '```\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build persona XML section
|
||||
*/
|
||||
buildPersonaXml(persona) {
|
||||
if (!persona) return '';
|
||||
|
||||
let xml = ' <persona>\n';
|
||||
|
||||
if (persona.role) {
|
||||
xml += ` <role>${escapeXml(persona.role)}</role>\n`;
|
||||
}
|
||||
|
||||
if (persona.identity) {
|
||||
xml += ` <identity>${escapeXml(persona.identity)}</identity>\n`;
|
||||
}
|
||||
|
||||
if (persona.communication_style) {
|
||||
xml += ` <communication_style>${escapeXml(persona.communication_style)}</communication_style>\n`;
|
||||
}
|
||||
|
||||
if (persona.principles) {
|
||||
// Principles can be array or string
|
||||
let principlesText;
|
||||
if (Array.isArray(persona.principles)) {
|
||||
principlesText = persona.principles.join(' ');
|
||||
} else {
|
||||
principlesText = persona.principles;
|
||||
}
|
||||
xml += ` <principles>${escapeXml(principlesText)}</principles>\n`;
|
||||
}
|
||||
|
||||
xml += ' </persona>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build memories XML section
|
||||
*/
|
||||
buildMemoriesXml(memories) {
|
||||
if (!memories || memories.length === 0) return '';
|
||||
|
||||
let xml = ' <memories>\n';
|
||||
|
||||
for (const memory of memories) {
|
||||
xml += ` <memory>${escapeXml(memory)}</memory>\n`;
|
||||
}
|
||||
|
||||
xml += ' </memories>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build prompts XML section
|
||||
* Handles both array format and object/dictionary format
|
||||
*/
|
||||
buildPromptsXml(prompts) {
|
||||
if (!prompts) return '';
|
||||
|
||||
// Handle object/dictionary format: { promptId: 'content', ... }
|
||||
// Convert to array format for processing
|
||||
let promptsArray = prompts;
|
||||
if (!Array.isArray(prompts)) {
|
||||
// Check if it's an object with no length property (dictionary format)
|
||||
if (typeof prompts === 'object' && prompts.length === undefined) {
|
||||
promptsArray = Object.entries(prompts).map(([id, content]) => ({
|
||||
id: id,
|
||||
content: content,
|
||||
}));
|
||||
} else {
|
||||
return ''; // Not a valid prompts format
|
||||
}
|
||||
}
|
||||
|
||||
if (promptsArray.length === 0) return '';
|
||||
|
||||
let xml = ' <prompts>\n';
|
||||
|
||||
for (const prompt of promptsArray) {
|
||||
xml += ` <prompt id="${prompt.id || ''}">\n`;
|
||||
xml += ` <content>\n`;
|
||||
xml += `${escapeXml(prompt.content || '')}\n`;
|
||||
xml += ` </content>\n`;
|
||||
xml += ` </prompt>\n`;
|
||||
}
|
||||
|
||||
xml += ' </prompts>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build menu XML section (renamed from commands for clarity)
|
||||
* Auto-injects *help and *exit, adds * prefix to all triggers
|
||||
* Supports both legacy format and new multi format with nested handlers
|
||||
* @param {Array} menuItems - Menu items from YAML
|
||||
* @param {boolean} forWebBundle - Whether building for web bundle
|
||||
*/
|
||||
buildCommandsXml(menuItems, forWebBundle = false) {
|
||||
let xml = ' <menu>\n';
|
||||
|
||||
// Always inject menu display option first
|
||||
xml += ` <item cmd="*menu">[M] Redisplay Menu Options</item>\n`;
|
||||
|
||||
// Add user-defined menu items with * prefix
|
||||
if (menuItems && menuItems.length > 0) {
|
||||
for (const item of menuItems) {
|
||||
// Skip ide-only items when building for web bundles
|
||||
if (forWebBundle && item['ide-only'] === true) {
|
||||
continue;
|
||||
}
|
||||
// Skip web-only items when NOT building for web bundles (i.e., IDE/local installation)
|
||||
if (!forWebBundle && item['web-only'] === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle multi format menu items with nested handlers
|
||||
if (item.multi && item.triggers && Array.isArray(item.triggers)) {
|
||||
xml += ` <item type="multi">${escapeXml(item.multi)}\n`;
|
||||
xml += this.buildNestedHandlers(item.triggers);
|
||||
xml += ` </item>\n`;
|
||||
}
|
||||
// Handle legacy format menu items
|
||||
else if (item.trigger) {
|
||||
// For legacy items, keep using cmd with *<trigger> format
|
||||
let trigger = item.trigger || '';
|
||||
if (!trigger.startsWith('*')) {
|
||||
trigger = '*' + trigger;
|
||||
}
|
||||
|
||||
const attrs = [`cmd="${trigger}"`];
|
||||
|
||||
// Add handler attributes
|
||||
if (item['validate-workflow']) attrs.push(`validate-workflow="${item['validate-workflow']}"`);
|
||||
if (item.exec) attrs.push(`exec="${item.exec}"`);
|
||||
if (item.tmpl) attrs.push(`tmpl="${item.tmpl}"`);
|
||||
if (item.data) attrs.push(`data="${item.data}"`);
|
||||
if (item.action) attrs.push(`action="${item.action}"`);
|
||||
|
||||
xml += ` <item ${attrs.join(' ')}>${escapeXml(item.description || '')}</item>\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always inject dismiss last
|
||||
xml += ` <item cmd="*dismiss">[D] Dismiss Agent</item>\n`;
|
||||
|
||||
xml += ' </menu>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build nested handlers for multi format menu items
|
||||
* @param {Array} triggers - Triggers array from multi format
|
||||
* @returns {string} Handler XML
|
||||
*/
|
||||
buildNestedHandlers(triggers) {
|
||||
let xml = '';
|
||||
|
||||
for (const triggerGroup of triggers) {
|
||||
for (const [triggerName, execArray] of Object.entries(triggerGroup)) {
|
||||
// Build trigger with * prefix
|
||||
let trigger = triggerName.startsWith('*') ? triggerName : '*' + triggerName;
|
||||
|
||||
// Extract the relevant execution data
|
||||
const execData = this.processExecArray(execArray);
|
||||
|
||||
// For nested handlers in multi items, we don't need cmd attribute
|
||||
// The match attribute will handle fuzzy matching
|
||||
const attrs = [`match="${escapeXml(execData.description || '')}"`];
|
||||
|
||||
// Add handler attributes based on exec data
|
||||
if (execData.route) attrs.push(`exec="${execData.route}"`);
|
||||
if (execData.action) attrs.push(`action="${execData.action}"`);
|
||||
if (execData.data) attrs.push(`data="${execData.data}"`);
|
||||
if (execData.tmpl) attrs.push(`tmpl="${execData.tmpl}"`);
|
||||
// Only add type if it's not 'exec' (exec is already implied by the exec attribute)
|
||||
if (execData.type && execData.type !== 'exec') attrs.push(`type="${execData.type}"`);
|
||||
|
||||
xml += ` <handler ${attrs.join(' ')}></handler>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the execution array from multi format triggers
|
||||
* Extracts relevant data for XML attributes
|
||||
* @param {Array} execArray - Array of execution objects
|
||||
* @returns {Object} Processed execution data
|
||||
*/
|
||||
processExecArray(execArray) {
|
||||
const result = {
|
||||
description: '',
|
||||
route: null,
|
||||
data: null,
|
||||
action: null,
|
||||
type: null,
|
||||
};
|
||||
|
||||
if (!Array.isArray(execArray)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const exec of execArray) {
|
||||
if (exec.input) {
|
||||
// Use input as description if no explicit description is provided
|
||||
result.description = exec.input;
|
||||
}
|
||||
|
||||
if (exec.route) {
|
||||
result.route = exec.route;
|
||||
}
|
||||
|
||||
if (exec.data !== null && exec.data !== undefined) {
|
||||
result.data = exec.data;
|
||||
}
|
||||
|
||||
if (exec.action) {
|
||||
result.action = exec.action;
|
||||
}
|
||||
|
||||
if (exec.type) {
|
||||
result.type = exec.type;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate file hash for build tracking
|
||||
*/
|
||||
async calculateFileHash(filePath) {
|
||||
if (!(await fs.pathExists(filePath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
return crypto.createHash('md5').update(content).digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build agent XML from YAML files and return as string (for in-memory use)
|
||||
* @param {string} agentYamlPath - Path to agent YAML
|
||||
* @param {string} customizeYamlPath - Path to customize YAML (optional)
|
||||
* @param {Object} options - Build options
|
||||
* @returns {Promise<string>} XML content as string
|
||||
*/
|
||||
async buildFromYaml(agentYamlPath, customizeYamlPath = null, options = {}) {
|
||||
// Load and merge YAML files
|
||||
const mergedAgent = await this.loadAndMergeAgent(agentYamlPath, customizeYamlPath);
|
||||
|
||||
// Calculate hashes for build tracking
|
||||
const sourceHash = await this.calculateFileHash(agentYamlPath);
|
||||
const customizeHash = customizeYamlPath ? await this.calculateFileHash(customizeYamlPath) : null;
|
||||
|
||||
// Extract module from path (e.g., /path/to/modules/bmm/agents/pm.yaml -> bmm)
|
||||
// or /path/to/bmad/bmm/agents/pm.yaml -> bmm
|
||||
// or /path/to/src/bmm-skills/agents/pm.yaml -> bmm
|
||||
let module = 'core'; // default to core
|
||||
const pathParts = agentYamlPath.split(path.sep);
|
||||
|
||||
// Look for module indicators in the path
|
||||
const modulesIndex = pathParts.indexOf('modules');
|
||||
const bmadIndex = pathParts.indexOf('bmad');
|
||||
const srcIndex = pathParts.indexOf('src');
|
||||
|
||||
if (modulesIndex !== -1 && pathParts[modulesIndex + 1]) {
|
||||
// Path contains /modules/{module}/
|
||||
module = pathParts[modulesIndex + 1];
|
||||
} else if (bmadIndex !== -1 && pathParts[bmadIndex + 1]) {
|
||||
// Path contains /bmad/{module}/
|
||||
const potentialModule = pathParts[bmadIndex + 1];
|
||||
// Check if it's a known module, not 'agents' or '_config'
|
||||
if (['bmm', 'bmb', 'cis', 'core'].includes(potentialModule)) {
|
||||
module = potentialModule;
|
||||
}
|
||||
} else if (srcIndex !== -1 && pathParts[srcIndex + 1]) {
|
||||
// Path contains /src/{module}/ (bmm-skills and core-skills are directly under src/)
|
||||
const potentialModule = pathParts[srcIndex + 1];
|
||||
if (potentialModule === 'bmm-skills') {
|
||||
module = 'bmm';
|
||||
} else if (potentialModule === 'core-skills') {
|
||||
module = 'core';
|
||||
}
|
||||
}
|
||||
|
||||
// Build metadata
|
||||
const buildMetadata = {
|
||||
sourceFile: path.basename(agentYamlPath),
|
||||
sourceHash,
|
||||
customizeFile: customizeYamlPath ? path.basename(customizeYamlPath) : null,
|
||||
customizeHash,
|
||||
builderVersion: '1.0.0',
|
||||
includeMetadata: options.includeMetadata !== false,
|
||||
skipActivation: options.skipActivation === true,
|
||||
forWebBundle: options.forWebBundle === true,
|
||||
module: module, // Add module to buildMetadata
|
||||
};
|
||||
|
||||
// Convert to XML and return
|
||||
return await this.convertToXml(mergedAgent, buildMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build agent XML from YAML files
|
||||
* @param {string} agentYamlPath - Path to agent YAML
|
||||
* @param {string} customizeYamlPath - Path to customize YAML (optional)
|
||||
* @param {string} outputPath - Path to write XML file
|
||||
* @param {Object} options - Build options
|
||||
*/
|
||||
async buildAgent(agentYamlPath, customizeYamlPath, outputPath, options = {}) {
|
||||
// Use buildFromYaml to get XML content
|
||||
const xml = await this.buildFromYaml(agentYamlPath, customizeYamlPath, options);
|
||||
|
||||
// Write output file
|
||||
await fs.ensureDir(path.dirname(outputPath));
|
||||
await fs.writeFile(outputPath, xml, 'utf8');
|
||||
|
||||
// Calculate hashes for return value
|
||||
const sourceHash = await this.calculateFileHash(agentYamlPath);
|
||||
const customizeHash = customizeYamlPath ? await this.calculateFileHash(customizeYamlPath) : null;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
outputPath,
|
||||
sourceHash,
|
||||
customizeHash,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { YamlXmlBuilder };
|
||||
Loading…
Reference in New Issue