Compare commits
11 Commits
c33ca0d95c
...
d4226f49b1
| Author | SHA1 | Date |
|---|---|---|
|
|
d4226f49b1 | |
|
|
3ba51e1bac | |
|
|
59b07c33e2 | |
|
|
f9925eb180 | |
|
|
b744408783 | |
|
|
5e038a8ce4 | |
|
|
5dbfb588ee | |
|
|
9ca0316674 | |
|
|
6cecab2626 | |
|
|
9924dc6344 | |
|
|
db7b497eeb |
|
|
@ -27,7 +27,6 @@ Vyžaduje [Node.js](https://nodejs.org) v20+ a `npx` (součástí npm).
|
||||||
| `--directory <cesta>` | Instalační adresář | `--directory ~/projects/myapp` |
|
| `--directory <cesta>` | Instalační adresář | `--directory ~/projects/myapp` |
|
||||||
| `--modules <moduly>` | Čárkou oddělená ID modulů | `--modules bmm,bmb` |
|
| `--modules <moduly>` | Čárkou oddělená ID modulů | `--modules bmm,bmb` |
|
||||||
| `--tools <nástroje>` | Čárkou oddělená ID nástrojů/IDE (použijte `none` pro přeskočení) | `--tools claude-code,cursor` nebo `--tools none` |
|
| `--tools <nástroje>` | Čárkou oddělená ID nástrojů/IDE (použijte `none` pro přeskočení) | `--tools claude-code,cursor` nebo `--tools none` |
|
||||||
| `--custom-content <cesty>` | Čárkou oddělené cesty k vlastním modulům | `--custom-content ~/my-module,~/another-module` |
|
|
||||||
| `--action <typ>` | Akce pro existující instalace: `install` (výchozí), `update` nebo `quick-update` | `--action quick-update` |
|
| `--action <typ>` | Akce pro existující instalace: `install` (výchozí), `update` nebo `quick-update` | `--action quick-update` |
|
||||||
|
|
||||||
### Základní konfigurace
|
### Základní konfigurace
|
||||||
|
|
@ -108,16 +107,6 @@ npx bmad-method install \
|
||||||
--action quick-update
|
--action quick-update
|
||||||
```
|
```
|
||||||
|
|
||||||
### Instalace s vlastním obsahem
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx bmad-method install \
|
|
||||||
--directory ~/projects/myapp \
|
|
||||||
--modules bmm \
|
|
||||||
--custom-content ~/my-custom-module,~/another-module \
|
|
||||||
--tools claude-code
|
|
||||||
```
|
|
||||||
|
|
||||||
## Co získáte
|
## Co získáte
|
||||||
|
|
||||||
- Plně nakonfigurovaný adresář `_bmad/` ve vašem projektu
|
- Plně nakonfigurovaný adresář `_bmad/` ve vašem projektu
|
||||||
|
|
@ -159,13 +148,6 @@ Neplatné hodnoty buď:
|
||||||
- Ověřte, že ID modulu je správné
|
- Ověřte, že ID modulu je správné
|
||||||
- Externí moduly musí být dostupné v registru
|
- Externí moduly musí být dostupné v registru
|
||||||
|
|
||||||
### Neplatná cesta k vlastnímu obsahu
|
|
||||||
|
|
||||||
Ujistěte se, že každá cesta k vlastnímu obsahu:
|
|
||||||
- Ukazuje na adresář
|
|
||||||
- Obsahuje soubor `module.yaml` v kořeni
|
|
||||||
- Má pole `code` v `module.yaml`
|
|
||||||
|
|
||||||
:::note[Stále jste uvízli?]
|
:::note[Stále jste uvízli?]
|
||||||
Spusťte s `--debug` pro detailní výstup, zkuste interaktivní režim pro izolaci problému, nebo nahlaste na <https://github.com/bmad-code-org/BMAD-METHOD/issues>.
|
Spusťte s `--debug` pro detailní výstup, zkuste interaktivní režim pro izolaci problému, nebo nahlaste na <https://github.com/bmad-code-org/BMAD-METHOD/issues>.
|
||||||
:::
|
:::
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ L'installateur affiche les modules disponibles. Sélectionnez ceux dont vous ave
|
||||||
|
|
||||||
### 5. Suivre les instructions
|
### 5. Suivre les instructions
|
||||||
|
|
||||||
L'installateur vous guide pour le reste — contenu personnalisé, paramètres, etc.
|
L'installateur vous guide pour le reste — paramètres, intégrations d'outils, etc.
|
||||||
|
|
||||||
## Ce que vous obtenez
|
## Ce que vous obtenez
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ Nécessite [Node.js](https://nodejs.org) v20+ et `npx` (inclus avec npm).
|
||||||
| `--directory <chemin>` | Répertoire d'installation | `--directory ~/projects/myapp` |
|
| `--directory <chemin>` | Répertoire d'installation | `--directory ~/projects/myapp` |
|
||||||
| `--modules <modules>` | IDs de modules séparés par des virgules | `--modules bmm,bmb` |
|
| `--modules <modules>` | IDs de modules séparés par des virgules | `--modules bmm,bmb` |
|
||||||
| `--tools <outils>` | IDs d'outils/IDE séparés par des virgules (utilisez `none` pour ignorer) | `--tools claude-code,cursor` ou `--tools none` |
|
| `--tools <outils>` | IDs d'outils/IDE séparés par des virgules (utilisez `none` pour ignorer) | `--tools claude-code,cursor` ou `--tools none` |
|
||||||
| `--custom-content <chemins>` | Chemins vers des modules personnalisés séparés par des virgules | `--custom-content ~/my-module,~/another-module` |
|
|
||||||
| `--action <type>` | Action pour les installations existantes : `install` (par défaut), `update`, ou `quick-update` | `--action quick-update` |
|
| `--action <type>` | Action pour les installations existantes : `install` (par défaut), `update`, ou `quick-update` | `--action quick-update` |
|
||||||
|
|
||||||
### Configuration principale
|
### Configuration principale
|
||||||
|
|
@ -120,16 +119,6 @@ npx bmad-method install \
|
||||||
--action quick-update
|
--action quick-update
|
||||||
```
|
```
|
||||||
|
|
||||||
### Installation avec du contenu personnalisé
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx bmad-method install \
|
|
||||||
--directory ~/projects/myapp \
|
|
||||||
--modules bmm \
|
|
||||||
--custom-content ~/my-custom-module,~/another-module \
|
|
||||||
--tools claude-code
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ce que vous obtenez
|
## Ce que vous obtenez
|
||||||
|
|
||||||
- Un répertoire `_bmad/` entièrement configuré dans votre projet
|
- Un répertoire `_bmad/` entièrement configuré dans votre projet
|
||||||
|
|
@ -143,12 +132,11 @@ BMad valide toutes les options fournis :
|
||||||
- **Directory** — Doit être un chemin valide avec des permissions d'écriture
|
- **Directory** — Doit être un chemin valide avec des permissions d'écriture
|
||||||
- **Modules** — Avertit des IDs de modules invalides (mais n'échoue pas)
|
- **Modules** — Avertit des IDs de modules invalides (mais n'échoue pas)
|
||||||
- **Tools** — Avertit des IDs d'outils invalides (mais n'échoue pas)
|
- **Tools** — Avertit des IDs d'outils invalides (mais n'échoue pas)
|
||||||
- **Custom Content** — Chaque chemin doit contenir un fichier `module.yaml` valide
|
|
||||||
- **Action** — Doit être l'une des suivantes : `install`, `update`, `quick-update`
|
- **Action** — Doit être l'une des suivantes : `install`, `update`, `quick-update`
|
||||||
|
|
||||||
Les valeurs invalides entraîneront soit :
|
Les valeurs invalides entraîneront soit :
|
||||||
1. L’affichage d’un message d'erreur suivi d’un exit (pour les options critiques comme le répertoire)
|
1. L’affichage d’un message d'erreur suivi d’un exit (pour les options critiques comme le répertoire)
|
||||||
2. Un avertissement puis la continuation de l’installation (pour les éléments optionnels comme le contenu personnalisé)
|
2. Un avertissement puis la continuation de l’installation (pour les éléments optionnels)
|
||||||
3. Un retour aux invites interactives (pour les valeurs requises manquantes)
|
3. Un retour aux invites interactives (pour les valeurs requises manquantes)
|
||||||
|
|
||||||
:::tip[Bonnes pratiques]
|
:::tip[Bonnes pratiques]
|
||||||
|
|
@ -172,13 +160,6 @@ Les valeurs invalides entraîneront soit :
|
||||||
- Vérifiez que l'ID du module est correct
|
- Vérifiez que l'ID du module est correct
|
||||||
- Les modules externes doivent être disponibles dans le registre
|
- Les modules externes doivent être disponibles dans le registre
|
||||||
|
|
||||||
### Chemin de contenu personnalisé invalide
|
|
||||||
|
|
||||||
Assurez-vous que chaque chemin de contenu personnalisé :
|
|
||||||
- Pointe vers un répertoire
|
|
||||||
- Contient un fichier `module.yaml` à la racine
|
|
||||||
- Possède un champ `code` dans `module.yaml`
|
|
||||||
|
|
||||||
:::note[Toujours bloqué ?]
|
:::note[Toujours bloqué ?]
|
||||||
Exécutez avec `--debug` pour une sortie détaillée, essayez le mode interactif pour isoler le problème, ou signalez-le à <https://github.com/bmad-code-org/BMAD-METHOD/issues>.
|
Exécutez avec `--debug` pour une sortie détaillée, essayez le mode interactif pour isoler le problème, ou signalez-le à <https://github.com/bmad-code-org/BMAD-METHOD/issues>.
|
||||||
:::
|
:::
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ The installer shows available modules. Select whichever ones you need — most u
|
||||||
|
|
||||||
### 5. Follow the Prompts
|
### 5. Follow the Prompts
|
||||||
|
|
||||||
The installer guides you through the rest — custom content, settings, etc.
|
The installer guides you through the rest — settings, tool integrations, etc.
|
||||||
|
|
||||||
## What You Get
|
## What You Get
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ Requires [Node.js](https://nodejs.org) v20+ and `npx` (included with npm).
|
||||||
| `--directory <path>` | Installation directory | `--directory ~/projects/myapp` |
|
| `--directory <path>` | Installation directory | `--directory ~/projects/myapp` |
|
||||||
| `--modules <modules>` | Comma-separated module IDs | `--modules bmm,bmb` |
|
| `--modules <modules>` | Comma-separated module IDs | `--modules bmm,bmb` |
|
||||||
| `--tools <tools>` | Comma-separated tool/IDE IDs (use `none` to skip) | `--tools claude-code,cursor` or `--tools none` |
|
| `--tools <tools>` | Comma-separated tool/IDE IDs (use `none` to skip) | `--tools claude-code,cursor` or `--tools none` |
|
||||||
| `--custom-content <paths>` | Comma-separated paths to custom modules | `--custom-content ~/my-module,~/another-module` |
|
|
||||||
| `--action <type>` | Action for existing installations: `install` (default), `update`, or `quick-update` | `--action quick-update` |
|
| `--action <type>` | Action for existing installations: `install` (default), `update`, or `quick-update` | `--action quick-update` |
|
||||||
|
|
||||||
### Core Configuration
|
### Core Configuration
|
||||||
|
|
@ -120,16 +119,6 @@ npx bmad-method install \
|
||||||
--action quick-update
|
--action quick-update
|
||||||
```
|
```
|
||||||
|
|
||||||
### Installation with Custom Content
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx bmad-method install \
|
|
||||||
--directory ~/projects/myapp \
|
|
||||||
--modules bmm \
|
|
||||||
--custom-content ~/my-custom-module,~/another-module \
|
|
||||||
--tools claude-code
|
|
||||||
```
|
|
||||||
|
|
||||||
## What You Get
|
## What You Get
|
||||||
|
|
||||||
- A fully configured `_bmad/` directory in your project
|
- A fully configured `_bmad/` directory in your project
|
||||||
|
|
@ -143,12 +132,11 @@ BMad validates all provided flags:
|
||||||
- **Directory** — Must be a valid path with write permissions
|
- **Directory** — Must be a valid path with write permissions
|
||||||
- **Modules** — Warns about invalid module IDs (but won't fail)
|
- **Modules** — Warns about invalid module IDs (but won't fail)
|
||||||
- **Tools** — Warns about invalid tool IDs (but won't fail)
|
- **Tools** — Warns about invalid tool IDs (but won't fail)
|
||||||
- **Custom Content** — Each path must contain a valid `module.yaml` file
|
|
||||||
- **Action** — Must be one of: `install`, `update`, `quick-update`
|
- **Action** — Must be one of: `install`, `update`, `quick-update`
|
||||||
|
|
||||||
Invalid values will either:
|
Invalid values will either:
|
||||||
1. Show an error and exit (for critical options like directory)
|
1. Show an error and exit (for critical options like directory)
|
||||||
2. Show a warning and skip (for optional items like custom content)
|
2. Show a warning and skip (for optional items)
|
||||||
3. Fall back to interactive prompts (for missing required values)
|
3. Fall back to interactive prompts (for missing required values)
|
||||||
|
|
||||||
:::tip[Best Practices]
|
:::tip[Best Practices]
|
||||||
|
|
@ -172,13 +160,6 @@ Invalid values will either:
|
||||||
- Verify the module ID is correct
|
- Verify the module ID is correct
|
||||||
- External modules must be available in the registry
|
- External modules must be available in the registry
|
||||||
|
|
||||||
### Custom content path invalid
|
|
||||||
|
|
||||||
Ensure each custom content path:
|
|
||||||
- Points to a directory
|
|
||||||
- Contains a `module.yaml` file in the root
|
|
||||||
- Has a `code` field in the `module.yaml`
|
|
||||||
|
|
||||||
:::note[Still stuck?]
|
:::note[Still stuck?]
|
||||||
Run with `--debug` for detailed output, try interactive mode to isolate the issue, or report at <https://github.com/bmad-code-org/BMAD-METHOD/issues>.
|
Run with `--debug` for detailed output, try interactive mode to isolate the issue, or report at <https://github.com/bmad-code-org/BMAD-METHOD/issues>.
|
||||||
:::
|
:::
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ Trình cài đặt sẽ hiện các module có sẵn. Chọn những module bạ
|
||||||
|
|
||||||
### 5. Làm theo các prompt
|
### 5. Làm theo các prompt
|
||||||
|
|
||||||
Trình cài đặt sẽ hướng dẫn các bước còn lại - nội dung tùy chỉnh, cài đặt, và các tùy chọn khác.
|
Trình cài đặt sẽ hướng dẫn các bước còn lại - cài đặt, tích hợp công cụ, và các tùy chọn khác.
|
||||||
|
|
||||||
## Bạn nhận được gì
|
## Bạn nhận được gì
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ Yêu cầu [Node.js](https://nodejs.org) v20+ và `npx` (đi kèm với npm).
|
||||||
| `--directory <path>` | Thư mục cài đặt | `--directory ~/projects/myapp` |
|
| `--directory <path>` | Thư mục cài đặt | `--directory ~/projects/myapp` |
|
||||||
| `--modules <modules>` | Danh sách ID module, cách nhau bởi dấu phẩy | `--modules bmm,bmb` |
|
| `--modules <modules>` | Danh sách ID module, cách nhau bởi dấu phẩy | `--modules bmm,bmb` |
|
||||||
| `--tools <tools>` | Danh sách ID công cụ/IDE, cách nhau bởi dấu phẩy (dùng `none` để bỏ qua) | `--tools claude-code,cursor` hoặc `--tools none` |
|
| `--tools <tools>` | Danh sách ID công cụ/IDE, cách nhau bởi dấu phẩy (dùng `none` để bỏ qua) | `--tools claude-code,cursor` hoặc `--tools none` |
|
||||||
| `--custom-content <paths>` | Danh sách đường dẫn đến module tùy chỉnh, cách nhau bởi dấu phẩy | `--custom-content ~/my-module,~/another-module` |
|
|
||||||
| `--action <type>` | Hành động cho bản cài đặt hiện có: `install` (mặc định), `update`, hoặc `quick-update` | `--action quick-update` |
|
| `--action <type>` | Hành động cho bản cài đặt hiện có: `install` (mặc định), `update`, hoặc `quick-update` | `--action quick-update` |
|
||||||
|
|
||||||
### Cấu hình cốt lõi
|
### Cấu hình cốt lõi
|
||||||
|
|
@ -120,16 +119,6 @@ npx bmad-method install \
|
||||||
--action quick-update
|
--action quick-update
|
||||||
```
|
```
|
||||||
|
|
||||||
### Cài đặt với nội dung tùy chỉnh
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx bmad-method install \
|
|
||||||
--directory ~/projects/myapp \
|
|
||||||
--modules bmm \
|
|
||||||
--custom-content ~/my-custom-module,~/another-module \
|
|
||||||
--tools claude-code
|
|
||||||
```
|
|
||||||
|
|
||||||
## Bạn nhận được gì
|
## Bạn nhận được gì
|
||||||
|
|
||||||
- Thư mục `_bmad/` đã được cấu hình đầy đủ trong dự án của bạn
|
- Thư mục `_bmad/` đã được cấu hình đầy đủ trong dự án của bạn
|
||||||
|
|
@ -143,12 +132,11 @@ BMad sẽ kiểm tra tất cả các cờ được cung cấp:
|
||||||
- **Directory** - Phải là đường dẫn hợp lệ và có quyền ghi
|
- **Directory** - Phải là đường dẫn hợp lệ và có quyền ghi
|
||||||
- **Modules** - Cảnh báo nếu ID module không hợp lệ (nhưng không thất bại)
|
- **Modules** - Cảnh báo nếu ID module không hợp lệ (nhưng không thất bại)
|
||||||
- **Tools** - Cảnh báo nếu ID công cụ không hợp lệ (nhưng không thất bại)
|
- **Tools** - Cảnh báo nếu ID công cụ không hợp lệ (nhưng không thất bại)
|
||||||
- **Custom Content** - Mỗi đường dẫn phải chứa tệp `module.yaml` hợp lệ
|
|
||||||
- **Action** - Phải là một trong: `install`, `update`, `quick-update`
|
- **Action** - Phải là một trong: `install`, `update`, `quick-update`
|
||||||
|
|
||||||
Giá trị không hợp lệ sẽ dẫn đến một trong các trường hợp sau:
|
Giá trị không hợp lệ sẽ dẫn đến một trong các trường hợp sau:
|
||||||
1. Hiện lỗi và thoát (với các tùy chọn quan trọng như directory)
|
1. Hiện lỗi và thoát (với các tùy chọn quan trọng như directory)
|
||||||
2. Hiện cảnh báo và bỏ qua (với mục tùy chọn như custom content)
|
2. Hiện cảnh báo và bỏ qua (với mục tùy chọn)
|
||||||
3. Quay lại hỏi interactive (với giá trị bắt buộc bị thiếu)
|
3. Quay lại hỏi interactive (với giá trị bắt buộc bị thiếu)
|
||||||
|
|
||||||
:::tip[Thực hành tốt]
|
:::tip[Thực hành tốt]
|
||||||
|
|
@ -172,13 +160,6 @@ Giá trị không hợp lệ sẽ dẫn đến một trong các trường hợp
|
||||||
- Xác minh ID module có đúng không
|
- Xác minh ID module có đúng không
|
||||||
- Module bên ngoài phải có sẵn trong registry
|
- Module bên ngoài phải có sẵn trong registry
|
||||||
|
|
||||||
### Đường dẫn custom content không hợp lệ
|
|
||||||
|
|
||||||
Đảm bảo mỗi đường dẫn custom content:
|
|
||||||
- Trỏ tới một thư mục
|
|
||||||
- Chứa tệp `module.yaml` ở cấp gốc
|
|
||||||
- Có trường `code` trong tệp `module.yaml`
|
|
||||||
|
|
||||||
:::note[Vẫn bị mắc?]
|
:::note[Vẫn bị mắc?]
|
||||||
Chạy với `--debug` để xem output chi tiết, thử chế độ interactive để cô lập vấn đề, hoặc báo cáo tại <https://github.com/bmad-code-org/BMAD-METHOD/issues>.
|
Chạy với `--debug` để xem output chi tiết, thử chế độ interactive để cô lập vấn đề, hoặc báo cáo tại <https://github.com/bmad-code-org/BMAD-METHOD/issues>.
|
||||||
:::
|
:::
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ npx github:bmad-code-org/BMAD-METHOD install
|
||||||
|
|
||||||
### 5. 按照提示操作
|
### 5. 按照提示操作
|
||||||
|
|
||||||
安装程序会引导你完成剩余步骤——自定义内容、设置等。
|
安装程序会引导你完成剩余步骤——设置、工具集成等。
|
||||||
|
|
||||||
## 你将获得
|
## 你将获得
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ sidebar:
|
||||||
| `--directory <path>` | 安装目录 | `--directory ~/projects/myapp` |
|
| `--directory <path>` | 安装目录 | `--directory ~/projects/myapp` |
|
||||||
| `--modules <modules>` | 逗号分隔的模块 ID | `--modules bmm,bmb` |
|
| `--modules <modules>` | 逗号分隔的模块 ID | `--modules bmm,bmb` |
|
||||||
| `--tools <tools>` | 逗号分隔的工具/IDE ID(使用 `none` 跳过) | `--tools claude-code,cursor` 或 `--tools none` |
|
| `--tools <tools>` | 逗号分隔的工具/IDE ID(使用 `none` 跳过) | `--tools claude-code,cursor` 或 `--tools none` |
|
||||||
| `--custom-content <paths>` | 逗号分隔的自定义模块路径 | `--custom-content ~/my-module,~/another-module` |
|
|
||||||
| `--action <type>` | 对现有安装的操作:`install`(默认)、`update` 或 `quick-update` | `--action quick-update` |
|
| `--action <type>` | 对现有安装的操作:`install`(默认)、`update` 或 `quick-update` | `--action quick-update` |
|
||||||
|
|
||||||
### 核心配置
|
### 核心配置
|
||||||
|
|
@ -108,16 +107,6 @@ npx bmad-method install \
|
||||||
--action quick-update
|
--action quick-update
|
||||||
```
|
```
|
||||||
|
|
||||||
### 使用自定义内容安装
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx bmad-method install \
|
|
||||||
--directory ~/projects/myapp \
|
|
||||||
--modules bmm \
|
|
||||||
--custom-content ~/my-custom-module,~/another-module \
|
|
||||||
--tools claude-code
|
|
||||||
```
|
|
||||||
|
|
||||||
## 安装结果
|
## 安装结果
|
||||||
|
|
||||||
- 项目中完全配置的 `_bmad/` 目录
|
- 项目中完全配置的 `_bmad/` 目录
|
||||||
|
|
@ -131,12 +120,11 @@ BMad 会验证你提供的所有参数:
|
||||||
- **目录** — 必须是具有写入权限的有效路径
|
- **目录** — 必须是具有写入权限的有效路径
|
||||||
- **模块** — 对无效的模块 ID 发出警告(但不会失败)
|
- **模块** — 对无效的模块 ID 发出警告(但不会失败)
|
||||||
- **工具** — 对无效的工具 ID 发出警告(但不会失败)
|
- **工具** — 对无效的工具 ID 发出警告(但不会失败)
|
||||||
- **自定义内容** — 每个路径必须包含有效的 `module.yaml` 文件
|
|
||||||
- **操作** — 必须是以下之一:`install`、`update`、`quick-update`
|
- **操作** — 必须是以下之一:`install`、`update`、`quick-update`
|
||||||
|
|
||||||
无效值将:
|
无效值将:
|
||||||
1. 显示错误并退出(对于目录等关键选项)
|
1. 显示错误并退出(对于目录等关键选项)
|
||||||
2. 显示警告并跳过(对于自定义内容等可选项目)
|
2. 显示警告并跳过(对于可选项目)
|
||||||
3. 回退到交互式提示(对于缺失的必需值)
|
3. 回退到交互式提示(对于缺失的必需值)
|
||||||
|
|
||||||
:::tip[最佳实践]
|
:::tip[最佳实践]
|
||||||
|
|
@ -159,13 +147,6 @@ BMad 会验证你提供的所有参数:
|
||||||
- 验证模块 ID 是否正确
|
- 验证模块 ID 是否正确
|
||||||
- 外部模块必须在注册表中可用
|
- 外部模块必须在注册表中可用
|
||||||
|
|
||||||
### 自定义内容路径无效
|
|
||||||
|
|
||||||
确保每个自定义内容路径:
|
|
||||||
- 指向一个目录
|
|
||||||
- 在根目录中包含 `module.yaml` 文件
|
|
||||||
- 在 `module.yaml` 中有 `code` 字段
|
|
||||||
|
|
||||||
:::note[仍然卡住了?]
|
:::note[仍然卡住了?]
|
||||||
使用 `--debug` 获取详细输出,尝试交互模式定位问题,或在 <https://github.com/bmad-code-org/BMAD-METHOD/issues> 提交反馈。
|
使用 `--debug` 获取详细输出,尝试交互模式定位问题,或在 <https://github.com/bmad-code-org/BMAD-METHOD/issues> 提交反馈。
|
||||||
:::
|
:::
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
# BMAD PRD Purpose
|
||||||
|
|
||||||
|
**The PRD is the top of the required funnel that feeds all subsequent product development work in rhw BMad Method.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is a BMAD PRD?
|
||||||
|
|
||||||
|
A dual-audience document serving:
|
||||||
|
1. **Human Product Managers and builders** - Vision, strategy, stakeholder communication
|
||||||
|
2. **LLM Downstream Consumption** - UX Design → Architecture → Epics → Development AI Agents
|
||||||
|
|
||||||
|
Each successive document becomes more AI-tailored and granular.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Philosophy: Information Density
|
||||||
|
|
||||||
|
**High Signal-to-Noise Ratio**
|
||||||
|
|
||||||
|
Every sentence must carry information weight. LLMs consume precise, dense content efficiently.
|
||||||
|
|
||||||
|
**Anti-Patterns (Eliminate These):**
|
||||||
|
- ❌ "The system will allow users to..." → ✅ "Users can..."
|
||||||
|
- ❌ "It is important to note that..." → ✅ State the fact directly
|
||||||
|
- ❌ "In order to..." → ✅ "To..."
|
||||||
|
- ❌ Conversational filler and padding → ✅ Direct, concise statements
|
||||||
|
|
||||||
|
**Goal:** Maximum information per word. Zero fluff.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Traceability Chain
|
||||||
|
|
||||||
|
**PRD starts the chain:**
|
||||||
|
```
|
||||||
|
Vision → Success Criteria → User Journeys → Functional Requirements → (future: User Stories)
|
||||||
|
```
|
||||||
|
|
||||||
|
**In the PRD, establish:**
|
||||||
|
- Vision → Success Criteria alignment
|
||||||
|
- Success Criteria → User Journey coverage
|
||||||
|
- User Journey → Functional Requirement mapping
|
||||||
|
- All requirements traceable to user needs
|
||||||
|
|
||||||
|
**Why:** Each downstream artifact (UX, Architecture, Epics, Stories) must trace back to documented user needs and business objectives. This chain ensures we build the right thing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Makes Great Functional Requirements?
|
||||||
|
|
||||||
|
### FRs are Capabilities, Not Implementation
|
||||||
|
|
||||||
|
**Good FR:** "Users can reset their password via email link"
|
||||||
|
**Bad FR:** "System sends JWT via email and validates with database" (implementation leakage)
|
||||||
|
|
||||||
|
**Good FR:** "Dashboard loads in under 2 seconds for 95th percentile"
|
||||||
|
**Bad FR:** "Fast loading time" (subjective, unmeasurable)
|
||||||
|
|
||||||
|
### SMART Quality Criteria
|
||||||
|
|
||||||
|
**Specific:** Clear, precisely defined capability
|
||||||
|
**Measurable:** Quantifiable with test criteria
|
||||||
|
**Attainable:** Realistic within constraints
|
||||||
|
**Relevant:** Aligns with business objectives
|
||||||
|
**Traceable:** Links to source (executive summary or user journey)
|
||||||
|
|
||||||
|
### FR Anti-Patterns
|
||||||
|
|
||||||
|
**Subjective Adjectives:**
|
||||||
|
- ❌ "easy to use", "intuitive", "user-friendly", "fast", "responsive"
|
||||||
|
- ✅ Use metrics: "completes task in under 3 clicks", "loads in under 2 seconds"
|
||||||
|
|
||||||
|
**Implementation Leakage:**
|
||||||
|
- ❌ Technology names, specific libraries, implementation details
|
||||||
|
- ✅ Focus on capability and measurable outcomes
|
||||||
|
|
||||||
|
**Vague Quantifiers:**
|
||||||
|
- ❌ "multiple users", "several options", "various formats"
|
||||||
|
- ✅ "up to 100 concurrent users", "3-5 options", "PDF, DOCX, TXT formats"
|
||||||
|
|
||||||
|
**Missing Test Criteria:**
|
||||||
|
- ❌ "The system shall provide notifications"
|
||||||
|
- ✅ "The system shall send email notifications within 30 seconds of trigger event"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Makes Great Non-Functional Requirements?
|
||||||
|
|
||||||
|
### NFRs Must Be Measurable
|
||||||
|
|
||||||
|
**Template:**
|
||||||
|
```
|
||||||
|
"The system shall [metric] [condition] [measurement method]"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
- ✅ "The system shall respond to API requests in under 200ms for 95th percentile as measured by APM monitoring"
|
||||||
|
- ✅ "The system shall maintain 99.9% uptime during business hours as measured by cloud provider SLA"
|
||||||
|
- ✅ "The system shall support 10,000 concurrent users as measured by load testing"
|
||||||
|
|
||||||
|
### NFR Anti-Patterns
|
||||||
|
|
||||||
|
**Unmeasurable Claims:**
|
||||||
|
- ❌ "The system shall be scalable" → ✅ "The system shall handle 10x load growth through horizontal scaling"
|
||||||
|
- ❌ "High availability required" → ✅ "99.9% uptime as measured by cloud provider SLA"
|
||||||
|
|
||||||
|
**Missing Context:**
|
||||||
|
- ❌ "Response time under 1 second" → ✅ "API response time under 1 second for 95th percentile under normal load"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Domain-Specific Requirements
|
||||||
|
|
||||||
|
**Auto-Detect and Enforce Based on Project Context**
|
||||||
|
|
||||||
|
Certain industries have mandatory requirements that must be present:
|
||||||
|
|
||||||
|
- **Healthcare:** HIPAA Privacy & Security Rules, PHI encryption, audit logging, MFA
|
||||||
|
- **Fintech:** PCI-DSS Level 1, AML/KYC compliance, SOX controls, financial audit trails
|
||||||
|
- **GovTech:** NIST framework, Section 508 accessibility (WCAG 2.1 AA), FedRAMP, data residency
|
||||||
|
- **E-Commerce:** PCI-DSS for payments, inventory accuracy, tax calculation by jurisdiction
|
||||||
|
|
||||||
|
**Why:** Missing these requirements in the PRD means they'll be missed in architecture and implementation, creating expensive rework. During PRD creation there is a step to cover this - during validation we want to make sure it was covered. For this purpose steps will utilize a domain-complexity.csv and project-types.csv.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Document Structure (Markdown, Human-Readable)
|
||||||
|
|
||||||
|
### Required Sections
|
||||||
|
1. **Executive Summary** - Vision, differentiator, target users
|
||||||
|
2. **Success Criteria** - Measurable outcomes (SMART)
|
||||||
|
3. **Product Scope** - MVP, Growth, Vision phases
|
||||||
|
4. **User Journeys** - Comprehensive coverage
|
||||||
|
5. **Domain Requirements** - Industry-specific compliance (if applicable)
|
||||||
|
6. **Innovation Analysis** - Competitive differentiation (if applicable)
|
||||||
|
7. **Project-Type Requirements** - Platform-specific needs
|
||||||
|
8. **Functional Requirements** - Capability contract (FRs)
|
||||||
|
9. **Non-Functional Requirements** - Quality attributes (NFRs)
|
||||||
|
|
||||||
|
### Formatting for Dual Consumption
|
||||||
|
|
||||||
|
**For Humans:**
|
||||||
|
- Clear, professional language
|
||||||
|
- Logical flow from vision to requirements
|
||||||
|
- Easy for stakeholders to review and approve
|
||||||
|
|
||||||
|
**For LLMs:**
|
||||||
|
- ## Level 2 headers for all main sections (enables extraction)
|
||||||
|
- Consistent structure and patterns
|
||||||
|
- Precise, testable language
|
||||||
|
- High information density
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Downstream Impact
|
||||||
|
|
||||||
|
**How the PRD Feeds Next Artifacts:**
|
||||||
|
|
||||||
|
**UX Design:**
|
||||||
|
- User journeys → interaction flows
|
||||||
|
- FRs → design requirements
|
||||||
|
- Success criteria → UX metrics
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- FRs → system capabilities
|
||||||
|
- NFRs → architecture decisions
|
||||||
|
- Domain requirements → compliance architecture
|
||||||
|
- Project-type requirements → platform choices
|
||||||
|
|
||||||
|
**Epics & Stories (created after architecture):**
|
||||||
|
- FRs → user stories (1 FR could map to 1-3 stories potentially)
|
||||||
|
- Acceptance criteria → story acceptance tests
|
||||||
|
- Priority → sprint sequencing
|
||||||
|
- Traceability → stories map back to vision
|
||||||
|
|
||||||
|
**Development AI Agents:**
|
||||||
|
- Precise requirements → implementation clarity
|
||||||
|
- Test criteria → automated test generation
|
||||||
|
- Domain requirements → compliance enforcement
|
||||||
|
- Measurable NFRs → performance targets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary: What Makes a Great BMAD PRD?
|
||||||
|
|
||||||
|
✅ **High Information Density** - Every sentence carries weight, zero fluff
|
||||||
|
✅ **Measurable Requirements** - All FRs and NFRs are testable with specific criteria
|
||||||
|
✅ **Clear Traceability** - Each requirement links to user need and business objective
|
||||||
|
✅ **Domain Awareness** - Industry-specific requirements auto-detected and included
|
||||||
|
✅ **Zero Anti-Patterns** - No subjective adjectives, implementation leakage, or vague quantifiers
|
||||||
|
✅ **Dual Audience Optimized** - Human-readable AND LLM-consumable
|
||||||
|
✅ **Markdown Format** - Professional, clean, accessible to all stakeholders
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Remember:** The PRD is the foundation. Quality here ripples through every subsequent phase. A dense, precise, well-traced PRD makes UX design, architecture, epic breakdown, and AI development dramatically more effective.
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
# File references (ONLY variables used in this step)
|
# File references (ONLY variables used in this step)
|
||||||
prdPurpose: '{project-root}/_bmad/bmm-skills/2-plan-workflows/bmad-create-prd/data/prd-purpose.md'
|
prdPurpose: '../data/prd-purpose.md'
|
||||||
---
|
---
|
||||||
|
|
||||||
# Step E-1: Discovery & Understanding
|
# Step E-1: Discovery & Understanding
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
# File references (ONLY variables used in this step)
|
# File references (ONLY variables used in this step)
|
||||||
prdFile: '{prd_file_path}'
|
prdFile: '{prd_file_path}'
|
||||||
prdPurpose: '{project-root}/_bmad/bmm-skills/2-plan-workflows/bmad-create-prd/data/prd-purpose.md'
|
prdPurpose: '../data/prd-purpose.md'
|
||||||
---
|
---
|
||||||
|
|
||||||
# Step E-1B: Legacy PRD Conversion Assessment
|
# Step E-1B: Legacy PRD Conversion Assessment
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
# File references (ONLY variables used in this step)
|
# File references (ONLY variables used in this step)
|
||||||
prdFile: '{prd_file_path}'
|
prdFile: '{prd_file_path}'
|
||||||
validationReport: '{validation_report_path}' # If provided
|
validationReport: '{validation_report_path}' # If provided
|
||||||
prdPurpose: '{project-root}/_bmad/bmm-skills/2-plan-workflows/bmad-create-prd/data/prd-purpose.md'
|
prdPurpose: '../data/prd-purpose.md'
|
||||||
---
|
---
|
||||||
|
|
||||||
# Step E-2: Deep Review & Analysis
|
# Step E-2: Deep Review & Analysis
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
# File references (ONLY variables used in this step)
|
# File references (ONLY variables used in this step)
|
||||||
prdFile: '{prd_file_path}'
|
prdFile: '{prd_file_path}'
|
||||||
prdPurpose: '{project-root}/_bmad/bmm-skills/2-plan-workflows/bmad-create-prd/data/prd-purpose.md'
|
prdPurpose: '../data/prd-purpose.md'
|
||||||
---
|
---
|
||||||
|
|
||||||
# Step E-3: Edit & Update
|
# Step E-3: Edit & Update
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
---
|
---
|
||||||
# File references (ONLY variables used in this step)
|
# File references (ONLY variables used in this step)
|
||||||
prdFile: '{prd_file_path}'
|
prdFile: '{prd_file_path}'
|
||||||
validationWorkflow: '{project-root}/_bmad/bmm-skills/2-plan-workflows/bmad-validate-prd/steps-v/step-v-01-discovery.md'
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Step E-4: Complete & Validate
|
# Step E-4: Complete & Validate
|
||||||
|
|
@ -117,8 +116,7 @@ Display:
|
||||||
- Display: "This will run all 13 validation checks on the updated PRD."
|
- Display: "This will run all 13 validation checks on the updated PRD."
|
||||||
- Display: "Preparing to validate: {prd_file_path}"
|
- Display: "Preparing to validate: {prd_file_path}"
|
||||||
- Display: "**Proceeding to validation...**"
|
- Display: "**Proceeding to validation...**"
|
||||||
- Read fully and follow: {validationWorkflow} (steps-v/step-v-01-discovery.md)
|
- Invoke the `bmad-validate-prd` skill to run the complete validation workflow
|
||||||
- Note: This hands off to the validation workflow which will run its complete 13-step process
|
|
||||||
|
|
||||||
- **IF E (Edit More):**
|
- **IF E (Edit More):**
|
||||||
- Display: "**Additional Edits**"
|
- Display: "**Additional Edits**"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Compile Epic Context
|
||||||
|
|
||||||
|
**Task**
|
||||||
|
Given an epic number, the epics file, the planning artifacts directory, and a desired output path, compile a clean, focused, developer-ready context file (`epic-<N>-context.md`).
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. Read the epics file and extract the target epic's title, goal, and list of stories.
|
||||||
|
2. Scan the planning artifacts directory for the standard files (PRD, architecture, UX/design, product brief).
|
||||||
|
3. Pull only the information relevant to this epic.
|
||||||
|
4. Write the compiled context to the exact output path using the format below.
|
||||||
|
|
||||||
|
## Exact Output Format
|
||||||
|
|
||||||
|
Use these headings:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Epic {N} Context: {Epic Title}
|
||||||
|
|
||||||
|
<!-- Compiled from planning artifacts. Edit freely. Regenerate with compile-epic-context if planning docs change. -->
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
{One clear paragraph: what this epic achieves and why it matters.}
|
||||||
|
|
||||||
|
## Stories
|
||||||
|
|
||||||
|
- Story X.Y: Brief title only
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## Requirements & Constraints
|
||||||
|
|
||||||
|
{Relevant functional/non-functional requirements and success criteria for this epic (describe by purpose, not source).}
|
||||||
|
|
||||||
|
## Technical Decisions
|
||||||
|
|
||||||
|
{Key architecture decisions, constraints, patterns, data models, and conventions relevant to this epic.}
|
||||||
|
|
||||||
|
## UX & Interaction Patterns
|
||||||
|
|
||||||
|
{Relevant UX flows, interaction patterns, and design constraints (omit section entirely if nothing relevant).}
|
||||||
|
|
||||||
|
## Cross-Story Dependencies
|
||||||
|
|
||||||
|
{Dependencies between stories in this epic or with other epics/systems (omit if none).}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Scope aggressively.** Include only what a developer working on any story in this epic actually needs. When in doubt, leave it out — the developer can always read the full planning doc.
|
||||||
|
- **Describe by purpose, not by source.** Write "API responses must include pagination metadata" not "Per PRD section 3.2.1, pagination is required." Planning doc internals will change; the constraint won't.
|
||||||
|
- **No full copies.** Never quote source documents, section numbers, or paste large blocks verbatim. Always distill.
|
||||||
|
- **No story-level details.** The story list is for orientation only. Individual story specs handle the details.
|
||||||
|
- **Nothing derivable from the codebase.** Don't document what a developer can learn by reading the code.
|
||||||
|
- **Be concise and actionable.** Target 800–1500 tokens total. This file loads into quick-dev's context alongside other material.
|
||||||
|
- **Never hallucinate content.** If source material doesn't say something, don't invent it.
|
||||||
|
- **Omit empty sections entirely**, except Goal and Stories, which are always required.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- **If the epics file is missing or the target epic is not found:** write nothing and report the problem to the calling agent. Goal and Stories cannot be populated without a usable epics file.
|
||||||
|
- **If planning artifacts are missing or empty:** still produce the file with Goal and Stories populated from the epics file, and note the gap in the Goal section. Never hallucinate content to fill missing sections.
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
---
|
---
|
||||||
wipFile: '{implementation_artifacts}/spec-wip.md'
|
|
||||||
deferred_work_file: '{implementation_artifacts}/deferred-work.md'
|
deferred_work_file: '{implementation_artifacts}/deferred-work.md'
|
||||||
spec_file: '' # set at runtime for both routes before leaving this step
|
spec_file: '' # set at runtime for both routes before leaving this step
|
||||||
---
|
---
|
||||||
|
|
@ -21,7 +20,7 @@ Before listing artifacts or prompting the user, check whether you already know t
|
||||||
|
|
||||||
1. Explicit argument
|
1. Explicit argument
|
||||||
Did the user pass a specific file path, spec name, or clear instruction this message?
|
Did the user pass a specific file path, spec name, or clear instruction this message?
|
||||||
- If it points to a file that matches the spec template (has `status` frontmatter with a recognized value: ready-for-dev, in-progress, or in-review) → set `spec_file` and **EARLY EXIT** to the appropriate step (step-03 for ready/in-progress, step-04 for review).
|
- If it points to a file that matches the spec template (has `status` frontmatter with a recognized value: draft, ready-for-dev, in-progress, in-review, or done) → set `spec_file` and **EARLY EXIT** to the appropriate step (step-02 for draft, step-03 for ready/in-progress, step-04 for review). For `done`, ingest as context and proceed to INSTRUCTIONS — do not resume.
|
||||||
- Anything else (intent files, external docs, plans, descriptions) → ingest it as starting intent and proceed to INSTRUCTIONS. Do not attempt to infer a workflow state from it.
|
- Anything else (intent files, external docs, plans, descriptions) → ingest it as starting intent and proceed to INSTRUCTIONS. Do not attempt to infer a workflow state from it.
|
||||||
|
|
||||||
2. Recent conversation
|
2. Recent conversation
|
||||||
|
|
@ -29,8 +28,8 @@ Before listing artifacts or prompting the user, check whether you already know t
|
||||||
Use the same routing as above.
|
Use the same routing as above.
|
||||||
|
|
||||||
3. Otherwise — scan artifacts and ask
|
3. Otherwise — scan artifacts and ask
|
||||||
- `{wipFile}` exists? → Offer resume or archive.
|
- Active specs (`draft`, `ready-for-dev`, `in-progress`, `in-review`) in `{implementation_artifacts}`? → List them and HALT. Ask user which to resume (or `[N]` for new).
|
||||||
- Active specs (`ready-for-dev`, `in-progress`, `in-review`) in `{implementation_artifacts}`? → List them and HALT. Ask user which to resume (or `[N]` for new).
|
- If `draft` selected: Set `spec_file`. **EARLY EXIT** → `./step-02-plan.md` (resume planning from the draft)
|
||||||
- If `ready-for-dev` or `in-progress` selected: Set `spec_file`. **EARLY EXIT** → `./step-03-implement.md`
|
- If `ready-for-dev` or `in-progress` selected: Set `spec_file`. **EARLY EXIT** → `./step-03-implement.md`
|
||||||
- If `in-review` selected: Set `spec_file`. **EARLY EXIT** → `./step-04-review.md`
|
- If `in-review` selected: Set `spec_file`. **EARLY EXIT** → `./step-04-review.md`
|
||||||
- Unformatted spec or intent file lacking `status` frontmatter? → Suggest treating its contents as the starting intent. Do NOT attempt to infer a state and resume it.
|
- Unformatted spec or intent file lacking `status` frontmatter? → Suggest treating its contents as the starting intent. Do NOT attempt to infer a state and resume it.
|
||||||
|
|
@ -42,6 +41,25 @@ Never ask extra questions if you already understand what the user intends.
|
||||||
1. Load context.
|
1. Load context.
|
||||||
- List files in `{planning_artifacts}` and `{implementation_artifacts}`.
|
- List files in `{planning_artifacts}` and `{implementation_artifacts}`.
|
||||||
- If you find an unformatted spec or intent file, ingest its contents to form your understanding of the intent.
|
- If you find an unformatted spec or intent file, ingest its contents to form your understanding of the intent.
|
||||||
|
- **Determine context strategy.** Using the intent and the artifact listing, infer whether the current work is a story from an epic. Do not rely on filename patterns or regex — reason about the intent, the listing, and any epics file content together.
|
||||||
|
|
||||||
|
**A) Epic story path** — if the intent is clearly an epic story:
|
||||||
|
|
||||||
|
1. Identify the epic number and (if present) the story number. If you can't identify an epic number, use path B.
|
||||||
|
|
||||||
|
2. **Check for a valid cached epic context.** Look for `{implementation_artifacts}/epic-<N>-context.md` (where `<N>` is the epic number). A file is **valid** when it exists, is non-empty, starts with `# Epic <N> Context:` (with the correct epic number), and no file in `{planning_artifacts}` is newer.
|
||||||
|
- **If valid:** load it as the primary planning context. Do not load raw planning docs (PRD, architecture, UX, etc.). Skip to step 5.
|
||||||
|
- **If missing, empty, or invalid:** continue to step 3.
|
||||||
|
|
||||||
|
3. **Compile epic context.** Produce `{implementation_artifacts}/epic-<N>-context.md` by following `./compile-epic-context.md`, in order of preference:
|
||||||
|
- **Preferred — sub-agent:** spawn a sub-agent with `./compile-epic-context.md` as its prompt. Pass it the epic number, the epics file path, the `{planning_artifacts}` directory, and the output path `{implementation_artifacts}/epic-<N>-context.md`.
|
||||||
|
- **Fallback — inline** (for runtimes without sub-agent support, e.g. Copilot, Codex, local Ollama, older Claude): if your runtime cannot spawn sub-agents, or the spawn fails/times out, read `./compile-epic-context.md` yourself and follow its instructions to produce the same output file.
|
||||||
|
|
||||||
|
4. **Verify.** After compilation, verify the output file exists, is non-empty, and starts with `# Epic <N> Context:`. If valid, load it. If verification fails, HALT and report the failure.
|
||||||
|
|
||||||
|
5. **Previous story continuity.** Regardless of which context source succeeded above, scan `{implementation_artifacts}` for specs from the same epic with `status: done` and a lower story number. Load the most recent one (highest story number below current). Extract its **Code Map**, **Design Notes**, **Spec Change Log**, and **task list** as continuity context for step-02 planning. If no `done` spec is found but an `in-review` spec exists for the same epic with a lower story number, note it to the user and ask whether to load it.
|
||||||
|
|
||||||
|
**B) Freeform path** — if the intent is not an epic story:
|
||||||
- Planning artifacts are the output of BMAD phases 1-3. Typical files include:
|
- Planning artifacts are the output of BMAD phases 1-3. Typical files include:
|
||||||
- **PRD** (`*prd*`) — product requirements and success criteria
|
- **PRD** (`*prd*`) — product requirements and success criteria
|
||||||
- **Architecture** (`*architecture*`) — technical design decisions and constraints
|
- **Architecture** (`*architecture*`) — technical design decisions and constraints
|
||||||
|
|
@ -49,12 +67,6 @@ Never ask extra questions if you already understand what the user intends.
|
||||||
- **Epics** (`*epic*`) — feature breakdown into implementable stories
|
- **Epics** (`*epic*`) — feature breakdown into implementable stories
|
||||||
- **Product Brief** (`*brief*`) — project vision and scope
|
- **Product Brief** (`*brief*`) — project vision and scope
|
||||||
- Scan the listing for files matching these patterns. If any look relevant to the current intent, load them selectively — you don't need all of them, but you need the right constraints and requirements rather than guessing from code alone.
|
- Scan the listing for files matching these patterns. If any look relevant to the current intent, load them selectively — you don't need all of them, but you need the right constraints and requirements rather than guessing from code alone.
|
||||||
- **Previous story continuity.** Using the intent and loaded context (especially any epics file), infer whether the current work is a story from an epic. Do not rely on filename patterns or regex — reason about the intent, the artifact listing, and epics content together. If the intent is an epic story:
|
|
||||||
1. Identify the epic and story number.
|
|
||||||
2. Scan `{implementation_artifacts}` for specs from the same epic with `status: done` and a lower story number.
|
|
||||||
3. Load the most recent one (highest story number below current).
|
|
||||||
4. Extract its **Code Map**, **Design Notes**, **Spec Change Log**, and **task list** as continuity context for step-02 planning.
|
|
||||||
If no `done` spec is found but an `in-review` spec exists for the same epic with a lower story number, note it to the user and ask whether to load it. If the intent is not an epic story, or no previous spec exists, skip this silently.
|
|
||||||
2. Clarify intent. Do not fantasize, do not leave open questions. If you must ask questions, ask them as a numbered list. When the human replies, verify that every single numbered question was answered. If any were ignored, HALT and re-ask only the missing questions before proceeding. Keep looping until intent is clear enough to implement.
|
2. Clarify intent. Do not fantasize, do not leave open questions. If you must ask questions, ask them as a numbered list. When the human replies, verify that every single numbered question was answered. If any were ignored, HALT and re-ask only the missing questions before proceeding. Keep looping until intent is clear enough to implement.
|
||||||
3. Version control sanity check. Is the working tree clean? Does the current branch make sense for this intent — considering its name and recent history? If the tree is dirty or the branch is an obvious mismatch, HALT and ask the human before proceeding. If version control is unavailable, skip this check.
|
3. Version control sanity check. Is the working tree clean? Does the current branch make sense for this intent — considering its name and recent history? If the tree is dirty or the branch is an obvious mismatch, HALT and ask the human before proceeding. If version control is unavailable, skip this check.
|
||||||
4. Multi-goal check (see SCOPE STANDARD). If the intent fails the single-goal criteria:
|
4. Multi-goal check (see SCOPE STANDARD). If the intent fails the single-goal criteria:
|
||||||
|
|
@ -65,7 +77,7 @@ Never ask extra questions if you already understand what the user intends.
|
||||||
- On **K**: Proceed as-is.
|
- On **K**: Proceed as-is.
|
||||||
5. Route — choose exactly one:
|
5. Route — choose exactly one:
|
||||||
|
|
||||||
Derive a valid kebab-case slug from the clarified intent. If the intent references a tracking identifier (story number, issue number, ticket ID), lead the slug with it (e.g. `3-2-digest-delivery`, `gh-47-fix-auth`). If `{implementation_artifacts}/spec-{slug}.md` already exists, append `-2`, `-3`, etc. Set `spec_file` = `{implementation_artifacts}/spec-{slug}.md`.
|
Derive a valid kebab-case slug from the clarified intent. If the intent references a tracking identifier (story number, issue number, ticket ID), lead the slug with it (e.g. `3-2-digest-delivery`, `gh-47-fix-auth`). If `{implementation_artifacts}/spec-{slug}.md` already exists: if its status is `draft`, treat it as the same work and resume it (set `spec_file` to that path, **EARLY EXIT** → `./step-02-plan.md`); otherwise append `-2`, `-3`, etc. Set `spec_file` = `{implementation_artifacts}/spec-{slug}.md`.
|
||||||
|
|
||||||
**a) One-shot** — zero blast radius: no plausible path by which this change causes unintended consequences elsewhere. Clear intent, no architectural decisions.
|
**a) One-shot** — zero blast radius: no plausible path by which this change causes unintended consequences elsewhere. Clear intent, no architectural decisions.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
---
|
---
|
||||||
wipFile: '{implementation_artifacts}/spec-wip.md'
|
|
||||||
deferred_work_file: '{implementation_artifacts}/deferred-work.md'
|
deferred_work_file: '{implementation_artifacts}/deferred-work.md'
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -12,11 +11,12 @@ deferred_work_file: '{implementation_artifacts}/deferred-work.md'
|
||||||
|
|
||||||
## INSTRUCTIONS
|
## INSTRUCTIONS
|
||||||
|
|
||||||
1. Investigate codebase. _Isolate deep exploration in sub-agents/tasks where available. To prevent context snowballing, instruct subagents to give you distilled summaries only._
|
1. Draft resume check. If `{spec_file}` exists with `status: draft`, read it and capture the verbatim `<frozen-after-approval>...</frozen-after-approval>` block as `preserved_intent`. Otherwise `preserved_intent` is empty.
|
||||||
2. Read `./spec-template.md` fully. Fill it out based on the intent and investigation, and write the result to `{wipFile}`.
|
2. Investigate codebase. _Isolate deep exploration in sub-agents/tasks where available. To prevent context snowballing, instruct subagents to give you distilled summaries only._
|
||||||
3. Self-review against READY FOR DEVELOPMENT standard.
|
3. Read `./spec-template.md` fully. Fill it out based on the intent and investigation. If `{preserved_intent}` is non-empty, substitute it for the `<frozen-after-approval>` block in your filled spec before writing. Write the result to `{spec_file}`.
|
||||||
4. If intent gaps exist, do not fantasize, do not leave open questions, HALT and ask the human.
|
4. Self-review against READY FOR DEVELOPMENT standard.
|
||||||
5. Token count check (see SCOPE STANDARD). If spec exceeds 1600 tokens:
|
5. If intent gaps exist, do not fantasize, do not leave open questions, HALT and ask the human.
|
||||||
|
6. Token count check (see SCOPE STANDARD). If spec exceeds 1600 tokens:
|
||||||
- Show user the token count.
|
- Show user the token count.
|
||||||
- HALT and ask human: `[S] Split — carve off secondary goals` | `[K] Keep full spec — accept the risks`
|
- HALT and ask human: `[S] Split — carve off secondary goals` | `[K] Keep full spec — accept the risks`
|
||||||
- On **S**: Propose the split — name each secondary goal. Append deferred goals to `{deferred_work_file}`. Rewrite the current spec to cover only the main goal — do not surgically carve sections out; regenerate the spec for the narrowed scope. Continue to checkpoint.
|
- On **S**: Propose the split — name each secondary goal. Append deferred goals to `{deferred_work_file}`. Rewrite the current spec to cover only the main goal — do not surgically carve sections out; regenerate the spec for the narrowed scope. Continue to checkpoint.
|
||||||
|
|
@ -24,9 +24,21 @@ deferred_work_file: '{implementation_artifacts}/deferred-work.md'
|
||||||
|
|
||||||
### CHECKPOINT 1
|
### CHECKPOINT 1
|
||||||
|
|
||||||
Present summary. If token count exceeded 1600 and user chose [K], include the token count and explain why it may be a problem. HALT and ask human: `[A] Approve` | `[E] Edit`
|
Present summary. Display the spec file path as a CWD-relative path (no leading `/`) so it is clickable in the terminal. If token count exceeded 1600 and user chose [K], include the token count and explain why it may be a problem.
|
||||||
|
|
||||||
- **A**: Rename `{wipFile}` to `{spec_file}`, set status `ready-for-dev`. Everything inside `<frozen-after-approval>` is now locked — only the human can change it. Display the finalized spec path to the user as a CWD-relative path (no leading `/`) so it is clickable in the terminal. → Step 3.
|
After presenting the summary, display this note:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Before approving, you can open the spec file in an editor or ask me questions and tell me what to change. You can also use `bmad-advanced-elicitation`, `bmad-party-mode`, or `bmad-code-review` skills, ideally in another session to avoid context bloat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
HALT and ask human: `[A] Approve` | `[E] Edit`
|
||||||
|
|
||||||
|
- **A**: Re-read `{spec_file}` from disk.
|
||||||
|
- **If the file is missing:** HALT. Tell the user the spec file is gone and STOP — do not write anything to `{spec_file}`, do not set status, do not proceed to Step 3. Nothing below this point runs.
|
||||||
|
- **If the file exists:** Compare the content to what you wrote. If it has changed since you wrote it, acknowledge the external edits — show a brief summary of what changed — and proceed with the updated version. Then set status `ready-for-dev` in `{spec_file}`. Everything inside `<frozen-after-approval>` is now locked — only the human can change it. → Step 3.
|
||||||
- **E**: Apply changes, then return to CHECKPOINT 1.
|
- **E**: Apply changes, then return to CHECKPOINT 1.
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
---
|
---
|
||||||
deferred_work_file: '{implementation_artifacts}/deferred-work.md'
|
deferred_work_file: '{implementation_artifacts}/deferred-work.md'
|
||||||
spec_file: '' # set by step-01 before entering this step
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Step One-Shot: Implement, Review, Present
|
# Step One-Shot: Implement, Review, Present
|
||||||
|
|
|
||||||
|
|
@ -70,10 +70,6 @@ Load and read full config from `{main_config}` and resolve:
|
||||||
|
|
||||||
YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}`.
|
YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}`.
|
||||||
|
|
||||||
### 2. Paths
|
### 2. First Step Execution
|
||||||
|
|
||||||
- `wipFile` = `{implementation_artifacts}/spec-wip.md`
|
|
||||||
|
|
||||||
### 3. First Step Execution
|
|
||||||
|
|
||||||
Read fully and follow: `./step-01-clarify-and-route.md` to begin the workflow.
|
Read fully and follow: `./step-01-clarify-and-route.md` to begin the workflow.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs
|
module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs
|
||||||
|
BMad Method,_meta,,,,,,,,,false,https://docs.bmad-method.org/llms.txt,
|
||||||
BMad Method,bmad-document-project,Document Project,DP,Analyze an existing project to produce useful documentation.,,anytime,,,false,project-knowledge,*
|
BMad Method,bmad-document-project,Document Project,DP,Analyze an existing project to produce useful documentation.,,anytime,,,false,project-knowledge,*
|
||||||
BMad Method,bmad-generate-project-context,Generate Project Context,GPC,Scan existing codebase to generate a lean LLM-optimized project-context.md. Essential for brownfield projects.,,anytime,,,false,output_folder,project context
|
BMad Method,bmad-generate-project-context,Generate Project Context,GPC,Scan existing codebase to generate a lean LLM-optimized project-context.md. Essential for brownfield projects.,,anytime,,,false,output_folder,project context
|
||||||
BMad Method,bmad-quick-dev,Quick Dev,QQ,Unified intent-in code-out workflow: clarify plan implement review and present.,,anytime,,,false,implementation_artifacts,spec and project implementation
|
BMad Method,bmad-quick-dev,Quick Dev,QQ,Unified intent-in code-out workflow: clarify plan implement review and present.,,anytime,,,false,implementation_artifacts,spec and project implementation
|
||||||
|
|
|
||||||
|
Can't render this file because it has a wrong number of fields in line 2.
|
|
|
@ -7,7 +7,7 @@ description: 'Analyzes current state and user query to answer BMad questions or
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Help the user understand where they are in their BMad workflow and what to do next. Answer BMad questions when asked.
|
Help the user understand where they are in their BMad workflow and what to do next, and also answer broader questions when asked that could be augmented with remote sources such as module documentation sources.
|
||||||
|
|
||||||
## Desired Outcomes
|
## Desired Outcomes
|
||||||
|
|
||||||
|
|
@ -18,6 +18,7 @@ When this skill completes, the user should:
|
||||||
3. **Know how to invoke it** — skill name, menu code, action context, and any args that shortcut the conversation
|
3. **Know how to invoke it** — skill name, menu code, action context, and any args that shortcut the conversation
|
||||||
4. **Get offered a quick start** — when a single skill is the clear next step, offer to run it for the user right now rather than just listing it
|
4. **Get offered a quick start** — when a single skill is the clear next step, offer to run it for the user right now rather than just listing it
|
||||||
5. **Feel oriented, not overwhelmed** — surface only what's relevant to their current position; don't dump the entire catalog
|
5. **Feel oriented, not overwhelmed** — surface only what's relevant to their current position; don't dump the entire catalog
|
||||||
|
6. **Get answers to general questions** — when the question doesn't map to a specific skill, use the module's registered documentation to give a grounded answer
|
||||||
|
|
||||||
## Data Sources
|
## Data Sources
|
||||||
|
|
||||||
|
|
@ -25,6 +26,7 @@ When this skill completes, the user should:
|
||||||
- **Config**: `config.yaml` and `user-config.yaml` files in `{project-root}/_bmad/` and its subfolders — resolve `output-location` variables, provide `communication_language` and `project_knowledge`
|
- **Config**: `config.yaml` and `user-config.yaml` files in `{project-root}/_bmad/` and its subfolders — resolve `output-location` variables, provide `communication_language` and `project_knowledge`
|
||||||
- **Artifacts**: Files matching `outputs` patterns at resolved `output-location` paths reveal which steps are possibly completed; their content may also provide grounding context for recommendations
|
- **Artifacts**: Files matching `outputs` patterns at resolved `output-location` paths reveal which steps are possibly completed; their content may also provide grounding context for recommendations
|
||||||
- **Project knowledge**: If `project_knowledge` resolves to an existing path, read it for grounding context. Never fabricate project-specific details.
|
- **Project knowledge**: If `project_knowledge` resolves to an existing path, read it for grounding context. Never fabricate project-specific details.
|
||||||
|
- **Module docs**: Rows with `_meta` in the `skill` column carry a URL or path in `output-location` pointing to the module's documentation (e.g., llms.txt). Fetch and use these to answer general questions about that module.
|
||||||
|
|
||||||
## CSV Interpretation
|
## CSV Interpretation
|
||||||
|
|
||||||
|
|
@ -70,4 +72,4 @@ For each recommended item, present:
|
||||||
- Present all output in `{communication_language}`
|
- Present all output in `{communication_language}`
|
||||||
- Recommend running each skill in a **fresh context window**
|
- Recommend running each skill in a **fresh context window**
|
||||||
- Match the user's tone — conversational when they're casual, structured when they want specifics
|
- Match the user's tone — conversational when they're casual, structured when they want specifics
|
||||||
- If the active module is ambiguous, ask rather than guess
|
- If the active module is ambiguous, retrieve all meta rows remote sources to find relevant info also to help answer their question
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs
|
module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs
|
||||||
|
Core,_meta,,,,,,,,,false,https://docs.bmad-method.org/llms.txt,
|
||||||
Core,bmad-brainstorming,Brainstorming,BSP,Use early in ideation or when stuck generating ideas.,,anytime,,,false,{output_folder}/brainstorming,brainstorming session
|
Core,bmad-brainstorming,Brainstorming,BSP,Use early in ideation or when stuck generating ideas.,,anytime,,,false,{output_folder}/brainstorming,brainstorming session
|
||||||
Core,bmad-party-mode,Party Mode,PM,Orchestrate multi-agent discussions when you need multiple perspectives or want agents to collaborate.,,anytime,,,false,,
|
Core,bmad-party-mode,Party Mode,PM,Orchestrate multi-agent discussions when you need multiple perspectives or want agents to collaborate.,,anytime,,,false,,
|
||||||
Core,bmad-help,BMad Help,BH,,,anytime,,,false,,
|
Core,bmad-help,BMad Help,BH,,,anytime,,,false,,
|
||||||
|
|
|
||||||
|
Can't render this file because it has a wrong number of fields in line 2.
|
|
|
@ -1,154 +0,0 @@
|
||||||
/**
|
|
||||||
* install_to_bmad Flag — Design Contract Tests
|
|
||||||
*
|
|
||||||
* Unit tests against the functions that implement the install_to_bmad flag.
|
|
||||||
* These nail down the 4 core design decisions:
|
|
||||||
*
|
|
||||||
* 1. true/omitted → skill stays in _bmad/ (default behavior)
|
|
||||||
* 2. false → skill removed from _bmad/ after IDE install
|
|
||||||
* 3. No platform → no cleanup runs (cleanup lives in installVerbatimSkills)
|
|
||||||
* 4. Mixed flags → each skill evaluated independently
|
|
||||||
*
|
|
||||||
* Usage: node test/test-install-to-bmad.js
|
|
||||||
*/
|
|
||||||
|
|
||||||
const path = require('node:path');
|
|
||||||
const os = require('node:os');
|
|
||||||
const fs = require('fs-extra');
|
|
||||||
const { loadSkillManifest, getInstallToBmad } = require('../tools/installer/ide/shared/skill-manifest');
|
|
||||||
|
|
||||||
// ANSI colors
|
|
||||||
const colors = {
|
|
||||||
reset: '\u001B[0m',
|
|
||||||
green: '\u001B[32m',
|
|
||||||
red: '\u001B[31m',
|
|
||||||
yellow: '\u001B[33m',
|
|
||||||
cyan: '\u001B[36m',
|
|
||||||
dim: '\u001B[2m',
|
|
||||||
};
|
|
||||||
|
|
||||||
let passed = 0;
|
|
||||||
let failed = 0;
|
|
||||||
|
|
||||||
function assert(condition, testName, errorMessage = '') {
|
|
||||||
if (condition) {
|
|
||||||
console.log(`${colors.green}✓${colors.reset} ${testName}`);
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
console.log(`${colors.red}✗${colors.reset} ${testName}`);
|
|
||||||
if (errorMessage) {
|
|
||||||
console.log(` ${colors.dim}${errorMessage}${colors.reset}`);
|
|
||||||
}
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runTests() {
|
|
||||||
console.log(`${colors.cyan}========================================`);
|
|
||||||
console.log('install_to_bmad — Design Contract Tests');
|
|
||||||
console.log(`========================================${colors.reset}\n`);
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 1. true/omitted → getInstallToBmad returns true (keep in _bmad/)
|
|
||||||
// ============================================================
|
|
||||||
console.log(`${colors.yellow}Design decision 1: true or omitted → skill stays in _bmad/${colors.reset}\n`);
|
|
||||||
|
|
||||||
// Null manifest (no bmad-skill-manifest.yaml) → true
|
|
||||||
assert(getInstallToBmad(null, 'workflow.md') === true, 'null manifest defaults to true');
|
|
||||||
|
|
||||||
// Single-entry, flag omitted → true
|
|
||||||
assert(
|
|
||||||
getInstallToBmad({ __single: { type: 'skill' } }, 'workflow.md') === true,
|
|
||||||
'single-entry manifest with flag omitted defaults to true',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Single-entry, explicit true → true
|
|
||||||
assert(
|
|
||||||
getInstallToBmad({ __single: { type: 'skill', install_to_bmad: true } }, 'workflow.md') === true,
|
|
||||||
'single-entry manifest with explicit true returns true',
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 2. false → getInstallToBmad returns false (remove from _bmad/)
|
|
||||||
// ============================================================
|
|
||||||
console.log(`${colors.yellow}Design decision 2: false → skill removed from _bmad/${colors.reset}\n`);
|
|
||||||
|
|
||||||
// Single-entry, explicit false → false
|
|
||||||
assert(
|
|
||||||
getInstallToBmad({ __single: { type: 'skill', install_to_bmad: false } }, 'workflow.md') === false,
|
|
||||||
'single-entry manifest with explicit false returns false',
|
|
||||||
);
|
|
||||||
|
|
||||||
// loadSkillManifest round-trip: YAML with false is preserved through load
|
|
||||||
{
|
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-itb-'));
|
|
||||||
await fs.writeFile(path.join(tmpDir, 'bmad-skill-manifest.yaml'), 'type: skill\ninstall_to_bmad: false\n');
|
|
||||||
const loaded = await loadSkillManifest(tmpDir);
|
|
||||||
assert(getInstallToBmad(loaded, 'workflow.md') === false, 'loadSkillManifest preserves install_to_bmad: false through round-trip');
|
|
||||||
await fs.remove(tmpDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 3. No platform → cleanup only runs inside installVerbatimSkills
|
|
||||||
// (This is a design invariant: getInstallToBmad is only consulted
|
|
||||||
// during IDE install. Without a platform, the flag has no effect.)
|
|
||||||
// ============================================================
|
|
||||||
console.log(`${colors.yellow}Design decision 3: flag is a per-skill property, not a pipeline gate${colors.reset}\n`);
|
|
||||||
|
|
||||||
// The flag value is stored but doesn't trigger any side effects by itself.
|
|
||||||
// Cleanup is driven by reading the CSV column inside installVerbatimSkills.
|
|
||||||
// We verify the flag is just data — getInstallToBmad doesn't touch the filesystem.
|
|
||||||
{
|
|
||||||
const manifest = { __single: { type: 'skill', install_to_bmad: false } };
|
|
||||||
const result = getInstallToBmad(manifest, 'workflow.md');
|
|
||||||
assert(typeof result === 'boolean', 'getInstallToBmad returns a boolean (pure data, no side effects)');
|
|
||||||
assert(result === false, 'false value is faithfully returned for consumer to act on');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 4. Mixed flags → each skill evaluated independently
|
|
||||||
// ============================================================
|
|
||||||
console.log(`${colors.yellow}Design decision 4: mixed flags — each skill independent${colors.reset}\n`);
|
|
||||||
|
|
||||||
// Multi-entry manifest: different files can have different flags
|
|
||||||
{
|
|
||||||
const manifest = {
|
|
||||||
'workflow.md': { type: 'skill', install_to_bmad: false },
|
|
||||||
'other.md': { type: 'skill', install_to_bmad: true },
|
|
||||||
};
|
|
||||||
assert(getInstallToBmad(manifest, 'workflow.md') === false, 'multi-entry: workflow.md with false returns false');
|
|
||||||
assert(getInstallToBmad(manifest, 'other.md') === true, 'multi-entry: other.md with true returns true');
|
|
||||||
assert(getInstallToBmad(manifest, 'unknown.md') === true, 'multi-entry: unknown file defaults to true');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Summary
|
|
||||||
// ============================================================
|
|
||||||
console.log(`${colors.cyan}========================================`);
|
|
||||||
console.log('Results:');
|
|
||||||
console.log(` Passed: ${colors.green}${passed}${colors.reset}`);
|
|
||||||
console.log(` Failed: ${colors.red}${failed}${colors.reset}`);
|
|
||||||
console.log(`========================================${colors.reset}\n`);
|
|
||||||
|
|
||||||
if (failed === 0) {
|
|
||||||
console.log(`${colors.green}All install_to_bmad contract tests passed!${colors.reset}\n`);
|
|
||||||
process.exit(0);
|
|
||||||
} else {
|
|
||||||
console.log(`${colors.red}Some install_to_bmad contract tests failed${colors.reset}\n`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runTests().catch((error) => {
|
|
||||||
console.error(`${colors.red}Test runner failed:${colors.reset}`, error.message);
|
|
||||||
console.error(error.stack);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -59,8 +59,8 @@ async function createTestBmadFixture() {
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(fixtureDir, '_config', 'skill-manifest.csv'),
|
path.join(fixtureDir, '_config', 'skill-manifest.csv'),
|
||||||
[
|
[
|
||||||
'canonicalId,name,description,module,path,install_to_bmad',
|
'canonicalId,name,description,module,path',
|
||||||
'"bmad-master","bmad-master","Minimal test agent fixture","core","_bmad/core/bmad-master/SKILL.md","true"',
|
'"bmad-master","bmad-master","Minimal test agent fixture","core","_bmad/core/bmad-master/SKILL.md"',
|
||||||
'',
|
'',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
);
|
);
|
||||||
|
|
@ -103,8 +103,8 @@ async function createSkillCollisionFixture() {
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(configDir, 'skill-manifest.csv'),
|
path.join(configDir, 'skill-manifest.csv'),
|
||||||
[
|
[
|
||||||
'canonicalId,name,description,module,path,install_to_bmad',
|
'canonicalId,name,description,module,path',
|
||||||
'"bmad-help","bmad-help","Native help skill","core","_bmad/core/tasks/bmad-help/SKILL.md","true"',
|
'"bmad-help","bmad-help","Native help skill","core","_bmad/core/tasks/bmad-help/SKILL.md"',
|
||||||
'',
|
'',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
);
|
);
|
||||||
|
|
@ -128,56 +128,6 @@ async function createSkillCollisionFixture() {
|
||||||
return { root: fixtureRoot, bmadDir: fixtureDir };
|
return { root: fixtureRoot, bmadDir: fixtureDir };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createCustomModuleManifestFixture() {
|
|
||||||
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-custom-manifest-'));
|
|
||||||
const bmadDir = path.join(fixtureRoot, '_bmad');
|
|
||||||
const configDir = path.join(bmadDir, '_config');
|
|
||||||
const moduleSourceDir = path.join(fixtureRoot, 'test-module-source');
|
|
||||||
await fs.ensureDir(configDir);
|
|
||||||
await fs.ensureDir(moduleSourceDir);
|
|
||||||
|
|
||||||
const minimalAgent = '<agent name="Test" title="T"><persona>p</persona></agent>';
|
|
||||||
await fs.ensureDir(path.join(bmadDir, 'core', 'agents'));
|
|
||||||
await fs.writeFile(path.join(bmadDir, 'core', 'agents', 'test.md'), minimalAgent);
|
|
||||||
await fs.ensureDir(path.join(bmadDir, 'test-module', 'agents'));
|
|
||||||
await fs.writeFile(path.join(bmadDir, 'test-module', 'agents', 'test.md'), minimalAgent);
|
|
||||||
await fs.writeFile(path.join(moduleSourceDir, 'module.yaml'), ['code: test-module', 'name: Test Module', ''].join('\n'));
|
|
||||||
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(configDir, 'manifest.yaml'),
|
|
||||||
[
|
|
||||||
'installation:',
|
|
||||||
' version: 6.2.2',
|
|
||||||
' installDate: 2026-03-30T00:00:00.000Z',
|
|
||||||
' lastUpdated: 2026-03-30T00:00:00.000Z',
|
|
||||||
'modules:',
|
|
||||||
' - name: core',
|
|
||||||
' version: 6.2.2',
|
|
||||||
' installDate: 2026-03-30T00:00:00.000Z',
|
|
||||||
' lastUpdated: 2026-03-30T00:00:00.000Z',
|
|
||||||
' source: built-in',
|
|
||||||
' npmPackage: null',
|
|
||||||
' repoUrl: null',
|
|
||||||
' - name: test-module',
|
|
||||||
' version: null',
|
|
||||||
' installDate: 2026-03-30T00:00:00.000Z',
|
|
||||||
' lastUpdated: 2026-03-30T00:00:00.000Z',
|
|
||||||
' source: custom',
|
|
||||||
' npmPackage: null',
|
|
||||||
' repoUrl: null',
|
|
||||||
'customModules:',
|
|
||||||
' - id: test-module',
|
|
||||||
' name: "Test Module"',
|
|
||||||
` sourcePath: ${JSON.stringify(moduleSourceDir)}`,
|
|
||||||
'ides:',
|
|
||||||
' - codex',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
return { root: fixtureRoot, bmadDir, manifestPath: path.join(configDir, 'manifest.yaml'), moduleSourceDir };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test Suite
|
* Test Suite
|
||||||
*/
|
*/
|
||||||
|
|
@ -1306,7 +1256,7 @@ async function runTests() {
|
||||||
const existingCsv27 = await fs.readFile(path.join(configDir27, 'skill-manifest.csv'), 'utf8');
|
const existingCsv27 = await fs.readFile(path.join(configDir27, 'skill-manifest.csv'), 'utf8');
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(configDir27, 'skill-manifest.csv'),
|
path.join(configDir27, 'skill-manifest.csv'),
|
||||||
existingCsv27.trimEnd() + '\n"bmad-architect","bmad-architect","Architect","bmm","_bmad/bmm/agents/bmad-architect/SKILL.md","true"\n',
|
existingCsv27.trimEnd() + '\n"bmad-architect","bmad-architect","Architect","bmm","_bmad/bmm/agents/bmad-architect/SKILL.md"\n',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Run Claude Code setup (which triggers cleanup then install)
|
// Run Claude Code setup (which triggers cleanup then install)
|
||||||
|
|
@ -1774,102 +1724,253 @@ async function runTests() {
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Suite 33: Main manifest preserves active customModules only
|
// Test Suite 33: Community & Custom Module Managers
|
||||||
// ============================================================
|
// ============================================================
|
||||||
console.log(`${colors.yellow}Test Suite 33: Preserve active customModules in main manifest${colors.reset}\n`);
|
console.log(`${colors.yellow}Test Suite 33: Community & Custom Module Managers${colors.reset}\n`);
|
||||||
|
|
||||||
let customManifestFixture = null;
|
// --- CustomModuleManager.validateGitHubUrl ---
|
||||||
try {
|
{
|
||||||
customManifestFixture = await createCustomModuleManifestFixture();
|
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
|
||||||
const yaml = require('yaml');
|
const mgr = new CustomModuleManager();
|
||||||
const originalManifest = yaml.parse(await fs.readFile(customManifestFixture.manifestPath, 'utf8'));
|
|
||||||
originalManifest.customModules.push({
|
|
||||||
id: 'removed-module',
|
|
||||||
name: 'Removed Module',
|
|
||||||
sourcePath: path.join(customManifestFixture.root, 'removed-module-source'),
|
|
||||||
});
|
|
||||||
await fs.writeFile(customManifestFixture.manifestPath, yaml.stringify(originalManifest), 'utf8');
|
|
||||||
|
|
||||||
const generator33 = new ManifestGenerator();
|
const https1 = mgr.validateGitHubUrl('https://github.com/owner/repo');
|
||||||
await generator33.generateManifests(customManifestFixture.bmadDir, ['core', 'test-module'], [], { ides: ['codex'] });
|
assert(https1.isValid === true, 'validateGitHubUrl accepts HTTPS URL');
|
||||||
|
assert(https1.owner === 'owner' && https1.repo === 'repo', 'validateGitHubUrl extracts owner/repo from HTTPS');
|
||||||
|
|
||||||
const updatedManifest = yaml.parse(await fs.readFile(customManifestFixture.manifestPath, 'utf8'));
|
const https2 = mgr.validateGitHubUrl('https://github.com/owner/repo.git');
|
||||||
const customModule = updatedManifest.customModules?.find((entry) => entry.id === 'test-module');
|
assert(https2.isValid === true, 'validateGitHubUrl accepts HTTPS URL with .git');
|
||||||
|
assert(https2.repo === 'repo', 'validateGitHubUrl strips .git suffix');
|
||||||
|
|
||||||
assert(Array.isArray(updatedManifest.customModules), 'Main manifest keeps customModules array');
|
const ssh1 = mgr.validateGitHubUrl('git@github.com:owner/repo.git');
|
||||||
assert(customModule !== undefined, 'Main manifest preserves existing custom module entry');
|
assert(ssh1.isValid === true, 'validateGitHubUrl accepts SSH URL');
|
||||||
assert(
|
assert(ssh1.owner === 'owner' && ssh1.repo === 'repo', 'validateGitHubUrl extracts owner/repo from SSH');
|
||||||
customModule && customModule.sourcePath === customManifestFixture.moduleSourceDir,
|
|
||||||
'Main manifest preserves custom module sourcePath',
|
const bad1 = mgr.validateGitHubUrl('https://gitlab.com/owner/repo');
|
||||||
);
|
assert(bad1.isValid === false, 'validateGitHubUrl rejects non-GitHub URL');
|
||||||
assert(
|
|
||||||
!updatedManifest.customModules?.some((entry) => entry.id === 'removed-module'),
|
const bad2 = mgr.validateGitHubUrl('');
|
||||||
'Main manifest drops stale custom module entries',
|
assert(bad2.isValid === false, 'validateGitHubUrl rejects empty string');
|
||||||
);
|
|
||||||
} catch (error) {
|
const bad3 = mgr.validateGitHubUrl(null);
|
||||||
assert(false, 'Main manifest preserves customModules test succeeds', error.message);
|
assert(bad3.isValid === false, 'validateGitHubUrl rejects null');
|
||||||
} finally {
|
|
||||||
if (customManifestFixture?.root) await fs.remove(customManifestFixture.root).catch(() => {});
|
const bad4 = mgr.validateGitHubUrl('https://github.com/owner');
|
||||||
|
assert(bad4.isValid === false, 'validateGitHubUrl rejects URL without repo');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('');
|
// --- CustomModuleManager._normalizeCustomModule ---
|
||||||
|
{
|
||||||
|
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
|
||||||
|
const mgr = new CustomModuleManager();
|
||||||
|
|
||||||
// ============================================================
|
const plugin = { name: 'test-plugin', description: 'A test', version: '1.0.0', author: 'tester', source: './src' };
|
||||||
// Suite 34: Quick update uses manifest-backed custom sources
|
const data = { owner: 'Fallback Owner' };
|
||||||
// ============================================================
|
const result = mgr._normalizeCustomModule(plugin, 'https://github.com/o/r', data);
|
||||||
console.log(`${colors.yellow}Test Suite 34: Quick update uses manifest-backed custom module sources${colors.reset}\n`);
|
|
||||||
|
|
||||||
let quickUpdateFixture = null;
|
assert(result.code === 'test-plugin', 'normalizeCustomModule sets code from plugin name');
|
||||||
const originalListAvailable34 = OfficialModules.prototype.listAvailable;
|
assert(result.type === 'custom', 'normalizeCustomModule sets type to custom');
|
||||||
const originalLoadExistingConfig34 = OfficialModules.prototype.loadExistingConfig;
|
assert(result.trustTier === 'unverified', 'normalizeCustomModule sets trustTier to unverified');
|
||||||
const originalCollectModuleConfigQuick34 = OfficialModules.prototype.collectModuleConfigQuick;
|
assert(result.version === '1.0.0', 'normalizeCustomModule preserves version');
|
||||||
try {
|
assert(result.author === 'tester', 'normalizeCustomModule uses plugin author over data.owner');
|
||||||
quickUpdateFixture = await createCustomModuleManifestFixture();
|
|
||||||
const installer34 = new Installer();
|
|
||||||
installer34.externalModuleManager.hasModule = async () => false;
|
|
||||||
installer34.externalModuleManager.listAvailable = async () => [];
|
|
||||||
|
|
||||||
let capturedInstallConfig34 = null;
|
const pluginNoAuthor = { name: 'x', description: '', version: null };
|
||||||
installer34.install = async (config) => {
|
const result2 = mgr._normalizeCustomModule(pluginNoAuthor, 'https://github.com/o/r', data);
|
||||||
capturedInstallConfig34 = config;
|
assert(result2.author === 'Fallback Owner', 'normalizeCustomModule falls back to data.owner');
|
||||||
return { success: true };
|
|
||||||
};
|
|
||||||
|
|
||||||
OfficialModules.prototype.listAvailable = async function () {
|
|
||||||
return { modules: [], customModules: [] };
|
|
||||||
};
|
|
||||||
OfficialModules.prototype.loadExistingConfig = async function () {
|
|
||||||
this.collectedConfig = this.collectedConfig || {};
|
|
||||||
};
|
|
||||||
OfficialModules.prototype.collectModuleConfigQuick = async function (moduleName) {
|
|
||||||
this.collectedConfig = this.collectedConfig || {};
|
|
||||||
if (!this.collectedConfig[moduleName]) {
|
|
||||||
this.collectedConfig[moduleName] = {};
|
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
// --- CommunityModuleManager._normalizeCommunityModule ---
|
||||||
|
{
|
||||||
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
const mod = {
|
||||||
|
name: 'test-mod',
|
||||||
|
display_name: 'Test Module',
|
||||||
|
code: 'tm',
|
||||||
|
description: 'desc',
|
||||||
|
repository: 'https://github.com/o/r',
|
||||||
|
module_definition: 'src/module.yaml',
|
||||||
|
category: 'software-development',
|
||||||
|
subcategory: 'dev-tools',
|
||||||
|
trust_tier: 'bmad-certified',
|
||||||
|
version: '2.0.0',
|
||||||
|
approved_sha: 'abc123',
|
||||||
|
promoted: true,
|
||||||
|
promoted_rank: 1,
|
||||||
|
keywords: ['test', 'module'],
|
||||||
|
};
|
||||||
|
const result = mgr._normalizeCommunityModule(mod);
|
||||||
|
|
||||||
|
assert(result.code === 'tm', 'normalizeCommunityModule sets code');
|
||||||
|
assert(result.displayName === 'Test Module', 'normalizeCommunityModule sets displayName from display_name');
|
||||||
|
assert(result.type === 'community', 'normalizeCommunityModule sets type to community');
|
||||||
|
assert(result.category === 'software-development', 'normalizeCommunityModule preserves category');
|
||||||
|
assert(result.trustTier === 'bmad-certified', 'normalizeCommunityModule maps trust_tier');
|
||||||
|
assert(result.approvedSha === 'abc123', 'normalizeCommunityModule maps approved_sha');
|
||||||
|
assert(result.promoted === true, 'normalizeCommunityModule maps promoted');
|
||||||
|
assert(result.promotedRank === 1, 'normalizeCommunityModule maps promoted_rank');
|
||||||
|
assert(result.builtIn === false, 'normalizeCommunityModule sets builtIn false');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CommunityModuleManager.searchByKeyword (with injected cache) ---
|
||||||
|
{
|
||||||
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
// Inject cached index to avoid network call
|
||||||
|
mgr._cachedIndex = {
|
||||||
|
modules: [
|
||||||
|
{ name: 'mod-a', display_name: 'Alpha', code: 'a', description: 'testing tools', category: 'dev', keywords: ['test'] },
|
||||||
|
{ name: 'mod-b', display_name: 'Beta', code: 'b', description: 'design suite', category: 'design', keywords: ['ux'] },
|
||||||
|
{ name: 'mod-c', display_name: 'Gamma', code: 'c', description: 'game engine', category: 'game', keywords: ['unity'] },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
await installer34.quickUpdate({
|
const r1 = await mgr.searchByKeyword('test');
|
||||||
directory: quickUpdateFixture.root,
|
assert(r1.length === 1 && r1[0].code === 'a', 'searchByKeyword matches keyword');
|
||||||
skipPrompts: true,
|
|
||||||
|
const r2 = await mgr.searchByKeyword('design');
|
||||||
|
assert(r2.length === 1 && r2[0].code === 'b', 'searchByKeyword matches description');
|
||||||
|
|
||||||
|
const r3 = await mgr.searchByKeyword('alpha');
|
||||||
|
assert(r3.length === 1 && r3[0].code === 'a', 'searchByKeyword matches display name');
|
||||||
|
|
||||||
|
const r4 = await mgr.searchByKeyword('xyz');
|
||||||
|
assert(r4.length === 0, 'searchByKeyword returns empty for no match');
|
||||||
|
|
||||||
|
const r5 = await mgr.searchByKeyword('UNITY');
|
||||||
|
assert(r5.length === 1 && r5[0].code === 'c', 'searchByKeyword is case-insensitive');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CommunityModuleManager.listFeatured (with injected cache) ---
|
||||||
|
{
|
||||||
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
mgr._cachedIndex = {
|
||||||
|
modules: [
|
||||||
|
{ name: 'a', code: 'a', promoted: true, promoted_rank: 3 },
|
||||||
|
{ name: 'b', code: 'b', promoted: false },
|
||||||
|
{ name: 'c', code: 'c', promoted: true, promoted_rank: 1 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const featured = await mgr.listFeatured();
|
||||||
|
assert(featured.length === 2, 'listFeatured returns only promoted modules');
|
||||||
|
assert(featured[0].code === 'c' && featured[1].code === 'a', 'listFeatured sorts by promoted_rank ascending');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CommunityModuleManager.getCategoryList (with injected cache) ---
|
||||||
|
{
|
||||||
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
mgr._cachedIndex = {
|
||||||
|
modules: [
|
||||||
|
{ name: 'a', code: 'a', category: 'software-development' },
|
||||||
|
{ name: 'b', code: 'b', category: 'design-and-creative' },
|
||||||
|
{ name: 'c', code: 'c', category: 'software-development' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mgr._cachedCategories = {
|
||||||
|
categories: {
|
||||||
|
'software-development': { name: 'Software Development' },
|
||||||
|
'design-and-creative': { name: 'Design & Creative' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const cats = await mgr.getCategoryList();
|
||||||
|
assert(cats.length === 2, 'getCategoryList returns categories with modules');
|
||||||
|
const swDev = cats.find((c) => c.slug === 'software-development');
|
||||||
|
assert(swDev && swDev.moduleCount === 2, 'getCategoryList counts modules per category');
|
||||||
|
assert(cats[0].name === 'Design & Creative', 'getCategoryList sorts alphabetically');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CommunityModuleManager SHA pinning normalization ---
|
||||||
|
{
|
||||||
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
// Module with SHA set
|
||||||
|
const withSha = mgr._normalizeCommunityModule({
|
||||||
|
name: 'pinned-mod',
|
||||||
|
code: 'pm',
|
||||||
|
approved_sha: 'abc123def456',
|
||||||
|
approved_tag: 'v1.0.0',
|
||||||
});
|
});
|
||||||
|
assert(withSha.approvedSha === 'abc123def456', 'SHA is preserved when set');
|
||||||
|
assert(withSha.approvedTag === 'v1.0.0', 'Tag is preserved as metadata');
|
||||||
|
|
||||||
const customModule34 = capturedInstallConfig34?._customModuleSources?.get('test-module');
|
// Module with null SHA (trusted contributor)
|
||||||
|
const noSha = mgr._normalizeCommunityModule({
|
||||||
|
name: 'trusted-mod',
|
||||||
|
code: 'tm',
|
||||||
|
approved_sha: null,
|
||||||
|
});
|
||||||
|
assert(noSha.approvedSha === null, 'Null SHA means no pinning (trusted contributor)');
|
||||||
|
}
|
||||||
|
|
||||||
assert(capturedInstallConfig34 !== null, 'Quick update forwards config to install');
|
// --- CommunityModuleManager.listByCategory (with injected cache) ---
|
||||||
assert(customModule34 !== undefined, 'Quick update keeps manifest-backed custom module updateable');
|
{
|
||||||
assert(customModule34 && customModule34.cached === false, 'Quick update uses manifest-backed source before cache');
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
mgr._cachedIndex = {
|
||||||
|
modules: [
|
||||||
|
{ name: 'a', code: 'a', category: 'design-and-creative' },
|
||||||
|
{ name: 'b', code: 'b', category: 'software-development' },
|
||||||
|
{ name: 'c', code: 'c', category: 'design-and-creative' },
|
||||||
|
{ name: 'd', code: 'd', category: 'game-development' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const design = await mgr.listByCategory('design-and-creative');
|
||||||
|
assert(design.length === 2, 'listByCategory filters to matching category');
|
||||||
assert(
|
assert(
|
||||||
customModule34 && customModule34.sourcePath === quickUpdateFixture.moduleSourceDir,
|
design.every((m) => m.category === 'design-and-creative'),
|
||||||
'Quick update uses preserved manifest sourcePath for custom modules',
|
'listByCategory returns only matching modules',
|
||||||
);
|
);
|
||||||
} catch (error) {
|
|
||||||
assert(false, 'Quick update manifest-backed custom source test succeeds', error.message);
|
const empty = await mgr.listByCategory('nonexistent');
|
||||||
} finally {
|
assert(empty.length === 0, 'listByCategory returns empty for unknown category');
|
||||||
OfficialModules.prototype.listAvailable = originalListAvailable34;
|
}
|
||||||
OfficialModules.prototype.loadExistingConfig = originalLoadExistingConfig34;
|
|
||||||
OfficialModules.prototype.collectModuleConfigQuick = originalCollectModuleConfigQuick34;
|
// --- CommunityModuleManager.getModuleByCode (with injected cache) ---
|
||||||
if (quickUpdateFixture?.root) await fs.remove(quickUpdateFixture.root).catch(() => {});
|
{
|
||||||
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
mgr._cachedIndex = {
|
||||||
|
modules: [
|
||||||
|
{ name: 'test-mod', code: 'tm', display_name: 'Test Module' },
|
||||||
|
{ name: 'other-mod', code: 'om', display_name: 'Other Module' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const found = await mgr.getModuleByCode('tm');
|
||||||
|
assert(found !== null && found.code === 'tm', 'getModuleByCode finds existing module');
|
||||||
|
|
||||||
|
const notFound = await mgr.getModuleByCode('xyz');
|
||||||
|
assert(notFound === null, 'getModuleByCode returns null for unknown code');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CustomModuleManager URL edge cases ---
|
||||||
|
{
|
||||||
|
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
|
||||||
|
const mgr = new CustomModuleManager();
|
||||||
|
|
||||||
|
// HTTP (not HTTPS) should work
|
||||||
|
const http = mgr.validateGitHubUrl('http://github.com/owner/repo');
|
||||||
|
assert(http.isValid === true, 'validateGitHubUrl accepts HTTP URL');
|
||||||
|
|
||||||
|
// Trailing slash should be rejected (strict matching)
|
||||||
|
const trailing = mgr.validateGitHubUrl('https://github.com/owner/repo/');
|
||||||
|
assert(trailing.isValid === false, 'validateGitHubUrl rejects trailing slash');
|
||||||
|
|
||||||
|
// SSH without .git should work
|
||||||
|
const sshNoDotGit = mgr.validateGitHubUrl('git@github.com:owner/repo');
|
||||||
|
assert(sshNoDotGit.isValid === true, 'validateGitHubUrl accepts SSH without .git');
|
||||||
|
assert(sshNoDotGit.repo === 'repo', 'validateGitHubUrl extracts repo from SSH without .git');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ module.exports = {
|
||||||
'--tools <tools>',
|
'--tools <tools>',
|
||||||
'Comma-separated list of tool/IDE IDs to configure (e.g., "claude-code,cursor"). Use "none" to skip tool configuration.',
|
'Comma-separated list of tool/IDE IDs to configure (e.g., "claude-code,cursor"). Use "none" to skip tool configuration.',
|
||||||
],
|
],
|
||||||
['--custom-content <paths>', 'Comma-separated list of paths to custom modules/agents/workflows'],
|
|
||||||
['--action <type>', 'Action type for existing installations: install, update, or quick-update'],
|
['--action <type>', 'Action type for existing installations: install, update, or quick-update'],
|
||||||
['--user-name <name>', 'Name for agents to use (default: system username)'],
|
['--user-name <name>', 'Name for agents to use (default: system username)'],
|
||||||
['--communication-language <lang>', 'Language for agent communication (default: English)'],
|
['--communication-language <lang>', 'Language for agent communication (default: English)'],
|
||||||
|
|
|
||||||
|
|
@ -1,260 +0,0 @@
|
||||||
/**
|
|
||||||
* Custom Module Source Cache
|
|
||||||
* Caches custom module sources under _config/custom/ to ensure they're never lost
|
|
||||||
* and can be checked into source control
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('fs-extra');
|
|
||||||
const path = require('node:path');
|
|
||||||
const crypto = require('node:crypto');
|
|
||||||
const prompts = require('../prompts');
|
|
||||||
|
|
||||||
class CustomModuleCache {
|
|
||||||
constructor(bmadDir) {
|
|
||||||
this.bmadDir = bmadDir;
|
|
||||||
this.customCacheDir = path.join(bmadDir, '_config', 'custom');
|
|
||||||
this.manifestPath = path.join(this.customCacheDir, 'cache-manifest.yaml');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure the custom cache directory exists
|
|
||||||
*/
|
|
||||||
async ensureCacheDir() {
|
|
||||||
await fs.ensureDir(this.customCacheDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cache manifest
|
|
||||||
*/
|
|
||||||
async getCacheManifest() {
|
|
||||||
if (!(await fs.pathExists(this.manifestPath))) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = await fs.readFile(this.manifestPath, 'utf8');
|
|
||||||
const yaml = require('yaml');
|
|
||||||
return yaml.parse(content) || {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update cache manifest
|
|
||||||
*/
|
|
||||||
async updateCacheManifest(manifest) {
|
|
||||||
const yaml = require('yaml');
|
|
||||||
// Clean the manifest to remove any non-serializable values
|
|
||||||
const cleanManifest = structuredClone(manifest);
|
|
||||||
|
|
||||||
const content = yaml.stringify(cleanManifest, {
|
|
||||||
indent: 2,
|
|
||||||
lineWidth: 0,
|
|
||||||
sortKeys: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await fs.writeFile(this.manifestPath, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stream a file into the hash to avoid loading entire file into memory
|
|
||||||
*/
|
|
||||||
async hashFileStream(filePath, hash) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const stream = require('node:fs').createReadStream(filePath);
|
|
||||||
stream.on('data', (chunk) => hash.update(chunk));
|
|
||||||
stream.on('end', resolve);
|
|
||||||
stream.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate hash of a file or directory using streaming to minimize memory usage
|
|
||||||
*/
|
|
||||||
async calculateHash(sourcePath) {
|
|
||||||
const hash = crypto.createHash('sha256');
|
|
||||||
|
|
||||||
const isDir = (await fs.stat(sourcePath)).isDirectory();
|
|
||||||
|
|
||||||
if (isDir) {
|
|
||||||
// For directories, hash all files
|
|
||||||
const files = [];
|
|
||||||
async function collectFiles(dir) {
|
|
||||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isFile()) {
|
|
||||||
files.push(path.join(dir, entry.name));
|
|
||||||
} else if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
||||||
await collectFiles(path.join(dir, entry.name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await collectFiles(sourcePath);
|
|
||||||
files.sort(); // Ensure consistent order
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const relativePath = path.relative(sourcePath, file);
|
|
||||||
// Hash the path first, then stream file contents
|
|
||||||
hash.update(relativePath + '|');
|
|
||||||
await this.hashFileStream(file, hash);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For single files, stream directly into hash
|
|
||||||
await this.hashFileStream(sourcePath, hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
return hash.digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache a custom module source
|
|
||||||
* @param {string} moduleId - Module ID
|
|
||||||
* @param {string} sourcePath - Original source path
|
|
||||||
* @param {Object} metadata - Additional metadata to store
|
|
||||||
* @returns {Object} Cached module info
|
|
||||||
*/
|
|
||||||
async cacheModule(moduleId, sourcePath, metadata = {}) {
|
|
||||||
await this.ensureCacheDir();
|
|
||||||
|
|
||||||
const cacheDir = path.join(this.customCacheDir, moduleId);
|
|
||||||
const cacheManifest = await this.getCacheManifest();
|
|
||||||
|
|
||||||
// Check if already cached and unchanged
|
|
||||||
if (cacheManifest[moduleId]) {
|
|
||||||
const cached = cacheManifest[moduleId];
|
|
||||||
if (cached.originalHash && cached.originalHash === (await this.calculateHash(sourcePath))) {
|
|
||||||
// Source unchanged, return existing cache info
|
|
||||||
return {
|
|
||||||
moduleId,
|
|
||||||
cachePath: cacheDir,
|
|
||||||
...cached,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove existing cache if it exists
|
|
||||||
if (await fs.pathExists(cacheDir)) {
|
|
||||||
await fs.remove(cacheDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy module to cache
|
|
||||||
await fs.copy(sourcePath, cacheDir, {
|
|
||||||
filter: (src) => {
|
|
||||||
const relative = path.relative(sourcePath, src);
|
|
||||||
// Skip node_modules, .git, and other common ignore patterns
|
|
||||||
return !relative.includes('node_modules') && !relative.startsWith('.git') && !relative.startsWith('.DS_Store');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate hash of the source
|
|
||||||
const sourceHash = await this.calculateHash(sourcePath);
|
|
||||||
const cacheHash = await this.calculateHash(cacheDir);
|
|
||||||
|
|
||||||
// Update manifest - don't store absolute paths for portability
|
|
||||||
// Clean metadata to remove absolute paths
|
|
||||||
const cleanMetadata = { ...metadata };
|
|
||||||
if (cleanMetadata.sourcePath) {
|
|
||||||
delete cleanMetadata.sourcePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
cacheManifest[moduleId] = {
|
|
||||||
originalHash: sourceHash,
|
|
||||||
cacheHash: cacheHash,
|
|
||||||
cachedAt: new Date().toISOString(),
|
|
||||||
...cleanMetadata,
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.updateCacheManifest(cacheManifest);
|
|
||||||
|
|
||||||
return {
|
|
||||||
moduleId,
|
|
||||||
cachePath: cacheDir,
|
|
||||||
...cacheManifest[moduleId],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cached module info
|
|
||||||
* @param {string} moduleId - Module ID
|
|
||||||
* @returns {Object|null} Cached module info or null
|
|
||||||
*/
|
|
||||||
async getCachedModule(moduleId) {
|
|
||||||
const cacheManifest = await this.getCacheManifest();
|
|
||||||
const cached = cacheManifest[moduleId];
|
|
||||||
|
|
||||||
if (!cached) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheDir = path.join(this.customCacheDir, moduleId);
|
|
||||||
|
|
||||||
if (!(await fs.pathExists(cacheDir))) {
|
|
||||||
// Cache dir missing, remove from manifest
|
|
||||||
delete cacheManifest[moduleId];
|
|
||||||
await this.updateCacheManifest(cacheManifest);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify cache integrity
|
|
||||||
const currentCacheHash = await this.calculateHash(cacheDir);
|
|
||||||
if (currentCacheHash !== cached.cacheHash) {
|
|
||||||
await prompts.log.warn(`Cache integrity check failed for ${moduleId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
moduleId,
|
|
||||||
cachePath: cacheDir,
|
|
||||||
...cached,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all cached modules
|
|
||||||
* @returns {Array} Array of cached module info
|
|
||||||
*/
|
|
||||||
async getAllCachedModules() {
|
|
||||||
const cacheManifest = await this.getCacheManifest();
|
|
||||||
const cached = [];
|
|
||||||
|
|
||||||
for (const [moduleId, info] of Object.entries(cacheManifest)) {
|
|
||||||
const cachedModule = await this.getCachedModule(moduleId);
|
|
||||||
if (cachedModule) {
|
|
||||||
cached.push(cachedModule);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a cached module
|
|
||||||
* @param {string} moduleId - Module ID to remove
|
|
||||||
*/
|
|
||||||
async removeCachedModule(moduleId) {
|
|
||||||
const cacheManifest = await this.getCacheManifest();
|
|
||||||
const cacheDir = path.join(this.customCacheDir, moduleId);
|
|
||||||
|
|
||||||
// Remove cache directory
|
|
||||||
if (await fs.pathExists(cacheDir)) {
|
|
||||||
await fs.remove(cacheDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from manifest
|
|
||||||
delete cacheManifest[moduleId];
|
|
||||||
await this.updateCacheManifest(cacheManifest);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync cached modules with a list of module IDs
|
|
||||||
* @param {Array<string>} moduleIds - Module IDs to keep
|
|
||||||
*/
|
|
||||||
async syncCache(moduleIds) {
|
|
||||||
const cached = await this.getAllCachedModules();
|
|
||||||
|
|
||||||
for (const cachedModule of cached) {
|
|
||||||
if (!moduleIds.includes(cachedModule.moduleId)) {
|
|
||||||
await this.removeCachedModule(cachedModule.moduleId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { CustomModuleCache };
|
|
||||||
|
|
@ -10,14 +10,13 @@ const { Manifest } = require('./manifest');
|
||||||
class ExistingInstall {
|
class ExistingInstall {
|
||||||
#version;
|
#version;
|
||||||
|
|
||||||
constructor({ installed, version, hasCore, modules, ides, customModules }) {
|
constructor({ installed, version, hasCore, modules, ides }) {
|
||||||
this.installed = installed;
|
this.installed = installed;
|
||||||
this.#version = version;
|
this.#version = version;
|
||||||
this.hasCore = hasCore;
|
this.hasCore = hasCore;
|
||||||
this.modules = Object.freeze(modules.map((m) => Object.freeze({ ...m })));
|
this.modules = Object.freeze(modules.map((m) => Object.freeze({ ...m })));
|
||||||
this.moduleIds = Object.freeze(this.modules.map((m) => m.id));
|
this.moduleIds = Object.freeze(this.modules.map((m) => m.id));
|
||||||
this.ides = Object.freeze([...ides]);
|
this.ides = Object.freeze([...ides]);
|
||||||
this.customModules = Object.freeze([...customModules]);
|
|
||||||
Object.freeze(this);
|
Object.freeze(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,7 +34,6 @@ class ExistingInstall {
|
||||||
hasCore: false,
|
hasCore: false,
|
||||||
modules: [],
|
modules: [],
|
||||||
ides: [],
|
ides: [],
|
||||||
customModules: [],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,15 +51,11 @@ class ExistingInstall {
|
||||||
let hasCore = false;
|
let hasCore = false;
|
||||||
const modules = [];
|
const modules = [];
|
||||||
let ides = [];
|
let ides = [];
|
||||||
let customModules = [];
|
|
||||||
|
|
||||||
const manifest = new Manifest();
|
const manifest = new Manifest();
|
||||||
const manifestData = await manifest.read(bmadDir);
|
const manifestData = await manifest.read(bmadDir);
|
||||||
if (manifestData) {
|
if (manifestData) {
|
||||||
version = manifestData.version;
|
version = manifestData.version;
|
||||||
if (manifestData.customModules) {
|
|
||||||
customModules = manifestData.customModules;
|
|
||||||
}
|
|
||||||
if (manifestData.ides) {
|
if (manifestData.ides) {
|
||||||
ides = manifestData.ides.filter((ide) => ide && typeof ide === 'string');
|
ides = manifestData.ides.filter((ide) => ide && typeof ide === 'string');
|
||||||
}
|
}
|
||||||
|
|
@ -120,7 +114,7 @@ class ExistingInstall {
|
||||||
return ExistingInstall.empty();
|
return ExistingInstall.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ExistingInstall({ installed, version, hasCore, modules, ides, customModules });
|
return new ExistingInstall({ installed, version, hasCore, modules, ides });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,12 @@ class InstallPaths {
|
||||||
|
|
||||||
const configDir = path.join(bmadDir, '_config');
|
const configDir = path.join(bmadDir, '_config');
|
||||||
const agentsDir = path.join(configDir, 'agents');
|
const agentsDir = path.join(configDir, 'agents');
|
||||||
const customCacheDir = path.join(configDir, 'custom');
|
|
||||||
const coreDir = path.join(bmadDir, 'core');
|
const coreDir = path.join(bmadDir, 'core');
|
||||||
|
|
||||||
for (const [dir, label] of [
|
for (const [dir, label] of [
|
||||||
[bmadDir, 'bmad directory'],
|
[bmadDir, 'bmad directory'],
|
||||||
[configDir, 'config directory'],
|
[configDir, 'config directory'],
|
||||||
[agentsDir, 'agents config directory'],
|
[agentsDir, 'agents config directory'],
|
||||||
[customCacheDir, 'custom modules cache'],
|
|
||||||
[coreDir, 'core module directory'],
|
[coreDir, 'core module directory'],
|
||||||
]) {
|
]) {
|
||||||
await ensureWritableDir(dir, label);
|
await ensureWritableDir(dir, label);
|
||||||
|
|
@ -40,7 +38,6 @@ class InstallPaths {
|
||||||
bmadDir,
|
bmadDir,
|
||||||
configDir,
|
configDir,
|
||||||
agentsDir,
|
agentsDir,
|
||||||
customCacheDir,
|
|
||||||
coreDir,
|
coreDir,
|
||||||
isUpdate,
|
isUpdate,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ const path = require('node:path');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const { Manifest } = require('./manifest');
|
const { Manifest } = require('./manifest');
|
||||||
const { OfficialModules } = require('../modules/official-modules');
|
const { OfficialModules } = require('../modules/official-modules');
|
||||||
const { CustomModules } = require('../modules/custom-modules');
|
|
||||||
const { IdeManager } = require('../ide/manager');
|
const { IdeManager } = require('../ide/manager');
|
||||||
const { FileOps } = require('../file-ops');
|
const { FileOps } = require('../file-ops');
|
||||||
const { Config } = require('./config');
|
const { Config } = require('./config');
|
||||||
|
|
@ -19,7 +18,6 @@ class Installer {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.externalModuleManager = new ExternalModuleManager();
|
this.externalModuleManager = new ExternalModuleManager();
|
||||||
this.manifest = new Manifest();
|
this.manifest = new Manifest();
|
||||||
this.customModules = new CustomModules();
|
|
||||||
this.ideManager = new IdeManager();
|
this.ideManager = new IdeManager();
|
||||||
this.fileOps = new FileOps();
|
this.fileOps = new FileOps();
|
||||||
this.installedFiles = new Set(); // Track all installed files
|
this.installedFiles = new Set(); // Track all installed files
|
||||||
|
|
@ -80,8 +78,6 @@ class Installer {
|
||||||
const officialModules = await OfficialModules.build(config, paths);
|
const officialModules = await OfficialModules.build(config, paths);
|
||||||
const existingInstall = await ExistingInstall.detect(paths.bmadDir);
|
const existingInstall = await ExistingInstall.detect(paths.bmadDir);
|
||||||
|
|
||||||
await this.customModules.discoverPaths(originalConfig, paths);
|
|
||||||
|
|
||||||
if (existingInstall.installed) {
|
if (existingInstall.installed) {
|
||||||
await this._removeDeselectedModules(existingInstall, config, paths);
|
await this._removeDeselectedModules(existingInstall, config, paths);
|
||||||
updateState = await this._prepareUpdateState(paths, config, existingInstall, officialModules);
|
updateState = await this._prepareUpdateState(paths, config, existingInstall, officialModules);
|
||||||
|
|
@ -121,17 +117,16 @@ class Installer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._cacheCustomModules(paths, addResult);
|
const allModules = config.modules || [];
|
||||||
|
|
||||||
// Compute module lists: official = selected minus custom, all = both
|
await this._installAndConfigure(config, originalConfig, paths, allModules, allModules, addResult, officialModules);
|
||||||
const customModuleIds = new Set(this.customModules.paths.keys());
|
|
||||||
const officialModuleIds = (config.modules || []).filter((m) => !customModuleIds.has(m));
|
|
||||||
const allModules = [...officialModuleIds, ...[...customModuleIds].filter((id) => !officialModuleIds.includes(id))];
|
|
||||||
|
|
||||||
await this._installAndConfigure(config, originalConfig, paths, officialModuleIds, allModules, addResult, officialModules);
|
|
||||||
|
|
||||||
await this._setupIdes(config, allModules, paths, addResult, previousSkillIds);
|
await this._setupIdes(config, allModules, paths, addResult, previousSkillIds);
|
||||||
|
|
||||||
|
// Skills are now in IDE directories — remove redundant copies from _bmad/.
|
||||||
|
// Also cleans up skill dirs left by older installer versions.
|
||||||
|
await this._cleanupSkillDirs(paths.bmadDir);
|
||||||
|
|
||||||
const restoreResult = await this._restoreUserFiles(paths, updateState);
|
const restoreResult = await this._restoreUserFiles(paths, updateState);
|
||||||
|
|
||||||
// Render consolidated summary
|
// Render consolidated summary
|
||||||
|
|
@ -238,26 +233,6 @@ class Installer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache custom modules into the local cache directory.
|
|
||||||
* Updates this.customModules.paths in place with cached locations.
|
|
||||||
*/
|
|
||||||
async _cacheCustomModules(paths, addResult) {
|
|
||||||
if (!this.customModules.paths || this.customModules.paths.size === 0) return;
|
|
||||||
|
|
||||||
const { CustomModuleCache } = require('./custom-module-cache');
|
|
||||||
const customCache = new CustomModuleCache(paths.bmadDir);
|
|
||||||
|
|
||||||
for (const [moduleId, sourcePath] of this.customModules.paths) {
|
|
||||||
const cachedInfo = await customCache.cacheModule(moduleId, sourcePath, {
|
|
||||||
sourcePath: sourcePath,
|
|
||||||
});
|
|
||||||
this.customModules.paths.set(moduleId, cachedInfo.cachePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
addResult('Custom modules cached', 'ok');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install modules, create directories, generate configs and manifests.
|
* Install modules, create directories, generate configs and manifests.
|
||||||
*/
|
*/
|
||||||
|
|
@ -280,11 +255,6 @@ class Installer {
|
||||||
installedModuleNames,
|
installedModuleNames,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this._installCustomModules(config, paths, addResult, officialModules, {
|
|
||||||
message,
|
|
||||||
installedModuleNames,
|
|
||||||
});
|
|
||||||
|
|
||||||
return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`;
|
return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -413,6 +383,33 @@ class Installer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove skill directories from _bmad/ after IDE installation.
|
||||||
|
* Skills are self-contained in IDE directories, so _bmad/ only needs
|
||||||
|
* module-level files (config.yaml, _config/, etc.).
|
||||||
|
* Also cleans up skill dirs left by older installer versions.
|
||||||
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
|
*/
|
||||||
|
async _cleanupSkillDirs(bmadDir) {
|
||||||
|
const csv = require('csv-parse/sync');
|
||||||
|
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
|
||||||
|
if (!(await fs.pathExists(csvPath))) return;
|
||||||
|
|
||||||
|
const csvContent = await fs.readFile(csvPath, 'utf8');
|
||||||
|
const records = csv.parse(csvContent, { columns: true, skip_empty_lines: true });
|
||||||
|
const bmadFolderName = path.basename(bmadDir);
|
||||||
|
const bmadPrefix = bmadFolderName + '/';
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
if (!record.path) continue;
|
||||||
|
const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path;
|
||||||
|
const sourceDir = path.dirname(path.join(bmadDir, relativePath));
|
||||||
|
if (await fs.pathExists(sourceDir)) {
|
||||||
|
await fs.remove(sourceDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore custom and modified files that were backed up before the update.
|
* Restore custom and modified files that were backed up before the update.
|
||||||
* No-op for fresh installs (updateState is null).
|
* No-op for fresh installs (updateState is null).
|
||||||
|
|
@ -484,48 +481,7 @@ class Installer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan the custom module cache directory and register any cached custom modules
|
* Common update preparation: detect files, preserve core config, back up.
|
||||||
* that aren't already known from the manifest or external module list.
|
|
||||||
* @param {Object} paths - InstallPaths instance
|
|
||||||
*/
|
|
||||||
async _scanCachedCustomModules(paths) {
|
|
||||||
const cacheDir = paths.customCacheDir;
|
|
||||||
if (!(await fs.pathExists(cacheDir))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const cachedModule of cachedModules) {
|
|
||||||
const moduleId = cachedModule.name;
|
|
||||||
const cachedPath = path.join(cacheDir, moduleId);
|
|
||||||
|
|
||||||
// Skip if path doesn't exist (broken symlink, deleted dir) - avoids lstat ENOENT
|
|
||||||
if (!(await fs.pathExists(cachedPath)) || !cachedModule.isDirectory()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if we already have this module from manifest
|
|
||||||
if (this.customModules.paths.has(moduleId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is an external official module - skip cache for those
|
|
||||||
const isExternal = await this.externalModuleManager.hasModule(moduleId);
|
|
||||||
if (isExternal) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is actually a custom module (has module.yaml)
|
|
||||||
const moduleYamlPath = path.join(cachedPath, 'module.yaml');
|
|
||||||
if (await fs.pathExists(moduleYamlPath)) {
|
|
||||||
this.customModules.paths.set(moduleId, cachedPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Common update preparation: detect files, preserve core config, scan cache, back up.
|
|
||||||
* @param {Object} paths - InstallPaths instance
|
* @param {Object} paths - InstallPaths instance
|
||||||
* @param {Object} config - Clean config (may have coreConfig updated)
|
* @param {Object} config - Clean config (may have coreConfig updated)
|
||||||
* @param {Object} existingInstall - Detection result
|
* @param {Object} existingInstall - Detection result
|
||||||
|
|
@ -553,8 +509,6 @@ class Installer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._scanCachedCustomModules(paths);
|
|
||||||
|
|
||||||
const backupDirs = await this._backupUserFiles(paths, customFiles, modifiedFiles);
|
const backupDirs = await this._backupUserFiles(paths, customFiles, modifiedFiles);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -646,38 +600,6 @@ class Installer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Install custom modules using CustomModules.install().
|
|
||||||
* Source paths come from this.customModules.paths (populated by discoverPaths).
|
|
||||||
*/
|
|
||||||
async _installCustomModules(config, paths, addResult, officialModules, ctx) {
|
|
||||||
const { message, installedModuleNames } = ctx;
|
|
||||||
const isQuickUpdate = config.isQuickUpdate();
|
|
||||||
|
|
||||||
for (const [moduleName, sourcePath] of this.customModules.paths) {
|
|
||||||
if (installedModuleNames.has(moduleName)) continue;
|
|
||||||
installedModuleNames.add(moduleName);
|
|
||||||
|
|
||||||
message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`);
|
|
||||||
|
|
||||||
const collectedModuleConfig = officialModules.moduleConfigs[moduleName] || {};
|
|
||||||
const result = await this.customModules.install(moduleName, paths.bmadDir, (filePath) => this.installedFiles.add(filePath), {
|
|
||||||
moduleConfig: collectedModuleConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate runtime config.yaml with merged values
|
|
||||||
await this.generateModuleConfigs(paths.bmadDir, {
|
|
||||||
[moduleName]: { ...config.coreConfig, ...result.moduleConfig, ...collectedModuleConfig },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get display name from source module.yaml; version from marketplace.json
|
|
||||||
const moduleInfo = await officialModules.getModuleInfo(sourcePath, moduleName, '');
|
|
||||||
const displayName = moduleInfo?.name || moduleName;
|
|
||||||
const version = await this._getMarketplaceVersion(sourcePath);
|
|
||||||
addResult(displayName, 'ok', '', { moduleCode: moduleName, newVersion: version });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read files-manifest.csv
|
* Read files-manifest.csv
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
|
|
@ -1047,6 +969,14 @@ class Installer {
|
||||||
outputs,
|
outputs,
|
||||||
] = columns;
|
] = columns;
|
||||||
|
|
||||||
|
// Pass through _meta rows as-is (module metadata, not a skill)
|
||||||
|
if (phase === '_meta') {
|
||||||
|
const finalModule = (!module || module.trim() === '') && moduleName !== 'core' ? moduleName : module || '';
|
||||||
|
const metaRow = [finalModule, '_meta', '', '', '', '', '', 'false', '', '', '', '', '', '', outputLocation || '', ''];
|
||||||
|
allRows.push(metaRow.map((c) => this.escapeCSVField(c)).join(','));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// If module column is empty, set it to this module's name (except for core which stays empty for universal tools)
|
// If module column is empty, set it to this module's name (except for core which stays empty for universal tools)
|
||||||
const finalModule = (!module || module.trim() === '') && moduleName !== 'core' ? moduleName : module || '';
|
const finalModule = (!module || module.trim() === '') && moduleName !== 'core' ? moduleName : module || '';
|
||||||
|
|
||||||
|
|
@ -1222,16 +1152,9 @@ class Installer {
|
||||||
const configuredIdes = existingInstall.ides;
|
const configuredIdes = existingInstall.ides;
|
||||||
const projectRoot = path.dirname(bmadDir);
|
const projectRoot = path.dirname(bmadDir);
|
||||||
|
|
||||||
const customModuleSources = await this.customModules.assembleQuickUpdateSources(
|
|
||||||
config,
|
|
||||||
existingInstall,
|
|
||||||
bmadDir,
|
|
||||||
this.externalModuleManager,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get available modules (what we have source for)
|
// Get available modules (what we have source for)
|
||||||
const availableModulesData = await new OfficialModules().listAvailable();
|
const availableModulesData = await new OfficialModules().listAvailable();
|
||||||
const availableModules = [...availableModulesData.modules, ...availableModulesData.customModules];
|
const availableModules = [...availableModulesData.modules];
|
||||||
|
|
||||||
// Add external official modules to available modules
|
// Add external official modules to available modules
|
||||||
const externalModules = await this.externalModuleManager.listAvailable();
|
const externalModules = await this.externalModuleManager.listAvailable();
|
||||||
|
|
@ -1246,52 +1169,44 @@ class Installer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add custom modules from manifest if their sources exist
|
// Add installed community modules to available modules
|
||||||
for (const [moduleId, customModule] of customModuleSources) {
|
const { CommunityModuleManager } = require('../modules/community-manager');
|
||||||
const sourcePath = customModule.sourcePath;
|
const communityMgr = new CommunityModuleManager();
|
||||||
if (sourcePath && (await fs.pathExists(sourcePath)) && !availableModules.some((m) => m.id === moduleId)) {
|
const communityModules = await communityMgr.listAll();
|
||||||
|
for (const communityModule of communityModules) {
|
||||||
|
if (installedModules.includes(communityModule.code) && !availableModules.some((m) => m.id === communityModule.code)) {
|
||||||
availableModules.push({
|
availableModules.push({
|
||||||
id: moduleId,
|
id: communityModule.code,
|
||||||
name: customModule.name || moduleId,
|
name: communityModule.displayName,
|
||||||
path: sourcePath,
|
isExternal: true,
|
||||||
isCustom: true,
|
fromCommunity: true,
|
||||||
fromManifest: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle missing custom module sources
|
// Add installed custom modules to available modules
|
||||||
const customModuleResult = await this.handleMissingCustomSources(
|
const { CustomModuleManager } = require('../modules/custom-module-manager');
|
||||||
customModuleSources,
|
const customMgr = new CustomModuleManager();
|
||||||
bmadDir,
|
for (const moduleId of installedModules) {
|
||||||
projectRoot,
|
if (!availableModules.some((m) => m.id === moduleId)) {
|
||||||
'update',
|
const customSource = await customMgr.findModuleSourceByCode(moduleId);
|
||||||
installedModules,
|
if (customSource) {
|
||||||
config.skipPrompts || false,
|
availableModules.push({
|
||||||
);
|
id: moduleId,
|
||||||
|
name: moduleId,
|
||||||
|
isExternal: true,
|
||||||
|
fromCustom: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { validCustomModules, keptModulesWithoutSources } = customModuleResult;
|
const availableModuleIds = new Set(availableModules.map((m) => m.id));
|
||||||
|
|
||||||
const customModulesFromManifest = validCustomModules.map((m) => ({
|
|
||||||
...m,
|
|
||||||
isCustom: true,
|
|
||||||
hasUpdate: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const allAvailableModules = [...availableModules, ...customModulesFromManifest];
|
|
||||||
const availableModuleIds = new Set(allAvailableModules.map((m) => m.id));
|
|
||||||
|
|
||||||
// Only update modules that are BOTH installed AND available (we have source for)
|
// Only update modules that are BOTH installed AND available (we have source for)
|
||||||
const modulesToUpdate = installedModules.filter((id) => availableModuleIds.has(id));
|
const modulesToUpdate = installedModules.filter((id) => availableModuleIds.has(id));
|
||||||
const skippedModules = installedModules.filter((id) => !availableModuleIds.has(id));
|
const skippedModules = installedModules.filter((id) => !availableModuleIds.has(id));
|
||||||
|
|
||||||
// Add custom modules that were kept without sources to the skipped modules
|
|
||||||
for (const keptModule of keptModulesWithoutSources) {
|
|
||||||
if (!skippedModules.includes(keptModule)) {
|
|
||||||
skippedModules.push(keptModule);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (skippedModules.length > 0) {
|
if (skippedModules.length > 0) {
|
||||||
await prompts.log.warn(`Skipping ${skippedModules.length} module(s) - no source available: ${skippedModules.join(', ')}`);
|
await prompts.log.warn(`Skipping ${skippedModules.length} module(s) - no source available: ${skippedModules.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
@ -1336,9 +1251,7 @@ class Installer {
|
||||||
actionType: 'install',
|
actionType: 'install',
|
||||||
_quickUpdate: true,
|
_quickUpdate: true,
|
||||||
_preserveModules: skippedModules,
|
_preserveModules: skippedModules,
|
||||||
_customModuleSources: customModuleSources,
|
|
||||||
_existingModules: installedModules,
|
_existingModules: installedModules,
|
||||||
customContent: config.customContent,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.install(installConfig);
|
await this.install(installConfig);
|
||||||
|
|
@ -1473,239 +1386,6 @@ class Installer {
|
||||||
return this._readOutputFolder(bmadDir);
|
return this._readOutputFolder(bmadDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle missing custom module sources interactively
|
|
||||||
* @param {Map} customModuleSources - Map of custom module ID to info
|
|
||||||
* @param {string} bmadDir - BMAD directory
|
|
||||||
* @param {string} projectRoot - Project root directory
|
|
||||||
* @param {string} operation - Current operation ('update', 'compile', etc.)
|
|
||||||
* @param {Array} installedModules - Array of installed module IDs (will be modified)
|
|
||||||
* @param {boolean} [skipPrompts=false] - Skip interactive prompts and keep all modules with missing sources
|
|
||||||
* @returns {Object} Object with validCustomModules array and keptModulesWithoutSources array
|
|
||||||
*/
|
|
||||||
async handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, operation, installedModules, skipPrompts = false) {
|
|
||||||
const validCustomModules = [];
|
|
||||||
const keptModulesWithoutSources = []; // Track modules kept without sources
|
|
||||||
const customModulesWithMissingSources = [];
|
|
||||||
|
|
||||||
// Check which sources exist
|
|
||||||
for (const [moduleId, customInfo] of customModuleSources) {
|
|
||||||
if (await fs.pathExists(customInfo.sourcePath)) {
|
|
||||||
validCustomModules.push({
|
|
||||||
id: moduleId,
|
|
||||||
name: customInfo.name,
|
|
||||||
path: customInfo.sourcePath,
|
|
||||||
info: customInfo,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// For cached modules that are missing, we just skip them without prompting
|
|
||||||
if (customInfo.cached) {
|
|
||||||
// Skip cached modules without prompting
|
|
||||||
keptModulesWithoutSources.push({
|
|
||||||
id: moduleId,
|
|
||||||
name: customInfo.name,
|
|
||||||
cached: true,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
customModulesWithMissingSources.push({
|
|
||||||
id: moduleId,
|
|
||||||
name: customInfo.name,
|
|
||||||
sourcePath: customInfo.sourcePath,
|
|
||||||
relativePath: customInfo.relativePath,
|
|
||||||
info: customInfo,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no missing sources, return immediately
|
|
||||||
if (customModulesWithMissingSources.length === 0) {
|
|
||||||
return {
|
|
||||||
validCustomModules,
|
|
||||||
keptModulesWithoutSources: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-interactive mode: keep all modules with missing sources
|
|
||||||
if (skipPrompts) {
|
|
||||||
for (const missing of customModulesWithMissingSources) {
|
|
||||||
keptModulesWithoutSources.push(missing.id);
|
|
||||||
}
|
|
||||||
return { validCustomModules, keptModulesWithoutSources };
|
|
||||||
}
|
|
||||||
|
|
||||||
await prompts.log.warn(`Found ${customModulesWithMissingSources.length} custom module(s) with missing sources:`);
|
|
||||||
|
|
||||||
let keptCount = 0;
|
|
||||||
let updatedCount = 0;
|
|
||||||
let removedCount = 0;
|
|
||||||
|
|
||||||
for (const missing of customModulesWithMissingSources) {
|
|
||||||
await prompts.log.message(
|
|
||||||
`${missing.name} (${missing.id})\n Original source: ${missing.relativePath}\n Full path: ${missing.sourcePath}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const choices = [
|
|
||||||
{
|
|
||||||
name: 'Keep installed (will not be processed)',
|
|
||||||
value: 'keep',
|
|
||||||
hint: 'Keep',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Specify new source location',
|
|
||||||
value: 'update',
|
|
||||||
hint: 'Update',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Only add remove option if not just compiling agents
|
|
||||||
if (operation !== 'compile-agents') {
|
|
||||||
choices.push({
|
|
||||||
name: '⚠️ REMOVE module completely (destructive!)',
|
|
||||||
value: 'remove',
|
|
||||||
hint: 'Remove',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const action = await prompts.select({
|
|
||||||
message: `How would you like to handle "${missing.name}"?`,
|
|
||||||
choices,
|
|
||||||
});
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'update': {
|
|
||||||
// Use sync validation because @clack/prompts doesn't support async validate
|
|
||||||
const newSourcePath = await prompts.text({
|
|
||||||
message: 'Enter the new path to the custom module:',
|
|
||||||
default: missing.sourcePath,
|
|
||||||
validate: (input) => {
|
|
||||||
if (!input || input.trim() === '') {
|
|
||||||
return 'Please enter a path';
|
|
||||||
}
|
|
||||||
const expandedPath = path.resolve(input.trim());
|
|
||||||
if (!fs.pathExistsSync(expandedPath)) {
|
|
||||||
return 'Path does not exist';
|
|
||||||
}
|
|
||||||
// Check if it looks like a valid module
|
|
||||||
const moduleYamlPath = path.join(expandedPath, 'module.yaml');
|
|
||||||
const agentsPath = path.join(expandedPath, 'agents');
|
|
||||||
const workflowsPath = path.join(expandedPath, 'workflows');
|
|
||||||
|
|
||||||
if (!fs.pathExistsSync(moduleYamlPath) && !fs.pathExistsSync(agentsPath) && !fs.pathExistsSync(workflowsPath)) {
|
|
||||||
return 'Path does not appear to contain a valid custom module';
|
|
||||||
}
|
|
||||||
return; // clack expects undefined for valid input
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Defensive: handleCancel should have exited, but guard against symbol propagation
|
|
||||||
if (typeof newSourcePath !== 'string') {
|
|
||||||
keptCount++;
|
|
||||||
keptModulesWithoutSources.push(missing.id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the source in manifest
|
|
||||||
const resolvedPath = path.resolve(newSourcePath.trim());
|
|
||||||
missing.info.sourcePath = resolvedPath;
|
|
||||||
// Remove relativePath - we only store absolute sourcePath now
|
|
||||||
delete missing.info.relativePath;
|
|
||||||
await this.manifest.addCustomModule(bmadDir, missing.info);
|
|
||||||
|
|
||||||
validCustomModules.push({
|
|
||||||
id: missing.id,
|
|
||||||
name: missing.name,
|
|
||||||
path: resolvedPath,
|
|
||||||
info: missing.info,
|
|
||||||
});
|
|
||||||
|
|
||||||
updatedCount++;
|
|
||||||
await prompts.log.success('Updated source location');
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'remove': {
|
|
||||||
// Extra confirmation for destructive remove
|
|
||||||
await prompts.log.error(
|
|
||||||
`WARNING: This will PERMANENTLY DELETE "${missing.name}" and all its files!\n Module location: ${path.join(bmadDir, missing.id)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const confirmDelete = await prompts.confirm({
|
|
||||||
message: 'Are you absolutely sure you want to delete this module?',
|
|
||||||
default: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (confirmDelete) {
|
|
||||||
const typedConfirm = await prompts.text({
|
|
||||||
message: 'Type "DELETE" to confirm permanent deletion:',
|
|
||||||
validate: (input) => {
|
|
||||||
if (input !== 'DELETE') {
|
|
||||||
return 'You must type "DELETE" exactly to proceed';
|
|
||||||
}
|
|
||||||
return; // clack expects undefined for valid input
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (typedConfirm === 'DELETE') {
|
|
||||||
// Remove the module from filesystem and manifest
|
|
||||||
const modulePath = path.join(bmadDir, missing.id);
|
|
||||||
if (await fs.pathExists(modulePath)) {
|
|
||||||
const fsExtra = require('fs-extra');
|
|
||||||
await fsExtra.remove(modulePath);
|
|
||||||
await prompts.log.warn(`Deleted module directory: ${path.relative(projectRoot, modulePath)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.manifest.removeModule(bmadDir, missing.id);
|
|
||||||
await this.manifest.removeCustomModule(bmadDir, missing.id);
|
|
||||||
await prompts.log.warn('Removed from manifest');
|
|
||||||
|
|
||||||
// Also remove from installedModules list
|
|
||||||
if (installedModules && installedModules.includes(missing.id)) {
|
|
||||||
const index = installedModules.indexOf(missing.id);
|
|
||||||
if (index !== -1) {
|
|
||||||
installedModules.splice(index, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removedCount++;
|
|
||||||
await prompts.log.error(`"${missing.name}" has been permanently removed`);
|
|
||||||
} else {
|
|
||||||
await prompts.log.message('Removal cancelled - module will be kept');
|
|
||||||
keptCount++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await prompts.log.message('Removal cancelled - module will be kept');
|
|
||||||
keptCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'keep': {
|
|
||||||
keptCount++;
|
|
||||||
keptModulesWithoutSources.push(missing.id);
|
|
||||||
await prompts.log.message('Module will be kept as-is');
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// No default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show summary
|
|
||||||
if (keptCount > 0 || updatedCount > 0 || removedCount > 0) {
|
|
||||||
let summary = 'Summary for custom modules with missing sources:';
|
|
||||||
if (keptCount > 0) summary += `\n • ${keptCount} module(s) kept as-is`;
|
|
||||||
if (updatedCount > 0) summary += `\n • ${updatedCount} module(s) updated with new sources`;
|
|
||||||
if (removedCount > 0) summary += `\n • ${removedCount} module(s) permanently deleted`;
|
|
||||||
await prompts.log.message(summary);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
validCustomModules,
|
|
||||||
keptModulesWithoutSources,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the bmad installation directory in a project
|
* Find the bmad installation directory in a project
|
||||||
* Always uses the standard _bmad folder name
|
* Always uses the standard _bmad folder name
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ const {
|
||||||
loadSkillManifest: loadSkillManifestShared,
|
loadSkillManifest: loadSkillManifestShared,
|
||||||
getCanonicalId: getCanonicalIdShared,
|
getCanonicalId: getCanonicalIdShared,
|
||||||
getArtifactType: getArtifactTypeShared,
|
getArtifactType: getArtifactTypeShared,
|
||||||
getInstallToBmad: getInstallToBmadShared,
|
|
||||||
} = require('../ide/shared/skill-manifest');
|
} = require('../ide/shared/skill-manifest');
|
||||||
|
|
||||||
// Load package.json for version info
|
// Load package.json for version info
|
||||||
|
|
@ -42,11 +41,6 @@ class ManifestGenerator {
|
||||||
return getArtifactTypeShared(manifest, filename);
|
return getArtifactTypeShared(manifest, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delegate to shared skill-manifest module */
|
|
||||||
getInstallToBmad(manifest, filename) {
|
|
||||||
return getInstallToBmadShared(manifest, filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean text for CSV output by normalizing whitespace.
|
* Clean text for CSV output by normalizing whitespace.
|
||||||
* Note: Quote escaping is handled by escapeCsv() at write time.
|
* Note: Quote escaping is handled by escapeCsv() at write time.
|
||||||
|
|
@ -127,7 +121,7 @@ class ManifestGenerator {
|
||||||
* Recursively walk a module directory tree, collecting native SKILL.md entrypoints.
|
* Recursively walk a module directory tree, collecting native SKILL.md entrypoints.
|
||||||
* A directory is discovered as a skill when it contains a SKILL.md file with
|
* A directory is discovered as a skill when it contains a SKILL.md file with
|
||||||
* valid name/description frontmatter (name must match directory name).
|
* valid name/description frontmatter (name must match directory name).
|
||||||
* Manifest YAML is loaded only when present — for install_to_bmad and agent metadata.
|
* Manifest YAML is loaded only when present — for agent metadata.
|
||||||
* Populates this.skills[] and this.skillClaimedDirs (Set of absolute paths).
|
* Populates this.skills[] and this.skillClaimedDirs (Set of absolute paths).
|
||||||
*/
|
*/
|
||||||
async collectSkills() {
|
async collectSkills() {
|
||||||
|
|
@ -156,7 +150,7 @@ class ManifestGenerator {
|
||||||
const skillMeta = await this.parseSkillMd(skillMdPath, dir, dirName, debug);
|
const skillMeta = await this.parseSkillMd(skillMdPath, dir, dirName, debug);
|
||||||
|
|
||||||
if (skillMeta) {
|
if (skillMeta) {
|
||||||
// Load manifest when present (for install_to_bmad and agent metadata)
|
// Load manifest when present (for agent metadata)
|
||||||
const manifest = await this.loadSkillManifest(dir);
|
const manifest = await this.loadSkillManifest(dir);
|
||||||
const artifactType = this.getArtifactType(manifest, skillFile);
|
const artifactType = this.getArtifactType(manifest, skillFile);
|
||||||
|
|
||||||
|
|
@ -182,7 +176,6 @@ class ManifestGenerator {
|
||||||
module: moduleName,
|
module: moduleName,
|
||||||
path: installPath,
|
path: installPath,
|
||||||
canonicalId,
|
canonicalId,
|
||||||
install_to_bmad: this.getInstallToBmad(manifest, skillFile),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add to files list
|
// Add to files list
|
||||||
|
|
@ -382,8 +375,6 @@ class ManifestGenerator {
|
||||||
// Read existing manifest to preserve install date
|
// Read existing manifest to preserve install date
|
||||||
let existingInstallDate = null;
|
let existingInstallDate = null;
|
||||||
const existingModulesMap = new Map();
|
const existingModulesMap = new Map();
|
||||||
let existingCustomModules = [];
|
|
||||||
|
|
||||||
if (await fs.pathExists(manifestPath)) {
|
if (await fs.pathExists(manifestPath)) {
|
||||||
try {
|
try {
|
||||||
const existingContent = await fs.readFile(manifestPath, 'utf8');
|
const existingContent = await fs.readFile(manifestPath, 'utf8');
|
||||||
|
|
@ -404,12 +395,6 @@ class ManifestGenerator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingManifest.customModules && Array.isArray(existingManifest.customModules)) {
|
|
||||||
// We filter here so manifest regeneration preserves source metadata only for custom modules that
|
|
||||||
// are still installed. Without that, customModules can retain stale entries for modules that were removed.
|
|
||||||
existingCustomModules = existingManifest.customModules.filter((customModule) => installedModuleSet.has(customModule?.id));
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// If we can't read existing manifest, continue with defaults
|
// If we can't read existing manifest, continue with defaults
|
||||||
}
|
}
|
||||||
|
|
@ -445,7 +430,6 @@ class ManifestGenerator {
|
||||||
lastUpdated: new Date().toISOString(),
|
lastUpdated: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
modules: updatedModules,
|
modules: updatedModules,
|
||||||
customModules: existingCustomModules,
|
|
||||||
ides: this.selectedIdes,
|
ides: this.selectedIdes,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -472,7 +456,7 @@ class ManifestGenerator {
|
||||||
const csvPath = path.join(cfgDir, 'skill-manifest.csv');
|
const csvPath = path.join(cfgDir, 'skill-manifest.csv');
|
||||||
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
|
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
|
||||||
|
|
||||||
let csvContent = 'canonicalId,name,description,module,path,install_to_bmad\n';
|
let csvContent = 'canonicalId,name,description,module,path\n';
|
||||||
|
|
||||||
for (const skill of this.skills) {
|
for (const skill of this.skills) {
|
||||||
const row = [
|
const row = [
|
||||||
|
|
@ -481,7 +465,6 @@ class ManifestGenerator {
|
||||||
escapeCsv(skill.description),
|
escapeCsv(skill.description),
|
||||||
escapeCsv(skill.module),
|
escapeCsv(skill.module),
|
||||||
escapeCsv(skill.path),
|
escapeCsv(skill.path),
|
||||||
escapeCsv(skill.install_to_bmad),
|
|
||||||
].join(',');
|
].join(',');
|
||||||
csvContent += row + '\n';
|
csvContent += row + '\n';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,6 @@ class Manifest {
|
||||||
lastUpdated: manifestData.installation?.lastUpdated,
|
lastUpdated: manifestData.installation?.lastUpdated,
|
||||||
modules: moduleNames, // Simple array of module names for backward compatibility
|
modules: moduleNames, // Simple array of module names for backward compatibility
|
||||||
modulesDetailed: hasDetailedModules ? modules : null, // New detailed format
|
modulesDetailed: hasDetailedModules ? modules : null, // New detailed format
|
||||||
customModules: manifestData.customModules || [], // Keep for backward compatibility
|
|
||||||
ides: manifestData.ides || [],
|
ides: manifestData.ides || [],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -254,7 +253,6 @@ class Manifest {
|
||||||
lastUpdated: manifest.installation?.lastUpdated,
|
lastUpdated: manifest.installation?.lastUpdated,
|
||||||
modules: moduleNames,
|
modules: moduleNames,
|
||||||
modulesDetailed: hasDetailedModules ? modules : null,
|
modulesDetailed: hasDetailedModules ? modules : null,
|
||||||
customModules: manifest.customModules || [],
|
|
||||||
ides: manifest.ides || [],
|
ides: manifest.ides || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -783,52 +781,6 @@ class Manifest {
|
||||||
|
|
||||||
return configs;
|
return configs;
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Add a custom module to the manifest with its source path
|
|
||||||
* @param {string} bmadDir - Path to bmad directory
|
|
||||||
* @param {Object} customModule - Custom module info
|
|
||||||
*/
|
|
||||||
async addCustomModule(bmadDir, customModule) {
|
|
||||||
const manifest = await this.read(bmadDir);
|
|
||||||
if (!manifest) {
|
|
||||||
throw new Error('No manifest found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!manifest.customModules) {
|
|
||||||
manifest.customModules = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if custom module already exists
|
|
||||||
const existingIndex = manifest.customModules.findIndex((m) => m.id === customModule.id);
|
|
||||||
if (existingIndex === -1) {
|
|
||||||
// Add new entry
|
|
||||||
manifest.customModules.push(customModule);
|
|
||||||
} else {
|
|
||||||
// Update existing entry
|
|
||||||
manifest.customModules[existingIndex] = customModule;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.update(bmadDir, { customModules: manifest.customModules });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a custom module from the manifest
|
|
||||||
* @param {string} bmadDir - Path to bmad directory
|
|
||||||
* @param {string} moduleId - Module ID to remove
|
|
||||||
*/
|
|
||||||
async removeCustomModule(bmadDir, moduleId) {
|
|
||||||
const manifest = await this.read(bmadDir);
|
|
||||||
if (!manifest || !manifest.customModules) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = manifest.customModules.findIndex((m) => m.id === moduleId);
|
|
||||||
if (index !== -1) {
|
|
||||||
manifest.customModules.splice(index, 1);
|
|
||||||
await this.update(bmadDir, { customModules: manifest.customModules });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get module version info from source
|
* Get module version info from source
|
||||||
* @param {string} moduleName - Module name/code
|
* @param {string} moduleName - Module name/code
|
||||||
|
|
@ -866,29 +818,36 @@ class Manifest {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom module: resolve path from source or cache before reading version
|
// Check if this is a community module
|
||||||
const customSourcePath = moduleSourcePath || path.join(bmadDir, '_config', 'custom', moduleName);
|
const { CommunityModuleManager } = require('../modules/community-manager');
|
||||||
const version = await this._readMarketplaceVersion(moduleName, customSourcePath);
|
const communityMgr = new CommunityModuleManager();
|
||||||
|
const communityInfo = await communityMgr.getModuleByCode(moduleName);
|
||||||
const cacheDir = path.join(bmadDir, '_config', 'custom', moduleName);
|
if (communityInfo) {
|
||||||
const moduleYamlPath = path.join(cacheDir, 'module.yaml');
|
const communityVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
||||||
|
|
||||||
if (await fs.pathExists(moduleYamlPath)) {
|
|
||||||
try {
|
|
||||||
const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
|
|
||||||
const moduleConfig = yaml.parse(yamlContent);
|
|
||||||
return {
|
return {
|
||||||
version: version || moduleConfig.version || null,
|
version: communityVersion || communityInfo.version,
|
||||||
source: 'custom',
|
source: 'community',
|
||||||
npmPackage: moduleConfig.npmPackage || null,
|
npmPackage: communityInfo.npmPackage || null,
|
||||||
repoUrl: moduleConfig.repoUrl || null,
|
repoUrl: communityInfo.url || null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
await prompts.log.warn(`Failed to read module.yaml for ${moduleName}: ${error.message}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is a custom module (from user-provided URL)
|
||||||
|
const { CustomModuleManager } = require('../modules/custom-module-manager');
|
||||||
|
const customMgr = new CustomModuleManager();
|
||||||
|
const customSource = await customMgr.findModuleSourceByCode(moduleName);
|
||||||
|
if (customSource) {
|
||||||
|
const customVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
||||||
|
return {
|
||||||
|
version: customVersion,
|
||||||
|
source: 'custom',
|
||||||
|
npmPackage: null,
|
||||||
|
repoUrl: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unknown module
|
// Unknown module
|
||||||
|
const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
||||||
return {
|
return {
|
||||||
version,
|
version,
|
||||||
source: 'unknown',
|
source: 'unknown',
|
||||||
|
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
const path = require('node:path');
|
|
||||||
const fs = require('fs-extra');
|
|
||||||
const yaml = require('yaml');
|
|
||||||
const prompts = require('./prompts');
|
|
||||||
/**
|
|
||||||
* Handler for custom content (custom.yaml)
|
|
||||||
* Discovers custom agents and workflows in the project
|
|
||||||
*/
|
|
||||||
class CustomHandler {
|
|
||||||
/**
|
|
||||||
* Find all custom.yaml files in the project
|
|
||||||
* @param {string} projectRoot - Project root directory
|
|
||||||
* @returns {Array} List of custom content paths
|
|
||||||
*/
|
|
||||||
async findCustomContent(projectRoot) {
|
|
||||||
const customPaths = [];
|
|
||||||
|
|
||||||
// Helper function to recursively scan directories
|
|
||||||
async function scanDirectory(dir, excludePaths = []) {
|
|
||||||
try {
|
|
||||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
|
|
||||||
// Skip hidden directories and common exclusions
|
|
||||||
if (
|
|
||||||
entry.name.startsWith('.') ||
|
|
||||||
entry.name === 'node_modules' ||
|
|
||||||
entry.name === 'dist' ||
|
|
||||||
entry.name === 'build' ||
|
|
||||||
entry.name === '.git' ||
|
|
||||||
entry.name === 'bmad'
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip excluded paths
|
|
||||||
if (excludePaths.some((exclude) => fullPath.startsWith(exclude))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
// Recursively scan subdirectories
|
|
||||||
await scanDirectory(fullPath, excludePaths);
|
|
||||||
} else if (entry.name === 'custom.yaml') {
|
|
||||||
// Found a custom.yaml file
|
|
||||||
customPaths.push(fullPath);
|
|
||||||
} else if (
|
|
||||||
entry.name === 'module.yaml' && // Check if this is a custom module (in root directory)
|
|
||||||
// Skip if it's in src/modules (those are standard modules)
|
|
||||||
!fullPath.includes(path.join('src', 'modules'))
|
|
||||||
) {
|
|
||||||
customPaths.push(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore errors (e.g., permission denied)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan the entire project, but exclude source directories
|
|
||||||
await scanDirectory(projectRoot, [path.join(projectRoot, 'src'), path.join(projectRoot, 'tools'), path.join(projectRoot, 'test')]);
|
|
||||||
|
|
||||||
return customPaths;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get custom content info from a custom.yaml or module.yaml file
|
|
||||||
* @param {string} configPath - Path to config file
|
|
||||||
* @param {string} projectRoot - Project root directory for calculating relative paths
|
|
||||||
* @returns {Object|null} Custom content info
|
|
||||||
*/
|
|
||||||
async getCustomInfo(configPath, projectRoot = null) {
|
|
||||||
try {
|
|
||||||
const configContent = await fs.readFile(configPath, 'utf8');
|
|
||||||
|
|
||||||
// Try to parse YAML with error handling
|
|
||||||
let config;
|
|
||||||
try {
|
|
||||||
config = yaml.parse(configContent);
|
|
||||||
} catch (parseError) {
|
|
||||||
await prompts.log.warn('YAML parse error in ' + configPath + ': ' + parseError.message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is an module.yaml (module) or custom.yaml (custom content)
|
|
||||||
const isInstallConfig = configPath.endsWith('module.yaml');
|
|
||||||
const configDir = path.dirname(configPath);
|
|
||||||
|
|
||||||
// Use provided projectRoot or fall back to process.cwd()
|
|
||||||
const basePath = projectRoot || process.cwd();
|
|
||||||
const relativePath = path.relative(basePath, configDir);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: config.code || 'unknown-code',
|
|
||||||
name: config.name,
|
|
||||||
description: config.description || '',
|
|
||||||
path: configDir,
|
|
||||||
relativePath: relativePath,
|
|
||||||
defaultSelected: config.default_selected === true,
|
|
||||||
config: config,
|
|
||||||
isInstallConfig: isInstallConfig, // Track which type this is
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
await prompts.log.warn('Failed to read ' + configPath + ': ' + error.message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { CustomHandler };
|
|
||||||
|
|
@ -183,18 +183,6 @@ class ConfigDrivenIdeSetup {
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post-install cleanup: remove _bmad/ directories for skills with install_to_bmad === "false"
|
|
||||||
for (const record of records) {
|
|
||||||
if (record.install_to_bmad === 'false') {
|
|
||||||
const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path;
|
|
||||||
const sourceFile = path.join(bmadDir, relativePath);
|
|
||||||
const sourceDir = path.dirname(sourceFile);
|
|
||||||
if (await fs.pathExists(sourceDir)) {
|
|
||||||
await fs.remove(sourceDir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -237,6 +225,12 @@ class ConfigDrivenIdeSetup {
|
||||||
// Migrate legacy target directories (e.g. .opencode/agent → .opencode/agents)
|
// Migrate legacy target directories (e.g. .opencode/agent → .opencode/agents)
|
||||||
// Legacy dirs are abandoned entirely, so use prefix matching (null removalSet)
|
// Legacy dirs are abandoned entirely, so use prefix matching (null removalSet)
|
||||||
if (this.installerConfig?.legacy_targets) {
|
if (this.installerConfig?.legacy_targets) {
|
||||||
|
const legacyDirsExist = await Promise.all(
|
||||||
|
this.installerConfig.legacy_targets.map((d) =>
|
||||||
|
this.isGlobalPath(d) ? fs.pathExists(d.replace(/^~/, os.homedir())) : fs.pathExists(path.join(projectDir, d)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (legacyDirsExist.some(Boolean)) {
|
||||||
if (!options.silent) await prompts.log.message(' Migrating legacy directories...');
|
if (!options.silent) await prompts.log.message(' Migrating legacy directories...');
|
||||||
for (const legacyDir of this.installerConfig.legacy_targets) {
|
for (const legacyDir of this.installerConfig.legacy_targets) {
|
||||||
if (this.isGlobalPath(legacyDir)) {
|
if (this.isGlobalPath(legacyDir)) {
|
||||||
|
|
@ -247,6 +241,7 @@ class ConfigDrivenIdeSetup {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Strip BMAD markers from copilot-instructions.md if present
|
// Strip BMAD markers from copilot-instructions.md if present
|
||||||
if (this.name === 'github-copilot') {
|
if (this.name === 'github-copilot') {
|
||||||
|
|
|
||||||
|
|
@ -54,19 +54,4 @@ function getArtifactType(manifest, filename) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
module.exports = { loadSkillManifest, getCanonicalId, getArtifactType };
|
||||||
* Get the install_to_bmad flag 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
|
|
||||||
* @returns {boolean} install_to_bmad value (defaults to true)
|
|
||||||
*/
|
|
||||||
function getInstallToBmad(manifest, filename) {
|
|
||||||
if (!manifest) return true;
|
|
||||||
// Single-entry manifest applies to all files in the directory
|
|
||||||
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;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { loadSkillManifest, getCanonicalId, getArtifactType, getInstallToBmad };
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,377 @@
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { execSync } = require('node:child_process');
|
||||||
|
const prompts = require('../prompts');
|
||||||
|
const { RegistryClient } = require('./registry-client');
|
||||||
|
|
||||||
|
const MARKETPLACE_BASE = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main';
|
||||||
|
const COMMUNITY_INDEX_URL = `${MARKETPLACE_BASE}/registry/community-index.yaml`;
|
||||||
|
const CATEGORIES_URL = `${MARKETPLACE_BASE}/categories.yaml`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages community modules from the BMad marketplace registry.
|
||||||
|
* Fetches community-index.yaml and categories.yaml from GitHub.
|
||||||
|
* Returns empty results when the registry is unreachable.
|
||||||
|
* Community modules are pinned to approved SHA when set; uses HEAD otherwise.
|
||||||
|
*/
|
||||||
|
class CommunityModuleManager {
|
||||||
|
constructor() {
|
||||||
|
this._client = new RegistryClient();
|
||||||
|
this._cachedIndex = null;
|
||||||
|
this._cachedCategories = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data Loading ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the community module index from the marketplace repo.
|
||||||
|
* Returns empty when the registry is unreachable.
|
||||||
|
* @returns {Object} Parsed YAML with modules array
|
||||||
|
*/
|
||||||
|
async loadCommunityIndex() {
|
||||||
|
if (this._cachedIndex) return this._cachedIndex;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await this._client.fetchYaml(COMMUNITY_INDEX_URL);
|
||||||
|
if (config?.modules?.length) {
|
||||||
|
this._cachedIndex = config;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Registry unreachable - no community modules available
|
||||||
|
}
|
||||||
|
|
||||||
|
return { modules: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load categories from the marketplace repo.
|
||||||
|
* Returns empty when the registry is unreachable.
|
||||||
|
* @returns {Object} Parsed categories.yaml content
|
||||||
|
*/
|
||||||
|
async loadCategories() {
|
||||||
|
if (this._cachedCategories) return this._cachedCategories;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await this._client.fetchYaml(CATEGORIES_URL);
|
||||||
|
if (config?.categories) {
|
||||||
|
this._cachedCategories = config;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Registry unreachable - no categories available
|
||||||
|
}
|
||||||
|
|
||||||
|
return { categories: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Listing & Filtering ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all community modules, normalized.
|
||||||
|
* @returns {Array<Object>} Normalized community modules
|
||||||
|
*/
|
||||||
|
async listAll() {
|
||||||
|
const index = await this.loadCommunityIndex();
|
||||||
|
return (index.modules || []).map((mod) => this._normalizeCommunityModule(mod));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get community modules filtered to a category.
|
||||||
|
* @param {string} categorySlug - Category slug (e.g., 'design-and-creative')
|
||||||
|
* @returns {Array<Object>} Filtered modules
|
||||||
|
*/
|
||||||
|
async listByCategory(categorySlug) {
|
||||||
|
const all = await this.listAll();
|
||||||
|
return all.filter((mod) => mod.category === categorySlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get promoted/featured community modules, sorted by rank.
|
||||||
|
* @returns {Array<Object>} Featured modules
|
||||||
|
*/
|
||||||
|
async listFeatured() {
|
||||||
|
const all = await this.listAll();
|
||||||
|
return all.filter((mod) => mod.promoted === true).sort((a, b) => (a.promotedRank || 999) - (b.promotedRank || 999));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search community modules by keyword.
|
||||||
|
* Matches against name, display name, description, and keywords array.
|
||||||
|
* @param {string} query - Search query
|
||||||
|
* @returns {Array<Object>} Matching modules
|
||||||
|
*/
|
||||||
|
async searchByKeyword(query) {
|
||||||
|
const all = await this.listAll();
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return all.filter((mod) => {
|
||||||
|
const searchable = [mod.name, mod.displayName, mod.description, ...(mod.keywords || [])].join(' ').toLowerCase();
|
||||||
|
return searchable.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get categories with module counts for UI display.
|
||||||
|
* Only returns categories that have at least one community module.
|
||||||
|
* @returns {Array<Object>} Array of { slug, name, moduleCount }
|
||||||
|
*/
|
||||||
|
async getCategoryList() {
|
||||||
|
const all = await this.listAll();
|
||||||
|
const categoriesData = await this.loadCategories();
|
||||||
|
const categories = categoriesData.categories || {};
|
||||||
|
|
||||||
|
// Count modules per category
|
||||||
|
const counts = {};
|
||||||
|
for (const mod of all) {
|
||||||
|
counts[mod.category] = (counts[mod.category] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build list with display names from categories.yaml
|
||||||
|
const result = [];
|
||||||
|
for (const [slug, count] of Object.entries(counts)) {
|
||||||
|
const catInfo = categories[slug];
|
||||||
|
result.push({
|
||||||
|
slug,
|
||||||
|
name: catInfo?.name || slug,
|
||||||
|
moduleCount: count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort alphabetically by name
|
||||||
|
result.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Module Lookup ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a community module by its code.
|
||||||
|
* @param {string} code - Module code (e.g., 'wds')
|
||||||
|
* @returns {Object|null} Normalized module or null
|
||||||
|
*/
|
||||||
|
async getModuleByCode(code) {
|
||||||
|
const all = await this.listAll();
|
||||||
|
return all.find((m) => m.code === code) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Clone with Tag Pinning ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cache directory for community modules.
|
||||||
|
* @returns {string} Path to the community modules cache directory
|
||||||
|
*/
|
||||||
|
getCacheDir() {
|
||||||
|
return path.join(os.homedir(), '.bmad', 'cache', 'community-modules');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone a community module repository, pinned to its approved tag.
|
||||||
|
* @param {string} moduleCode - Module code
|
||||||
|
* @param {Object} [options] - Clone options
|
||||||
|
* @param {boolean} [options.silent] - Suppress spinner output
|
||||||
|
* @returns {string} Path to the cloned repository
|
||||||
|
*/
|
||||||
|
async cloneModule(moduleCode, options = {}) {
|
||||||
|
const moduleInfo = await this.getModuleByCode(moduleCode);
|
||||||
|
if (!moduleInfo) {
|
||||||
|
throw new Error(`Community module '${moduleCode}' not found in the registry`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheDir = this.getCacheDir();
|
||||||
|
const moduleCacheDir = path.join(cacheDir, moduleCode);
|
||||||
|
const silent = options.silent || false;
|
||||||
|
|
||||||
|
await fs.ensureDir(cacheDir);
|
||||||
|
|
||||||
|
const createSpinner = async () => {
|
||||||
|
if (silent) {
|
||||||
|
return { start() {}, stop() {}, error() {}, message() {} };
|
||||||
|
}
|
||||||
|
return await prompts.spinner();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sha = moduleInfo.approvedSha;
|
||||||
|
let needsDependencyInstall = false;
|
||||||
|
let wasNewClone = false;
|
||||||
|
|
||||||
|
if (await fs.pathExists(moduleCacheDir)) {
|
||||||
|
// Already cloned - update to latest HEAD
|
||||||
|
const fetchSpinner = await createSpinner();
|
||||||
|
fetchSpinner.start(`Checking ${moduleInfo.displayName}...`);
|
||||||
|
try {
|
||||||
|
const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
||||||
|
execSync('git fetch origin --depth 1', {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
execSync('git reset --hard origin/HEAD', {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
||||||
|
if (currentRef !== newRef) needsDependencyInstall = true;
|
||||||
|
fetchSpinner.stop(`Verified ${moduleInfo.displayName}`);
|
||||||
|
} catch {
|
||||||
|
fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.displayName}`);
|
||||||
|
await fs.remove(moduleCacheDir);
|
||||||
|
wasNewClone = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wasNewClone = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasNewClone) {
|
||||||
|
const fetchSpinner = await createSpinner();
|
||||||
|
fetchSpinner.start(`Fetching ${moduleInfo.displayName}...`);
|
||||||
|
try {
|
||||||
|
execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
fetchSpinner.stop(`Fetched ${moduleInfo.displayName}`);
|
||||||
|
needsDependencyInstall = true;
|
||||||
|
} catch (error) {
|
||||||
|
fetchSpinner.error(`Failed to fetch ${moduleInfo.displayName}`);
|
||||||
|
throw new Error(`Failed to clone community module '${moduleCode}': ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pinned to a specific SHA, check out that exact commit.
|
||||||
|
// Refuse to install if the approved SHA cannot be reached - security requirement.
|
||||||
|
if (sha) {
|
||||||
|
const headSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
||||||
|
if (headSha !== sha) {
|
||||||
|
try {
|
||||||
|
execSync(`git fetch --depth 1 origin ${sha}`, {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
execSync(`git checkout ${sha}`, {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
needsDependencyInstall = true;
|
||||||
|
} catch {
|
||||||
|
await fs.remove(moduleCacheDir);
|
||||||
|
throw new Error(
|
||||||
|
`Community module '${moduleCode}' could not be pinned to its approved commit (${sha}). ` +
|
||||||
|
`Installation refused for security. The module registry entry may need updating.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install dependencies if needed
|
||||||
|
const packageJsonPath = path.join(moduleCacheDir, 'package.json');
|
||||||
|
if ((needsDependencyInstall || wasNewClone) && (await fs.pathExists(packageJsonPath))) {
|
||||||
|
const installSpinner = await createSpinner();
|
||||||
|
installSpinner.start(`Installing dependencies for ${moduleInfo.displayName}...`);
|
||||||
|
try {
|
||||||
|
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
installSpinner.stop(`Installed dependencies for ${moduleInfo.displayName}`);
|
||||||
|
} catch (error) {
|
||||||
|
installSpinner.error(`Failed to install dependencies for ${moduleInfo.displayName}`);
|
||||||
|
if (!silent) await prompts.log.warn(` ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return moduleCacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Source Finding ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the source path for a community module (clone + locate module.yaml).
|
||||||
|
* @param {string} moduleCode - Module code
|
||||||
|
* @param {Object} [options] - Options passed to cloneModule
|
||||||
|
* @returns {string|null} Path to the module source or null
|
||||||
|
*/
|
||||||
|
async findModuleSource(moduleCode, options = {}) {
|
||||||
|
const moduleInfo = await this.getModuleByCode(moduleCode);
|
||||||
|
if (!moduleInfo) return null;
|
||||||
|
|
||||||
|
const cloneDir = await this.cloneModule(moduleCode, options);
|
||||||
|
|
||||||
|
// Check configured module_definition path first
|
||||||
|
if (moduleInfo.moduleDefinition) {
|
||||||
|
const configuredPath = path.join(cloneDir, moduleInfo.moduleDefinition);
|
||||||
|
if (await fs.pathExists(configuredPath)) {
|
||||||
|
return path.dirname(configuredPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: search skills/ and src/ directories
|
||||||
|
for (const dir of ['skills', 'src']) {
|
||||||
|
const rootCandidate = path.join(cloneDir, dir, 'module.yaml');
|
||||||
|
if (await fs.pathExists(rootCandidate)) {
|
||||||
|
return path.dirname(rootCandidate);
|
||||||
|
}
|
||||||
|
const dirPath = path.join(cloneDir, dir);
|
||||||
|
if (await fs.pathExists(dirPath)) {
|
||||||
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const subCandidate = path.join(dirPath, entry.name, 'module.yaml');
|
||||||
|
if (await fs.pathExists(subCandidate)) {
|
||||||
|
return path.dirname(subCandidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check repo root
|
||||||
|
const rootCandidate = path.join(cloneDir, 'module.yaml');
|
||||||
|
if (await fs.pathExists(rootCandidate)) {
|
||||||
|
return path.dirname(rootCandidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return moduleInfo.moduleDefinition ? path.dirname(path.join(cloneDir, moduleInfo.moduleDefinition)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Normalization ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a community module entry to a consistent shape.
|
||||||
|
* @param {Object} mod - Raw module from community-index.yaml
|
||||||
|
* @returns {Object} Normalized module info
|
||||||
|
*/
|
||||||
|
_normalizeCommunityModule(mod) {
|
||||||
|
return {
|
||||||
|
key: mod.name,
|
||||||
|
code: mod.code,
|
||||||
|
name: mod.display_name || mod.name,
|
||||||
|
displayName: mod.display_name || mod.name,
|
||||||
|
description: mod.description || '',
|
||||||
|
url: mod.repository || mod.url,
|
||||||
|
moduleDefinition: mod.module_definition || mod['module-definition'],
|
||||||
|
npmPackage: mod.npm_package || mod.npmPackage || null,
|
||||||
|
author: mod.author || '',
|
||||||
|
license: mod.license || '',
|
||||||
|
type: 'community',
|
||||||
|
category: mod.category || '',
|
||||||
|
subcategory: mod.subcategory || '',
|
||||||
|
keywords: mod.keywords || [],
|
||||||
|
version: mod.version || null,
|
||||||
|
approvedTag: mod.approved_tag || null,
|
||||||
|
approvedSha: mod.approved_sha || null,
|
||||||
|
approvedDate: mod.approved_date || null,
|
||||||
|
reviewer: mod.reviewer || null,
|
||||||
|
trustTier: mod.trust_tier || 'unverified',
|
||||||
|
promoted: mod.promoted === true,
|
||||||
|
promotedRank: mod.promoted_rank || null,
|
||||||
|
defaultSelected: false,
|
||||||
|
builtIn: false,
|
||||||
|
isExternal: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { CommunityModuleManager };
|
||||||
|
|
@ -0,0 +1,308 @@
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { execSync } = require('node:child_process');
|
||||||
|
const prompts = require('../prompts');
|
||||||
|
const { RegistryClient } = require('./registry-client');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages custom modules installed from user-provided GitHub URLs.
|
||||||
|
* Validates URLs, fetches .claude-plugin/marketplace.json, clones repos.
|
||||||
|
*/
|
||||||
|
class CustomModuleManager {
|
||||||
|
constructor() {
|
||||||
|
this._client = new RegistryClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── URL Validation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate a GitHub repository URL.
|
||||||
|
* Supports HTTPS and SSH formats.
|
||||||
|
* @param {string} url - GitHub URL to validate
|
||||||
|
* @returns {Object} { owner, repo, isValid, error }
|
||||||
|
*/
|
||||||
|
validateGitHubUrl(url) {
|
||||||
|
if (!url || typeof url !== 'string') {
|
||||||
|
return { owner: null, repo: null, isValid: false, error: 'URL is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = url.trim();
|
||||||
|
|
||||||
|
// HTTPS format: https://github.com/owner/repo[.git]
|
||||||
|
const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
||||||
|
if (httpsMatch) {
|
||||||
|
return { owner: httpsMatch[1], repo: httpsMatch[2], isValid: true, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSH format: git@github.com:owner/repo.git
|
||||||
|
const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
||||||
|
if (sshMatch) {
|
||||||
|
return { owner: sshMatch[1], repo: sshMatch[2], isValid: true, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { owner: null, repo: null, isValid: false, error: 'Not a valid GitHub URL (expected https://github.com/owner/repo)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Discovery ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch .claude-plugin/marketplace.json from a GitHub repository.
|
||||||
|
* @param {string} repoUrl - GitHub repository URL
|
||||||
|
* @returns {Object} Parsed marketplace.json content
|
||||||
|
*/
|
||||||
|
async fetchMarketplaceJson(repoUrl) {
|
||||||
|
const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl);
|
||||||
|
if (!isValid) throw new Error(error);
|
||||||
|
|
||||||
|
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/.claude-plugin/marketplace.json`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this._client.fetchJson(rawUrl);
|
||||||
|
} catch (error_) {
|
||||||
|
if (error_.message.includes('404')) {
|
||||||
|
throw new Error(`No .claude-plugin/marketplace.json found in ${owner}/${repo}. This repository may not be a BMad module.`);
|
||||||
|
}
|
||||||
|
if (error_.message.includes('403')) {
|
||||||
|
throw new Error(`Repository ${owner}/${repo} is not accessible. Make sure it is public.`);
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch marketplace.json from ${owner}/${repo}: ${error_.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover modules from a GitHub repository's marketplace.json.
|
||||||
|
* @param {string} repoUrl - GitHub repository URL
|
||||||
|
* @returns {Array<Object>} Normalized plugin list
|
||||||
|
*/
|
||||||
|
async discoverModules(repoUrl) {
|
||||||
|
const data = await this.fetchMarketplaceJson(repoUrl);
|
||||||
|
const plugins = data?.plugins;
|
||||||
|
|
||||||
|
if (!Array.isArray(plugins) || plugins.length === 0) {
|
||||||
|
throw new Error('marketplace.json contains no plugins');
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugins.map((plugin) => this._normalizeCustomModule(plugin, repoUrl, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Clone ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cache directory for custom modules.
|
||||||
|
* @returns {string} Path to the custom modules cache directory
|
||||||
|
*/
|
||||||
|
getCacheDir() {
|
||||||
|
return path.join(os.homedir(), '.bmad', 'cache', 'custom-modules');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone a custom module repository to cache.
|
||||||
|
* @param {string} repoUrl - GitHub repository URL
|
||||||
|
* @param {Object} [options] - Clone options
|
||||||
|
* @param {boolean} [options.silent] - Suppress spinner output
|
||||||
|
* @returns {string} Path to the cloned repository
|
||||||
|
*/
|
||||||
|
async cloneRepo(repoUrl, options = {}) {
|
||||||
|
const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl);
|
||||||
|
if (!isValid) throw new Error(error);
|
||||||
|
|
||||||
|
const cacheDir = this.getCacheDir();
|
||||||
|
const repoCacheDir = path.join(cacheDir, owner, repo);
|
||||||
|
const silent = options.silent || false;
|
||||||
|
|
||||||
|
await fs.ensureDir(path.join(cacheDir, owner));
|
||||||
|
|
||||||
|
const createSpinner = async () => {
|
||||||
|
if (silent) {
|
||||||
|
return { start() {}, stop() {}, error() {} };
|
||||||
|
}
|
||||||
|
return await prompts.spinner();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (await fs.pathExists(repoCacheDir)) {
|
||||||
|
// Update existing clone
|
||||||
|
const fetchSpinner = await createSpinner();
|
||||||
|
fetchSpinner.start(`Updating ${owner}/${repo}...`);
|
||||||
|
try {
|
||||||
|
execSync('git fetch origin --depth 1', {
|
||||||
|
cwd: repoCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
execSync('git reset --hard origin/HEAD', {
|
||||||
|
cwd: repoCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
fetchSpinner.stop(`Updated ${owner}/${repo}`);
|
||||||
|
} catch {
|
||||||
|
fetchSpinner.error(`Update failed, re-downloading ${owner}/${repo}`);
|
||||||
|
await fs.remove(repoCacheDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(repoCacheDir))) {
|
||||||
|
const fetchSpinner = await createSpinner();
|
||||||
|
fetchSpinner.start(`Cloning ${owner}/${repo}...`);
|
||||||
|
try {
|
||||||
|
execSync(`git clone --depth 1 "${repoUrl}" "${repoCacheDir}"`, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
fetchSpinner.stop(`Cloned ${owner}/${repo}`);
|
||||||
|
} catch (error_) {
|
||||||
|
fetchSpinner.error(`Failed to clone ${owner}/${repo}`);
|
||||||
|
throw new Error(`Failed to clone ${repoUrl}: ${error_.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install dependencies if package.json exists
|
||||||
|
const packageJsonPath = path.join(repoCacheDir, 'package.json');
|
||||||
|
if (await fs.pathExists(packageJsonPath)) {
|
||||||
|
const installSpinner = await createSpinner();
|
||||||
|
installSpinner.start(`Installing dependencies for ${owner}/${repo}...`);
|
||||||
|
try {
|
||||||
|
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
||||||
|
cwd: repoCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
installSpinner.stop(`Installed dependencies for ${owner}/${repo}`);
|
||||||
|
} catch (error_) {
|
||||||
|
installSpinner.error(`Failed to install dependencies for ${owner}/${repo}`);
|
||||||
|
if (!silent) await prompts.log.warn(` ${error_.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return repoCacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Source Finding ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the module source path within a cloned custom repo.
|
||||||
|
* @param {string} repoUrl - GitHub repository URL (for cache location)
|
||||||
|
* @param {string} [pluginSource] - Plugin source path from marketplace.json
|
||||||
|
* @returns {string|null} Path to directory containing module.yaml
|
||||||
|
*/
|
||||||
|
async findModuleSource(repoUrl, pluginSource) {
|
||||||
|
const { owner, repo } = this.validateGitHubUrl(repoUrl);
|
||||||
|
const repoCacheDir = path.join(this.getCacheDir(), owner, repo);
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(repoCacheDir))) return null;
|
||||||
|
|
||||||
|
// Try plugin source path first (e.g., "./src/pro-skills")
|
||||||
|
if (pluginSource) {
|
||||||
|
const sourcePath = path.join(repoCacheDir, pluginSource);
|
||||||
|
const moduleYaml = path.join(sourcePath, 'module.yaml');
|
||||||
|
if (await fs.pathExists(moduleYaml)) {
|
||||||
|
return sourcePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: search skills/ and src/ directories
|
||||||
|
for (const dir of ['skills', 'src']) {
|
||||||
|
const rootCandidate = path.join(repoCacheDir, dir, 'module.yaml');
|
||||||
|
if (await fs.pathExists(rootCandidate)) {
|
||||||
|
return path.dirname(rootCandidate);
|
||||||
|
}
|
||||||
|
const dirPath = path.join(repoCacheDir, dir);
|
||||||
|
if (await fs.pathExists(dirPath)) {
|
||||||
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const subCandidate = path.join(dirPath, entry.name, 'module.yaml');
|
||||||
|
if (await fs.pathExists(subCandidate)) {
|
||||||
|
return path.dirname(subCandidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check repo root
|
||||||
|
const rootCandidate = path.join(repoCacheDir, 'module.yaml');
|
||||||
|
if (await fs.pathExists(rootCandidate)) {
|
||||||
|
return repoCacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find module source by module code, searching the custom cache.
|
||||||
|
* @param {string} moduleCode - Module code to search for
|
||||||
|
* @param {Object} [options] - Options
|
||||||
|
* @returns {string|null} Path to the module source or null
|
||||||
|
*/
|
||||||
|
async findModuleSourceByCode(moduleCode, options = {}) {
|
||||||
|
const cacheDir = this.getCacheDir();
|
||||||
|
if (!(await fs.pathExists(cacheDir))) return null;
|
||||||
|
|
||||||
|
// Search through all custom repo caches
|
||||||
|
try {
|
||||||
|
const owners = await fs.readdir(cacheDir, { withFileTypes: true });
|
||||||
|
for (const ownerEntry of owners) {
|
||||||
|
if (!ownerEntry.isDirectory()) continue;
|
||||||
|
const ownerPath = path.join(cacheDir, ownerEntry.name);
|
||||||
|
const repos = await fs.readdir(ownerPath, { withFileTypes: true });
|
||||||
|
for (const repoEntry of repos) {
|
||||||
|
if (!repoEntry.isDirectory()) continue;
|
||||||
|
const repoPath = path.join(ownerPath, repoEntry.name);
|
||||||
|
|
||||||
|
// Check marketplace.json for matching module code
|
||||||
|
const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
|
||||||
|
if (await fs.pathExists(marketplacePath)) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
||||||
|
for (const plugin of data.plugins || []) {
|
||||||
|
if (plugin.name === moduleCode) {
|
||||||
|
// Found the module - find its source
|
||||||
|
const sourcePath = plugin.source ? path.join(repoPath, plugin.source) : repoPath;
|
||||||
|
const moduleYaml = path.join(sourcePath, 'module.yaml');
|
||||||
|
if (await fs.pathExists(moduleYaml)) {
|
||||||
|
return sourcePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip malformed marketplace.json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Cache doesn't exist or is inaccessible
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Normalization ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a plugin from marketplace.json to a consistent shape.
|
||||||
|
* @param {Object} plugin - Plugin object from marketplace.json
|
||||||
|
* @param {string} repoUrl - Source repository URL
|
||||||
|
* @param {Object} data - Full marketplace.json data
|
||||||
|
* @returns {Object} Normalized module info
|
||||||
|
*/
|
||||||
|
_normalizeCustomModule(plugin, repoUrl, data) {
|
||||||
|
return {
|
||||||
|
code: plugin.name,
|
||||||
|
name: plugin.name,
|
||||||
|
displayName: plugin.name,
|
||||||
|
description: plugin.description || '',
|
||||||
|
version: plugin.version || null,
|
||||||
|
author: plugin.author || data.owner || '',
|
||||||
|
url: repoUrl,
|
||||||
|
source: plugin.source || null,
|
||||||
|
type: 'custom',
|
||||||
|
trustTier: 'unverified',
|
||||||
|
builtIn: false,
|
||||||
|
isExternal: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { CustomModuleManager };
|
||||||
|
|
@ -1,302 +0,0 @@
|
||||||
const path = require('node:path');
|
|
||||||
const fs = require('fs-extra');
|
|
||||||
const yaml = require('yaml');
|
|
||||||
const { CustomHandler } = require('../custom-handler');
|
|
||||||
const { Manifest } = require('../core/manifest');
|
|
||||||
const prompts = require('../prompts');
|
|
||||||
|
|
||||||
class CustomModules {
|
|
||||||
constructor() {
|
|
||||||
this.paths = new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
has(moduleCode) {
|
|
||||||
return this.paths.has(moduleCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
get(moduleCode) {
|
|
||||||
return this.paths.get(moduleCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
set(moduleId, sourcePath) {
|
|
||||||
this.paths.set(moduleId, sourcePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install a custom module from its source path.
|
|
||||||
* @param {string} moduleName - Module identifier
|
|
||||||
* @param {string} bmadDir - Target bmad directory
|
|
||||||
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
|
||||||
* @param {Object} options - Install options
|
|
||||||
* @param {Object} options.moduleConfig - Pre-collected module configuration
|
|
||||||
* @returns {Object} Install result
|
|
||||||
*/
|
|
||||||
async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) {
|
|
||||||
const sourcePath = this.paths.get(moduleName);
|
|
||||||
if (!sourcePath) {
|
|
||||||
throw new Error(`No source path for custom module '${moduleName}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await fs.pathExists(sourcePath))) {
|
|
||||||
throw new Error(`Source for custom module '${moduleName}' not found at: ${sourcePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetPath = path.join(bmadDir, moduleName);
|
|
||||||
|
|
||||||
// Read custom.yaml and merge into module config
|
|
||||||
let moduleConfig = options.moduleConfig ? { ...options.moduleConfig } : {};
|
|
||||||
const customConfigPath = path.join(sourcePath, 'custom.yaml');
|
|
||||||
if (await fs.pathExists(customConfigPath)) {
|
|
||||||
try {
|
|
||||||
const content = await fs.readFile(customConfigPath, 'utf8');
|
|
||||||
const customConfig = yaml.parse(content);
|
|
||||||
if (customConfig) {
|
|
||||||
moduleConfig = { ...moduleConfig, ...customConfig };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
await prompts.log.warn(`Failed to read custom.yaml for ${moduleName}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove existing installation
|
|
||||||
if (await fs.pathExists(targetPath)) {
|
|
||||||
await fs.remove(targetPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy files with filtering
|
|
||||||
await this._copyWithFiltering(sourcePath, targetPath, fileTrackingCallback);
|
|
||||||
|
|
||||||
// Add to manifest
|
|
||||||
const manifest = new Manifest();
|
|
||||||
const versionInfo = await manifest.getModuleVersionInfo(moduleName, bmadDir, sourcePath);
|
|
||||||
await manifest.addModule(bmadDir, moduleName, {
|
|
||||||
version: versionInfo.version,
|
|
||||||
source: versionInfo.source,
|
|
||||||
npmPackage: versionInfo.npmPackage,
|
|
||||||
repoUrl: versionInfo.repoUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, module: moduleName, path: targetPath, moduleConfig };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy module files, filtering out install-time-only artifacts.
|
|
||||||
* @param {string} sourcePath - Source module directory
|
|
||||||
* @param {string} targetPath - Target module directory
|
|
||||||
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
|
||||||
*/
|
|
||||||
async _copyWithFiltering(sourcePath, targetPath, fileTrackingCallback = null) {
|
|
||||||
const files = await this._getFileList(sourcePath);
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
if (file.startsWith('sub-modules/')) continue;
|
|
||||||
|
|
||||||
const isInSidecar = path
|
|
||||||
.dirname(file)
|
|
||||||
.split('/')
|
|
||||||
.some((dir) => dir.toLowerCase().endsWith('-sidecar'));
|
|
||||||
if (isInSidecar) continue;
|
|
||||||
|
|
||||||
if (file === 'module.yaml') continue;
|
|
||||||
if (file === 'config.yaml') continue;
|
|
||||||
|
|
||||||
const sourceFile = path.join(sourcePath, file);
|
|
||||||
const targetFile = path.join(targetPath, file);
|
|
||||||
|
|
||||||
// Skip web-only agents
|
|
||||||
if (file.startsWith('agents/') && file.endsWith('.md')) {
|
|
||||||
const content = await fs.readFile(sourceFile, 'utf8');
|
|
||||||
if (/<agent[^>]*\slocalskip="true"[^>]*>/.test(content)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.ensureDir(path.dirname(targetFile));
|
|
||||||
await fs.copy(sourceFile, targetFile, { overwrite: true });
|
|
||||||
|
|
||||||
if (fileTrackingCallback) {
|
|
||||||
fileTrackingCallback(targetFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively list all files in a directory.
|
|
||||||
* @param {string} dir - Directory to scan
|
|
||||||
* @param {string} baseDir - Base directory for relative paths
|
|
||||||
* @returns {string[]} Relative file paths
|
|
||||||
*/
|
|
||||||
async _getFileList(dir, baseDir = dir) {
|
|
||||||
const files = [];
|
|
||||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
files.push(...(await this._getFileList(fullPath, baseDir)));
|
|
||||||
} else {
|
|
||||||
files.push(path.relative(baseDir, fullPath));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Discover custom module source paths from all available sources.
|
|
||||||
* @param {Object} config - Installation configuration
|
|
||||||
* @param {Object} paths - InstallPaths instance
|
|
||||||
* @returns {Map<string, string>} Map of module ID to source path
|
|
||||||
*/
|
|
||||||
async discoverPaths(config, paths) {
|
|
||||||
this.paths = new Map();
|
|
||||||
|
|
||||||
if (config._quickUpdate) {
|
|
||||||
if (config._customModuleSources) {
|
|
||||||
for (const [moduleId, customInfo] of config._customModuleSources) {
|
|
||||||
this.paths.set(moduleId, customInfo.sourcePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.paths;
|
|
||||||
}
|
|
||||||
|
|
||||||
// From UI: selectedFiles
|
|
||||||
if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) {
|
|
||||||
const customHandler = new CustomHandler();
|
|
||||||
for (const customFile of config.customContent.selectedFiles) {
|
|
||||||
const customInfo = await customHandler.getCustomInfo(customFile, paths.projectRoot);
|
|
||||||
if (customInfo && customInfo.id) {
|
|
||||||
this.paths.set(customInfo.id, customInfo.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// From UI: sources
|
|
||||||
if (config.customContent && config.customContent.sources) {
|
|
||||||
for (const source of config.customContent.sources) {
|
|
||||||
this.paths.set(source.id, source.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// From UI: cachedModules
|
|
||||||
if (config.customContent && config.customContent.cachedModules) {
|
|
||||||
const selectedCachedIds = config.customContent.selectedCachedModules || [];
|
|
||||||
const shouldIncludeAll = selectedCachedIds.length === 0 && config.customContent.selected;
|
|
||||||
|
|
||||||
for (const cachedModule of config.customContent.cachedModules) {
|
|
||||||
if (cachedModule.id && cachedModule.cachePath && (shouldIncludeAll || selectedCachedIds.includes(cachedModule.id))) {
|
|
||||||
this.paths.set(cachedModule.id, cachedModule.cachePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.paths;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assemble quick-update source candidates before install() hands them to discoverPaths().
|
|
||||||
* This exists because discoverPaths() consumes already-prepared quick-update sources,
|
|
||||||
* while quickUpdate() still has to build that source map from manifest, explicit inputs,
|
|
||||||
* and cache conventions.
|
|
||||||
* Precedence: manifest-backed paths, explicit sources override them, then cached modules.
|
|
||||||
* @param {Object} config - Quick update configuration
|
|
||||||
* @param {Object} existingInstall - Existing installation snapshot
|
|
||||||
* @param {string} bmadDir - BMAD directory
|
|
||||||
* @param {Object} externalModuleManager - External module manager
|
|
||||||
* @returns {Promise<Map<string, Object>>} Map of custom module ID to source info
|
|
||||||
*/
|
|
||||||
async assembleQuickUpdateSources(config, existingInstall, bmadDir, externalModuleManager) {
|
|
||||||
const projectRoot = path.dirname(bmadDir);
|
|
||||||
const customModuleSources = new Map();
|
|
||||||
|
|
||||||
if (existingInstall.customModules) {
|
|
||||||
for (const customModule of existingInstall.customModules) {
|
|
||||||
// Skip if no ID - can't reliably track or re-cache without it
|
|
||||||
if (!customModule?.id) continue;
|
|
||||||
|
|
||||||
let sourcePath = customModule.sourcePath;
|
|
||||||
if (sourcePath && sourcePath.startsWith('_config')) {
|
|
||||||
// Paths are relative to BMAD dir, but we want absolute paths for install
|
|
||||||
sourcePath = path.join(bmadDir, sourcePath);
|
|
||||||
} else if (!sourcePath && customModule.relativePath) {
|
|
||||||
// Fall back to relativePath
|
|
||||||
sourcePath = path.resolve(projectRoot, customModule.relativePath);
|
|
||||||
} else if (sourcePath && !path.isAbsolute(sourcePath)) {
|
|
||||||
// If we have a sourcePath but it's not absolute, resolve it relative to project root
|
|
||||||
sourcePath = path.resolve(projectRoot, sourcePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we still don't have a valid source path, skip this module
|
|
||||||
if (!sourcePath || !(await fs.pathExists(sourcePath))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
customModuleSources.set(customModule.id, {
|
|
||||||
id: customModule.id,
|
|
||||||
name: customModule.name || customModule.id,
|
|
||||||
sourcePath,
|
|
||||||
relativePath: customModule.relativePath,
|
|
||||||
cached: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.customContent?.sources?.length > 0) {
|
|
||||||
for (const source of config.customContent.sources) {
|
|
||||||
if (source.id && source.path) {
|
|
||||||
customModuleSources.set(source.id, {
|
|
||||||
id: source.id,
|
|
||||||
name: source.name || source.id,
|
|
||||||
sourcePath: source.path,
|
|
||||||
cached: false, // From CLI, will be re-cached
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheDir = path.join(bmadDir, '_config', 'custom');
|
|
||||||
if (!(await fs.pathExists(cacheDir))) {
|
|
||||||
return customModuleSources;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
|
|
||||||
for (const cachedModule of cachedModules) {
|
|
||||||
const moduleId = cachedModule.name;
|
|
||||||
const cachedPath = path.join(cacheDir, moduleId);
|
|
||||||
|
|
||||||
// Skip if path doesn't exist (broken symlink, deleted dir) - avoids lstat ENOENT
|
|
||||||
if (!(await fs.pathExists(cachedPath))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!cachedModule.isDirectory()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if we already have this module from manifest
|
|
||||||
if (customModuleSources.has(moduleId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is an external official module - skip cache for those
|
|
||||||
const isExternal = await externalModuleManager.hasModule(moduleId);
|
|
||||||
if (isExternal) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is actually a custom module (has module.yaml)
|
|
||||||
const moduleYamlPath = path.join(cachedPath, 'module.yaml');
|
|
||||||
if (await fs.pathExists(moduleYamlPath)) {
|
|
||||||
customModuleSources.set(moduleId, {
|
|
||||||
id: moduleId,
|
|
||||||
name: moduleId,
|
|
||||||
sourcePath: cachedPath,
|
|
||||||
cached: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return customModuleSources;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { CustomModules };
|
|
||||||
|
|
@ -4,64 +4,98 @@ const path = require('node:path');
|
||||||
const { execSync } = require('node:child_process');
|
const { execSync } = require('node:child_process');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
const prompts = require('../prompts');
|
const prompts = require('../prompts');
|
||||||
|
const { RegistryClient } = require('./registry-client');
|
||||||
|
|
||||||
|
const REGISTRY_RAW_URL = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml';
|
||||||
|
const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages external official modules defined in external-official-modules.yaml
|
* Manages official modules from the remote BMad marketplace registry.
|
||||||
* These are modules hosted in external repositories that can be installed
|
* Fetches registry/official.yaml from GitHub; falls back to the bundled
|
||||||
|
* external-official-modules.yaml when the network is unavailable.
|
||||||
*
|
*
|
||||||
* @class ExternalModuleManager
|
* @class ExternalModuleManager
|
||||||
*/
|
*/
|
||||||
class ExternalModuleManager {
|
class ExternalModuleManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.externalModulesConfigPath = path.join(__dirname, '../external-official-modules.yaml');
|
this._client = new RegistryClient();
|
||||||
this.cachedModules = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load and parse the external-official-modules.yaml file
|
* Load the official modules registry from GitHub, falling back to the
|
||||||
* @returns {Object} Parsed YAML content with modules object
|
* bundled YAML file if the fetch fails.
|
||||||
|
* @returns {Object} Parsed YAML content with modules array
|
||||||
*/
|
*/
|
||||||
async loadExternalModulesConfig() {
|
async loadExternalModulesConfig() {
|
||||||
if (this.cachedModules) {
|
if (this.cachedModules) {
|
||||||
return this.cachedModules;
|
return this.cachedModules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try remote registry first
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(this.externalModulesConfigPath, 'utf8');
|
const content = await this._client.fetch(REGISTRY_RAW_URL);
|
||||||
const config = yaml.parse(content);
|
const config = yaml.parse(content);
|
||||||
|
if (config?.modules?.length) {
|
||||||
this.cachedModules = config;
|
this.cachedModules = config;
|
||||||
return config;
|
return config;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to local fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to bundled file
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(FALLBACK_CONFIG_PATH, 'utf8');
|
||||||
|
const config = yaml.parse(content);
|
||||||
|
this.cachedModules = config;
|
||||||
|
await prompts.log.warn('Could not reach BMad registry; using bundled module list.');
|
||||||
|
return config;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await prompts.log.warn(`Failed to load external modules config: ${error.message}`);
|
await prompts.log.warn(`Failed to load modules config: ${error.message}`);
|
||||||
return { modules: {} };
|
return { modules: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of available external modules
|
* Normalize a module entry from either the remote registry format
|
||||||
|
* (snake_case, array) or the legacy bundled format (kebab-case, object map).
|
||||||
|
* @param {Object} mod - Raw module config from YAML
|
||||||
|
* @param {string} [key] - Key name (only for legacy map format)
|
||||||
|
* @returns {Object} Normalized module info
|
||||||
|
*/
|
||||||
|
_normalizeModule(mod, key) {
|
||||||
|
return {
|
||||||
|
key: key || mod.name,
|
||||||
|
url: mod.repository || mod.url,
|
||||||
|
moduleDefinition: mod.module_definition || mod['module-definition'],
|
||||||
|
code: mod.code,
|
||||||
|
name: mod.display_name || mod.name,
|
||||||
|
description: mod.description || '',
|
||||||
|
defaultSelected: mod.default_selected === true || mod.defaultSelected === true,
|
||||||
|
type: mod.type || 'bmad-org',
|
||||||
|
npmPackage: mod.npm_package || mod.npmPackage || null,
|
||||||
|
builtIn: mod.built_in === true,
|
||||||
|
isExternal: mod.built_in !== true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of available modules from the registry
|
||||||
* @returns {Array<Object>} Array of module info objects
|
* @returns {Array<Object>} Array of module info objects
|
||||||
*/
|
*/
|
||||||
async listAvailable() {
|
async listAvailable() {
|
||||||
const config = await this.loadExternalModulesConfig();
|
const config = await this.loadExternalModulesConfig();
|
||||||
const modules = [];
|
|
||||||
|
|
||||||
for (const [key, moduleConfig] of Object.entries(config.modules || {})) {
|
// Remote format: modules is an array
|
||||||
modules.push({
|
if (Array.isArray(config.modules)) {
|
||||||
key,
|
return config.modules.map((mod) => this._normalizeModule(mod));
|
||||||
url: moduleConfig.url,
|
|
||||||
moduleDefinition: moduleConfig['module-definition'],
|
|
||||||
code: moduleConfig.code,
|
|
||||||
name: moduleConfig.name,
|
|
||||||
header: moduleConfig.header,
|
|
||||||
subheader: moduleConfig.subheader,
|
|
||||||
description: moduleConfig.description || '',
|
|
||||||
defaultSelected: moduleConfig.defaultSelected === true,
|
|
||||||
type: moduleConfig.type || 'community', // bmad-org or community
|
|
||||||
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
|
|
||||||
isExternal: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy bundled format: modules is an object map
|
||||||
|
const modules = [];
|
||||||
|
for (const [key, mod] of Object.entries(config.modules || {})) {
|
||||||
|
modules.push(this._normalizeModule(mod, key));
|
||||||
|
}
|
||||||
return modules;
|
return modules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,27 +115,8 @@ class ExternalModuleManager {
|
||||||
* @returns {Object|null} Module info or null if not found
|
* @returns {Object|null} Module info or null if not found
|
||||||
*/
|
*/
|
||||||
async getModuleByKey(key) {
|
async getModuleByKey(key) {
|
||||||
const config = await this.loadExternalModulesConfig();
|
const modules = await this.listAvailable();
|
||||||
const moduleConfig = config.modules?.[key];
|
return modules.find((m) => m.key === key) || null;
|
||||||
|
|
||||||
if (!moduleConfig) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
url: moduleConfig.url,
|
|
||||||
moduleDefinition: moduleConfig['module-definition'],
|
|
||||||
code: moduleConfig.code,
|
|
||||||
name: moduleConfig.name,
|
|
||||||
header: moduleConfig.header,
|
|
||||||
subheader: moduleConfig.subheader,
|
|
||||||
description: moduleConfig.description || '',
|
|
||||||
defaultSelected: moduleConfig.defaultSelected === true,
|
|
||||||
type: moduleConfig.type || 'community', // bmad-org or community
|
|
||||||
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
|
|
||||||
isExternal: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -154,7 +169,7 @@ class ExternalModuleManager {
|
||||||
const moduleInfo = await this.getModuleByCode(moduleCode);
|
const moduleInfo = await this.getModuleByCode(moduleCode);
|
||||||
|
|
||||||
if (!moduleInfo) {
|
if (!moduleInfo) {
|
||||||
throw new Error(`External module '${moduleCode}' not found in external-official-modules.yaml`);
|
throw new Error(`External module '${moduleCode}' not found in the BMad registry`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheDir = this.getExternalCacheDir();
|
const cacheDir = this.getExternalCacheDir();
|
||||||
|
|
@ -304,7 +319,7 @@ class ExternalModuleManager {
|
||||||
async findExternalModuleSource(moduleCode, options = {}) {
|
async findExternalModuleSource(moduleCode, options = {}) {
|
||||||
const moduleInfo = await this.getModuleByCode(moduleCode);
|
const moduleInfo = await this.getModuleByCode(moduleCode);
|
||||||
|
|
||||||
if (!moduleInfo) {
|
if (!moduleInfo || moduleInfo.builtIn) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -349,6 +364,7 @@ class ExternalModuleManager {
|
||||||
// Nothing found: return configured path (preserves old behavior for error messaging)
|
// Nothing found: return configured path (preserves old behavior for error messaging)
|
||||||
return path.dirname(configuredPath);
|
return path.dirname(configuredPath);
|
||||||
}
|
}
|
||||||
|
cachedModules = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { ExternalModuleManager };
|
module.exports = { ExternalModuleManager };
|
||||||
|
|
|
||||||
|
|
@ -98,11 +98,10 @@ class OfficialModules {
|
||||||
/**
|
/**
|
||||||
* List all available built-in modules (core and bmm).
|
* List all available built-in modules (core and bmm).
|
||||||
* All other modules come from external-official-modules.yaml
|
* All other modules come from external-official-modules.yaml
|
||||||
* @returns {Object} Object with modules array and customModules array
|
* @returns {Object} Object with modules array
|
||||||
*/
|
*/
|
||||||
async listAvailable() {
|
async listAvailable() {
|
||||||
const modules = [];
|
const modules = [];
|
||||||
const customModules = [];
|
|
||||||
|
|
||||||
// Add built-in core module (directly under src/core-skills)
|
// Add built-in core module (directly under src/core-skills)
|
||||||
const corePath = getSourcePath('core-skills');
|
const corePath = getSourcePath('core-skills');
|
||||||
|
|
@ -122,7 +121,7 @@ class OfficialModules {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { modules, customModules };
|
return { modules };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -133,25 +132,12 @@ class OfficialModules {
|
||||||
* @returns {Object|null} Module info or null if not a valid module
|
* @returns {Object|null} Module info or null if not a valid module
|
||||||
*/
|
*/
|
||||||
async getModuleInfo(modulePath, defaultName, sourceDescription) {
|
async getModuleInfo(modulePath, defaultName, sourceDescription) {
|
||||||
// Check for module structure (module.yaml OR custom.yaml)
|
|
||||||
const moduleConfigPath = path.join(modulePath, 'module.yaml');
|
const moduleConfigPath = path.join(modulePath, 'module.yaml');
|
||||||
const rootCustomConfigPath = path.join(modulePath, 'custom.yaml');
|
|
||||||
let configPath = null;
|
|
||||||
|
|
||||||
if (await fs.pathExists(moduleConfigPath)) {
|
if (!(await fs.pathExists(moduleConfigPath))) {
|
||||||
configPath = moduleConfigPath;
|
|
||||||
} else if (await fs.pathExists(rootCustomConfigPath)) {
|
|
||||||
configPath = rootCustomConfigPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if this doesn't look like a module
|
|
||||||
if (!configPath) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as custom if it's using custom.yaml OR if it's outside src/bmm or src/core
|
|
||||||
const isCustomSource =
|
|
||||||
sourceDescription !== 'src/bmm-skills' && sourceDescription !== 'src/core-skills' && sourceDescription !== 'src/modules';
|
|
||||||
const moduleInfo = {
|
const moduleInfo = {
|
||||||
id: defaultName,
|
id: defaultName,
|
||||||
path: modulePath,
|
path: modulePath,
|
||||||
|
|
@ -162,12 +148,11 @@ class OfficialModules {
|
||||||
description: 'BMAD Module',
|
description: 'BMAD Module',
|
||||||
version: '5.0.0',
|
version: '5.0.0',
|
||||||
source: sourceDescription,
|
source: sourceDescription,
|
||||||
isCustom: configPath === rootCustomConfigPath || isCustomSource,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Read module config for metadata
|
// Read module config for metadata
|
||||||
try {
|
try {
|
||||||
const configContent = await fs.readFile(configPath, 'utf8');
|
const configContent = await fs.readFile(moduleConfigPath, 'utf8');
|
||||||
const config = yaml.parse(configContent);
|
const config = yaml.parse(configContent);
|
||||||
|
|
||||||
// Use the code property as the id if available
|
// Use the code property as the id if available
|
||||||
|
|
@ -217,6 +202,22 @@ class OfficialModules {
|
||||||
return externalSource;
|
return externalSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check community modules
|
||||||
|
const { CommunityModuleManager } = require('./community-manager');
|
||||||
|
const communityMgr = new CommunityModuleManager();
|
||||||
|
const communitySource = await communityMgr.findModuleSource(moduleCode, options);
|
||||||
|
if (communitySource) {
|
||||||
|
return communitySource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check custom modules (from user-provided URLs, already cloned to cache)
|
||||||
|
const { CustomModuleManager } = require('./custom-module-manager');
|
||||||
|
const customMgr = new CustomModuleManager();
|
||||||
|
const customSource = await customMgr.findModuleSourceByCode(moduleCode, options);
|
||||||
|
if (customSource) {
|
||||||
|
return customSource;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -824,12 +825,8 @@ class OfficialModules {
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
for (const moduleName of modules) {
|
for (const moduleName of modules) {
|
||||||
// Resolve module.yaml path - custom paths first, then standard location, then OfficialModules search
|
// Resolve module.yaml path - standard location first, then OfficialModules search
|
||||||
let moduleConfigPath = null;
|
let moduleConfigPath = null;
|
||||||
const customPath = this.customModulePaths?.get(moduleName);
|
|
||||||
if (customPath) {
|
|
||||||
moduleConfigPath = path.join(customPath, 'module.yaml');
|
|
||||||
} else {
|
|
||||||
const standardPath = path.join(getModulePath(moduleName), 'module.yaml');
|
const standardPath = path.join(getModulePath(moduleName), 'module.yaml');
|
||||||
if (await fs.pathExists(standardPath)) {
|
if (await fs.pathExists(standardPath)) {
|
||||||
moduleConfigPath = standardPath;
|
moduleConfigPath = standardPath;
|
||||||
|
|
@ -839,7 +836,6 @@ class OfficialModules {
|
||||||
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!moduleConfigPath || !(await fs.pathExists(moduleConfigPath))) {
|
if (!moduleConfigPath || !(await fs.pathExists(moduleConfigPath))) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -882,12 +878,9 @@ class OfficialModules {
|
||||||
* @param {Array} modules - List of modules to configure (including 'core')
|
* @param {Array} modules - List of modules to configure (including 'core')
|
||||||
* @param {string} projectDir - Target project directory
|
* @param {string} projectDir - Target project directory
|
||||||
* @param {Object} options - Additional options
|
* @param {Object} options - Additional options
|
||||||
* @param {Map} options.customModulePaths - Map of module ID to source path for custom modules
|
|
||||||
* @param {boolean} options.skipPrompts - Skip prompts and use defaults (for --yes flag)
|
* @param {boolean} options.skipPrompts - Skip prompts and use defaults (for --yes flag)
|
||||||
*/
|
*/
|
||||||
async collectAllConfigurations(modules, projectDir, options = {}) {
|
async collectAllConfigurations(modules, projectDir, options = {}) {
|
||||||
// Store custom module paths for use in collectModuleConfig
|
|
||||||
this.customModulePaths = options.customModulePaths || new Map();
|
|
||||||
this.skipPrompts = options.skipPrompts || false;
|
this.skipPrompts = options.skipPrompts || false;
|
||||||
this.modulesToCustomize = undefined;
|
this.modulesToCustomize = undefined;
|
||||||
await this.loadExistingConfig(projectDir);
|
await this.loadExistingConfig(projectDir);
|
||||||
|
|
@ -1042,25 +1035,7 @@ class OfficialModules {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let configPath = null;
|
if (!(await fs.pathExists(moduleConfigPath))) {
|
||||||
let isCustomModule = false;
|
|
||||||
|
|
||||||
if (await fs.pathExists(moduleConfigPath)) {
|
|
||||||
configPath = moduleConfigPath;
|
|
||||||
} else {
|
|
||||||
// Check if this is a custom module with custom.yaml
|
|
||||||
const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true });
|
|
||||||
|
|
||||||
if (moduleSourcePath) {
|
|
||||||
const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml');
|
|
||||||
|
|
||||||
if (await fs.pathExists(rootCustomConfigPath)) {
|
|
||||||
isCustomModule = true;
|
|
||||||
// For custom modules, we don't have an install-config schema, so just use existing values
|
|
||||||
// The custom.yaml values will be loaded and merged during installation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No config schema for this module - use existing values
|
// No config schema for this module - use existing values
|
||||||
if (this._existingConfig && this._existingConfig[moduleName]) {
|
if (this._existingConfig && this._existingConfig[moduleName]) {
|
||||||
if (!this.collectedConfig[moduleName]) {
|
if (!this.collectedConfig[moduleName]) {
|
||||||
|
|
@ -1071,7 +1046,7 @@ class OfficialModules {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const configContent = await fs.readFile(configPath, 'utf8');
|
const configContent = await fs.readFile(moduleConfigPath, 'utf8');
|
||||||
const moduleConfig = yaml.parse(configContent);
|
const moduleConfig = yaml.parse(configContent);
|
||||||
|
|
||||||
if (!moduleConfig) {
|
if (!moduleConfig) {
|
||||||
|
|
@ -1172,7 +1147,13 @@ class OfficialModules {
|
||||||
// Collect all answers (static + prompted)
|
// Collect all answers (static + prompted)
|
||||||
let allAnswers = { ...staticAnswers };
|
let allAnswers = { ...staticAnswers };
|
||||||
|
|
||||||
if (questions.length > 0) {
|
if (questions.length > 0 && silentMode) {
|
||||||
|
// In silent mode (quick update), use defaults for new fields instead of prompting
|
||||||
|
for (const q of questions) {
|
||||||
|
allAnswers[q.name] = typeof q.default === 'function' ? q.default({}) : q.default;
|
||||||
|
}
|
||||||
|
await prompts.log.message(` \u2713 ${moduleName.toUpperCase()} module configured with defaults`);
|
||||||
|
} else if (questions.length > 0) {
|
||||||
// Only show header if we actually have questions
|
// Only show header if we actually have questions
|
||||||
await CLIUtils.displayModuleConfigHeader(moduleName, moduleConfig.header, moduleConfig.subheader);
|
await CLIUtils.displayModuleConfigHeader(moduleName, moduleConfig.header, moduleConfig.subheader);
|
||||||
await prompts.log.message('');
|
await prompts.log.message('');
|
||||||
|
|
@ -1332,16 +1313,7 @@ class OfficialModules {
|
||||||
this.allAnswers = {};
|
this.allAnswers = {};
|
||||||
}
|
}
|
||||||
// Load module's config
|
// Load module's config
|
||||||
// First, check if we have a custom module path for this module
|
let moduleConfigPath = path.join(getModulePath(moduleName), 'module.yaml');
|
||||||
let moduleConfigPath = null;
|
|
||||||
|
|
||||||
if (this.customModulePaths && this.customModulePaths.has(moduleName)) {
|
|
||||||
const customPath = this.customModulePaths.get(moduleName);
|
|
||||||
moduleConfigPath = path.join(customPath, 'module.yaml');
|
|
||||||
} else {
|
|
||||||
// Try the standard src/modules location
|
|
||||||
moduleConfigPath = path.join(getModulePath(moduleName), 'module.yaml');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not found in src/modules or custom paths, search the project
|
// If not found in src/modules or custom paths, search the project
|
||||||
if (!(await fs.pathExists(moduleConfigPath))) {
|
if (!(await fs.pathExists(moduleConfigPath))) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
const https = require('node:https');
|
||||||
|
const yaml = require('yaml');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared HTTP client for fetching registry data from GitHub.
|
||||||
|
* Used by ExternalModuleManager, CommunityModuleManager, and CustomModuleManager.
|
||||||
|
*/
|
||||||
|
class RegistryClient {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.timeout = options.timeout || 10_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a URL and return the response body as a string.
|
||||||
|
* Follows one redirect (GitHub sometimes 301s).
|
||||||
|
* @param {string} url - URL to fetch
|
||||||
|
* @param {number} [timeout] - Timeout in ms (overrides default)
|
||||||
|
* @returns {Promise<string>} Response body
|
||||||
|
*/
|
||||||
|
fetch(url, timeout) {
|
||||||
|
const timeoutMs = timeout || this.timeout;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https
|
||||||
|
.get(url, { timeout: timeoutMs }, (res) => {
|
||||||
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
return this.fetch(res.headers.location, timeoutMs).then(resolve, reject);
|
||||||
|
}
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
return reject(new Error(`HTTP ${res.statusCode}`));
|
||||||
|
}
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => (data += chunk));
|
||||||
|
res.on('end', () => resolve(data));
|
||||||
|
})
|
||||||
|
.on('error', reject)
|
||||||
|
.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('Request timed out'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a URL and parse the response as YAML.
|
||||||
|
* @param {string} url - URL to fetch
|
||||||
|
* @param {number} [timeout] - Timeout in ms
|
||||||
|
* @returns {Promise<Object>} Parsed YAML content
|
||||||
|
*/
|
||||||
|
async fetchYaml(url, timeout) {
|
||||||
|
const content = await this.fetch(url, timeout);
|
||||||
|
return yaml.parse(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a URL and parse the response as JSON.
|
||||||
|
* @param {string} url - URL to fetch
|
||||||
|
* @param {number} [timeout] - Timeout in ms
|
||||||
|
* @returns {Promise<Object>} Parsed JSON content
|
||||||
|
*/
|
||||||
|
async fetchJson(url, timeout) {
|
||||||
|
const content = await this.fetch(url, timeout);
|
||||||
|
return JSON.parse(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { RegistryClient };
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# This file allows these modules under bmad-code-org to also be installed with the bmad method installer, while
|
# Fallback module registry — used only when the BMad Marketplace repo
|
||||||
# allowing us to keep the source of these projects in separate repos.
|
# (bmad-code-org/bmad-plugins-marketplace) is unreachable.
|
||||||
|
# The remote registry/official.yaml is the source of truth.
|
||||||
|
|
||||||
modules:
|
modules:
|
||||||
bmad-builder:
|
bmad-builder:
|
||||||
|
|
@ -41,13 +42,3 @@ modules:
|
||||||
defaultSelected: false
|
defaultSelected: false
|
||||||
type: bmad-org
|
type: bmad-org
|
||||||
npmPackage: bmad-method-test-architecture-enterprise
|
npmPackage: bmad-method-test-architecture-enterprise
|
||||||
|
|
||||||
whiteport-design-studio:
|
|
||||||
url: https://github.com/bmad-code-org/bmad-method-wds-expansion
|
|
||||||
module-definition: src/module.yaml
|
|
||||||
code: wds
|
|
||||||
name: "Whiteport Design Studio (For UX Professionals)"
|
|
||||||
description: "Whiteport Design Studio (For UX Professionals)"
|
|
||||||
defaultSelected: false
|
|
||||||
type: community
|
|
||||||
npmPackage: bmad-method-wds-expansion
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,316 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.9"
|
||||||
|
# dependencies = []
|
||||||
|
# ///
|
||||||
|
"""Remove legacy module directories from _bmad/ after config migration.
|
||||||
|
|
||||||
|
After merge-config.py and merge-help-csv.py have migrated config data and
|
||||||
|
deleted individual legacy files, this script removes the now-redundant
|
||||||
|
directory trees. These directories contain skill files that are already
|
||||||
|
installed at .claude/skills/ (or equivalent) — only the config files at
|
||||||
|
_bmad/ root need to persist.
|
||||||
|
|
||||||
|
When --skills-dir is provided, the script verifies that every skill found
|
||||||
|
in the legacy directories exists at the installed location before removing
|
||||||
|
anything. Directories without skills (like _config/) are removed directly.
|
||||||
|
|
||||||
|
Exit codes: 0=success (including nothing to remove), 1=validation error, 2=runtime error
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Remove legacy module directories from _bmad/ after config migration."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--bmad-dir",
|
||||||
|
required=True,
|
||||||
|
help="Path to the _bmad/ directory",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--module-code",
|
||||||
|
required=True,
|
||||||
|
help="Module code being cleaned up (e.g. 'bmb')",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--also-remove",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="Additional directory names under _bmad/ to remove (repeatable)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--skills-dir",
|
||||||
|
help="Path to .claude/skills/ — enables safety verification that skills "
|
||||||
|
"are installed before removing legacy copies",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="Print detailed progress to stderr",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def find_skill_dirs(base_path: str) -> list:
|
||||||
|
"""Find installable skill directories under base_path.
|
||||||
|
|
||||||
|
Only considers SKILL.md files at recognized installable positions:
|
||||||
|
- Direct children: base_path/{name}/SKILL.md (legacy flat layout)
|
||||||
|
- Skills subfolder: base_path/skills/{name}/SKILL.md (current layout)
|
||||||
|
|
||||||
|
SKILL.md files nested deeper (e.g. in tasks/, assets/, or within a
|
||||||
|
skill's own subdirectories) are not installable skills and are skipped.
|
||||||
|
|
||||||
|
NOTE: These discovery rules are intentionally stricter than the installer's
|
||||||
|
recursive collectSkills() behavior. The installer is permissive — it walks
|
||||||
|
the entire tree to find all SKILL.md files for installation. Cleanup must
|
||||||
|
be conservative: we only match the two canonical installable layouts so we
|
||||||
|
never accidentally validate a SKILL.md buried in tasks/, assets/, or other
|
||||||
|
non-installable subdirectories as proof that a skill is present.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of skill directory names (e.g. ['bmad-agent-builder', 'bmad-builder-setup'])
|
||||||
|
"""
|
||||||
|
skills = []
|
||||||
|
root = Path(base_path)
|
||||||
|
if not root.exists():
|
||||||
|
return skills
|
||||||
|
|
||||||
|
# Direct child: {name}/SKILL.md
|
||||||
|
for skill_md in root.glob("*/SKILL.md"):
|
||||||
|
skills.append(skill_md.parent.name)
|
||||||
|
|
||||||
|
# Skills subfolder: skills/{name}/SKILL.md
|
||||||
|
skills_root = root / "skills"
|
||||||
|
if skills_root.exists():
|
||||||
|
for skill_md in skills_root.glob("*/SKILL.md"):
|
||||||
|
skills.append(skill_md.parent.name)
|
||||||
|
|
||||||
|
return sorted(set(skills))
|
||||||
|
|
||||||
|
|
||||||
|
def verify_skills_installed(
|
||||||
|
bmad_dir: str, dirs_to_check: list, skills_dir: str, verbose: bool = False
|
||||||
|
) -> list:
|
||||||
|
"""Verify that skills in legacy directories exist at the installed location.
|
||||||
|
|
||||||
|
Scans each directory in dirs_to_check for skill folders (containing SKILL.md),
|
||||||
|
then checks that a matching directory exists under skills_dir. Directories
|
||||||
|
that contain no skills (like _config/) are silently skipped.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of verified skill names.
|
||||||
|
|
||||||
|
Raises SystemExit(1) if any skills are missing from skills_dir.
|
||||||
|
"""
|
||||||
|
all_verified = []
|
||||||
|
missing = []
|
||||||
|
|
||||||
|
for dirname in dirs_to_check:
|
||||||
|
legacy_path = Path(bmad_dir) / dirname
|
||||||
|
if not legacy_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
skill_names = find_skill_dirs(str(legacy_path))
|
||||||
|
if not skill_names:
|
||||||
|
if verbose:
|
||||||
|
print(
|
||||||
|
f"No skills found in {dirname}/ — skipping verification",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for skill_name in skill_names:
|
||||||
|
installed_path = Path(skills_dir) / skill_name
|
||||||
|
if installed_path.is_dir():
|
||||||
|
all_verified.append(skill_name)
|
||||||
|
if verbose:
|
||||||
|
print(
|
||||||
|
f"Verified: {skill_name} exists at {installed_path}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
missing.append(skill_name)
|
||||||
|
if verbose:
|
||||||
|
print(
|
||||||
|
f"MISSING: {skill_name} not found at {installed_path}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"error": "Skills not found at installed location",
|
||||||
|
"missing_skills": missing,
|
||||||
|
"skills_dir": str(Path(skills_dir).resolve()),
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
return sorted(set(all_verified))
|
||||||
|
|
||||||
|
|
||||||
|
def count_files(path: Path) -> int:
|
||||||
|
"""Count all files recursively in a directory."""
|
||||||
|
count = 0
|
||||||
|
for item in path.rglob("*"):
|
||||||
|
if item.is_file():
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_directories(
|
||||||
|
bmad_dir: str, dirs_to_remove: list, verbose: bool = False
|
||||||
|
) -> tuple:
|
||||||
|
"""Remove specified directories under bmad_dir.
|
||||||
|
|
||||||
|
Preserves config.yaml files if present (needed by bmad-init at runtime).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(removed, not_found, total_files_removed) tuple
|
||||||
|
"""
|
||||||
|
removed = []
|
||||||
|
not_found = []
|
||||||
|
total_files = 0
|
||||||
|
|
||||||
|
for dirname in dirs_to_remove:
|
||||||
|
target = Path(bmad_dir) / dirname
|
||||||
|
if not target.exists():
|
||||||
|
not_found.append(dirname)
|
||||||
|
if verbose:
|
||||||
|
print(f"Not found (skipping): {target}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not target.is_dir():
|
||||||
|
if verbose:
|
||||||
|
print(f"Not a directory (skipping): {target}", file=sys.stderr)
|
||||||
|
not_found.append(dirname)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Validate directory name to prevent path traversal
|
||||||
|
if ".." in dirname or "/" in dirname or "\\" in dirname:
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"error": f"Invalid directory name (path traversal rejected): {dirname}",
|
||||||
|
"directories_removed": removed,
|
||||||
|
"directories_failed": dirname,
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, indent=2))
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
# Preserve config.yaml if present (bmad-init needs per-module configs)
|
||||||
|
config_path = target / "config.yaml"
|
||||||
|
config_backup = None
|
||||||
|
if config_path.exists():
|
||||||
|
config_backup = config_path.read_bytes()
|
||||||
|
if verbose:
|
||||||
|
print(f"Preserving config.yaml in {dirname}/", file=sys.stderr)
|
||||||
|
|
||||||
|
file_count = count_files(target)
|
||||||
|
if config_backup is not None:
|
||||||
|
file_count -= 1 # Don't count the preserved file
|
||||||
|
if verbose:
|
||||||
|
print(
|
||||||
|
f"Removing {target} ({file_count} files)",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.rmtree(target)
|
||||||
|
|
||||||
|
# Restore preserved config.yaml
|
||||||
|
if config_backup is not None:
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
config_path.write_bytes(config_backup)
|
||||||
|
if verbose:
|
||||||
|
print(
|
||||||
|
f"Restored config.yaml in {dirname}/",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
except OSError as e:
|
||||||
|
logger.error("Failed during cleanup of %s: %s", target, e)
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"error": f"Failed to remove {target}: {e}",
|
||||||
|
"directories_removed": removed,
|
||||||
|
"directories_failed": dirname,
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, indent=2))
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
removed.append(dirname)
|
||||||
|
total_files += file_count
|
||||||
|
|
||||||
|
return removed, not_found, total_files
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
bmad_dir = args.bmad_dir
|
||||||
|
module_code = args.module_code
|
||||||
|
|
||||||
|
# Build the list of directories to remove
|
||||||
|
dirs_to_remove = [module_code, "core"] + args.also_remove
|
||||||
|
# Deduplicate while preserving order
|
||||||
|
seen = set()
|
||||||
|
unique_dirs = []
|
||||||
|
for d in dirs_to_remove:
|
||||||
|
if d not in seen:
|
||||||
|
seen.add(d)
|
||||||
|
unique_dirs.append(d)
|
||||||
|
dirs_to_remove = unique_dirs
|
||||||
|
|
||||||
|
if args.verbose:
|
||||||
|
print(f"Directories to remove: {dirs_to_remove}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Safety check: verify skills are installed before removing
|
||||||
|
verified_skills = None
|
||||||
|
if args.skills_dir:
|
||||||
|
if args.verbose:
|
||||||
|
print(
|
||||||
|
f"Verifying skills installed at {args.skills_dir}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
verified_skills = verify_skills_installed(
|
||||||
|
bmad_dir, dirs_to_remove, args.skills_dir, args.verbose
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove directories
|
||||||
|
removed, not_found, total_files = cleanup_directories(
|
||||||
|
bmad_dir, dirs_to_remove, args.verbose
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build result
|
||||||
|
result = {
|
||||||
|
"status": "success",
|
||||||
|
"bmad_dir": str(Path(bmad_dir).resolve()),
|
||||||
|
"directories_removed": removed,
|
||||||
|
"directories_not_found": not_found,
|
||||||
|
"files_removed_count": total_files,
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.skills_dir:
|
||||||
|
result["safety_checks"] = {
|
||||||
|
"skills_verified": True,
|
||||||
|
"skills_dir": str(Path(args.skills_dir).resolve()),
|
||||||
|
"verified_skills": verified_skills,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
result["safety_checks"] = None
|
||||||
|
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -156,8 +156,15 @@ function mapInstalledToSource(refPath) {
|
||||||
// Skip install-only paths (generated at install time, not in source)
|
// Skip install-only paths (generated at install time, not in source)
|
||||||
if (isInstallOnly(cleaned)) return null;
|
if (isInstallOnly(cleaned)) return null;
|
||||||
|
|
||||||
// core/, bmm/, and utility/ are directly under src/
|
// Map installed module names to their source directory names
|
||||||
if (cleaned.startsWith('core/') || cleaned.startsWith('bmm/') || cleaned.startsWith('utility/')) {
|
// _bmad/core/ → src/core-skills/, _bmad/bmm/ → src/bmm-skills/
|
||||||
|
if (cleaned.startsWith('core/')) {
|
||||||
|
return path.join(SRC_DIR, 'core-skills', cleaned.slice('core/'.length));
|
||||||
|
}
|
||||||
|
if (cleaned.startsWith('bmm/')) {
|
||||||
|
return path.join(SRC_DIR, 'bmm-skills', cleaned.slice('bmm/'.length));
|
||||||
|
}
|
||||||
|
if (cleaned.startsWith('utility/')) {
|
||||||
return path.join(SRC_DIR, cleaned);
|
return path.join(SRC_DIR, cleaned);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue