Compare commits
13 Commits
796ab64fc8
...
645ba47f7a
| Author | SHA1 | Date |
|---|---|---|
|
|
645ba47f7a | |
|
|
6b60599e3b | |
|
|
95941d7768 | |
|
|
3778cc1082 | |
|
|
e6cdc93b79 | |
|
|
e174bebc60 | |
|
|
fcf20f1c7b | |
|
|
e011192525 | |
|
|
91a57499e9 | |
|
|
48a7ec8bff | |
|
|
3da984a491 | |
|
|
815600e4ca | |
|
|
7ee5fa313b |
|
|
@ -13,7 +13,7 @@
|
||||||
"name": "bmad-pro-skills",
|
"name": "bmad-pro-skills",
|
||||||
"source": "./",
|
"source": "./",
|
||||||
"description": "Next level skills for power users — advanced prompting techniques, agent management, and more.",
|
"description": "Next level skills for power users — advanced prompting techniques, agent management, and more.",
|
||||||
"version": "6.3.0",
|
"version": "6.6.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Brian (BMad) Madison"
|
"name": "Brian (BMad) Madison"
|
||||||
},
|
},
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
"name": "bmad-method-lifecycle",
|
"name": "bmad-method-lifecycle",
|
||||||
"source": "./",
|
"source": "./",
|
||||||
"description": "Full-lifecycle AI development framework — agents and workflows for product analysis, planning, architecture, and implementation.",
|
"description": "Full-lifecycle AI development framework — agents and workflows for product analysis, planning, architecture, and implementation.",
|
||||||
"version": "6.3.0",
|
"version": "6.6.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Brian (BMad) Madison"
|
"name": "Brian (BMad) Madison"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
26
CHANGELOG.md
26
CHANGELOG.md
|
|
@ -1,5 +1,31 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v6.6.0 - 2026-04-28
|
||||||
|
|
||||||
|
### 💥 Breaking Changes
|
||||||
|
|
||||||
|
* `--tools none` is no longer accepted; fresh `--yes` installs now require an explicit `--tools <id>`. Existing-install flows are unchanged. Run `npx bmad-method --list-tools` to see supported IDs (#2346)
|
||||||
|
* `project_name` has moved from `[modules.bmm]` to `[core]` in `config.toml`. Existing installs are auto-migrated on next install/update — no manual action required (#2348)
|
||||||
|
|
||||||
|
### 🎁 Features
|
||||||
|
|
||||||
|
* **Non-interactive config for CI/Docker** — new `--set <module>.<key>=<value>` (repeatable) and `--list-options [module]` flags allow installer configuration without prompts. Routes values to the correct config file with prototype-pollution defenses (#2354)
|
||||||
|
* **Brownfield epic scoping** — Create Epics and Stories workflow now detects file-overlap between epics and applies an Implementation Efficiency principle plus a design completeness gate, reducing unnecessary file churn (#1826)
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
* **Custom module installer** — Azure DevOps URLs now parse correctly with multi-segment paths and `_git` prefixes (#2269); HTTP (non-HTTPS) Git URLs are preserved for self-hosted servers (#2344); community installs route through `PluginResolver` so marketplace plugins with nested `module.yaml` install all skills (#2331); URL-source modules resolve from disk cache on re-install instead of warning (#2323); local `--custom-content` modules resolve correctly and `[modules.<code>]` TOML keys use the module code rather than display name (#2316); `--yes` with `--custom-source` now runs the full update path so version tags are respected (#2336)
|
||||||
|
* **Installer safety** — `--list-tools` flag added; empty/typo'd tool IDs rejected with specific errors (#2346)
|
||||||
|
* **Channel and dist-tag handling** — installer launched from a prerelease (e.g. `@next`) now defaults external module channels to `next` instead of silently downgrading to stable (#2321); stable publishes advance the `@next` dist-tag so prerelease users no longer leapfrog or miss update notifications (#2320)
|
||||||
|
* **Architecture validation gate** — step-07 validation template no longer ships pre-checked; status field is now templated against actual checklist completion (#2347)
|
||||||
|
* **bmad-help data integrity** — `bmad-help.csv` is no longer transformed at merge time and is emitted in its documented schema; 31 misaligned rows in core/bmm `module-help.csv` repaired (#2349)
|
||||||
|
* **Config robustness** — malformed `module.yaml` (scalars, arrays) is now rejected before crash (#2348)
|
||||||
|
* **Legacy cleanup** — pre-v6.2.0 wrapper skills (`bmad-bmm-*`, `bmad-agent-bmm-*`) are removed automatically on upgrade so they no longer error with missing-file warnings (#2315)
|
||||||
|
|
||||||
|
### 📚 Docs
|
||||||
|
|
||||||
|
* Complete Chinese (zh-CN) translations for `named-agents.md` and `expand-bmad-for-your-org.md`; localized BMad Ecosystem sidebar (CIS, BMB, TEA, WDS) across zh-cn, vi-vn, fr-fr, cs-cz (#2355)
|
||||||
|
|
||||||
## v6.5.0 - 2026-04-26
|
## v6.5.0 - 2026-04-26
|
||||||
|
|
||||||
### 🎁 Features
|
### 🎁 Features
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,15 @@ Follow the installer prompts, then open your AI IDE (Claude Code, Cursor, etc.)
|
||||||
npx bmad-method install --directory /path/to/project --modules bmm --tools claude-code --yes
|
npx bmad-method install --directory /path/to/project --modules bmm --tools claude-code --yes
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Override any module config option with `--set <module>.<key>=<value>` (repeatable). Run `--list-options [module]` to see locally-known official keys (built-in modules plus any external officials cached on this machine):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx bmad-method install --yes \
|
||||||
|
--modules bmm --tools claude-code \
|
||||||
|
--set bmm.project_knowledge=research \
|
||||||
|
--set bmm.user_skill_level=expert
|
||||||
|
```
|
||||||
|
|
||||||
[See all installation options](https://docs.bmad-method.org/how-to/non-interactive-installation/)
|
[See all installation options](https://docs.bmad-method.org/how-to/non-interactive-installation/)
|
||||||
|
|
||||||
> **Not sure what to do?** Ask `bmad-help` — it tells you exactly what's next and what's optional. You can also ask questions like `bmad-help I just finished the architecture, what do I do next?`
|
> **Not sure what to do?** Ask `bmad-help` — it tells you exactly what's next and what's optional. You can also ask questions like `bmad-help I just finished the architecture, what do I do next?`
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ Use `npx bmad-method install` to set up BMad in your project. One command handle
|
||||||
|
|
||||||
- **Node.js** 20+ (the installer requires it)
|
- **Node.js** 20+ (the installer requires it)
|
||||||
- **Git** (for cloning external modules)
|
- **Git** (for cloning external modules)
|
||||||
- **An AI tool** such as Claude Code or Cursor — or install without one using `--tools none`
|
- **An AI tool** such as Claude Code or Cursor (run `npx bmad-method install --list-tools` to see all supported tools)
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
|
@ -31,13 +31,17 @@ npx bmad-method install
|
||||||
The interactive flow asks you five things:
|
The interactive flow asks you five things:
|
||||||
|
|
||||||
1. Installation directory (defaults to the current working directory)
|
1. Installation directory (defaults to the current working directory)
|
||||||
2. Which modules to install (checkboxes for core, bmm, bmb, cis, gds, tea)
|
2. Which modules to install (checkboxes for core, bmm, bmb, cis, gds, tea, bma)
|
||||||
3. **"Ready to install (all stable)?"** — Yes accepts the latest released tag for every external module
|
3. **"Ready to install (all stable)?"** — Yes accepts the latest released tag for every external module
|
||||||
4. Which AI tools/IDEs to integrate with (claude-code, cursor, and others)
|
4. Which AI tools/IDEs to integrate with (claude-code, cursor, and others)
|
||||||
5. Per-module config (name, language, output folder)
|
5. Per-module config (name, language, output folder)
|
||||||
|
|
||||||
Accept the defaults and you land on the latest stable release of every module, configured for your chosen tool.
|
Accept the defaults and you land on the latest stable release of every module, configured for your chosen tool.
|
||||||
|
|
||||||
|
:::caution[BMad Automator constraints]
|
||||||
|
`bma` installs runnable Automator skills only for the Claude Code entrypoint. Codex is supported as a worker target only, and worker sessions currently require `tmux` on macOS.
|
||||||
|
:::
|
||||||
|
|
||||||
:::tip[Just want the newest prerelease?]
|
:::tip[Just want the newest prerelease?]
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -53,7 +57,7 @@ Two independent axes control what ends up on disk.
|
||||||
|
|
||||||
### Axis 1: external module channels
|
### Axis 1: external module channels
|
||||||
|
|
||||||
Every external module — bmb, cis, gds, tea, and any community module — installs on one of three channels:
|
Every external module — bmb, cis, gds, tea, bma, and any community module — installs on one of three channels:
|
||||||
|
|
||||||
| Channel | What gets installed | Who picks this |
|
| Channel | What gets installed | Who picks this |
|
||||||
| ------------------ | ---------------------------------------------------------------------------- | --------------------------------------- |
|
| ------------------ | ---------------------------------------------------------------------------- | --------------------------------------- |
|
||||||
|
|
@ -118,11 +122,12 @@ Under `--yes`, patch and minor upgrades apply automatically. Majors stay frozen
|
||||||
### Flag reference
|
### Flag reference
|
||||||
|
|
||||||
| Flag | Purpose |
|
| Flag | Purpose |
|
||||||
| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
|
| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `--yes`, `-y` | Skip all prompts; accept flag values + defaults |
|
| `--yes`, `-y` | Skip all prompts; accept flag values + defaults |
|
||||||
| `--directory <path>` | Install into this directory (default: current working dir) |
|
| `--directory <path>` | Install into this directory (default: current working dir) |
|
||||||
| `--modules <a,b,c>` | Exact module set. Core is auto-added. Not a delta — list everything you want kept. |
|
| `--modules <a,b,c>` | Exact module set. Core is auto-added. Not a delta — list everything you want kept. |
|
||||||
| `--tools <a,b>` or `--tools none` | IDE/tool selection. `none` skips tool config entirely. |
|
| `--tools <a,b>` | IDE/tool selection. Required for fresh `--yes` installs. Run `--list-tools` for valid IDs. |
|
||||||
|
| `--list-tools` | Print all supported tool/IDE IDs (with target directories) and exit. |
|
||||||
| `--action <type>` | `install`, `update`, or `quick-update`. Defaults based on existing install state. |
|
| `--action <type>` | `install`, `update`, or `quick-update`. Defaults based on existing install state. |
|
||||||
| `--custom-source <urls>` | Install custom modules from Git URLs or local paths |
|
| `--custom-source <urls>` | Install custom modules from Git URLs or local paths |
|
||||||
| `--channel <stable\|next>` | Apply to all externals (aliased as `--all-stable` / `--all-next`) |
|
| `--channel <stable\|next>` | Apply to all externals (aliased as `--all-stable` / `--all-next`) |
|
||||||
|
|
@ -130,7 +135,9 @@ Under `--yes`, patch and minor upgrades apply automatically. Majors stay frozen
|
||||||
| `--all-next` | Alias for `--channel=next` |
|
| `--all-next` | Alias for `--channel=next` |
|
||||||
| `--next=<code>` | Put one module on next. Repeatable. |
|
| `--next=<code>` | Put one module on next. Repeatable. |
|
||||||
| `--pin <code>=<tag>` | Pin one module to a specific tag. Repeatable. |
|
| `--pin <code>=<tag>` | Pin one module to a specific tag. Repeatable. |
|
||||||
| `--user-name`, `--communication-language`, `--document-output-language`, `--output-folder` | Override per-user config defaults |
|
| `--set <module>.<key>=<value>` | Set any module config option non-interactively (preferred — see [Module config overrides](#module-config-overrides)). Repeatable. |
|
||||||
|
| `--list-options [module]` | Print every `--set` key for built-in and locally-cached official modules, then exit. Pass a module code to scope to one module. |
|
||||||
|
| `--user-name`, `--communication-language`, `--document-output-language`, `--output-folder` | Legacy shortcuts equivalent to `--set core.<key>=<value>` (still supported) |
|
||||||
|
|
||||||
Precedence when flags overlap: `--pin` beats `--next=` beats `--channel` / `--all-*` beats the registry default (`stable`).
|
Precedence when flags overlap: `--pin` beats `--next=` beats `--channel` / `--all-*` beats the registry default (`stable`).
|
||||||
|
|
||||||
|
|
@ -165,19 +172,56 @@ npx bmad-method install --yes --modules bmm,bmb --all-next --tools claude-code
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx bmad-method install --yes --action update \
|
npx bmad-method install --yes --action update \
|
||||||
--modules bmm,bmb,gds \
|
--modules bmm,bmb,gds
|
||||||
--tools none
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--tools` is omitted intentionally — `--action update` reuses the tools configured during the first install.
|
||||||
|
|
||||||
**Mix channels — bmb on next, gds on stable:**
|
**Mix channels — bmb on next, gds on stable:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx bmad-method install --yes --action update \
|
npx bmad-method install --yes --action update \
|
||||||
--modules bmm,bmb,cis,gds \
|
--modules bmm,bmb,cis,gds \
|
||||||
--next=bmb \
|
--next=bmb
|
||||||
--tools none
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Module config overrides
|
||||||
|
|
||||||
|
`--set <module>.<key>=<value>` lets you set any module config option non-interactively. It's repeatable and scales to every module — present and future. The flag is applied as a post-install patch: the installer runs its normal flow first, then `--set` upserts each value into `_bmad/config.toml` (team scope) or `_bmad/config.user.toml` (user scope), and into `_bmad/<module>/config.yaml` so declared values carry forward to the next install.
|
||||||
|
|
||||||
|
**Example — install bmm with explicit project knowledge and skill level:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx bmad-method install --yes \
|
||||||
|
--modules bmm \
|
||||||
|
--tools claude-code \
|
||||||
|
--set bmm.project_knowledge=research \
|
||||||
|
--set bmm.user_skill_level=expert
|
||||||
|
```
|
||||||
|
|
||||||
|
**Discover available keys for a module:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx bmad-method install --list-options bmm
|
||||||
|
```
|
||||||
|
|
||||||
|
`--list-options` (no argument) lists every key the installer can find locally — built-in modules (`core`, `bmm`) plus any currently cached official modules. The cache is per-machine and can be cleared, so previously installed officials won't appear on a fresh checkout or an ephemeral CI worker until they're installed again. Community and custom modules aren't enumerated here; read the module's `module.yaml` directly to see what keys it declares.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
- **Routing.** The patch step looks for `[modules.<module>] <key>` (or `[core] <key>`) in `config.user.toml` first; if found there, it updates that file. Otherwise it writes to the team-scope `config.toml`. So user-scope keys (e.g. `core.user_name`, `bmm.user_skill_level`) end up in `config.user.toml` and team-scope keys end up in `config.toml`, matching the partition the installer uses.
|
||||||
|
- **Verbatim values.** The value is written exactly as you provided it — no `result:` template rendering. To get the rendered form (e.g. `{project-root}/research`), pass it explicitly: `--set bmm.project_knowledge='{project-root}/research'`.
|
||||||
|
- **Carry-forward, declared keys.** Values for keys declared in `module.yaml` survive subsequent installs because they're also written to `_bmad/<module>/config.yaml`, which the installer reads as the prompt default on the next run.
|
||||||
|
- **Carry-forward, undeclared keys.** A value for a key the module's schema doesn't declare lands in `config.toml` for the current install but won't be re-emitted on the next install (the manifest writer's schema-strict partition drops unknown keys). Re-pass `--set` if you need it sticky, or edit `_bmad/config.toml` directly.
|
||||||
|
- **No validation.** `single-select` values aren't checked against the allowed choices, and unknown keys aren't rejected — whatever you assert is written.
|
||||||
|
- **Modules not in `--modules`.** Setting a value for a module you didn't include prints a warning and the value is dropped (no file gets created for an uninstalled module).
|
||||||
|
|
||||||
|
The legacy core shortcuts (`--user-name`, `--output-folder`, etc.) still work and remain documented for backward compatibility, but `--set core.user_name=...` is equivalent.
|
||||||
|
|
||||||
|
:::note[Works with quick-update]
|
||||||
|
`--set` is a post-install patch, so it applies the same way regardless of action type. Under `bmad install --action quick-update` (or `--yes` against an existing install, where quick-update is the default), `--set` patches the central config files at the end just like a regular install.
|
||||||
|
:::
|
||||||
|
|
||||||
:::caution[Rate limit on shared IPs]
|
:::caution[Rate limit on shared IPs]
|
||||||
Anonymous GitHub API calls are capped at 60/hour per IP. A single install hits the API once per external module to resolve the stable tag. Offices behind NAT, CI runner pools, and VPNs can collectively exhaust this.
|
Anonymous GitHub API calls are capped at 60/hour per IP. A single install hits the API once per external module to resolve the stable tag. Offices behind NAT, CI runner pools, and VPNs can collectively exhaust this.
|
||||||
|
|
||||||
|
|
@ -204,7 +248,7 @@ For cross-machine reproducibility, don't rely on rerunning the same `--modules`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx bmad-method install --yes --modules bmb,cis \
|
npx bmad-method install --yes --modules bmb,cis \
|
||||||
--pin bmb=v1.7.0 --pin cis=v0.4.2 --tools none
|
--pin bmb=v1.7.0 --pin cis=v0.4.2 --tools claude-code
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,24 @@ Enterprise-grade test strategy, automation guidance, and release gate decisions
|
||||||
- NFR assessment, CI setup, and framework scaffolding
|
- NFR assessment, CI setup, and framework scaffolding
|
||||||
- P0-P3 prioritization with optional Playwright Utils and MCP integrations
|
- P0-P3 prioritization with optional Playwright Utils and MCP integrations
|
||||||
|
|
||||||
|
## BMad Automator (Experimental)
|
||||||
|
|
||||||
|
Automates the BMad story build loop with a pure skill bundle sourced from the separate Automator repository.
|
||||||
|
|
||||||
|
- **Code:** `bma`
|
||||||
|
- **npm:** [`bmad-story-automator`](https://www.npmjs.com/package/bmad-story-automator)
|
||||||
|
- **GitHub:** [bmad-code-org/bmad-automator](https://github.com/bmad-code-org/bmad-automator)
|
||||||
|
|
||||||
|
:::caution[Experimental Claude Code-only entrypoint]
|
||||||
|
BMad Automator only runs from Claude Code. It currently supports Claude Code and Codex worker sessions, and requires tmux on macOS.
|
||||||
|
:::
|
||||||
|
|
||||||
|
**Provides:**
|
||||||
|
|
||||||
|
- Story build-cycle automation across story creation, development, QA automation, review, and retrospective
|
||||||
|
- Resumable tmux orchestration state
|
||||||
|
- Claude Code entry skill plus Claude Code/Codex worker-session coordination
|
||||||
|
|
||||||
## Community Modules
|
## Community Modules
|
||||||
|
|
||||||
Community modules and a module marketplace are coming. Check the [BMad GitHub organization](https://github.com/bmad-code-org) for updates.
|
Community modules and a module marketplace are coming. Check the [BMad GitHub organization](https://github.com/bmad-code-org) for updates.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
---
|
||||||
|
title: "命名智能体"
|
||||||
|
description: 为什么 BMad 的智能体有名字、人设和自定义能力——相比菜单驱动或纯提示驱动的方案,这解锁了哪些可能性
|
||||||
|
sidebar:
|
||||||
|
order: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
你说"嘿 Mary,咱们来头脑风暴",Mary 就激活了。她用你配置的语言、以她独特的人设向你打招呼,并提醒你随时可以用 `bmad-help`。然后她跳过菜单,直接进入头脑风暴——因为你的意图已经足够明确。
|
||||||
|
|
||||||
|
这一页解释背后发生了什么,以及 BMad 为什么这样设计。
|
||||||
|
|
||||||
|
## 三足鼎立
|
||||||
|
|
||||||
|
BMad 的智能体模型建立在三个可组合的基本要素之上:
|
||||||
|
|
||||||
|
| 要素 | 提供什么 | 所在位置 |
|
||||||
|
|---|---|---|
|
||||||
|
| **技能(Skill)** | 能力——一项智能体能做的具体事(头脑风暴、撰写 PRD、实现 story) | `.claude/skills/{skill-name}/SKILL.md`(或你所用 IDE 的等价位置) |
|
||||||
|
| **命名智能体(Named Agent)** | 人设连续性——一个可辨识的身份,把一组相关技能包装在统一的语气、原则和视觉标识下 | 目录名以 `bmad-agent-*` 开头的技能 |
|
||||||
|
| **自定义(Customization)** | 让它成为你的——覆盖选项可以重塑智能体行为、添加 MCP 集成、替换模板、叠加组织规范 | `_bmad/custom/{skill-name}.toml`(团队提交的覆盖)和 `.user.toml`(个人,已 gitignore) |
|
||||||
|
|
||||||
|
抽掉任何一条腿,体验就会坍塌:
|
||||||
|
|
||||||
|
- 有技能没智能体 → 用户只能靠名称或编号在能力列表里自行查找
|
||||||
|
- 有智能体没技能 → 空有人设,没有能力
|
||||||
|
- 没有自定义 → 所有人用一模一样的开箱默认,任何组织特有需求都只能靠 fork
|
||||||
|
|
||||||
|
## 命名智能体带来了什么
|
||||||
|
|
||||||
|
BMad 内置六个命名智能体,各自对应 BMad Method 的一个阶段:
|
||||||
|
|
||||||
|
| 智能体 | 阶段 | 模块 |
|
||||||
|
|---|---|---|
|
||||||
|
| 📊 **Mary**,商业分析师 | 分析 | 市场调研、头脑风暴、产品摘要、PRFAQ |
|
||||||
|
| 📚 **Paige**,技术文档工程师 | 分析 | 项目文档、流程图、文档校验 |
|
||||||
|
| 📋 **John**,产品经理 | 规划 | PRD 创建、Epic/Story 拆分、实施就绪评审 |
|
||||||
|
| 🎨 **Sally**,UX 设计师 | 规划 | UX 设计规范 |
|
||||||
|
| 🏗️ **Winston**,系统架构师 | 方案设计 | 技术架构、一致性检查 |
|
||||||
|
| 💻 **Amelia**,高级工程师 | 实现 | Story 执行、快速开发、代码评审、Sprint 规划 |
|
||||||
|
|
||||||
|
每位智能体都有硬编码的身份(名字、职衔、专业领域)和可自定义的层(角色、原则、沟通风格、图标、菜单)。你可以重写 Mary 的原则或添加菜单项,但无法改她的名字——这是刻意为之的。品牌辨识度经得起自定义,所以"嘿 Mary"永远激活分析师,无论团队怎样塑造她的行为。
|
||||||
|
|
||||||
|
## 激活流程
|
||||||
|
|
||||||
|
调用命名智能体时,八个步骤依次执行:
|
||||||
|
|
||||||
|
1. **解析智能体配置** — 通过 Python 解析器(使用 stdlib `tomllib`)将内置 `customize.toml` 与团队覆盖和个人覆盖合并
|
||||||
|
2. **执行前置步骤** — 团队配置的任何预处理行为
|
||||||
|
3. **采用人设** — 硬编码身份加上自定义的角色、沟通风格、原则
|
||||||
|
4. **加载持久化事实** — 组织规则、合规说明,可通过 `file:` 前缀加载文件(如 `file:{project-root}/docs/project-context.md`)
|
||||||
|
5. **加载配置** — 用户名、沟通语言、输出语言、产物路径
|
||||||
|
6. **打招呼** — 个性化问候,使用配置的语言,带上智能体的 emoji 前缀让你一眼认出谁在说话
|
||||||
|
7. **执行后置步骤** — 团队配置的任何问候后设置
|
||||||
|
8. **分发或展示菜单** — 如果你的开场消息能匹配某个菜单项,直接执行;否则展示菜单等待输入
|
||||||
|
|
||||||
|
第 8 步是意图与能力的交汇点。"嘿 Mary,咱们来头脑风暴"之所以跳过菜单渲染,是因为 `bmad-brainstorming` 显然对应 Mary 菜单上的 `BP`。如果你说的比较模糊,她会简短问一句,而不是走确认仪式。如果完全不匹配,她会正常继续对话。
|
||||||
|
|
||||||
|
## 为什么不只用菜单?
|
||||||
|
|
||||||
|
菜单迫使用户迁就工具。你得记住头脑风暴在分析师智能体的 `BP` 编码下,而不是 PM 智能体上,还得知道哪个人设负责哪些功能。这些都是工具强加给你的认知负担。
|
||||||
|
|
||||||
|
命名智能体把这个关系反转了。你用任何自然的方式,对着某个人说你想做什么。智能体知道自己是谁、能做什么。当你的意图足够清晰,她就直接开始。
|
||||||
|
|
||||||
|
菜单仍然作为兜底存在——探索时展示,确定时跳过。
|
||||||
|
|
||||||
|
## 为什么不直接用空白提示?
|
||||||
|
|
||||||
|
空白提示假设你知道"魔法咒语"。"帮我头脑风暴"也许有用,但"帮我发散下我这个 SaaS 创意"可能就不灵了,而结果取决于你怎么措辞。你变成了提示工程师。
|
||||||
|
|
||||||
|
命名智能体在不牺牲自由度的前提下增加了结构。人设保持一致,能力随时可发现,`bmad-help` 永远只差一个命令。你不用猜智能体能做什么,也不需要翻手册才能用它。
|
||||||
|
|
||||||
|
## 自定义是一等公民
|
||||||
|
|
||||||
|
自定义模型让这套方案能从单个开发者扩展到整个组织。
|
||||||
|
|
||||||
|
每个智能体自带 `customize.toml` 及合理默认值。团队在 `_bmad/custom/bmad-agent-{role}.toml` 中提交覆盖。个人可以在 `.user.toml`(已 gitignore)中叠加偏好。解析器在激活时按可预测的结构化规则合并三层配置。
|
||||||
|
|
||||||
|
大多数用户从不需要手写这些文件。`bmad-customize` 技能会引导你选择目标、区分智能体/工作流作用域、撰写覆盖、验证合并结果——让自定义能力对任何理解自己意图的人开放,不限于精通 TOML 的人。
|
||||||
|
|
||||||
|
举个例子:团队提交一个文件,告诉 Amelia 查库文档时一律用 Context7 MCP 工具,本地 epics 列表找不到 story 时回退到 Linear。Amelia 分发的每个开发工作流(dev-story、quick-dev、create-story、code-review)都继承这些行为,无需改源码、无需逐工作流重复配置。
|
||||||
|
|
||||||
|
此外还有第二个自定义面,用于**跨领域关注点**:中央配置 `_bmad/config.toml` 和 `_bmad/config.user.toml`(由安装器维护,从每个模块的 `module.yaml` 重建)加上 `_bmad/custom/config.toml`(团队提交)和 `_bmad/custom/config.user.toml`(个人,已 gitignore)作为覆盖。这里存放着 **智能体花名册** ——轻量级描述符,`bmad-party-mode`、`bmad-retrospective` 和 `bmad-advanced-elicitation` 等花名册消费者读取它来了解有哪些智能体可用、如何扮演它们。用团队覆盖在全组织范围重新定义某个智能体;用 `.user.toml` 覆盖添加虚构角色(Kirk、Spock、领域专家)作为个人实验——无需碰任何技能目录。每个技能的配置文件塑造 Mary **激活时的行为**;中央配置塑造其他技能**查看花名册时看到的 Mary**。
|
||||||
|
|
||||||
|
完整自定义文档和实操示例请参见:
|
||||||
|
|
||||||
|
- [如何自定义 BMad](../how-to/customize-bmad.md) — 可自定义项和合并规则的参考
|
||||||
|
- [如何为组织扩展 BMad](../how-to/expand-bmad-for-your-org.md) — 五个实操方案,覆盖智能体全局规则、工作流约定、外部发布、模板替换和花名册管理
|
||||||
|
- `bmad-customize` 技能 — 引导式编写助手,将你的意图转换为正确放置并经过验证的覆盖文件
|
||||||
|
|
||||||
|
## 更大的理念
|
||||||
|
|
||||||
|
当今大多数 AI 助手要么是菜单,要么是提示框,两者都把认知负担推给了用户。命名智能体加上可自定义技能,让你可以和一个了解项目的队友对话,并且让你的组织能塑造这个队友而不必 fork。
|
||||||
|
|
||||||
|
下次你输入"嘿 Mary,咱们来头脑风暴",她直接上手干活时,留意一下哪些事情**没有**发生。没有斜杠命令,没有菜单要翻,没有尴尬的功能介绍。这种"无感",正是设计本身。
|
||||||
|
|
@ -0,0 +1,258 @@
|
||||||
|
---
|
||||||
|
title: "如何为组织扩展 BMad"
|
||||||
|
description: 五个自定义方案,无需 fork 即可重塑 BMad——涵盖智能体全局规则、工作流约定、外部发布、模板替换和花名册变更
|
||||||
|
sidebar:
|
||||||
|
order: 9
|
||||||
|
---
|
||||||
|
|
||||||
|
BMad 的自定义机制让组织无需编辑已安装文件或 fork 技能就能重塑行为。本指南介绍五个方案,覆盖大部分企业级需求。
|
||||||
|
|
||||||
|
:::note[前置条件]
|
||||||
|
|
||||||
|
- 已在项目中安装 BMad(参见[如何安装 BMad](./install-bmad.md))
|
||||||
|
- 熟悉自定义模型(参见[如何自定义 BMad](./customize-bmad.md))
|
||||||
|
- PATH 中有 Python 3.11+(解析器只用标准库,不需要 `pip install`)
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::tip[如何应用这些方案]
|
||||||
|
下面的**逐技能方案**(方案 1–4)可以通过运行 `bmad-customize` 技能并描述意图来应用——它会选择正确的配置面、生成覆盖文件并验证合并结果。方案 5(中央配置的花名册覆盖)超出 v1 技能范围,仍需手动编写。本文档中的方案是覆盖**什么**的权威参考;`bmad-customize` 负责处理**怎么做**的部分(针对智能体/工作流层面)。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 三层心智模型
|
||||||
|
|
||||||
|
在选择方案之前,先理解你的覆盖落在哪一层:
|
||||||
|
|
||||||
|
| 层 | 覆盖文件位置 | 作用范围 |
|
||||||
|
|---|---|---|
|
||||||
|
| **智能体**(如 Amelia、Mary、John) | `_bmad/custom/bmad-agent-{role}.toml` 中的 `[agent]` 段 | 跟随人设进入**该智能体分发的每个工作流** |
|
||||||
|
| **工作流**(如 product-brief、create-prd) | `_bmad/custom/{workflow-name}.toml` 中的 `[workflow]` 段 | 仅作用于该工作流的单次运行 |
|
||||||
|
| **中央配置** | `_bmad/custom/config.toml` 中的 `[agents.*]`、`[core]`、`[modules.*]` | 花名册(party-mode、retrospective、elicitation 可用的角色)、全组织统一的安装设置 |
|
||||||
|
|
||||||
|
经验法则:如果规则应当在工程师做任何开发工作时生效,就自定义**开发智能体**。如果只在撰写产品摘要时生效,就自定义 **product-brief 工作流**。如果要改变"谁在场"(重命名智能体、添加自定义角色、统一产物路径),就编辑**中央配置**。
|
||||||
|
|
||||||
|
## 方案 1:让智能体的规则贯穿其分发的所有工作流
|
||||||
|
|
||||||
|
**场景:** 统一工具使用和外部系统集成,让智能体分发的每个工作流都继承这些行为。这是影响面最大的模式。
|
||||||
|
|
||||||
|
**示例:Amelia(开发智能体)查库文档一律用 Context7,本地 epics 列表找不到 story 时回退到 Linear。**
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# _bmad/custom/bmad-agent-dev.toml
|
||||||
|
|
||||||
|
[agent]
|
||||||
|
|
||||||
|
# 每次激活时加载。传递到 dev-story、quick-dev、
|
||||||
|
# create-story、code-review、qa-generate——Amelia 分发的每个技能。
|
||||||
|
persistent_facts = [
|
||||||
|
"For any library documentation lookup (React, TypeScript, Zod, Prisma, etc.), call the context7 MCP tool (`mcp__context7__resolve_library_id` then `mcp__context7__get_library_docs`) before relying on training-data knowledge. Up-to-date docs trump memorized APIs.",
|
||||||
|
"When a story reference isn't found in {planning_artifacts}/epics-and-stories.md, search Linear via `mcp__linear__search_issues` using the story ID or title before asking the user to clarify. If Linear returns a match, treat it as the authoritative story source.",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么有效:** 两句话就能重塑组织内所有开发工作流,无需逐工作流重复配置、无需改源码。每个新工程师拉下仓库就自动继承这些约定。
|
||||||
|
|
||||||
|
**团队文件 vs 个人文件:**
|
||||||
|
- `bmad-agent-dev.toml`:提交到 git,对整个团队生效
|
||||||
|
- `bmad-agent-dev.user.toml`:已 gitignore,个人偏好叠加在上面
|
||||||
|
|
||||||
|
## 方案 2:在特定工作流中强制执行组织规范
|
||||||
|
|
||||||
|
**场景:** 塑造工作流输出的*内容*,使其满足合规、审计或下游消费者的要求。
|
||||||
|
|
||||||
|
**示例:每份产品摘要都必须包含合规字段,智能体知晓组织的发布规范。**
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# _bmad/custom/bmad-product-brief.toml
|
||||||
|
|
||||||
|
[workflow]
|
||||||
|
|
||||||
|
persistent_facts = [
|
||||||
|
"Every brief must include an 'Owner' field, a 'Target Release' field, and a 'Security Review Status' field.",
|
||||||
|
"Non-commercial briefs (internal tools, research projects) must still include a user-value section, but can omit market differentiation.",
|
||||||
|
"file:{project-root}/docs/enterprise/brief-publishing-conventions.md",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果:** 这些事实在工作流激活的第 3 步加载。当智能体起草摘要时,它已了解必填字段和企业规范文档。内置默认值(`file:{project-root}/**/project-context.md`)仍会加载,因为这是追加操作。
|
||||||
|
|
||||||
|
## 方案 3:将完成的产出发布到外部系统
|
||||||
|
|
||||||
|
**场景:** 工作流生成输出后,自动发布到企业级记录系统(Confluence、Notion、SharePoint)并创建后续工作项(Jira、Linear、Asana)。
|
||||||
|
|
||||||
|
**示例:摘要自动发布到 Confluence,并提供可选的 Jira Epic 创建。**
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# _bmad/custom/bmad-product-brief.toml
|
||||||
|
|
||||||
|
[workflow]
|
||||||
|
|
||||||
|
# 终端钩子。标量覆盖会整体替换空默认值。
|
||||||
|
on_complete = """
|
||||||
|
Publish and offer follow-up:
|
||||||
|
|
||||||
|
1. Read the finalized brief file path from the prior step.
|
||||||
|
2. Call `mcp__atlassian__confluence_create_page` with:
|
||||||
|
- space: "PRODUCT"
|
||||||
|
- parent: "Product Briefs"
|
||||||
|
- title: the brief's title
|
||||||
|
- body: the brief's markdown contents
|
||||||
|
Capture the returned page URL.
|
||||||
|
3. Tell the user: "Brief published to Confluence: <url>".
|
||||||
|
4. Ask: "Want me to open a Jira epic for this brief now?"
|
||||||
|
5. If yes, call `mcp__atlassian__jira_create_issue` with:
|
||||||
|
- type: "Epic"
|
||||||
|
- project: "PROD"
|
||||||
|
- summary: the brief's title
|
||||||
|
- description: a short summary plus a link back to the Confluence page.
|
||||||
|
Report the epic key and URL.
|
||||||
|
6. If no, exit cleanly.
|
||||||
|
|
||||||
|
If either MCP tool fails, report the failure, print the brief path,
|
||||||
|
and ask the user to publish manually.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么用 `on_complete` 而不是 `activation_steps_append`:** `on_complete` 只在终端阶段运行一次,在工作流主输出写入之后。这是发布产物的正确时机。`activation_steps_append` 在每次激活时运行,在工作流开始之前。
|
||||||
|
|
||||||
|
**权衡:**
|
||||||
|
- **Confluence 发布是非破坏性的**,完成时始终运行
|
||||||
|
- **Jira Epic 创建对全团队可见**,会触发 Sprint 规划信号,因此需用户确认
|
||||||
|
- **优雅降级:** 如果 MCP 工具失败,交给用户手动处理,而不是静默丢弃输出
|
||||||
|
|
||||||
|
## 方案 4:替换为你自己的输出模板
|
||||||
|
|
||||||
|
**场景:** 默认输出结构不符合组织期望的格式,或同一仓库中不同团队需要不同模板。
|
||||||
|
|
||||||
|
**示例:将 product-brief 工作流指向企业自有模板。**
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# _bmad/custom/bmad-product-brief.toml
|
||||||
|
|
||||||
|
[workflow]
|
||||||
|
brief_template = "{project-root}/docs/enterprise/brief-template.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
**原理:** 工作流自带的 `customize.toml` 中 `brief_template = "resources/brief-template.md"`(裸路径,从技能根目录解析)。你的覆盖指向 `{project-root}` 下的文件,智能体在第 4 步读取你的模板而非内置模板。
|
||||||
|
|
||||||
|
**模板编写建议:**
|
||||||
|
- 将模板放在 `{project-root}/docs/` 或 `{project-root}/_bmad/custom/templates/` 下,使它们与覆盖文件一起版本管理
|
||||||
|
- 沿用内置模板的结构约定(章节标题、frontmatter),智能体会适配实际内容
|
||||||
|
- 对于多团队仓库,使用 `.user.toml` 让各团队指向自己的模板,无需改动已提交的团队文件
|
||||||
|
|
||||||
|
## 方案 5:自定义花名册
|
||||||
|
|
||||||
|
**场景:** 改变 `bmad-party-mode`、`bmad-retrospective` 和 `bmad-advanced-elicitation` 等花名册驱动技能中*谁在场*,无需编辑源码或 fork。以下是三种常见变体。
|
||||||
|
|
||||||
|
### 5a. 在全组织范围内重塑 BMad 智能体
|
||||||
|
|
||||||
|
每个真实智能体都有一段安装器从 `module.yaml` 合成的描述符。覆盖它可以在所有花名册消费者中改变语气和定位:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# _bmad/custom/config.toml(提交到 git——对每个开发者生效)
|
||||||
|
|
||||||
|
[agents.bmad-agent-analyst]
|
||||||
|
description = "Mary the Regulatory-Aware Business Analyst — channels Porter and Minto, but lives and breathes FDA audit trails. Speaks like a forensic investigator presenting a case file."
|
||||||
|
```
|
||||||
|
|
||||||
|
Party-mode 会用新描述来生成 Mary。分析师激活流程本身不受影响,因为 Mary 的行为由她的每技能 `customize.toml` 控制。这个覆盖改变的是**外部技能如何感知和介绍她**,而不是她的内部工作方式。
|
||||||
|
|
||||||
|
### 5b. 添加虚构或自定义智能体
|
||||||
|
|
||||||
|
一段完整的描述符就足以让花名册功能识别,不需要技能目录。适合在 party mode 或头脑风暴中增加性格多样性:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# _bmad/custom/config.user.toml(个人——已 gitignore)
|
||||||
|
|
||||||
|
[agents.spock]
|
||||||
|
team = "startrek"
|
||||||
|
name = "Commander Spock"
|
||||||
|
title = "Science Officer"
|
||||||
|
icon = "🖖"
|
||||||
|
description = "Logic first, emotion suppressed. Begins observations with 'Fascinating.' Never rounds up. Counterpoint to any argument that relies on gut instinct."
|
||||||
|
|
||||||
|
[agents.mccoy]
|
||||||
|
team = "startrek"
|
||||||
|
name = "Dr. Leonard McCoy"
|
||||||
|
title = "Chief Medical Officer"
|
||||||
|
icon = "⚕️"
|
||||||
|
description = "Country doctor's warmth, short fuse. 'Dammit Jim, I'm a doctor not a ___.' Ethics-driven counterweight to Spock."
|
||||||
|
```
|
||||||
|
|
||||||
|
让 party-mode "邀请企业号船员",它会按 `team = "startrek"` 过滤并生成 Spock 和 McCoy。真实的 BMad 智能体(Mary、Amelia)也可以同桌。
|
||||||
|
|
||||||
|
### 5c. 锁定团队安装设置
|
||||||
|
|
||||||
|
安装器会向每个开发者提示 `planning_artifacts` 路径等值。当组织需要一个统一答案时,在中央配置中锁定——任何开发者本地的提示回答都会在解析时被覆盖:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# _bmad/custom/config.toml
|
||||||
|
|
||||||
|
[modules.bmm]
|
||||||
|
planning_artifacts = "{project-root}/shared/planning"
|
||||||
|
implementation_artifacts = "{project-root}/shared/implementation"
|
||||||
|
|
||||||
|
[core]
|
||||||
|
document_output_language = "English"
|
||||||
|
```
|
||||||
|
|
||||||
|
个人设置如 `user_name`、`communication_language` 或 `user_skill_level` 留在各开发者自己的 `_bmad/config.user.toml` 中。团队文件不应触碰这些。
|
||||||
|
|
||||||
|
**为什么用中央配置而不是逐智能体的 customize.toml:** 逐智能体文件塑造*一个*智能体激活时的行为。中央配置塑造花名册消费者*查看全局时看到的内容:*有哪些智能体、叫什么、属于哪个团队,以及整个仓库共识的安装设置。两个层面,各司其职。
|
||||||
|
|
||||||
|
## 在 IDE 会话文件中强化全局规则
|
||||||
|
|
||||||
|
BMad 的自定义在技能激活时加载。许多 IDE 工具还会在**每次会话开始时**加载一个全局指令文件,在任何技能运行之前(`CLAUDE.md`、`AGENTS.md`、`.cursor/rules/`、`.github/copilot-instructions.md` 等)。对于即使在 BMad 技能之外也应生效的规则,请在全局指令中也声明一份。
|
||||||
|
|
||||||
|
**何时需要"双重声明":**
|
||||||
|
- 规则足够重要,即使在普通对话(没有激活技能)中也应遵守
|
||||||
|
- 你需要"双保险",因为模型的训练数据默认值可能会拉偏方向
|
||||||
|
- 规则足够精简,重复一次不会让会话文件臃肿
|
||||||
|
|
||||||
|
**示例:在仓库的 `CLAUDE.md` 中强化方案 1 的开发智能体规则。**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
<!-- Any file-read of library docs goes through the context7 MCP tool
|
||||||
|
(`mcp__context7__resolve_library_id` then `mcp__context7__get_library_docs`)
|
||||||
|
before relying on training-data knowledge. -->
|
||||||
|
```
|
||||||
|
|
||||||
|
一句话,每次会话加载。它与 `bmad-agent-dev.toml` 自定义配合,使规则在 Amelia 的工作流内和与助手的临时对话中都生效。各层各管各的范围:
|
||||||
|
|
||||||
|
| 层 | 作用范围 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| IDE 会话文件(`CLAUDE.md` / `AGENTS.md`) | 每次会话,在任何技能激活之前 | 简短的、应在 BMad 之外也生效的通用规则 |
|
||||||
|
| BMad 智能体自定义 | 该智能体分发的每个工作流 | 智能体人设相关的行为 |
|
||||||
|
| BMad 工作流自定义 | 单次工作流运行 | 工作流特定的输出格式、发布钩子、模板 |
|
||||||
|
| BMad 中央配置 | 花名册 + 共享安装设置 | 谁在场、团队使用的共享路径 |
|
||||||
|
|
||||||
|
IDE 会话文件要**精简**。十几行精挑细选的规则比长篇大论有效得多。模型每轮都会读取它,噪声会淹没信号。
|
||||||
|
|
||||||
|
## 组合使用
|
||||||
|
|
||||||
|
五个方案可以自由组合。一个典型的企业级 `bmad-product-brief` 覆盖可能同时设置 `persistent_facts`(方案 2)、`on_complete`(方案 3)和 `brief_template`(方案 4)。智能体级规则(方案 1)在另一个以智能体命名的文件中,中央配置(方案 5)锁定共享花名册和团队设置,四者并行生效。
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# _bmad/custom/bmad-product-brief.toml(工作流级)
|
||||||
|
|
||||||
|
[workflow]
|
||||||
|
persistent_facts = ["..."]
|
||||||
|
brief_template = "{project-root}/docs/enterprise/brief-template.md"
|
||||||
|
on_complete = """ ... """
|
||||||
|
```
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# _bmad/custom/bmad-agent-analyst.toml(智能体级——Mary 分发 product-brief)
|
||||||
|
|
||||||
|
[agent]
|
||||||
|
persistent_facts = ["Always include a 'Regulatory Review' section when the domain involves healthcare, finance, or children's data."]
|
||||||
|
```
|
||||||
|
|
||||||
|
效果:Mary 在人设激活时加载监管评审规则。当用户选择 product-brief 菜单项时,工作流加载自己的规范、写入企业模板,完成后发布到 Confluence。每一层各有贡献,且无一需要编辑 BMad 源码。
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
**覆盖没有生效?** 检查文件是否在 `_bmad/custom/` 下且使用了准确的技能目录名(如 `bmad-agent-dev.toml`,而非 `bmad-dev.toml`)。参见[如何自定义 BMad](./customize-bmad.md)。
|
||||||
|
|
||||||
|
**MCP 工具名称不确定?** 使用 MCP 服务器在当前会话中暴露的准确名称。如果不确定,让 Claude Code 列出可用的 MCP 工具。在 `persistent_facts` 或 `on_complete` 中硬编码的名称,在 MCP 服务器未连接时不会生效。
|
||||||
|
|
||||||
|
**方案不适用于你的场景?** 以上方案是示例性的。底层机制(三层合并、结构化规则、智能体贯穿工作流)支持更多模式,按需组合即可。
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "bmad-method",
|
"name": "bmad-method",
|
||||||
"version": "6.5.0",
|
"version": "6.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bmad-method",
|
"name": "bmad-method",
|
||||||
"version": "6.5.0",
|
"version": "6.6.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/core": "^1.0.0",
|
"@clack/core": "^1.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "bmad-method",
|
"name": "bmad-method",
|
||||||
"version": "6.5.0",
|
"version": "6.6.0",
|
||||||
"description": "Breakthrough Method of Agile AI-driven Development",
|
"description": "Breakthrough Method of Agile AI-driven Development",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"agile",
|
"agile",
|
||||||
|
|
@ -39,12 +39,13 @@
|
||||||
"lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix",
|
"lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix",
|
||||||
"lint:md": "markdownlint-cli2 \"**/*.md\"",
|
"lint:md": "markdownlint-cli2 \"**/*.md\"",
|
||||||
"prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0",
|
"prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0",
|
||||||
"quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run validate:refs && npm run validate:skills",
|
"quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run test:urls && npm run validate:refs && npm run validate:skills",
|
||||||
"rebundle": "node tools/installer/bundlers/bundle-web.js rebundle",
|
"rebundle": "node tools/installer/bundlers/bundle-web.js rebundle",
|
||||||
"test": "npm run test:refs && npm run test:install && npm run test:channels && npm run lint && npm run lint:md && npm run format:check",
|
"test": "npm run test:refs && npm run test:install && npm run test:urls && npm run test:channels && npm run lint && npm run lint:md && npm run format:check",
|
||||||
"test:channels": "node test/test-installer-channels.js",
|
"test:channels": "node test/test-installer-channels.js",
|
||||||
"test:install": "node test/test-installation-components.js",
|
"test:install": "node test/test-installation-components.js",
|
||||||
"test:refs": "node test/test-file-refs-csv.js",
|
"test:refs": "node test/test-file-refs-csv.js",
|
||||||
|
"test:urls": "node test/test-parse-source-urls.js",
|
||||||
"validate:refs": "node tools/validate-file-refs.js --strict",
|
"validate:refs": "node tools/validate-file-refs.js --strict",
|
||||||
"validate:skills": "node tools/validate-skills.js --strict"
|
"validate:skills": "node tools/validate-skills.js --strict"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -227,37 +227,39 @@ Prepare the content to append to the document:
|
||||||
|
|
||||||
### Architecture Completeness Checklist
|
### Architecture Completeness Checklist
|
||||||
|
|
||||||
**✅ Requirements Analysis**
|
Mark each item `[x]` only if validation confirms it; leave `[ ]` if it is missing, partial, or unverified. Any unchecked item must be reflected in the Gap Analysis above and in the Overall Status below.
|
||||||
|
|
||||||
- [x] Project context thoroughly analyzed
|
**Requirements Analysis**
|
||||||
- [x] Scale and complexity assessed
|
|
||||||
- [x] Technical constraints identified
|
|
||||||
- [x] Cross-cutting concerns mapped
|
|
||||||
|
|
||||||
**✅ Architectural Decisions**
|
- [ ] Project context thoroughly analyzed
|
||||||
|
- [ ] Scale and complexity assessed
|
||||||
|
- [ ] Technical constraints identified
|
||||||
|
- [ ] Cross-cutting concerns mapped
|
||||||
|
|
||||||
- [x] Critical decisions documented with versions
|
**Architectural Decisions**
|
||||||
- [x] Technology stack fully specified
|
|
||||||
- [x] Integration patterns defined
|
|
||||||
- [x] Performance considerations addressed
|
|
||||||
|
|
||||||
**✅ Implementation Patterns**
|
- [ ] Critical decisions documented with versions
|
||||||
|
- [ ] Technology stack fully specified
|
||||||
|
- [ ] Integration patterns defined
|
||||||
|
- [ ] Performance considerations addressed
|
||||||
|
|
||||||
- [x] Naming conventions established
|
**Implementation Patterns**
|
||||||
- [x] Structure patterns defined
|
|
||||||
- [x] Communication patterns specified
|
|
||||||
- [x] Process patterns documented
|
|
||||||
|
|
||||||
**✅ Project Structure**
|
- [ ] Naming conventions established
|
||||||
|
- [ ] Structure patterns defined
|
||||||
|
- [ ] Communication patterns specified
|
||||||
|
- [ ] Process patterns documented
|
||||||
|
|
||||||
- [x] Complete directory structure defined
|
**Project Structure**
|
||||||
- [x] Component boundaries established
|
|
||||||
- [x] Integration points mapped
|
- [ ] Complete directory structure defined
|
||||||
- [x] Requirements to structure mapping complete
|
- [ ] Component boundaries established
|
||||||
|
- [ ] Integration points mapped
|
||||||
|
- [ ] Requirements to structure mapping complete
|
||||||
|
|
||||||
### Architecture Readiness Assessment
|
### Architecture Readiness Assessment
|
||||||
|
|
||||||
**Overall Status:** READY FOR IMPLEMENTATION
|
**Overall Status:** {{READY FOR IMPLEMENTATION | READY WITH MINOR GAPS | NOT READY}} (choose READY FOR IMPLEMENTATION only when all 16 checklist items are `[x]` and no Critical Gaps remain; choose NOT READY when any Critical Gap is open or any Requirements Analysis or Architectural Decisions item is unchecked; otherwise READY WITH MINOR GAPS)
|
||||||
|
|
||||||
**Confidence Level:** {{high/medium/low}} based on validation results
|
**Confidence Level:** {{high/medium/low}} based on validation results
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,33 @@
|
||||||
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,_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
|
||||||
BMad Method,bmad-correct-course,Correct Course,CC,Navigate significant changes. May recommend start over update PRD redo architecture sprint planning or correct epics and stories.,,anytime,,,false,planning_artifacts,change proposal
|
BMad Method,bmad-correct-course,Correct Course,CC,Navigate significant changes. May recommend start over update PRD redo architecture sprint planning or correct epics and stories.,,,anytime,,,false,planning_artifacts,change proposal
|
||||||
BMad Method,bmad-agent-tech-writer,Write Document,WD,"Describe in detail what you want, and the agent will follow documentation best practices. Multi-turn conversation with subprocess for research/review.",write,,anytime,,,false,project-knowledge,document
|
BMad Method,bmad-agent-tech-writer,Write Document,WD,"Describe in detail what you want, and the agent will follow documentation best practices. Multi-turn conversation with subprocess for research/review.",write,,anytime,,,false,project-knowledge,document
|
||||||
BMad Method,bmad-agent-tech-writer,Update Standards,US,Update agent memory documentation-standards.md with your specific preferences if you discover missing document conventions.,update-standards,,anytime,,,false,_bmad/_memory/tech-writer-sidecar,standards
|
BMad Method,bmad-agent-tech-writer,Update Standards,US,Update agent memory documentation-standards.md with your specific preferences if you discover missing document conventions.,update-standards,,anytime,,,false,_bmad/_memory/tech-writer-sidecar,standards
|
||||||
BMad Method,bmad-agent-tech-writer,Mermaid Generate,MG,Create a Mermaid diagram based on user description. Will suggest diagram types if not specified.,mermaid,,anytime,,,false,planning_artifacts,mermaid diagram
|
BMad Method,bmad-agent-tech-writer,Mermaid Generate,MG,Create a Mermaid diagram based on user description. Will suggest diagram types if not specified.,mermaid,,anytime,,,false,planning_artifacts,mermaid diagram
|
||||||
BMad Method,bmad-agent-tech-writer,Validate Document,VD,Review the specified document against documentation standards and best practices. Returns specific actionable improvement suggestions organized by priority.,validate,[path],anytime,,,false,planning_artifacts,validation report
|
BMad Method,bmad-agent-tech-writer,Validate Document,VD,Review the specified document against documentation standards and best practices. Returns specific actionable improvement suggestions organized by priority.,validate,[path],anytime,,,false,planning_artifacts,validation report
|
||||||
BMad Method,bmad-agent-tech-writer,Explain Concept,EC,Create clear technical explanations with examples and diagrams for complex concepts.,explain,[topic],anytime,,,false,project_knowledge,explanation
|
BMad Method,bmad-agent-tech-writer,Explain Concept,EC,Create clear technical explanations with examples and diagrams for complex concepts.,explain,[topic],anytime,,,false,project_knowledge,explanation
|
||||||
BMad Method,bmad-brainstorming,Brainstorm Project,BP,Expert guided facilitation through a single or multiple techniques.,,1-analysis,,,false,planning_artifacts,brainstorming session
|
BMad Method,bmad-brainstorming,Brainstorm Project,BP,Expert guided facilitation through a single or multiple techniques.,,,1-analysis,,,false,planning_artifacts,brainstorming session
|
||||||
BMad Method,bmad-market-research,Market Research,MR,"Market analysis competitive landscape customer needs and trends.",,1-analysis,,,false,"planning_artifacts|project-knowledge",research documents
|
BMad Method,bmad-market-research,Market Research,MR,Market analysis competitive landscape customer needs and trends.,,,1-analysis,,,false,planning_artifacts|project-knowledge,research documents
|
||||||
BMad Method,bmad-domain-research,Domain Research,DR,Industry domain deep dive subject matter expertise and terminology.,,1-analysis,,,false,"planning_artifacts|project_knowledge",research documents
|
BMad Method,bmad-domain-research,Domain Research,DR,Industry domain deep dive subject matter expertise and terminology.,,,1-analysis,,,false,planning_artifacts|project_knowledge,research documents
|
||||||
BMad Method,bmad-technical-research,Technical Research,TR,Technical feasibility architecture options and implementation approaches.,,1-analysis,,,false,"planning_artifacts|project_knowledge",research documents
|
BMad Method,bmad-technical-research,Technical Research,TR,Technical feasibility architecture options and implementation approaches.,,,1-analysis,,,false,planning_artifacts|project_knowledge,research documents
|
||||||
BMad Method,bmad-product-brief,Create Brief,CB,An expert guided experience to nail down your product idea in a brief. a gentler approach than PRFAQ when you are already sure of your concept and nothing will sway you.,,-A,1-analysis,,,false,planning_artifacts,product brief
|
BMad Method,bmad-product-brief,Create Brief,CB,An expert guided experience to nail down your product idea in a brief. a gentler approach than PRFAQ when you are already sure of your concept and nothing will sway you.,,-A,1-analysis,,,false,planning_artifacts,product brief
|
||||||
BMad Method,bmad-prfaq,PRFAQ Challenge,WB,Working Backwards guided experience to forge and stress-test your product concept to ensure you have a great product that users will love and need through the PRFAQ gauntlet to determine feasibility and alignment with user needs. alternative to product brief.,,-H,1-analysis,,,false,planning_artifacts,prfaq document
|
BMad Method,bmad-prfaq,PRFAQ Challenge,WB,Working Backwards guided experience to forge and stress-test your product concept to ensure you have a great product that users will love and need through the PRFAQ gauntlet to determine feasibility and alignment with user needs. alternative to product brief.,,-H,1-analysis,,,false,planning_artifacts,prfaq document
|
||||||
BMad Method,bmad-create-prd,Create PRD,CP,Expert led facilitation to produce your Product Requirements Document.,,2-planning,,,true,planning_artifacts,prd
|
BMad Method,bmad-create-prd,Create PRD,CP,Expert led facilitation to produce your Product Requirements Document.,,,2-planning,,,true,planning_artifacts,prd
|
||||||
BMad Method,bmad-validate-prd,Validate PRD,VP,,,[path],2-planning,bmad-create-prd,,false,planning_artifacts,prd validation report
|
BMad Method,bmad-validate-prd,Validate PRD,VP,,,[path],2-planning,bmad-create-prd,,false,planning_artifacts,prd validation report
|
||||||
BMad Method,bmad-edit-prd,Edit PRD,EP,,,[path],2-planning,bmad-validate-prd,,false,planning_artifacts,updated prd
|
BMad Method,bmad-edit-prd,Edit PRD,EP,,,[path],2-planning,bmad-validate-prd,,false,planning_artifacts,updated prd
|
||||||
BMad Method,bmad-create-ux-design,Create UX,CU,"Guidance through realizing the plan for your UX, strongly recommended if a UI is a primary piece of the proposed project.",,2-planning,bmad-create-prd,,false,planning_artifacts,ux design
|
BMad Method,bmad-create-ux-design,Create UX,CU,"Guidance through realizing the plan for your UX, strongly recommended if a UI is a primary piece of the proposed project.",,,2-planning,bmad-create-prd,,false,planning_artifacts,ux design
|
||||||
BMad Method,bmad-create-architecture,Create Architecture,CA,Guided workflow to document technical decisions.,,3-solutioning,,,true,planning_artifacts,architecture
|
BMad Method,bmad-create-architecture,Create Architecture,CA,Guided workflow to document technical decisions.,,,3-solutioning,,,true,planning_artifacts,architecture
|
||||||
BMad Method,bmad-create-epics-and-stories,Create Epics and Stories,CE,,,3-solutioning,bmad-create-architecture,,true,planning_artifacts,epics and stories
|
BMad Method,bmad-create-epics-and-stories,Create Epics and Stories,CE,,,,3-solutioning,bmad-create-architecture,,true,planning_artifacts,epics and stories
|
||||||
BMad Method,bmad-check-implementation-readiness,Check Implementation Readiness,IR,Ensure PRD UX Architecture and Epics Stories are aligned.,,3-solutioning,bmad-create-epics-and-stories,,true,planning_artifacts,readiness report
|
BMad Method,bmad-check-implementation-readiness,Check Implementation Readiness,IR,Ensure PRD UX Architecture and Epics Stories are aligned.,,,3-solutioning,bmad-create-epics-and-stories,,true,planning_artifacts,readiness report
|
||||||
BMad Method,bmad-sprint-planning,Sprint Planning,SP,Kicks off implementation by producing a plan the implementation agents will follow in sequence for every story.,,4-implementation,,,true,implementation_artifacts,sprint status
|
BMad Method,bmad-sprint-planning,Sprint Planning,SP,Kicks off implementation by producing a plan the implementation agents will follow in sequence for every story.,,,4-implementation,,,true,implementation_artifacts,sprint status
|
||||||
BMad Method,bmad-sprint-status,Sprint Status,SS,Anytime: Summarize sprint status and route to next workflow.,,4-implementation,bmad-sprint-planning,,false,,
|
BMad Method,bmad-sprint-status,Sprint Status,SS,Anytime: Summarize sprint status and route to next workflow.,,,4-implementation,bmad-sprint-planning,,false,,
|
||||||
BMad Method,bmad-create-story,Create Story,CS,"Story cycle start: Prepare first found story in the sprint plan that is next or a specific epic/story designation.",create,,4-implementation,bmad-sprint-planning,bmad-create-story:validate,true,implementation_artifacts,story
|
BMad Method,bmad-create-story,Create Story,CS,Story cycle start: Prepare first found story in the sprint plan that is next or a specific epic/story designation.,create,,4-implementation,bmad-sprint-planning,bmad-create-story:validate,true,implementation_artifacts,story
|
||||||
BMad Method,bmad-create-story,Validate Story,VS,Validates story readiness and completeness before development work begins.,validate,,4-implementation,bmad-create-story:create,bmad-dev-story,false,implementation_artifacts,story validation report
|
BMad Method,bmad-create-story,Validate Story,VS,Validates story readiness and completeness before development work begins.,validate,,4-implementation,bmad-create-story:create,bmad-dev-story,false,implementation_artifacts,story validation report
|
||||||
BMad Method,bmad-dev-story,Dev Story,DS,Story cycle: Execute story implementation tasks and tests then CR then back to DS if fixes needed.,,4-implementation,bmad-create-story:validate,,true,,
|
BMad Method,bmad-dev-story,Dev Story,DS,Story cycle: Execute story implementation tasks and tests then CR then back to DS if fixes needed.,,,4-implementation,bmad-create-story:validate,,true,,
|
||||||
BMad Method,bmad-code-review,Code Review,CR,Story cycle: If issues back to DS if approved then next CS or ER if epic complete.,,4-implementation,bmad-dev-story,,false,,
|
BMad Method,bmad-code-review,Code Review,CR,Story cycle: If issues back to DS if approved then next CS or ER if epic complete.,,,4-implementation,bmad-dev-story,,false,,
|
||||||
BMad Method,bmad-checkpoint-preview,Checkpoint,CK,Guided walkthrough of a change from purpose and context into details. Use for human review of commits branches or PRs.,,4-implementation,,,false,,
|
BMad Method,bmad-checkpoint-preview,Checkpoint,CK,Guided walkthrough of a change from purpose and context into details. Use for human review of commits branches or PRs.,,,4-implementation,,,false,,
|
||||||
BMad Method,bmad-qa-generate-e2e-tests,QA Automation Test,QA,Generate automated API and E2E tests for implemented code. NOT for code review or story validation — use CR for that.,,4-implementation,bmad-dev-story,,false,implementation_artifacts,test suite
|
BMad Method,bmad-qa-generate-e2e-tests,QA Automation Test,QA,Generate automated API and E2E tests for implemented code. NOT for code review or story validation — use CR for that.,,,4-implementation,bmad-dev-story,,false,implementation_artifacts,test suite
|
||||||
BMad Method,bmad-retrospective,Retrospective,ER,Optional at epic end: Review completed work lessons learned and next epic or if major issues consider CC.,,4-implementation,bmad-code-review,,false,implementation_artifacts,retrospective
|
BMad Method,bmad-retrospective,Retrospective,ER,Optional at epic end: Review completed work lessons learned and next epic or if major issues consider CC.,,,4-implementation,bmad-code-review,,false,implementation_artifacts,retrospective
|
||||||
|
|
|
||||||
|
Can't render this file because it has a wrong number of fields in line 3.
|
|
|
@ -5,15 +5,11 @@ default_selected: true # This module will be selected by default for new install
|
||||||
|
|
||||||
# Variables from Core Config inserted:
|
# Variables from Core Config inserted:
|
||||||
## user_name
|
## user_name
|
||||||
|
## project_name
|
||||||
## communication_language
|
## communication_language
|
||||||
## document_output_language
|
## document_output_language
|
||||||
## output_folder
|
## output_folder
|
||||||
|
|
||||||
project_name:
|
|
||||||
prompt: "What is your project called?"
|
|
||||||
default: "{directory_name}"
|
|
||||||
result: "{value}"
|
|
||||||
|
|
||||||
user_skill_level:
|
user_skill_level:
|
||||||
prompt:
|
prompt:
|
||||||
- "What is your development experience level?"
|
- "What is your development experience level?"
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
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,_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,,
|
||||||
Core,bmad-index-docs,Index Docs,ID,Use when LLM needs to understand available docs without loading everything.,,anytime,,,false,,
|
Core,bmad-index-docs,Index Docs,ID,Use when LLM needs to understand available docs without loading everything.,,,anytime,,,false,,
|
||||||
Core,bmad-shard-doc,Shard Document,SD,Use when doc becomes too large (>500 lines) to manage effectively.,[path],anytime,,,false,,
|
Core,bmad-shard-doc,Shard Document,SD,Use when doc becomes too large (>500 lines) to manage effectively.,,[path],anytime,,,false,,
|
||||||
Core,bmad-editorial-review-prose,Editorial Review - Prose,EP,Use after drafting to polish written content.,[path],anytime,,,false,report located with target document,three-column markdown table with suggested fixes
|
Core,bmad-editorial-review-prose,Editorial Review - Prose,EP,Use after drafting to polish written content.,,[path],anytime,,,false,report located with target document,three-column markdown table with suggested fixes
|
||||||
Core,bmad-editorial-review-structure,Editorial Review - Structure,ES,Use when doc produced from multiple subprocesses or needs structural improvement.,[path],anytime,,,false,report located with target document,
|
Core,bmad-editorial-review-structure,Editorial Review - Structure,ES,Use when doc produced from multiple subprocesses or needs structural improvement.,,[path],anytime,,,false,report located with target document,
|
||||||
Core,bmad-review-adversarial-general,Adversarial Review,AR,"Use for quality assurance or before finalizing deliverables. Code Review in other modules runs this automatically, but also useful for document reviews.",[path],anytime,,,false,,
|
Core,bmad-review-adversarial-general,Adversarial Review,AR,"Use for quality assurance or before finalizing deliverables. Code Review in other modules runs this automatically, but also useful for document reviews.",,[path],anytime,,,false,,
|
||||||
Core,bmad-review-edge-case-hunter,Edge Case Hunter Review,ECH,Use alongside adversarial review for orthogonal coverage — method-driven not attitude-driven.,[path],anytime,,,false,,
|
Core,bmad-review-edge-case-hunter,Edge Case Hunter Review,ECH,Use alongside adversarial review for orthogonal coverage — method-driven not attitude-driven.,,[path],anytime,,,false,,
|
||||||
Core,bmad-distillator,Distillator,DG,Use when you need token-efficient distillates that preserve all information for downstream LLM consumption.,[path],anytime,,,false,adjacent to source document or specified output_path,distillate markdown file(s)
|
Core,bmad-distillator,Distillator,DG,Use when you need token-efficient distillates that preserve all information for downstream LLM consumption.,,[path],anytime,,,false,adjacent to source document or specified output_path,distillate markdown file(s)
|
||||||
Core,bmad-customize,BMad Customize,BC,"Use when you want to change how an agent or workflow behaves — add persistent facts, swap templates, insert activation hooks, or customize menus. Scans what's customizable, picks the right scope (agent vs workflow), writes the override to _bmad/custom/, and verifies the merge. No TOML hand-authoring required.",,anytime,,,false,{project-root}/_bmad/custom,TOML override files
|
Core,bmad-customize,BMad Customize,BC,"Use when you want to change how an agent or workflow behaves — add persistent facts, swap templates, insert activation hooks, or customize menus. Scans what's customizable, picks the right scope (agent vs workflow), writes the override to _bmad/custom/, and verifies the merge. No TOML hand-authoring required.",,,anytime,,,false,{project-root}/_bmad/custom,TOML override files
|
||||||
|
|
|
||||||
|
Can't render this file because it has a wrong number of fields in line 3.
|
|
|
@ -11,6 +11,11 @@ user_name:
|
||||||
default: "BMad"
|
default: "BMad"
|
||||||
result: "{value}"
|
result: "{value}"
|
||||||
|
|
||||||
|
project_name:
|
||||||
|
prompt: "What is your project called?"
|
||||||
|
default: "{directory_name}"
|
||||||
|
result: "{value}"
|
||||||
|
|
||||||
communication_language:
|
communication_language:
|
||||||
prompt: "What language should agents use when chatting with you?"
|
prompt: "What language should agents use when chatting with you?"
|
||||||
scope: user
|
scope: user
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ const { Installer } = require('../tools/installer/core/installer');
|
||||||
const { ManifestGenerator } = require('../tools/installer/core/manifest-generator');
|
const { ManifestGenerator } = require('../tools/installer/core/manifest-generator');
|
||||||
const { OfficialModules } = require('../tools/installer/modules/official-modules');
|
const { OfficialModules } = require('../tools/installer/modules/official-modules');
|
||||||
const { IdeManager } = require('../tools/installer/ide/manager');
|
const { IdeManager } = require('../tools/installer/ide/manager');
|
||||||
|
const { ExternalModuleManager } = require('../tools/installer/modules/external-manager');
|
||||||
const { clearCache, loadPlatformCodes } = require('../tools/installer/ide/platform-codes');
|
const { clearCache, loadPlatformCodes } = require('../tools/installer/ide/platform-codes');
|
||||||
|
|
||||||
// ANSI colors
|
// ANSI colors
|
||||||
|
|
@ -85,6 +86,42 @@ async function createTestBmadFixture() {
|
||||||
return fixtureDir;
|
return fixtureDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createAutomatorBmadFixture() {
|
||||||
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-fixture-'));
|
||||||
|
const fixtureDir = path.join(fixtureRoot, '_bmad');
|
||||||
|
await fs.ensureDir(path.join(fixtureDir, '_config'));
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(fixtureDir, '_config', 'skill-manifest.csv'),
|
||||||
|
[
|
||||||
|
'canonicalId,name,description,module,path',
|
||||||
|
'"bmad-master","bmad-master","Minimal core skill","core","_bmad/core/bmad-master/SKILL.md"',
|
||||||
|
'"bmad-story-automator","bmad-story-automator","Automator skill","bma","_bmad/bma/bmad-story-automator/SKILL.md"',
|
||||||
|
'"bmad-story-automator-review","bmad-story-automator-review","Automator review skill","bma","_bmad/bma/bmad-story-automator-review/SKILL.md"',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const coreSkillDir = path.join(fixtureDir, 'core', 'bmad-master');
|
||||||
|
await fs.ensureDir(coreSkillDir);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(coreSkillDir, 'SKILL.md'),
|
||||||
|
['---', 'name: bmad-master', 'description: Minimal core skill', '---', '', 'Core skill body.'].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const skillName of ['bmad-story-automator', 'bmad-story-automator-review']) {
|
||||||
|
const skillDir = path.join(fixtureDir, 'bma', skillName);
|
||||||
|
await fs.ensureDir(skillDir);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(skillDir, 'SKILL.md'),
|
||||||
|
['---', `name: ${skillName}`, 'description: Automator skill', '---', '', 'Automator body.'].join('\n'),
|
||||||
|
);
|
||||||
|
await fs.writeFile(path.join(skillDir, 'workflow.md'), `# ${skillName}\n\nAutomator workflow body.\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fixtureDir;
|
||||||
|
}
|
||||||
|
|
||||||
async function createSkillCollisionFixture() {
|
async function createSkillCollisionFixture() {
|
||||||
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-skill-collision-'));
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-skill-collision-'));
|
||||||
const fixtureDir = path.join(fixtureRoot, '_bmad');
|
const fixtureDir = path.join(fixtureRoot, '_bmad');
|
||||||
|
|
@ -1813,12 +1850,12 @@ async function runTests() {
|
||||||
const moduleConfigs = {
|
const moduleConfigs = {
|
||||||
core: {
|
core: {
|
||||||
user_name: 'TestUser',
|
user_name: 'TestUser',
|
||||||
|
project_name: 'demo-project',
|
||||||
communication_language: 'Spanish',
|
communication_language: 'Spanish',
|
||||||
document_output_language: 'English',
|
document_output_language: 'English',
|
||||||
output_folder: '_bmad-output',
|
output_folder: '_bmad-output',
|
||||||
},
|
},
|
||||||
bmm: {
|
bmm: {
|
||||||
project_name: 'demo-project',
|
|
||||||
user_skill_level: 'expert',
|
user_skill_level: 'expert',
|
||||||
planning_artifacts: '{project-root}/_bmad-output/planning-artifacts',
|
planning_artifacts: '{project-root}/_bmad-output/planning-artifacts',
|
||||||
implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts',
|
implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts',
|
||||||
|
|
@ -1826,7 +1863,10 @@ async function runTests() {
|
||||||
// Spread-from-core pollution: legacy per-module config.yaml merges
|
// Spread-from-core pollution: legacy per-module config.yaml merges
|
||||||
// core values into every module; writeCentralConfig must strip these
|
// core values into every module; writeCentralConfig must strip these
|
||||||
// from [modules.bmm] so core values only live in [core].
|
// from [modules.bmm] so core values only live in [core].
|
||||||
|
// project_name is now a core key (#2279), so it joins user_name etc.
|
||||||
|
// as a spread-from-core key that must be stripped.
|
||||||
user_name: 'TestUser',
|
user_name: 'TestUser',
|
||||||
|
project_name: 'stale-bmm-copy',
|
||||||
communication_language: 'Spanish',
|
communication_language: 'Spanish',
|
||||||
document_output_language: 'English',
|
document_output_language: 'English',
|
||||||
output_folder: '_bmad-output',
|
output_folder: '_bmad-output',
|
||||||
|
|
@ -1874,6 +1914,7 @@ async function runTests() {
|
||||||
assert(teamContent.includes('[core]'), 'config.toml has [core] section');
|
assert(teamContent.includes('[core]'), 'config.toml has [core] section');
|
||||||
assert(teamContent.includes('document_output_language = "English"'), 'Team-scope core key lands in config.toml');
|
assert(teamContent.includes('document_output_language = "English"'), 'Team-scope core key lands in config.toml');
|
||||||
assert(teamContent.includes('output_folder = "_bmad-output"'), 'Team-scope output_folder lands in config.toml');
|
assert(teamContent.includes('output_folder = "_bmad-output"'), 'Team-scope output_folder lands in config.toml');
|
||||||
|
assert(teamContent.includes('project_name = "demo-project"'), 'project_name lands in [core] (core key as of #2279)');
|
||||||
assert(!teamContent.includes('user_name'), 'user_name (scope: user) is absent from config.toml');
|
assert(!teamContent.includes('user_name'), 'user_name (scope: user) is absent from config.toml');
|
||||||
assert(!teamContent.includes('communication_language'), 'communication_language (scope: user) is absent from config.toml');
|
assert(!teamContent.includes('communication_language'), 'communication_language (scope: user) is absent from config.toml');
|
||||||
|
|
||||||
|
|
@ -1888,7 +1929,9 @@ async function runTests() {
|
||||||
assert(bmmTeamMatch !== null, 'config.toml has [modules.bmm] section');
|
assert(bmmTeamMatch !== null, 'config.toml has [modules.bmm] section');
|
||||||
if (bmmTeamMatch) {
|
if (bmmTeamMatch) {
|
||||||
const bmmTeamBlock = bmmTeamMatch[0];
|
const bmmTeamBlock = bmmTeamMatch[0];
|
||||||
assert(bmmTeamBlock.includes('project_name = "demo-project"'), 'bmm team-scope key lands under [modules.bmm]');
|
assert(bmmTeamBlock.includes('planning_artifacts'), 'bmm-owned team-scope key (planning_artifacts) lands under [modules.bmm]');
|
||||||
|
assert(!bmmTeamBlock.includes('project_name'), 'project_name stripped from [modules.bmm] (now a core key, #2279)');
|
||||||
|
assert(!bmmTeamBlock.includes('stale-bmm-copy'), 'stale bmm-copy of project_name not leaked into config.toml');
|
||||||
assert(!bmmTeamBlock.includes('user_name'), 'user_name stripped from [modules.bmm] (core-key pollution)');
|
assert(!bmmTeamBlock.includes('user_name'), 'user_name stripped from [modules.bmm] (core-key pollution)');
|
||||||
assert(!bmmTeamBlock.includes('communication_language'), 'communication_language stripped from [modules.bmm]');
|
assert(!bmmTeamBlock.includes('communication_language'), 'communication_language stripped from [modules.bmm]');
|
||||||
assert(!bmmTeamBlock.includes('user_skill_level'), 'user_skill_level (scope: user) absent from [modules.bmm] in config.toml');
|
assert(!bmmTeamBlock.includes('user_skill_level'), 'user_skill_level (scope: user) absent from [modules.bmm] in config.toml');
|
||||||
|
|
@ -2773,6 +2816,574 @@ async function runTests() {
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 42: --tools flag parsing & validation (#2326)
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 42: --tools flag parsing & validation${colors.reset}\n`);
|
||||||
|
try {
|
||||||
|
const { UI } = require('../tools/installer/ui');
|
||||||
|
const ui = new UI();
|
||||||
|
const known = new Set(['claude-code', 'cursor', 'windsurf']);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
JSON.stringify(ui._parseToolsFlag('claude-code', known)) === JSON.stringify(['claude-code']),
|
||||||
|
'parseToolsFlag returns single ID',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
JSON.stringify(ui._parseToolsFlag('claude-code,cursor', known)) === JSON.stringify(['claude-code', 'cursor']),
|
||||||
|
'parseToolsFlag returns multiple IDs',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
JSON.stringify(ui._parseToolsFlag(' claude-code , cursor ', known)) === JSON.stringify(['claude-code', 'cursor']),
|
||||||
|
'parseToolsFlag trims whitespace',
|
||||||
|
);
|
||||||
|
|
||||||
|
let emptyErr;
|
||||||
|
try {
|
||||||
|
ui._parseToolsFlag('', known);
|
||||||
|
} catch (error) {
|
||||||
|
emptyErr = error;
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
emptyErr && emptyErr.expected === true && /empty/i.test(emptyErr.message),
|
||||||
|
'parseToolsFlag rejects empty string with expected=true',
|
||||||
|
);
|
||||||
|
|
||||||
|
let commasOnlyErr;
|
||||||
|
try {
|
||||||
|
ui._parseToolsFlag(' , , ', known);
|
||||||
|
} catch (error) {
|
||||||
|
commasOnlyErr = error;
|
||||||
|
}
|
||||||
|
assert(commasOnlyErr && commasOnlyErr.expected === true, 'parseToolsFlag rejects whitespace/comma-only input');
|
||||||
|
|
||||||
|
let noneErr;
|
||||||
|
try {
|
||||||
|
ui._parseToolsFlag('none', known);
|
||||||
|
} catch (error) {
|
||||||
|
noneErr = error;
|
||||||
|
}
|
||||||
|
assert(noneErr && noneErr.expected === true && /Unknown tool ID/.test(noneErr.message), 'parseToolsFlag rejects "none" as unknown ID');
|
||||||
|
|
||||||
|
let typoErr;
|
||||||
|
try {
|
||||||
|
ui._parseToolsFlag('claude-code,claude-cdoe', known);
|
||||||
|
} catch (error) {
|
||||||
|
typoErr = error;
|
||||||
|
}
|
||||||
|
const typoHeader = typoErr ? typoErr.message.split('\n')[0] : '';
|
||||||
|
assert(
|
||||||
|
typoErr && typoErr.expected === true && /claude-cdoe/.test(typoHeader) && !/claude-code/.test(typoHeader),
|
||||||
|
'parseToolsFlag reports only the unknown ID in error header (valid ones not listed as unknown)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// --list-tools and --tools validation must agree on what counts as a valid ID.
|
||||||
|
const { formatPlatformList } = require('../tools/installer/ide/platform-codes');
|
||||||
|
const { IdeManager } = require('../tools/installer/ide/manager');
|
||||||
|
const ideManager42 = new IdeManager();
|
||||||
|
await ideManager42.ensureInitialized();
|
||||||
|
const validIds = new Set(ideManager42.getAvailableIdes().map((i) => i.value));
|
||||||
|
const listed = await formatPlatformList();
|
||||||
|
// Each entry line starts with ' *' (preferred) or ' ' (other), followed by the ID, then padding.
|
||||||
|
const entryLines = listed.split('\n').filter((l) => /^( \*| {2})[a-z]/.test(l));
|
||||||
|
const listedIds = entryLines.map((l) => l.trim().replace(/^\*/, '').split(/\s+/)[0]);
|
||||||
|
const missingFromList = [...validIds].filter((id) => !listedIds.includes(id));
|
||||||
|
const extraInList = listedIds.filter((id) => !validIds.has(id));
|
||||||
|
assert(
|
||||||
|
missingFromList.length === 0 && extraInList.length === 0,
|
||||||
|
'--list-tools output matches the IDs that --tools accepts',
|
||||||
|
`Missing from list: ${missingFromList.join(',') || '(none)'}; Extra in list: ${extraInList.join(',') || '(none)'}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`${colors.red}Test Suite 42 setup failed: ${error.message}${colors.reset}`);
|
||||||
|
console.log(error.stack);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 43: project_name promoted to core + hoist migration (#2279)
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 43: project_name in core + hoist migration${colors.reset}\n`);
|
||||||
|
try {
|
||||||
|
const yamlLib = require('yaml');
|
||||||
|
const coreSchemaPath = path.join(__dirname, '..', 'src', 'core-skills', 'module.yaml');
|
||||||
|
const bmmSchemaPath = path.join(__dirname, '..', 'src', 'bmm-skills', 'module.yaml');
|
||||||
|
const coreSchema = yamlLib.parse(await fs.readFile(coreSchemaPath, 'utf8'));
|
||||||
|
const bmmSchema = yamlLib.parse(await fs.readFile(bmmSchemaPath, 'utf8'));
|
||||||
|
|
||||||
|
assert(
|
||||||
|
coreSchema.project_name && coreSchema.project_name.prompt && coreSchema.project_name.default === '{directory_name}',
|
||||||
|
'core/module.yaml declares project_name with {directory_name} default',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(coreSchema.project_name.scope === undefined, 'project_name has no user scope (project-scoped, not user-scoped)');
|
||||||
|
|
||||||
|
assert(bmmSchema.project_name === undefined, 'bmm/module.yaml no longer declares project_name (now inherited from core)');
|
||||||
|
|
||||||
|
// Set up a mock existing install: bmm directory has project_name (legacy),
|
||||||
|
// core has user_name but not project_name. After hoist, project_name should
|
||||||
|
// move to core, leaving bmm with only its own keys.
|
||||||
|
const fixtureRoot43 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-fixture-43-'));
|
||||||
|
const bmadDir43 = path.join(fixtureRoot43, '_bmad');
|
||||||
|
await fs.ensureDir(path.join(bmadDir43, '_config'));
|
||||||
|
await fs.writeFile(path.join(bmadDir43, '_config', 'manifest.yaml'), 'modules: []\n', 'utf8');
|
||||||
|
await fs.ensureDir(path.join(bmadDir43, 'core'));
|
||||||
|
await fs.ensureDir(path.join(bmadDir43, 'bmm'));
|
||||||
|
await fs.writeFile(path.join(bmadDir43, 'core', 'config.yaml'), 'user_name: alice\n', 'utf8');
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(bmadDir43, 'bmm', 'config.yaml'),
|
||||||
|
'project_name: legacy-from-bmm\nuser_skill_level: intermediate\n',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const officialModules43 = new OfficialModules();
|
||||||
|
await officialModules43.loadExistingConfig(fixtureRoot43);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
officialModules43.existingConfig.core?.project_name === 'legacy-from-bmm',
|
||||||
|
'loadExistingConfig hoists bmm.project_name to core on existing-install upgrade',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
!('project_name' in (officialModules43.existingConfig.bmm || {})),
|
||||||
|
'loadExistingConfig removes project_name from bmm after hoisting',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
officialModules43.existingConfig.bmm?.user_skill_level === 'intermediate',
|
||||||
|
'loadExistingConfig leaves non-core bmm keys (user_skill_level) untouched',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(officialModules43.existingConfig.core?.user_name === 'alice', 'loadExistingConfig preserves pre-existing core values');
|
||||||
|
|
||||||
|
// Precedence: if core already has the key, hoist must NOT overwrite it.
|
||||||
|
const fixtureRoot43b = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-fixture-43b-'));
|
||||||
|
const bmadDir43b = path.join(fixtureRoot43b, '_bmad');
|
||||||
|
await fs.ensureDir(path.join(bmadDir43b, '_config'));
|
||||||
|
await fs.writeFile(path.join(bmadDir43b, '_config', 'manifest.yaml'), 'modules: []\n', 'utf8');
|
||||||
|
await fs.ensureDir(path.join(bmadDir43b, 'core'));
|
||||||
|
await fs.ensureDir(path.join(bmadDir43b, 'bmm'));
|
||||||
|
await fs.writeFile(path.join(bmadDir43b, 'core', 'config.yaml'), 'project_name: from-core\n', 'utf8');
|
||||||
|
await fs.writeFile(path.join(bmadDir43b, 'bmm', 'config.yaml'), 'project_name: stale-from-bmm\n', 'utf8');
|
||||||
|
|
||||||
|
const officialModules43b = new OfficialModules();
|
||||||
|
await officialModules43b.loadExistingConfig(fixtureRoot43b);
|
||||||
|
|
||||||
|
assert(officialModules43b.existingConfig.core?.project_name === 'from-core', 'hoist does not overwrite an existing core value');
|
||||||
|
|
||||||
|
assert(
|
||||||
|
!('project_name' in (officialModules43b.existingConfig.bmm || {})),
|
||||||
|
'hoist still strips the duplicate from bmm so writeCentralConfig partition stays clean',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Malformed config.yaml (parses to a scalar) must not crash loadExistingConfig
|
||||||
|
// or the hoist pass — they should treat it as "no config for that module"
|
||||||
|
// and continue. Regression for augment review on PR #2348.
|
||||||
|
const fixtureRoot43c = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-fixture-43c-'));
|
||||||
|
const bmadDir43c = path.join(fixtureRoot43c, '_bmad');
|
||||||
|
await fs.ensureDir(path.join(bmadDir43c, '_config'));
|
||||||
|
await fs.writeFile(path.join(bmadDir43c, '_config', 'manifest.yaml'), 'modules: []\n', 'utf8');
|
||||||
|
await fs.ensureDir(path.join(bmadDir43c, 'core'));
|
||||||
|
await fs.ensureDir(path.join(bmadDir43c, 'bmm'));
|
||||||
|
// Scalar YAML — yaml.parse returns the literal 42 (truthy non-object).
|
||||||
|
// Pre-fix this crashed _hoistCoreKeysFromLegacyModuleConfigs with
|
||||||
|
// "Cannot use 'in' operator to search for 'project_name' in 42".
|
||||||
|
await fs.writeFile(path.join(bmadDir43c, 'core', 'config.yaml'), '42\n', 'utf8');
|
||||||
|
await fs.writeFile(path.join(bmadDir43c, 'bmm', 'config.yaml'), 'project_name: rescued\n', 'utf8');
|
||||||
|
|
||||||
|
const officialModules43c = new OfficialModules();
|
||||||
|
let crashErr;
|
||||||
|
try {
|
||||||
|
await officialModules43c.loadExistingConfig(fixtureRoot43c);
|
||||||
|
} catch (error) {
|
||||||
|
crashErr = error;
|
||||||
|
}
|
||||||
|
assert(!crashErr, 'loadExistingConfig does not crash on a scalar core/config.yaml', crashErr?.stack);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
officialModules43c.existingConfig.core?.project_name === 'rescued',
|
||||||
|
'scalar core gets replaced with {} and bmm.project_name still hoists in',
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.remove(fixtureRoot43).catch(() => {});
|
||||||
|
await fs.remove(fixtureRoot43b).catch(() => {});
|
||||||
|
await fs.remove(fixtureRoot43c).catch(() => {});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`${colors.red}Test Suite 43 setup failed: ${error.message}${colors.reset}`);
|
||||||
|
console.log(error.stack);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 44: --set <module>.<key>=<value> CLI overrides (#1663)
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 44: --set CLI overrides${colors.reset}\n`);
|
||||||
|
try {
|
||||||
|
const { parseSetEntry, parseSetEntries, applySetOverrides, upsertTomlKey, tomlString } = require('../tools/installer/set-overrides');
|
||||||
|
const { discoverOfficialModuleYamls, formatOptionsList } = require('../tools/installer/list-options');
|
||||||
|
|
||||||
|
// ---- Parser ----------------------------------------------------------
|
||||||
|
const ok = parseSetEntry('bmm.project_knowledge=research');
|
||||||
|
assert(
|
||||||
|
ok.module === 'bmm' && ok.key === 'project_knowledge' && ok.value === 'research',
|
||||||
|
'parseSetEntry splits <module>.<key>=<value> correctly',
|
||||||
|
);
|
||||||
|
assert(parseSetEntry('bmm.weird=a=b=c').value === 'a=b=c', 'parseSetEntry preserves additional "=" inside the value');
|
||||||
|
|
||||||
|
const badInputs = ['no-equals', 'no-dot=value', '=value', '.=value', 'foo.=value', '.bar=value', ''];
|
||||||
|
let allBadThrow = true;
|
||||||
|
for (const bad of badInputs) {
|
||||||
|
try {
|
||||||
|
parseSetEntry(bad);
|
||||||
|
allBadThrow = false;
|
||||||
|
} catch {
|
||||||
|
/* expected */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert(allBadThrow, `parseSetEntry rejects malformed inputs (${badInputs.length} cases)`);
|
||||||
|
|
||||||
|
const multi = parseSetEntries(['bmm.project_knowledge=research', 'bmm.user_skill_level=expert', 'core.user_name=Brian']);
|
||||||
|
assert(
|
||||||
|
multi.bmm.project_knowledge === 'research' && multi.bmm.user_skill_level === 'expert' && multi.core.user_name === 'Brian',
|
||||||
|
'parseSetEntries groups by module',
|
||||||
|
);
|
||||||
|
assert(parseSetEntries(['bmm.x=first', 'bmm.x=second']).bmm.x === 'second', 'parseSetEntries: later --set entry overrides earlier');
|
||||||
|
const empty = parseSetEntries();
|
||||||
|
assert(empty && Object.keys(empty).length === 0, 'parseSetEntries() returns empty object when called without args');
|
||||||
|
|
||||||
|
// Prototype-pollution guard. `--set __proto__.x=1` would otherwise reach
|
||||||
|
// `overrides.__proto__[x] = 1` and pollute every plain object.
|
||||||
|
const polluteProbe = {};
|
||||||
|
let pollutionThrown = false;
|
||||||
|
try {
|
||||||
|
parseSetEntries(['__proto__.polluted=1']);
|
||||||
|
} catch {
|
||||||
|
pollutionThrown = true;
|
||||||
|
}
|
||||||
|
assert(pollutionThrown, 'parseSetEntries rejects __proto__ as a module name');
|
||||||
|
assert(polluteProbe.polluted === undefined, 'Object.prototype is not polluted by __proto__ in --set entries');
|
||||||
|
let constructorThrown = false;
|
||||||
|
try {
|
||||||
|
parseSetEntries(['bmm.constructor=evil']);
|
||||||
|
} catch {
|
||||||
|
constructorThrown = true;
|
||||||
|
}
|
||||||
|
assert(constructorThrown, 'parseSetEntries rejects "constructor" as a key name');
|
||||||
|
|
||||||
|
// ---- tomlString ------------------------------------------------------
|
||||||
|
assert(tomlString('hello') === '"hello"', 'tomlString quotes a plain string');
|
||||||
|
assert(tomlString('with "quotes"') === String.raw`"with \"quotes\""`, 'tomlString escapes embedded double-quotes');
|
||||||
|
assert(tomlString(String.raw`back\slash`) === String.raw`"back\\slash"`, 'tomlString escapes backslashes');
|
||||||
|
assert(tomlString('line1\nline2') === String.raw`"line1\nline2"`, 'tomlString escapes newlines');
|
||||||
|
|
||||||
|
// ---- upsertTomlKey: insert into existing section ---------------------
|
||||||
|
{
|
||||||
|
const before = `[core]\nuser_name = "Brian"\n\n[modules.bmm]\nproject_knowledge = "{project-root}/docs"\n`;
|
||||||
|
const after = upsertTomlKey(before, '[modules.bmm]', 'future_thing', '"persists"');
|
||||||
|
assert(after.includes('future_thing = "persists"'), 'upsertTomlKey inserts a new key into an existing section');
|
||||||
|
assert(/project_knowledge = "{project-root}\/docs"/.test(after), 'upsertTomlKey preserves existing keys');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- upsertTomlKey: replace existing key, keep comment tail ----------
|
||||||
|
{
|
||||||
|
const before = `[core]\nuser_name = "old" # set on first install\n`;
|
||||||
|
const after = upsertTomlKey(before, '[core]', 'user_name', '"Brian"');
|
||||||
|
assert(/user_name = "Brian"\s+# set on first install/.test(after), 'upsertTomlKey preserves trailing comments');
|
||||||
|
assert(!after.includes('"old"'), 'upsertTomlKey replaces the prior value');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- upsertTomlKey: section missing → append new section -------------
|
||||||
|
{
|
||||||
|
const before = `[core]\nuser_name = "Brian"\n`;
|
||||||
|
const after = upsertTomlKey(before, '[modules.bmm]', 'project_knowledge', '"research"');
|
||||||
|
assert(after.includes('[modules.bmm]'), 'upsertTomlKey appends a new section when missing');
|
||||||
|
assert(after.includes('project_knowledge = "research"'), 'upsertTomlKey appends the key under the new section');
|
||||||
|
// Existing section remains untouched
|
||||||
|
assert(after.indexOf('[core]') < after.indexOf('[modules.bmm]'), 'upsertTomlKey adds the new section AFTER existing content');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- upsertTomlKey: empty file ---------------------------------------
|
||||||
|
{
|
||||||
|
const after = upsertTomlKey('', '[core]', 'user_name', '"Brian"');
|
||||||
|
assert(after.startsWith('[core]'), 'upsertTomlKey on an empty string emits the section header');
|
||||||
|
assert(after.includes('user_name = "Brian"'), 'upsertTomlKey on an empty string writes the key');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- upsertTomlKey: trailing newline preserved -----------------------
|
||||||
|
{
|
||||||
|
const withTrailing = upsertTomlKey('[core]\nuser_name = "old"\n', '[core]', 'user_name', '"new"');
|
||||||
|
assert(withTrailing.endsWith('\n'), 'upsertTomlKey preserves trailing newline');
|
||||||
|
const withoutTrailing = upsertTomlKey('[core]\nuser_name = "old"', '[core]', 'user_name', '"new"');
|
||||||
|
assert(!withoutTrailing.endsWith('\n'), 'upsertTomlKey preserves absence of trailing newline');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- applySetOverrides happy path ------------------------------------
|
||||||
|
{
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-applyset-'));
|
||||||
|
const bmadDir = path.join(tmp, '_bmad');
|
||||||
|
await fs.ensureDir(bmadDir);
|
||||||
|
// Seed a realistic post-install state: team config has bmm.project_knowledge,
|
||||||
|
// user config has core.user_name. The applySetOverrides router should
|
||||||
|
// route bmm.user_skill_level → user.toml (already there), core.user_name
|
||||||
|
// update → user.toml (already there), and a brand-new key → team.toml.
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(bmadDir, 'config.toml'),
|
||||||
|
'[core]\nproject_name = "demo"\n\n[modules.bmm]\nproject_knowledge = "{project-root}/docs"\n',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(bmadDir, 'config.user.toml'),
|
||||||
|
'[core]\nuser_name = "OldName"\n\n[modules.bmm]\nuser_skill_level = "intermediate"\n',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
// Per-module config.yaml stubs are the "is this module installed?"
|
||||||
|
// signal applySetOverrides uses to skip uninstalled-module overrides.
|
||||||
|
await fs.ensureDir(path.join(bmadDir, 'core'));
|
||||||
|
await fs.writeFile(path.join(bmadDir, 'core', 'config.yaml'), 'project_name: demo\n', 'utf8');
|
||||||
|
await fs.ensureDir(path.join(bmadDir, 'bmm'));
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(bmadDir, 'bmm', 'config.yaml'),
|
||||||
|
'project_knowledge: "{project-root}/docs"\nuser_skill_level: intermediate\n',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const overrides = {
|
||||||
|
core: { user_name: 'Brian' },
|
||||||
|
bmm: { user_skill_level: 'expert', future_thing: 'persists' },
|
||||||
|
};
|
||||||
|
const applied = await applySetOverrides(overrides, bmadDir);
|
||||||
|
|
||||||
|
const team = await fs.readFile(path.join(bmadDir, 'config.toml'), 'utf8');
|
||||||
|
const user = await fs.readFile(path.join(bmadDir, 'config.user.toml'), 'utf8');
|
||||||
|
|
||||||
|
assert(user.includes('user_name = "Brian"'), 'applySetOverrides updates user-scope key in config.user.toml');
|
||||||
|
assert(user.includes('user_skill_level = "expert"'), 'applySetOverrides updates pre-existing user-scope key in config.user.toml');
|
||||||
|
assert(team.includes('future_thing = "persists"'), 'applySetOverrides routes brand-new key to team config.toml');
|
||||||
|
assert(team.includes('project_knowledge = "{project-root}/docs"'), 'applySetOverrides leaves untouched team keys alone');
|
||||||
|
assert(!team.includes('user_name = "Brian"'), 'applySetOverrides does NOT duplicate user-scope key into team file');
|
||||||
|
|
||||||
|
const summary = applied
|
||||||
|
.map((a) => `${a.module}.${a.key}->${a.scope}`)
|
||||||
|
.sort()
|
||||||
|
.join(',');
|
||||||
|
assert(
|
||||||
|
summary === 'bmm.future_thing->team,bmm.user_skill_level->user,core.user_name->user',
|
||||||
|
`applySetOverrides reports correct routing decisions (got: ${summary})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.remove(tmp).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- applySetOverrides creates config.user.toml if missing -----------
|
||||||
|
{
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-applyset-nouser-'));
|
||||||
|
const bmadDir = path.join(tmp, '_bmad');
|
||||||
|
await fs.ensureDir(bmadDir);
|
||||||
|
await fs.writeFile(path.join(bmadDir, 'config.toml'), '[core]\nuser_name = "Brian"\n', 'utf8');
|
||||||
|
await fs.ensureDir(path.join(bmadDir, 'core'));
|
||||||
|
await fs.writeFile(path.join(bmadDir, 'core', 'config.yaml'), 'user_name: Brian\n', 'utf8');
|
||||||
|
// Override targets a key only in team config; routes to team. user.toml
|
||||||
|
// never gets created in this case (correct — no user-scope writes).
|
||||||
|
await applySetOverrides({ core: { user_name: 'Updated' } }, bmadDir);
|
||||||
|
const team = await fs.readFile(path.join(bmadDir, 'config.toml'), 'utf8');
|
||||||
|
assert(team.includes('user_name = "Updated"'), 'applySetOverrides updates team key when user.toml is absent');
|
||||||
|
assert(
|
||||||
|
!(await fs.pathExists(path.join(bmadDir, 'config.user.toml'))),
|
||||||
|
'applySetOverrides does not create config.user.toml unnecessarily',
|
||||||
|
);
|
||||||
|
await fs.remove(tmp).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- applySetOverrides skips modules without per-module config.yaml --
|
||||||
|
{
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-applyset-skip-'));
|
||||||
|
const bmadDir = path.join(tmp, '_bmad');
|
||||||
|
await fs.ensureDir(bmadDir);
|
||||||
|
await fs.writeFile(path.join(bmadDir, 'config.toml'), '[core]\nuser_name = "Brian"\n', 'utf8');
|
||||||
|
await fs.ensureDir(path.join(bmadDir, 'core'));
|
||||||
|
await fs.writeFile(path.join(bmadDir, 'core', 'config.yaml'), 'user_name: Brian\n', 'utf8');
|
||||||
|
// bmm is not installed (no `_bmad/bmm/config.yaml`). The override for
|
||||||
|
// bmm should be silently skipped, no `[modules.bmm]` section created.
|
||||||
|
const applied = await applySetOverrides({ bmm: { foo: 'bar' }, core: { user_name: 'Updated' } }, bmadDir);
|
||||||
|
const team = await fs.readFile(path.join(bmadDir, 'config.toml'), 'utf8');
|
||||||
|
assert(!team.includes('[modules.bmm]'), 'applySetOverrides does NOT create section for uninstalled module');
|
||||||
|
assert(team.includes('user_name = "Updated"'), 'applySetOverrides still applies overrides for installed modules');
|
||||||
|
assert(applied.length === 1 && applied[0].module === 'core', 'applySetOverrides reports only the installed-module entries');
|
||||||
|
await fs.remove(tmp).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- applySetOverrides: empty/missing input is a no-op ---------------
|
||||||
|
{
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-applyset-empty-'));
|
||||||
|
const bmadDir = path.join(tmp, '_bmad');
|
||||||
|
await fs.ensureDir(bmadDir);
|
||||||
|
const empty1 = await applySetOverrides({}, bmadDir);
|
||||||
|
const empty2 = await applySetOverrides(null, bmadDir);
|
||||||
|
const empty3 = await applySetOverrides(undefined, bmadDir);
|
||||||
|
assert(
|
||||||
|
empty1.length === 0 && empty2.length === 0 && empty3.length === 0,
|
||||||
|
'applySetOverrides is a no-op for empty/null/undefined input',
|
||||||
|
);
|
||||||
|
await fs.remove(tmp).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- discoverOfficialModuleYamls + formatOptionsList -----------------
|
||||||
|
// These read the on-disk external-module cache. Point that env at a temp
|
||||||
|
// dir so test results don't depend on whatever the developer / CI runner
|
||||||
|
// has cached.
|
||||||
|
const priorCacheEnv44 = process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
||||||
|
const tempCacheDir44 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-list-options-cache-'));
|
||||||
|
process.env.BMAD_EXTERNAL_MODULES_CACHE = tempCacheDir44;
|
||||||
|
try {
|
||||||
|
const discovered = await discoverOfficialModuleYamls();
|
||||||
|
const codes = new Set(discovered.map((d) => d.code));
|
||||||
|
assert(codes.has('core') && codes.has('bmm'), 'discoverOfficialModuleYamls finds core and bmm built-ins');
|
||||||
|
|
||||||
|
const bmmListing = await formatOptionsList('bmm');
|
||||||
|
assert(bmmListing.ok === true, '--list-options bmm reports ok: true');
|
||||||
|
assert(bmmListing.text.includes('bmm.project_knowledge'), '--list-options bmm renders bmm.project_knowledge');
|
||||||
|
assert(bmmListing.text.includes('bmm.user_skill_level'), '--list-options bmm renders bmm.user_skill_level');
|
||||||
|
|
||||||
|
// Case-insensitive filter.
|
||||||
|
const bmmUpper = await formatOptionsList('BMM');
|
||||||
|
assert(bmmUpper.ok === true && bmmUpper.text.includes('bmm.project_knowledge'), '--list-options is case-insensitive');
|
||||||
|
|
||||||
|
// Unknown module → non-zero exit signal.
|
||||||
|
const unknown = await formatOptionsList('definitely-not-a-module');
|
||||||
|
assert(unknown.ok === false, '--list-options <unknown> reports ok: false');
|
||||||
|
assert(unknown.text.includes('No locally-known module.yaml'), '--list-options unknown explains the miss');
|
||||||
|
} finally {
|
||||||
|
if (priorCacheEnv44 === undefined) {
|
||||||
|
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
||||||
|
} else {
|
||||||
|
process.env.BMAD_EXTERNAL_MODULES_CACHE = priorCacheEnv44;
|
||||||
|
}
|
||||||
|
await fs.remove(tempCacheDir44).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`${colors.red}Test Suite 44 setup failed: ${error.message}${colors.reset}`);
|
||||||
|
console.log(error.stack);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 45: Automator External Skill-Only Module
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 45: Automator External Skill-Only Module${colors.reset}\n`);
|
||||||
|
|
||||||
|
let tempProjectDir42;
|
||||||
|
let installedBmadDir42;
|
||||||
|
try {
|
||||||
|
const externalManager42 = new ExternalModuleManager();
|
||||||
|
const automatorInfo42 = await externalManager42.getModuleByCode('bma');
|
||||||
|
assert(automatorInfo42 !== null, 'BMad Automator is registered as an external module');
|
||||||
|
assert(automatorInfo42.type === 'experimental', 'BMad Automator is marked experimental');
|
||||||
|
assert(automatorInfo42.sourceRoot === 'payload/.claude/skills', 'BMad Automator uses source-root for pure skill payload');
|
||||||
|
assert(
|
||||||
|
automatorInfo42.installTargets.length === 1 && automatorInfo42.installTargets.includes('claude-code'),
|
||||||
|
'BMad Automator is limited to Claude Code skill installation',
|
||||||
|
);
|
||||||
|
const normalizedInfo42 = externalManager42._normalizeModule({
|
||||||
|
name: 'bad-shapes',
|
||||||
|
code: 'bad',
|
||||||
|
repository: 'https://example.com/bad.git',
|
||||||
|
install_targets: 'claude-code',
|
||||||
|
worker_targets: { bad: true },
|
||||||
|
requirements: ['tmux', { bad: true }, false],
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
Array.isArray(normalizedInfo42.installTargets) && normalizedInfo42.installTargets.includes('claude-code'),
|
||||||
|
'External module install targets normalize scalar values to arrays',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
Array.isArray(normalizedInfo42.workerTargets) && normalizedInfo42.workerTargets.length === 0,
|
||||||
|
'External module worker targets drop invalid shapes',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
normalizedInfo42.requirements.length === 2 &&
|
||||||
|
normalizedInfo42.requirements.includes('tmux') &&
|
||||||
|
normalizedInfo42.requirements.includes('false'),
|
||||||
|
'External module requirements normalize scalar array entries',
|
||||||
|
);
|
||||||
|
|
||||||
|
tempProjectDir42 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-target-'));
|
||||||
|
installedBmadDir42 = await createAutomatorBmadFixture();
|
||||||
|
|
||||||
|
const ideManager42 = new IdeManager();
|
||||||
|
await ideManager42.ensureInitialized();
|
||||||
|
|
||||||
|
const codexResult42 = await ideManager42.setup('codex', tempProjectDir42, installedBmadDir42, {
|
||||||
|
silent: true,
|
||||||
|
selectedModules: ['core', 'bma'],
|
||||||
|
});
|
||||||
|
assert(codexResult42.success === true, 'Codex setup succeeds with automator module selected');
|
||||||
|
assert(
|
||||||
|
await fs.pathExists(path.join(tempProjectDir42, '.agents', 'skills', 'bmad-master', 'SKILL.md')),
|
||||||
|
'Codex setup still installs supported core skills',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!(await fs.pathExists(path.join(tempProjectDir42, '.agents', 'skills', 'bmad-story-automator', 'SKILL.md'))),
|
||||||
|
'Codex setup skips Claude Code-only automator skill',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!(await fs.pathExists(path.join(tempProjectDir42, '.agents', 'skills', 'bmad-story-automator-review', 'SKILL.md'))),
|
||||||
|
'Codex setup skips Claude Code-only automator review skill',
|
||||||
|
);
|
||||||
|
|
||||||
|
const escapeRoot42 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-source-root-'));
|
||||||
|
const escapeRepo42 = path.join(escapeRoot42, 'repo');
|
||||||
|
await fs.ensureDir(escapeRepo42);
|
||||||
|
const escapeManager42 = new ExternalModuleManager();
|
||||||
|
escapeManager42.getModuleByCode = async () => ({
|
||||||
|
code: 'escape',
|
||||||
|
builtIn: false,
|
||||||
|
sourceRoot: '../outside',
|
||||||
|
});
|
||||||
|
escapeManager42.cloneExternalModule = async () => escapeRepo42;
|
||||||
|
let rejectedEscapingSourceRoot42 = false;
|
||||||
|
try {
|
||||||
|
await escapeManager42.findExternalModuleSource('escape');
|
||||||
|
} catch (error) {
|
||||||
|
rejectedEscapingSourceRoot42 = error.message.includes('source-root escapes repository');
|
||||||
|
} finally {
|
||||||
|
await fs.remove(escapeRoot42).catch(() => {});
|
||||||
|
}
|
||||||
|
assert(rejectedEscapingSourceRoot42, 'External module source-root cannot escape cloned repository');
|
||||||
|
|
||||||
|
const claudeResult42 = await ideManager42.setup('claude-code', tempProjectDir42, installedBmadDir42, {
|
||||||
|
silent: true,
|
||||||
|
selectedModules: ['core', 'bma'],
|
||||||
|
});
|
||||||
|
assert(claudeResult42.success === true, 'Claude Code setup succeeds with automator module selected');
|
||||||
|
assert(
|
||||||
|
await fs.pathExists(path.join(tempProjectDir42, '.claude', 'skills', 'bmad-story-automator', 'SKILL.md')),
|
||||||
|
'Claude Code setup installs automator skill',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
await fs.pathExists(path.join(tempProjectDir42, '.claude', 'skills', 'bmad-story-automator-review', 'SKILL.md')),
|
||||||
|
'Claude Code setup installs automator review skill',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
await fs.pathExists(path.join(tempProjectDir42, '.claude', 'skills', 'bmad-story-automator-review', 'workflow.md')),
|
||||||
|
'Claude Code setup copies automator workflow files',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
assert(false, `Automator external skill-only module test succeeds: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
if (tempProjectDir42) await fs.remove(tempProjectDir42).catch(() => {});
|
||||||
|
if (installedBmadDir42) await fs.remove(path.dirname(installedBmadDir42)).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Summary
|
// Summary
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,294 @@
|
||||||
|
/**
|
||||||
|
* parseSource() URL parsing tests
|
||||||
|
*
|
||||||
|
* Verifies that CustomModuleManager.parseSource() correctly handles Git URLs
|
||||||
|
* across arbitrary hosts and path shapes (deep paths, nested groups, browse
|
||||||
|
* links, repo names containing dots, etc.) using host-agnostic rules.
|
||||||
|
*
|
||||||
|
* Usage: node test/test-parse-source-urls.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
|
||||||
|
|
||||||
|
// ANSI colors
|
||||||
|
const colors = {
|
||||||
|
reset: '\u001B[0m',
|
||||||
|
green: '\u001B[32m',
|
||||||
|
red: '\u001B[31m',
|
||||||
|
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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = new CustomModuleManager();
|
||||||
|
|
||||||
|
// ─── Deep path shapes (4+ segments) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log(`\n${colors.cyan}Deep path shapes${colors.reset}\n`);
|
||||||
|
|
||||||
|
{
|
||||||
|
// Hosts that expose the repo at a nested path like /<org>/<project>/<marker>/<repo>.
|
||||||
|
// The parser must preserve the full path (no stripping of intermediate segments).
|
||||||
|
const result = manager.parseSource('https://git.example.com/myorg/MyProject/_git/my-module');
|
||||||
|
assert(result.isValid === true, 'nested-path URL is valid');
|
||||||
|
assert(result.type === 'url', 'nested-path type is url');
|
||||||
|
assert(
|
||||||
|
result.cloneUrl === 'https://git.example.com/myorg/MyProject/_git/my-module',
|
||||||
|
'nested-path cloneUrl preserves full path',
|
||||||
|
`Got: ${result.cloneUrl}`,
|
||||||
|
);
|
||||||
|
assert(result.subdir === null, 'nested-path URL has no subdir');
|
||||||
|
assert(
|
||||||
|
result.cacheKey === 'git.example.com/myorg/MyProject/_git/my-module',
|
||||||
|
'nested-path cacheKey includes full repo path',
|
||||||
|
`Got: ${result.cacheKey}`,
|
||||||
|
);
|
||||||
|
assert(result.displayName === '_git/my-module', 'nested-path displayName uses last two segments', `Got: ${result.displayName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const result = manager.parseSource('https://git.example.com/myorg/MyProject/_git/my-module.git');
|
||||||
|
assert(result.isValid === true, 'nested-path URL with .git suffix is valid');
|
||||||
|
assert(
|
||||||
|
result.cloneUrl === 'https://git.example.com/myorg/MyProject/_git/my-module',
|
||||||
|
'nested-path .git suffix stripped from cloneUrl',
|
||||||
|
`Got: ${result.cloneUrl}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// Browse links that use ?path=/... to point at a subdirectory.
|
||||||
|
const result = manager.parseSource('https://git.example.com/myorg/MyProject/_git/my-module?path=/path/to/subdir');
|
||||||
|
assert(result.isValid === true, 'URL with ?path= is valid');
|
||||||
|
assert(
|
||||||
|
result.cloneUrl === 'https://git.example.com/myorg/MyProject/_git/my-module',
|
||||||
|
'?path= cloneUrl excludes subdir',
|
||||||
|
`Got: ${result.cloneUrl}`,
|
||||||
|
);
|
||||||
|
assert(result.subdir === 'path/to/subdir', '?path= subdir correctly extracted', `Got: ${result.subdir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Azure DevOps URLs (Issue #2268) ────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log(`\n${colors.cyan}Azure DevOps URLs (Issue #2268)${colors.reset}\n`);
|
||||||
|
|
||||||
|
{
|
||||||
|
// Modern dev.azure.com format — the exact URL from the bug report.
|
||||||
|
const result = manager.parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module');
|
||||||
|
assert(result.isValid === true, 'ADO modern URL is valid');
|
||||||
|
assert(result.type === 'url', 'ADO modern type is url');
|
||||||
|
assert(
|
||||||
|
result.cloneUrl === 'https://dev.azure.com/myorg/MyProject/_git/my-module',
|
||||||
|
'ADO modern cloneUrl preserves full _git path',
|
||||||
|
`Got: ${result.cloneUrl}`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
result.cacheKey === 'dev.azure.com/myorg/MyProject/_git/my-module',
|
||||||
|
'ADO modern cacheKey includes full path',
|
||||||
|
`Got: ${result.cacheKey}`,
|
||||||
|
);
|
||||||
|
assert(result.subdir === null, 'ADO modern URL has no subdir');
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// Modern format with .git suffix
|
||||||
|
const result = manager.parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module.git');
|
||||||
|
assert(result.isValid === true, 'ADO modern .git suffix is valid');
|
||||||
|
assert(
|
||||||
|
result.cloneUrl === 'https://dev.azure.com/myorg/MyProject/_git/my-module',
|
||||||
|
'ADO modern .git suffix stripped from cloneUrl',
|
||||||
|
`Got: ${result.cloneUrl}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// Modern format with ?path= subdir (browse link)
|
||||||
|
const result = manager.parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module?path=/src/skills');
|
||||||
|
assert(result.isValid === true, 'ADO modern ?path= is valid');
|
||||||
|
assert(
|
||||||
|
result.cloneUrl === 'https://dev.azure.com/myorg/MyProject/_git/my-module',
|
||||||
|
'ADO modern ?path= cloneUrl excludes subdir',
|
||||||
|
`Got: ${result.cloneUrl}`,
|
||||||
|
);
|
||||||
|
assert(result.subdir === 'src/skills', 'ADO modern ?path= subdir extracted', `Got: ${result.subdir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// Legacy visualstudio.com format
|
||||||
|
const result = manager.parseSource('https://myorg.visualstudio.com/MyProject/_git/my-module');
|
||||||
|
assert(result.isValid === true, 'ADO legacy URL is valid');
|
||||||
|
assert(
|
||||||
|
result.cloneUrl === 'https://myorg.visualstudio.com/MyProject/_git/my-module',
|
||||||
|
'ADO legacy cloneUrl preserves full path',
|
||||||
|
`Got: ${result.cloneUrl}`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
result.cacheKey === 'myorg.visualstudio.com/MyProject/_git/my-module',
|
||||||
|
'ADO legacy cacheKey includes full path',
|
||||||
|
`Got: ${result.cacheKey}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// Legacy format with .git suffix
|
||||||
|
const result = manager.parseSource('https://myorg.visualstudio.com/MyProject/_git/my-module.git');
|
||||||
|
assert(result.isValid === true, 'ADO legacy .git suffix is valid');
|
||||||
|
assert(
|
||||||
|
result.cloneUrl === 'https://myorg.visualstudio.com/MyProject/_git/my-module',
|
||||||
|
'ADO legacy .git suffix stripped from cloneUrl',
|
||||||
|
`Got: ${result.cloneUrl}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// Legacy format with ?path= subdir
|
||||||
|
const result = manager.parseSource('https://myorg.visualstudio.com/MyProject/_git/my-module?path=/src');
|
||||||
|
assert(result.isValid === true, 'ADO legacy ?path= is valid');
|
||||||
|
assert(
|
||||||
|
result.cloneUrl === 'https://myorg.visualstudio.com/MyProject/_git/my-module',
|
||||||
|
'ADO legacy ?path= cloneUrl excludes subdir',
|
||||||
|
`Got: ${result.cloneUrl}`,
|
||||||
|
);
|
||||||
|
assert(result.subdir === 'src', 'ADO legacy ?path= subdir extracted', `Got: ${result.subdir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Subdomain hosts ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log(`\n${colors.cyan}Subdomain hosts${colors.reset}\n`);
|
||||||
|
|
||||||
|
{
|
||||||
|
const result = manager.parseSource('https://myorg.example.com/MyProject/_git/my-module');
|
||||||
|
assert(result.isValid === true, 'subdomain URL is valid');
|
||||||
|
assert(result.type === 'url', 'subdomain type is url');
|
||||||
|
assert(
|
||||||
|
result.cloneUrl === 'https://myorg.example.com/MyProject/_git/my-module',
|
||||||
|
'subdomain cloneUrl preserves full path',
|
||||||
|
`Got: ${result.cloneUrl}`,
|
||||||
|
);
|
||||||
|
assert(result.subdir === null, 'subdomain URL has no subdir');
|
||||||
|
assert(
|
||||||
|
result.cacheKey === 'myorg.example.com/MyProject/_git/my-module',
|
||||||
|
'subdomain cacheKey includes full repo path',
|
||||||
|
`Got: ${result.cacheKey}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Simple owner/repo URLs (regression) ────────────────────────────────────
|
||||||
|
|
||||||
|
console.log(`\n${colors.cyan}Simple owner/repo URLs (regression check)${colors.reset}\n`);
|
||||||
|
|
||||||
|
{
|
||||||
|
const result = manager.parseSource('https://github.com/owner/repo');
|
||||||
|
assert(result.isValid === true, 'GitHub basic URL still valid');
|
||||||
|
assert(result.cloneUrl === 'https://github.com/owner/repo', 'GitHub cloneUrl unchanged', `Got: ${result.cloneUrl}`);
|
||||||
|
assert(result.cacheKey === 'github.com/owner/repo', 'GitHub cacheKey unchanged', `Got: ${result.cacheKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const result = manager.parseSource('https://github.com/owner/repo/tree/main/subdir');
|
||||||
|
assert(result.isValid === true, 'GitHub URL with tree path still valid');
|
||||||
|
assert(result.cloneUrl === 'https://github.com/owner/repo', 'GitHub tree URL cloneUrl correct', `Got: ${result.cloneUrl}`);
|
||||||
|
assert(result.subdir === 'subdir', 'GitHub tree subdir still extracted', `Got: ${result.subdir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const result = manager.parseSource('git@github.com:owner/repo.git');
|
||||||
|
assert(result.isValid === true, 'SSH URL still valid');
|
||||||
|
assert(result.cloneUrl === 'git@github.com:owner/repo.git', 'SSH cloneUrl unchanged', `Got: ${result.cloneUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Generic URL handling (any host, any path depth) ────────────────────────
|
||||||
|
|
||||||
|
console.log(`\n${colors.cyan}Generic URL handling${colors.reset}\n`);
|
||||||
|
|
||||||
|
{
|
||||||
|
// GitLab nested groups — the old 2-segment regex would have failed this.
|
||||||
|
const result = manager.parseSource('https://gitlab.com/group/subgroup/repo');
|
||||||
|
assert(result.isValid === true, 'GitLab nested-group URL is valid');
|
||||||
|
assert(
|
||||||
|
result.cloneUrl === 'https://gitlab.com/group/subgroup/repo',
|
||||||
|
'GitLab nested-group cloneUrl preserves full path',
|
||||||
|
`Got: ${result.cloneUrl}`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
result.cacheKey === 'gitlab.com/group/subgroup/repo',
|
||||||
|
'GitLab nested-group cacheKey includes full path',
|
||||||
|
`Got: ${result.cacheKey}`,
|
||||||
|
);
|
||||||
|
assert(result.displayName === 'subgroup/repo', 'GitLab nested-group displayName uses last two segments', `Got: ${result.displayName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const result = manager.parseSource('https://gitlab.com/group/subgroup/repo/-/tree/main/src/module');
|
||||||
|
assert(result.isValid === true, 'GitLab nested-group tree URL is valid');
|
||||||
|
assert(
|
||||||
|
result.cloneUrl === 'https://gitlab.com/group/subgroup/repo',
|
||||||
|
'GitLab nested-group tree cloneUrl excludes subdir',
|
||||||
|
`Got: ${result.cloneUrl}`,
|
||||||
|
);
|
||||||
|
assert(result.subdir === 'src/module', 'GitLab nested-group tree subdir extracted', `Got: ${result.subdir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// Self-hosted host with a repo name containing dots — the old regex
|
||||||
|
// explicitly excluded dots from the repo segment.
|
||||||
|
const result = manager.parseSource('https://git.example.com/owner/my.repo.name');
|
||||||
|
assert(result.isValid === true, 'repo name with dots is valid');
|
||||||
|
assert(
|
||||||
|
result.cloneUrl === 'https://git.example.com/owner/my.repo.name',
|
||||||
|
'repo name with dots preserved in cloneUrl',
|
||||||
|
`Got: ${result.cloneUrl}`,
|
||||||
|
);
|
||||||
|
assert(result.displayName === 'owner/my.repo.name', 'repo name with dots preserved in displayName', `Got: ${result.displayName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// Browser URL pointing at a ref with NO trailing subdir must still strip
|
||||||
|
// the /tree/<ref> segment from the clone URL.
|
||||||
|
const result = manager.parseSource('https://github.com/owner/repo/tree/main');
|
||||||
|
assert(result.isValid === true, 'tree URL without subdir is valid');
|
||||||
|
assert(
|
||||||
|
result.cloneUrl === 'https://github.com/owner/repo',
|
||||||
|
'tree URL without subdir strips ref from cloneUrl',
|
||||||
|
`Got: ${result.cloneUrl}`,
|
||||||
|
);
|
||||||
|
assert(result.subdir === null, 'tree URL without subdir yields null subdir', `Got: ${result.subdir}`);
|
||||||
|
assert(result.displayName === 'owner/repo', 'tree URL without subdir displayName is owner/repo', `Got: ${result.displayName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// Same shape for GitLab's /-/tree form and Gitea's /src/branch form.
|
||||||
|
const gitlab = manager.parseSource('https://gitlab.com/group/repo/-/tree/main');
|
||||||
|
assert(
|
||||||
|
gitlab.cloneUrl === 'https://gitlab.com/group/repo' && gitlab.subdir === null,
|
||||||
|
'GitLab /-/tree/<ref> without subdir strips ref',
|
||||||
|
`Got: ${gitlab.cloneUrl} subdir=${gitlab.subdir}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const gitea = manager.parseSource('https://gitea.example.com/owner/repo/src/branch/main');
|
||||||
|
assert(
|
||||||
|
gitea.cloneUrl === 'https://gitea.example.com/owner/repo' && gitea.subdir === null,
|
||||||
|
'Gitea /src/branch/<ref> without subdir strips ref',
|
||||||
|
`Got: ${gitea.cloneUrl} subdir=${gitea.subdir}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Summary ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log(`\n${colors.cyan}Results: ${passed} passed, ${failed} failed${colors.reset}\n`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
|
@ -2,9 +2,15 @@
|
||||||
|
|
||||||
## Installing external repo BMad official modules
|
## Installing external repo BMad official modules
|
||||||
|
|
||||||
For external official modules to be discoverable during install, ensure an entry for the external repo is added to external-official-modules.yaml.
|
For external official modules to be discoverable during install, ensure an entry for the external repo is added to the marketplace `registry/official.yaml` source of truth. Add the same entry to `modules/registry-fallback.yaml` only when BMAD-METHOD needs a bundled fallback or a staged registry supplement.
|
||||||
|
|
||||||
For community modules - this will be handled in a different way. This file is only for registration of modules under the bmad-code-org.
|
For community modules - this is handled through the marketplace community registry.
|
||||||
|
|
||||||
|
Use `module-definition` for conventional module repos with `module.yaml`.
|
||||||
|
Use `source-root` for pure skill bundles that should be copied directly into `_bmad/<module-code>/`.
|
||||||
|
This keeps the external repo as the source of truth and avoids vendoring generated skill payloads into BMAD-METHOD.
|
||||||
|
|
||||||
|
Experimental modules can set `type: experimental` and `install-targets` to limit which IDE integrations receive their skills.
|
||||||
|
|
||||||
## Post-Install Notes
|
## Post-Install Notes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,18 @@ module.exports = {
|
||||||
['--modules <modules>', 'Comma-separated list of module IDs to install (e.g., "bmm,bmb")'],
|
['--modules <modules>', 'Comma-separated list of module IDs to install (e.g., "bmm,bmb")'],
|
||||||
[
|
[
|
||||||
'--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"). Required for fresh non-interactive (--yes) installs. Run with --list-tools to see all valid IDs.',
|
||||||
|
],
|
||||||
|
['--list-tools', 'Print all supported tool/IDE IDs (with target directories) and exit.'],
|
||||||
|
[
|
||||||
|
'--set <spec>',
|
||||||
|
'Set a module config option non-interactively. Spec format: <module>.<key>=<value> (e.g. bmm.project_knowledge=research). Repeatable. Run --list-options to see available keys.',
|
||||||
|
(value, prev) => [...(prev || []), value],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'--list-options [module]',
|
||||||
|
'List available --set keys for all locally-known official modules, or for a single module by code, then exit.',
|
||||||
],
|
],
|
||||||
['--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)'],
|
||||||
|
|
@ -40,12 +51,49 @@ module.exports = {
|
||||||
],
|
],
|
||||||
action: async (options) => {
|
action: async (options) => {
|
||||||
try {
|
try {
|
||||||
|
if (options.listTools) {
|
||||||
|
const { formatPlatformList } = require('../ide/platform-codes');
|
||||||
|
process.stdout.write((await formatPlatformList()) + '\n');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.listOptions !== undefined) {
|
||||||
|
const { formatOptionsList } = require('../list-options');
|
||||||
|
const moduleArg = options.listOptions === true ? null : options.listOptions;
|
||||||
|
const { text, ok } = await formatOptionsList(moduleArg);
|
||||||
|
const stream = ok ? process.stdout : process.stderr;
|
||||||
|
// process.exit() forces immediate termination and can truncate the
|
||||||
|
// buffered write when stdout/stderr is piped or captured by CI. Wait
|
||||||
|
// for the write to flush, then set process.exitCode and return so the
|
||||||
|
// event loop drains naturally. Non-zero exit when a single-module
|
||||||
|
// lookup misses so a CI typo like `--list-options bmn` doesn't look
|
||||||
|
// successful in scripts.
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
stream.write(text + '\n', (error) => (error ? reject(error) : resolve()));
|
||||||
|
});
|
||||||
|
process.exitCode = ok ? 0 : 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Set debug flag as environment variable for all components
|
// Set debug flag as environment variable for all components
|
||||||
if (options.debug) {
|
if (options.debug) {
|
||||||
process.env.BMAD_DEBUG_MANIFEST = 'true';
|
process.env.BMAD_DEBUG_MANIFEST = 'true';
|
||||||
await prompts.log.info('Debug mode enabled');
|
await prompts.log.info('Debug mode enabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate --set syntax up-front so malformed entries fail fast,
|
||||||
|
// before we touch the network or filesystem. Parsed entries are
|
||||||
|
// re-derived inside ui.js where overrides are seeded.
|
||||||
|
if (options.set && options.set.length > 0) {
|
||||||
|
const { parseSetEntries } = require('../set-overrides');
|
||||||
|
try {
|
||||||
|
parseSetEntries(options.set);
|
||||||
|
} catch (error) {
|
||||||
|
await prompts.log.error(error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const config = await ui.promptInstall(options);
|
const config = await ui.promptInstall(options);
|
||||||
|
|
||||||
// Handle cancel
|
// Handle cancel
|
||||||
|
|
@ -54,8 +102,13 @@ module.exports = {
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle quick update separately
|
// Handle quick update separately. --set is a post-install TOML patch so
|
||||||
|
// it works the same way for quick-update as for a regular install — the
|
||||||
|
// installer runs, then `applySetOverrides` patches the central config
|
||||||
|
// files. Pass the parsed overrides through.
|
||||||
if (config.actionType === 'quick-update') {
|
if (config.actionType === 'quick-update') {
|
||||||
|
const { parseSetEntries } = require('../set-overrides');
|
||||||
|
config.setOverrides = parseSetEntries(options.set || []);
|
||||||
const result = await installer.quickUpdate(config);
|
const result = await installer.quickUpdate(config);
|
||||||
await prompts.log.success('Quick update complete!');
|
await prompts.log.success('Quick update complete!');
|
||||||
await prompts.log.info(`Updated ${result.moduleCount} modules with preserved settings (${result.modules.join(', ')})`);
|
await prompts.log.info(`Updated ${result.moduleCount} modules with preserved settings (${result.modules.join(', ')})`);
|
||||||
|
|
@ -81,7 +134,7 @@ module.exports = {
|
||||||
} else {
|
} else {
|
||||||
await prompts.log.error(`Installation failed: ${error.message}`);
|
await prompts.log.error(`Installation failed: ${error.message}`);
|
||||||
}
|
}
|
||||||
if (error.stack) {
|
if (error.stack && !error.expected) {
|
||||||
await prompts.log.message(error.stack);
|
await prompts.log.message(error.stack);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,19 @@
|
||||||
* User input comes from either UI answers or headless CLI flags.
|
* User input comes from either UI answers or headless CLI flags.
|
||||||
*/
|
*/
|
||||||
class Config {
|
class Config {
|
||||||
constructor({ directory, modules, ides, skipPrompts, verbose, actionType, coreConfig, moduleConfigs, quickUpdate, channelOptions }) {
|
constructor({
|
||||||
|
directory,
|
||||||
|
modules,
|
||||||
|
ides,
|
||||||
|
skipPrompts,
|
||||||
|
verbose,
|
||||||
|
actionType,
|
||||||
|
coreConfig,
|
||||||
|
moduleConfigs,
|
||||||
|
quickUpdate,
|
||||||
|
channelOptions,
|
||||||
|
setOverrides,
|
||||||
|
}) {
|
||||||
this.directory = directory;
|
this.directory = directory;
|
||||||
this.modules = Object.freeze([...modules]);
|
this.modules = Object.freeze([...modules]);
|
||||||
this.ides = Object.freeze([...ides]);
|
this.ides = Object.freeze([...ides]);
|
||||||
|
|
@ -15,6 +27,11 @@ class Config {
|
||||||
this._quickUpdate = quickUpdate;
|
this._quickUpdate = quickUpdate;
|
||||||
// channelOptions carry a Map + Set; don't deep-freeze.
|
// channelOptions carry a Map + Set; don't deep-freeze.
|
||||||
this.channelOptions = channelOptions || null;
|
this.channelOptions = channelOptions || null;
|
||||||
|
// Parsed `--set <module>.<key>=<value>` overrides, applied as a TOML
|
||||||
|
// patch AFTER the install finishes. Shape: { moduleCode: { key: value } }.
|
||||||
|
// Intentionally NOT integrated with the prompt/template/schema flow; see
|
||||||
|
// `tools/installer/set-overrides.js` for the rationale and tradeoffs.
|
||||||
|
this.setOverrides = setOverrides || {};
|
||||||
Object.freeze(this);
|
Object.freeze(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,6 +57,7 @@ class Config {
|
||||||
moduleConfigs: userInput.moduleConfigs || null,
|
moduleConfigs: userInput.moduleConfigs || null,
|
||||||
quickUpdate: userInput._quickUpdate || false,
|
quickUpdate: userInput._quickUpdate || false,
|
||||||
channelOptions: userInput.channelOptions || null,
|
channelOptions: userInput.channelOptions || null,
|
||||||
|
setOverrides: userInput.setOverrides || {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -310,6 +310,19 @@ class Installer {
|
||||||
moduleConfigs,
|
moduleConfigs,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Apply post-install --set TOML patches. Runs after writeCentralConfig
|
||||||
|
// (inside generateManifests above) so the patch operates on the
|
||||||
|
// freshly written `_bmad/config.toml` / `_bmad/config.user.toml`.
|
||||||
|
// See `tools/installer/set-overrides.js` for routing rules.
|
||||||
|
if (config.setOverrides && Object.keys(config.setOverrides).length > 0) {
|
||||||
|
const { applySetOverrides } = require('../set-overrides');
|
||||||
|
const applied = await applySetOverrides(config.setOverrides, paths.bmadDir);
|
||||||
|
if (applied.length > 0) {
|
||||||
|
const summary = applied.map((a) => `${a.module}.${a.key} → ${a.file}`).join(', ');
|
||||||
|
await prompts.log.info(`Applied --set overrides: ${summary}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
message('Generating help catalog...');
|
message('Generating help catalog...');
|
||||||
await this.mergeModuleHelpCatalogs(paths.bmadDir, manifestGen.agents);
|
await this.mergeModuleHelpCatalogs(paths.bmadDir, manifestGen.agents);
|
||||||
addResult('Help catalog', 'ok');
|
addResult('Help catalog', 'ok');
|
||||||
|
|
@ -923,29 +936,15 @@ class Installer {
|
||||||
/**
|
/**
|
||||||
* Merge all module-help.csv files into a single bmad-help.csv.
|
* Merge all module-help.csv files into a single bmad-help.csv.
|
||||||
* Scans all installed modules for module-help.csv and merges them.
|
* Scans all installed modules for module-help.csv and merges them.
|
||||||
* Enriches agent info from the in-memory agent list produced by ManifestGenerator.
|
* Output preserves the source schema verbatim — see schema below.
|
||||||
* Output is written to _bmad/_config/bmad-help.csv.
|
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
* @param {string} bmadDir - BMAD installation directory
|
||||||
* @param {Array<Object>} agentEntries - Agents collected from module.yaml (code, name, title, icon, module, ...)
|
* @param {Array<Object>} _agentEntries - Unused; retained for call-site compatibility
|
||||||
*/
|
*/
|
||||||
async mergeModuleHelpCatalogs(bmadDir, agentEntries = []) {
|
async mergeModuleHelpCatalogs(bmadDir, _agentEntries = []) {
|
||||||
const allRows = [];
|
const allRows = [];
|
||||||
const headerRow =
|
const headerRow = 'module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs';
|
||||||
'module,phase,name,code,sequence,workflow-file,command,required,agent-name,agent-command,agent-display-name,agent-title,options,description,output-location,outputs';
|
const COLUMN_COUNT = 13;
|
||||||
|
const PHASE_INDEX = 7;
|
||||||
// Build agent lookup from the in-memory list (agent code → command + display fields).
|
|
||||||
const agentInfo = new Map();
|
|
||||||
for (const agent of agentEntries) {
|
|
||||||
if (!agent || !agent.code) continue;
|
|
||||||
const agentCommand = agent.module ? `bmad:${agent.module}:agent:${agent.code}` : `bmad:agent:${agent.code}`;
|
|
||||||
const displayName = agent.name || agent.code;
|
|
||||||
const titleCombined = agent.icon && agent.title ? `${agent.icon} ${agent.title}` : agent.title || agent.code;
|
|
||||||
agentInfo.set(agent.code, {
|
|
||||||
command: agentCommand,
|
|
||||||
displayName,
|
|
||||||
title: titleCombined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all installed module directories
|
// Get all installed module directories
|
||||||
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
|
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
|
||||||
|
|
@ -984,64 +983,19 @@ class Installer {
|
||||||
|
|
||||||
// Parse the line - handle quoted fields with commas
|
// Parse the line - handle quoted fields with commas
|
||||||
const columns = this.parseCSVLine(line);
|
const columns = this.parseCSVLine(line);
|
||||||
if (columns.length >= 12) {
|
if (columns.length < COLUMN_COUNT - 1) continue;
|
||||||
// Map old schema to new schema
|
|
||||||
// Old: module,phase,name,code,sequence,workflow-file,command,required,agent,options,description,output-location,outputs
|
|
||||||
// New: module,phase,name,code,sequence,workflow-file,command,required,agent-name,agent-command,agent-display-name,agent-title,options,description,output-location,outputs
|
|
||||||
|
|
||||||
const [
|
// Pad short rows; truncate over-long rows
|
||||||
module,
|
const padded = columns.slice(0, COLUMN_COUNT);
|
||||||
phase,
|
while (padded.length < COLUMN_COUNT) padded.push('');
|
||||||
name,
|
|
||||||
code,
|
|
||||||
sequence,
|
|
||||||
workflowFile,
|
|
||||||
command,
|
|
||||||
required,
|
|
||||||
agentName,
|
|
||||||
options,
|
|
||||||
description,
|
|
||||||
outputLocation,
|
|
||||||
outputs,
|
|
||||||
] = columns;
|
|
||||||
|
|
||||||
// Pass through _meta rows as-is (module metadata, not a skill)
|
// If module column is empty, fill with this module's name
|
||||||
if (phase === '_meta') {
|
// (core stays empty so its rows render as universal tools)
|
||||||
const finalModule = (!module || module.trim() === '') && moduleName !== 'core' ? moduleName : module || '';
|
if ((!padded[0] || padded[0].trim() === '') && moduleName !== 'core') {
|
||||||
const metaRow = [finalModule, '_meta', '', '', '', '', '', 'false', '', '', '', '', '', '', outputLocation || '', ''];
|
padded[0] = moduleName;
|
||||||
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)
|
allRows.push(padded.map((c) => this.escapeCSVField(c)).join(','));
|
||||||
const finalModule = (!module || module.trim() === '') && moduleName !== 'core' ? moduleName : module || '';
|
|
||||||
|
|
||||||
// Lookup agent info
|
|
||||||
const cleanAgentName = agentName ? agentName.trim() : '';
|
|
||||||
const agentData = agentInfo.get(cleanAgentName) || { command: '', displayName: '', title: '' };
|
|
||||||
|
|
||||||
// Build new row with agent info
|
|
||||||
const newRow = [
|
|
||||||
finalModule,
|
|
||||||
phase || '',
|
|
||||||
name || '',
|
|
||||||
code || '',
|
|
||||||
sequence || '',
|
|
||||||
workflowFile || '',
|
|
||||||
command || '',
|
|
||||||
required || 'false',
|
|
||||||
cleanAgentName,
|
|
||||||
agentData.command,
|
|
||||||
agentData.displayName,
|
|
||||||
agentData.title,
|
|
||||||
options || '',
|
|
||||||
description || '',
|
|
||||||
outputLocation || '',
|
|
||||||
outputs || '',
|
|
||||||
];
|
|
||||||
|
|
||||||
allRows.push(newRow.map((c) => this.escapeCSVField(c)).join(','));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
||||||
|
|
@ -1053,44 +1007,34 @@ class Installer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by module, then phase, then sequence
|
// Sort by module, then phase. Stable sort preserves authored order within a phase.
|
||||||
allRows.sort((a, b) => {
|
const decorated = allRows.map((row, index) => ({ row, index, cols: this.parseCSVLine(row) }));
|
||||||
const colsA = this.parseCSVLine(a);
|
decorated.sort((a, b) => {
|
||||||
const colsB = this.parseCSVLine(b);
|
const moduleA = (a.cols[0] || '').toLowerCase();
|
||||||
|
const moduleB = (b.cols[0] || '').toLowerCase();
|
||||||
|
if (moduleA !== moduleB) return moduleA.localeCompare(moduleB);
|
||||||
|
|
||||||
// Module comparison (empty module/universal tools come first)
|
const phaseA = a.cols[PHASE_INDEX] || '';
|
||||||
const moduleA = (colsA[0] || '').toLowerCase();
|
const phaseB = b.cols[PHASE_INDEX] || '';
|
||||||
const moduleB = (colsB[0] || '').toLowerCase();
|
if (phaseA !== phaseB) return phaseA.localeCompare(phaseB);
|
||||||
if (moduleA !== moduleB) {
|
|
||||||
return moduleA.localeCompare(moduleB);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase comparison
|
return a.index - b.index;
|
||||||
const phaseA = colsA[1] || '';
|
|
||||||
const phaseB = colsB[1] || '';
|
|
||||||
if (phaseA !== phaseB) {
|
|
||||||
return phaseA.localeCompare(phaseB);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sequence comparison
|
|
||||||
const seqA = parseInt(colsA[4] || '0', 10);
|
|
||||||
const seqB = parseInt(colsB[4] || '0', 10);
|
|
||||||
return seqA - seqB;
|
|
||||||
});
|
});
|
||||||
|
const sortedRows = decorated.map((d) => d.row);
|
||||||
|
|
||||||
// Write merged catalog
|
// Write merged catalog
|
||||||
const outputDir = path.join(bmadDir, '_config');
|
const outputDir = path.join(bmadDir, '_config');
|
||||||
await fs.ensureDir(outputDir);
|
await fs.ensureDir(outputDir);
|
||||||
const outputPath = path.join(outputDir, 'bmad-help.csv');
|
const outputPath = path.join(outputDir, 'bmad-help.csv');
|
||||||
|
|
||||||
const mergedContent = [headerRow, ...allRows].join('\n');
|
const mergedContent = [headerRow, ...sortedRows].join('\n');
|
||||||
await fs.writeFile(outputPath, mergedContent, 'utf8');
|
await fs.writeFile(outputPath, mergedContent, 'utf8');
|
||||||
|
|
||||||
// Track the installed file
|
// Track the installed file
|
||||||
this.installedFiles.add(outputPath);
|
this.installedFiles.add(outputPath);
|
||||||
|
|
||||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
||||||
await prompts.log.message(` Generated bmad-help.csv: ${allRows.length} workflows`);
|
await prompts.log.message(` Generated bmad-help.csv: ${sortedRows.length} workflows`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1352,6 +1296,10 @@ class Installer {
|
||||||
ides: configuredIdes,
|
ides: configuredIdes,
|
||||||
coreConfig: quickModules.collectedConfig.core,
|
coreConfig: quickModules.collectedConfig.core,
|
||||||
moduleConfigs: quickModules.collectedConfig,
|
moduleConfigs: quickModules.collectedConfig,
|
||||||
|
// Forward `--set` overrides so the post-install patch step
|
||||||
|
// (`applySetOverrides`) runs at the end of quick-update too. The
|
||||||
|
// installer.install path applies them after writeCentralConfig.
|
||||||
|
setOverrides: config.setOverrides || {},
|
||||||
actionType: 'install',
|
actionType: 'install',
|
||||||
_quickUpdate: true,
|
_quickUpdate: true,
|
||||||
_preserveModules: skippedModules,
|
_preserveModules: skippedModules,
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,9 @@ class ManifestGenerator {
|
||||||
for (const moduleName of this.updatedModules) {
|
for (const moduleName of this.updatedModules) {
|
||||||
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
||||||
if (!moduleYamlPath) {
|
if (!moduleYamlPath) {
|
||||||
|
if (await this._isSkillOnlyModule(moduleName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// External modules live in ~/.bmad/cache/external-modules, not src/modules.
|
// External modules live in ~/.bmad/cache/external-modules, not src/modules.
|
||||||
// Warn rather than silently skip so missing agent rosters don't vanish
|
// Warn rather than silently skip so missing agent rosters don't vanish
|
||||||
// from config.toml without notice.
|
// from config.toml without notice.
|
||||||
|
|
@ -441,6 +444,9 @@ class ManifestGenerator {
|
||||||
for (const moduleName of this.updatedModules) {
|
for (const moduleName of this.updatedModules) {
|
||||||
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
||||||
if (!moduleYamlPath) {
|
if (!moduleYamlPath) {
|
||||||
|
if (await this._isSkillOnlyModule(moduleName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
console.warn(
|
console.warn(
|
||||||
`[warn] writeCentralConfig: could not locate module.yaml for '${moduleName}'. ` +
|
`[warn] writeCentralConfig: could not locate module.yaml for '${moduleName}'. ` +
|
||||||
`Answers from this module will default to team scope — user-scoped keys may mis-file into config.toml.`,
|
`Answers from this module will default to team scope — user-scoped keys may mis-file into config.toml.`,
|
||||||
|
|
@ -799,6 +805,27 @@ class ManifestGenerator {
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _isSkillOnlyModule(moduleName) {
|
||||||
|
const modulePath = path.join(this.bmadDir, moduleName);
|
||||||
|
if (!(await fs.pathExists(modulePath))) return false;
|
||||||
|
if (await fs.pathExists(path.join(modulePath, 'module.yaml'))) return false;
|
||||||
|
if (!(await this._moduleUsesSourceRoot(moduleName))) return false;
|
||||||
|
return this._hasSkillMdRecursive(modulePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _moduleUsesSourceRoot(moduleName) {
|
||||||
|
if (!this.sourceRootModuleCodes) {
|
||||||
|
try {
|
||||||
|
const { ExternalModuleManager } = require('../modules/external-manager');
|
||||||
|
const externalModules = await new ExternalModuleManager().listAvailable();
|
||||||
|
this.sourceRootModuleCodes = new Set(externalModules.filter((mod) => mod.sourceRoot).map((mod) => mod.code));
|
||||||
|
} catch {
|
||||||
|
this.sourceRootModuleCodes = new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.sourceRootModuleCodes.has(moduleName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ class ConfigDrivenIdeSetup {
|
||||||
this.platformConfig = platformConfig;
|
this.platformConfig = platformConfig;
|
||||||
this.installerConfig = platformConfig.installer || null;
|
this.installerConfig = platformConfig.installer || null;
|
||||||
this.bmadFolderName = BMAD_FOLDER_NAME;
|
this.bmadFolderName = BMAD_FOLDER_NAME;
|
||||||
|
this.externalModuleManager = null;
|
||||||
|
this.moduleTargetCache = new Map();
|
||||||
|
|
||||||
// Set configDir from target_dir so detect() works
|
// Set configDir from target_dir so detect() works
|
||||||
this.configDir = this.installerConfig?.target_dir || null;
|
this.configDir = this.installerConfig?.target_dir || null;
|
||||||
|
|
@ -123,13 +125,16 @@ class ConfigDrivenIdeSetup {
|
||||||
await fs.ensureDir(targetPath);
|
await fs.ensureDir(targetPath);
|
||||||
|
|
||||||
this.skillWriteTracker = new Set();
|
this.skillWriteTracker = new Set();
|
||||||
|
this.skippedUnsupported = 0;
|
||||||
const results = { skills: 0 };
|
const results = { skills: 0 };
|
||||||
|
|
||||||
results.skills = await this.installVerbatimSkills(projectDir, bmadDir, targetPath, config);
|
results.skills = await this.installVerbatimSkills(projectDir, bmadDir, targetPath, config);
|
||||||
|
results.skippedUnsupported = this.skippedUnsupported || 0;
|
||||||
results.skillDirectories = this.skillWriteTracker.size;
|
results.skillDirectories = this.skillWriteTracker.size;
|
||||||
|
|
||||||
await this.printSummary(results, target_dir, options);
|
await this.printSummary(results, target_dir, options);
|
||||||
this.skillWriteTracker = null;
|
this.skillWriteTracker = null;
|
||||||
|
this.skippedUnsupported = 0;
|
||||||
return { success: true, results };
|
return { success: true, results };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,6 +167,11 @@ class ConfigDrivenIdeSetup {
|
||||||
const canonicalId = record.canonicalId;
|
const canonicalId = record.canonicalId;
|
||||||
if (!canonicalId) continue;
|
if (!canonicalId) continue;
|
||||||
|
|
||||||
|
if (!(await this.shouldInstallSkillRecord(record))) {
|
||||||
|
this.skippedUnsupported = (this.skippedUnsupported || 0) + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Derive source directory from path column
|
// Derive source directory from path column
|
||||||
// path is like "_bmad/bmm/workflows/bmad-quick-flow/bmad-quick-dev-new-preview/SKILL.md"
|
// path is like "_bmad/bmm/workflows/bmad-quick-flow/bmad-quick-dev-new-preview/SKILL.md"
|
||||||
// Strip bmadFolderName prefix and join with bmadDir, then get dirname
|
// Strip bmadFolderName prefix and join with bmadDir, then get dirname
|
||||||
|
|
@ -196,6 +206,24 @@ class ConfigDrivenIdeSetup {
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async shouldInstallSkillRecord(record) {
|
||||||
|
const moduleName = record.module;
|
||||||
|
if (!moduleName) return true;
|
||||||
|
|
||||||
|
if (this.moduleTargetCache.has(moduleName)) {
|
||||||
|
const targets = this.moduleTargetCache.get(moduleName);
|
||||||
|
return !targets || targets.includes(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ExternalModuleManager } = require('../modules/external-manager');
|
||||||
|
this.externalModuleManager = this.externalModuleManager || new ExternalModuleManager();
|
||||||
|
const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleName);
|
||||||
|
const targets = moduleInfo?.installTargets?.length ? moduleInfo.installTargets : null;
|
||||||
|
this.moduleTargetCache.set(moduleName, targets);
|
||||||
|
|
||||||
|
return !targets || targets.includes(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Print installation summary
|
* Print installation summary
|
||||||
* @param {Object} results - Installation results
|
* @param {Object} results - Installation results
|
||||||
|
|
@ -207,6 +235,9 @@ class ConfigDrivenIdeSetup {
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
await prompts.log.success(`${this.name} configured: ${count} skills → ${targetDir}`);
|
await prompts.log.success(`${this.name} configured: ${count} skills → ${targetDir}`);
|
||||||
}
|
}
|
||||||
|
if (results.skippedUnsupported > 0) {
|
||||||
|
await prompts.log.warn(`${this.name}: skipped ${results.skippedUnsupported} skill(s) that do not support this IDE`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,50 @@ function clearCache() {
|
||||||
_cachedPlatformCodes = null;
|
_cachedPlatformCodes = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the installable platform list for human-readable output (used by --list-tools).
|
||||||
|
* Sourced from IdeManager so this view matches what --tools accepts at install time
|
||||||
|
* (suspended platforms excluded).
|
||||||
|
* @returns {Promise<string>} Formatted multi-line string with id, name, target_dir, preferred flag.
|
||||||
|
*/
|
||||||
|
async function formatPlatformList() {
|
||||||
|
const { IdeManager } = require('./manager');
|
||||||
|
const ideManager = new IdeManager();
|
||||||
|
await ideManager.ensureInitialized();
|
||||||
|
|
||||||
|
const entries = ideManager.getAvailableIdes().map((ide) => {
|
||||||
|
const handler = ideManager.handlers.get(ide.value);
|
||||||
|
return {
|
||||||
|
id: ide.value,
|
||||||
|
name: ide.name,
|
||||||
|
targetDir: handler?.installerConfig?.target_dir || '',
|
||||||
|
preferred: ide.preferred,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const idWidth = Math.max(...entries.map((e) => e.id.length), 'ID'.length);
|
||||||
|
const nameWidth = Math.max(...entries.map((e) => e.name.length), 'Name'.length);
|
||||||
|
|
||||||
|
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
|
||||||
|
const lines = [
|
||||||
|
`Supported tool IDs (pass via --tools <id>[,<id>...]):`,
|
||||||
|
'',
|
||||||
|
` ${pad('ID', idWidth)} ${pad('Name', nameWidth)} Target dir`,
|
||||||
|
` ${pad('-'.repeat(idWidth), idWidth)} ${pad('-'.repeat(nameWidth), nameWidth)} ${'-'.repeat(10)}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const e of entries) {
|
||||||
|
const star = e.preferred ? ' *' : ' ';
|
||||||
|
lines.push(`${star}${pad(e.id, idWidth)} ${pad(e.name, nameWidth)} ${e.targetDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', '* = recommended / preferred', '', 'Example: bmad-method install --modules bmm --tools claude-code');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
loadPlatformCodes,
|
loadPlatformCodes,
|
||||||
clearCache,
|
clearCache,
|
||||||
|
formatPlatformList,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
const path = require('node:path');
|
||||||
|
const fs = require('./fs-native');
|
||||||
|
const yaml = require('yaml');
|
||||||
|
const { getProjectRoot, getModulePath, getExternalModuleCachePath } = require('./project-root');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a module.yaml and return its declared `code:` field, or null if missing/unparseable.
|
||||||
|
*/
|
||||||
|
async function readModuleCode(yamlPath) {
|
||||||
|
try {
|
||||||
|
const parsed = yaml.parse(await fs.readFile(yamlPath, 'utf8'));
|
||||||
|
if (parsed && typeof parsed === 'object' && typeof parsed.code === 'string') {
|
||||||
|
return parsed.code;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover module.yaml files for officials we can read locally:
|
||||||
|
* - core, bmm: bundled in src/ (always present)
|
||||||
|
* - external officials: only if previously cloned to ~/.bmad/cache/external-modules/
|
||||||
|
*
|
||||||
|
* Each result's `code` is the `code:` field from the module.yaml when present;
|
||||||
|
* that's the value `--set <module>.<key>=<value>` matches against.
|
||||||
|
*
|
||||||
|
* Community/custom modules are not enumerated; users reference their own
|
||||||
|
* module.yaml directly per the design (see issue #1663).
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<{code: string, yamlPath: string, source: string}>>}
|
||||||
|
*/
|
||||||
|
async function discoverOfficialModuleYamls() {
|
||||||
|
const found = [];
|
||||||
|
// Dedupe is case-insensitive because module caches occasionally retain a
|
||||||
|
// legacy UPPERCASE-named directory alongside the canonical lowercase one
|
||||||
|
// (same module, different cache key from an older schema). We pick whichever
|
||||||
|
// entry we see first and skip the alternate-case duplicate. NOTE: `--set`
|
||||||
|
// matching itself is case-sensitive (it keys on `moduleName` from the install
|
||||||
|
// flow's selected list, which is always lowercase short codes), so the
|
||||||
|
// surfaced `code` here is what users should type. Don't change to
|
||||||
|
// case-sensitive dedupe without revisiting that contract.
|
||||||
|
const seenCodes = new Set();
|
||||||
|
|
||||||
|
const addFound = async (yamlPath, source, fallbackCode) => {
|
||||||
|
const declaredCode = await readModuleCode(yamlPath);
|
||||||
|
const code = declaredCode || fallbackCode;
|
||||||
|
if (!code) return;
|
||||||
|
const lower = code.toLowerCase();
|
||||||
|
if (seenCodes.has(lower)) return;
|
||||||
|
seenCodes.add(lower);
|
||||||
|
found.push({ code, yamlPath, source });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Built-ins.
|
||||||
|
for (const code of ['core', 'bmm']) {
|
||||||
|
const yamlPath = path.join(getModulePath(code), 'module.yaml');
|
||||||
|
if (await fs.pathExists(yamlPath)) {
|
||||||
|
// Built-ins use their well-known short codes regardless of what the
|
||||||
|
// module.yaml `code:` says, since the install flow keys on these.
|
||||||
|
seenCodes.add(code.toLowerCase());
|
||||||
|
found.push({ code, yamlPath, source: 'built-in' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bundled in src/modules/<code>/module.yaml (rare, but supported by getModulePath).
|
||||||
|
const srcModulesDir = path.join(getProjectRoot(), 'src', 'modules');
|
||||||
|
if (await fs.pathExists(srcModulesDir)) {
|
||||||
|
const entries = await fs.readdir(srcModulesDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const yamlPath = path.join(srcModulesDir, entry.name, 'module.yaml');
|
||||||
|
if (await fs.pathExists(yamlPath)) {
|
||||||
|
await addFound(yamlPath, 'bundled', entry.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// External cache (~/.bmad/cache/external-modules/<code>/...).
|
||||||
|
const cacheRoot = getExternalModuleCachePath('').replace(/\/$/, '');
|
||||||
|
if (await fs.pathExists(cacheRoot)) {
|
||||||
|
const rawEntries = await fs.readdir(cacheRoot, { withFileTypes: true });
|
||||||
|
for (const entry of rawEntries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const candidates = [
|
||||||
|
path.join(cacheRoot, entry.name, 'module.yaml'),
|
||||||
|
path.join(cacheRoot, entry.name, 'src', 'module.yaml'),
|
||||||
|
path.join(cacheRoot, entry.name, 'skills', 'module.yaml'),
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (await fs.pathExists(candidate)) {
|
||||||
|
await addFound(candidate, 'cached', entry.name);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPromptText(item) {
|
||||||
|
if (Array.isArray(item.prompt)) return item.prompt.join(' ');
|
||||||
|
return String(item.prompt || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferType(item) {
|
||||||
|
if (item['single-select']) return 'single-select';
|
||||||
|
if (item['multi-select']) return 'multi-select';
|
||||||
|
if (typeof item.default === 'boolean') return 'boolean';
|
||||||
|
if (typeof item.default === 'number') return 'number';
|
||||||
|
return 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatModuleOptions(code, parsed, source) {
|
||||||
|
const lines = [];
|
||||||
|
const header = source === 'built-in' ? code : `${code} (${source})`;
|
||||||
|
lines.push(header + ':');
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const [key, item] of Object.entries(parsed)) {
|
||||||
|
if (!item || typeof item !== 'object' || !('prompt' in item)) continue;
|
||||||
|
count++;
|
||||||
|
const type = inferType(item);
|
||||||
|
const scope = item.scope === 'user' ? ' [user-scope]' : '';
|
||||||
|
const defaultStr = item.default === undefined || item.default === null ? '(none)' : String(item.default);
|
||||||
|
lines.push(` ${code}.${key} (${type}${scope}) default: ${defaultStr}`);
|
||||||
|
const promptText = formatPromptText(item);
|
||||||
|
if (promptText) lines.push(` ${promptText}`);
|
||||||
|
if (Array.isArray(item['single-select'])) {
|
||||||
|
const values = item['single-select'].map((v) => (typeof v === 'object' ? v.value : v)).filter((v) => v !== undefined);
|
||||||
|
if (values.length > 0) lines.push(` values: ${values.join(' | ')}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
lines.push(' (no configurable options)', '');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render `--list-options` output.
|
||||||
|
*
|
||||||
|
* Returns `{ text, ok }` so callers can surface a non-zero exit code on
|
||||||
|
* a typo'd module-code lookup. Discovery dedupes case-insensitively, so
|
||||||
|
* the lookup is also case-insensitive — typing `--list-options BMM` and
|
||||||
|
* `--list-options bmm` both find the bmm built-in.
|
||||||
|
*
|
||||||
|
* @param {string|null} moduleCode - if non-null, restrict to this module
|
||||||
|
* @returns {Promise<{text: string, ok: boolean}>}
|
||||||
|
*/
|
||||||
|
async function formatOptionsList(moduleCode) {
|
||||||
|
const discovered = await discoverOfficialModuleYamls();
|
||||||
|
const needle = moduleCode ? moduleCode.toLowerCase() : null;
|
||||||
|
const filtered = needle ? discovered.filter((d) => d.code.toLowerCase() === needle) : discovered;
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
if (moduleCode) {
|
||||||
|
const text = [
|
||||||
|
`No locally-known module.yaml for '${moduleCode}'.`,
|
||||||
|
'',
|
||||||
|
'Built-in modules (core, bmm) are always available. External officials',
|
||||||
|
'appear here after they have been installed at least once on this machine',
|
||||||
|
'(they are cached under ~/.bmad/cache/external-modules/).',
|
||||||
|
'',
|
||||||
|
'For community or custom modules, read the module.yaml file in that',
|
||||||
|
"module's source repository directly.",
|
||||||
|
].join('\n');
|
||||||
|
return { text, ok: false };
|
||||||
|
}
|
||||||
|
return { text: 'No modules found.', ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = [];
|
||||||
|
// Track when a module-scoped lookup couldn't actually be rendered (yaml
|
||||||
|
// unparseable or empty after parse). The full `--list-options` output is
|
||||||
|
// tolerant of one bad entry, but `--list-options <module>` against a single
|
||||||
|
// unreadable module should still fail tooling so a CI script catches it.
|
||||||
|
let moduleScopedFailure = false;
|
||||||
|
sections.push('Available --set keys', 'Format: --set <module>.<key>=<value> (repeatable)', '');
|
||||||
|
for (const { code, yamlPath, source } of filtered) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = yaml.parse(await fs.readFile(yamlPath, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
sections.push(`${code} (${source}): could not parse module.yaml`, '');
|
||||||
|
if (moduleCode) moduleScopedFailure = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
sections.push(`${code} (${source}): module.yaml is not a valid object (got ${Array.isArray(parsed) ? 'array' : typeof parsed})`, '');
|
||||||
|
if (moduleCode) moduleScopedFailure = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sections.push(formatModuleOptions(code, parsed, source));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!moduleCode) {
|
||||||
|
sections.push(
|
||||||
|
'Community and custom modules are not listed here — read their module.yaml directly. Unknown keys still persist with a warning.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: sections.join('\n'), ok: !moduleScopedFailure };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { formatOptionsList, discoverOfficialModuleYamls };
|
||||||
|
|
@ -128,43 +128,86 @@ class CustomModuleManager {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTPS/HTTP URL: https://host/owner/repo[/tree/branch/subdir][.git]
|
// HTTPS/HTTP URL: generic handling for any Git host.
|
||||||
const httpsMatch = trimmed.match(/^(https?):\/\/([^/]+)\/([^/]+)\/([^/.]+?)(?:\.git)?(\/.*)?$/);
|
// We avoid host-specific parsing — `git clone` will accept whatever URL the
|
||||||
if (httpsMatch) {
|
// user provides. We only need to (a) separate an optional browser-style
|
||||||
const [, protocol, host, owner, repo, remainder] = httpsMatch;
|
// subdir suffix from the clone URL, (b) extract any embedded ref
|
||||||
const cloneUrl = `${protocol}://${host}/${owner}/${repo}`;
|
// (branch/tag) from deep-path URLs, and (c) derive a cache key / display
|
||||||
|
// name from the path. The original protocol (http or https) is preserved.
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) {
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(trimmed);
|
||||||
|
} catch {
|
||||||
|
url = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url && url.host) {
|
||||||
|
const host = url.host;
|
||||||
|
let repoPath = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
let subdir = null;
|
let subdir = null;
|
||||||
let urlRef = null; // branch/tag extracted from /tree/<ref>/subdir
|
let urlRef = null; // branch/tag/commit extracted from deep-path URLs
|
||||||
|
|
||||||
if (remainder) {
|
// Detect browser-style deep-path patterns that embed a ref
|
||||||
// Extract subdir from deep path patterns used by various Git hosts
|
// (branch/tag/commit) and optional subdirectory. These appear
|
||||||
|
// across many hosts:
|
||||||
|
// GitHub /<repo>/tree|blob/<ref>[/<subdir>]
|
||||||
|
// GitLab /<repo>/-/tree|blob/<ref>[/<subdir>]
|
||||||
|
// Gitea /<repo>/src/<ref>[/<subdir>]
|
||||||
|
// Gitea /<repo>/src/(branch|commit|tag)/<ref>[/<subdir>]
|
||||||
|
// Group 1 = repo path prefix, Group 2 = ref, Group 3 = subdir (optional).
|
||||||
const deepPathPatterns = [
|
const deepPathPatterns = [
|
||||||
{ regex: /^\/(?:-\/)?tree\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 }, // GitHub, GitLab
|
/^(.+?)\/(?:-\/)?(?:tree|blob)\/([^/]+)(?:\/(.+))?$/,
|
||||||
{ regex: /^\/(?:-\/)?blob\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 },
|
/^(.+?)\/src\/(?:branch\/|commit\/|tag\/)?([^/]+)(?:\/(.+))?$/,
|
||||||
{ regex: /^\/src\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 }, // Gitea/Forgejo
|
|
||||||
];
|
];
|
||||||
// Also match `/tree/<ref>` with no subdir
|
for (const pattern of deepPathPatterns) {
|
||||||
const refOnlyPatterns = [/^\/(?:-\/)?tree\/([^/]+?)\/?$/, /^\/(?:-\/)?blob\/([^/]+?)\/?$/, /^\/src\/([^/]+?)\/?$/];
|
const match = repoPath.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
repoPath = match[1];
|
||||||
|
if (match[2]) urlRef = match[2];
|
||||||
|
if (match[3]) {
|
||||||
|
const cleaned = match[3].replace(/\/+$/, '');
|
||||||
|
if (cleaned) subdir = cleaned;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const p of deepPathPatterns) {
|
// Some hosts use ?path=/subdir on browse links to point at a file or
|
||||||
const match = remainder.match(p.regex);
|
// directory. Honor it when no deep-path marker matched above.
|
||||||
if (match) {
|
|
||||||
urlRef = match[p.refIdx];
|
|
||||||
subdir = match[p.pathIdx].replace(/\/$/, '');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!subdir) {
|
if (!subdir) {
|
||||||
for (const r of refOnlyPatterns) {
|
const pathParam = url.searchParams.get('path');
|
||||||
const match = remainder.match(r);
|
if (pathParam) {
|
||||||
if (match) {
|
const cleaned = pathParam.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
urlRef = match[1];
|
if (cleaned) subdir = cleaned;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strip a single trailing .git for a stable cacheKey/displayName.
|
||||||
|
const repoPathClean = repoPath.replace(/\.git$/i, '');
|
||||||
|
if (!repoPathClean) {
|
||||||
|
return {
|
||||||
|
type: null,
|
||||||
|
cloneUrl: null,
|
||||||
|
subdir: null,
|
||||||
|
localPath: null,
|
||||||
|
cacheKey: null,
|
||||||
|
displayName: null,
|
||||||
|
isValid: false,
|
||||||
|
error: 'Not a valid Git URL or local path',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cloneUrl = `${url.protocol}//${host}/${repoPathClean}`;
|
||||||
|
const cacheKey = `${host}/${repoPathClean}`;
|
||||||
|
|
||||||
|
// Display name: prefer "<owner>/<repo>" using the last two meaningful
|
||||||
|
// path segments.
|
||||||
|
const segments = repoPathClean.split('/').filter(Boolean);
|
||||||
|
const repoSeg = segments.at(-1);
|
||||||
|
const ownerSeg = segments.at(-2);
|
||||||
|
const displayName = ownerSeg ? `${ownerSeg}/${repoSeg}` : repoSeg;
|
||||||
|
|
||||||
// Precedence: explicit @version suffix > URL /tree/<ref> path segment.
|
// Precedence: explicit @version suffix > URL /tree/<ref> path segment.
|
||||||
const version = versionSuffix || urlRef || null;
|
const version = versionSuffix || urlRef || null;
|
||||||
|
|
||||||
|
|
@ -175,12 +218,13 @@ class CustomModuleManager {
|
||||||
localPath: null,
|
localPath: null,
|
||||||
version,
|
version,
|
||||||
rawInput: trimmedRaw,
|
rawInput: trimmedRaw,
|
||||||
cacheKey: `${host}/${owner}/${repo}`,
|
cacheKey,
|
||||||
displayName: `${owner}/${repo}`,
|
displayName,
|
||||||
isValid: true,
|
isValid: true,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: null,
|
type: null,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,15 @@ function normalizeChannelName(raw) {
|
||||||
return VALID_CHANNELS.has(lower) ? lower : null;
|
return VALID_CHANNELS.has(lower) ? lower : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStringList(raw) {
|
||||||
|
if (raw == null || raw === '') return [];
|
||||||
|
const values = Array.isArray(raw) ? raw : [raw];
|
||||||
|
return values
|
||||||
|
.filter((value) => ['string', 'number', 'boolean'].includes(typeof value))
|
||||||
|
.map((value) => String(value).trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Conservative quoting for tag names passed to git commands. Tags are
|
* Conservative quoting for tag names passed to git commands. Tags are
|
||||||
* user-typed (--pin) or come from the GitHub API. Only allow the semver
|
* user-typed (--pin) or come from the GitHub API. Only allow the semver
|
||||||
|
|
@ -120,22 +129,41 @@ class ExternalModuleManager {
|
||||||
* @returns {Object} Normalized module info
|
* @returns {Object} Normalized module info
|
||||||
*/
|
*/
|
||||||
_normalizeModule(mod, key) {
|
_normalizeModule(mod, key) {
|
||||||
|
const installTargets = mod.install_targets ?? mod['install-targets'] ?? mod.installTargets;
|
||||||
|
const workerTargets = mod.worker_targets ?? mod['worker-targets'] ?? mod.workerTargets;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: key || mod.name,
|
key: key || mod.name,
|
||||||
url: mod.repository || mod.url,
|
url: mod.repository || mod.url,
|
||||||
moduleDefinition: mod.module_definition || mod['module-definition'],
|
moduleDefinition: mod.module_definition || mod['module-definition'],
|
||||||
|
sourceRoot: mod.source_root || mod['source-root'] || mod.sourceRoot || null,
|
||||||
code: mod.code,
|
code: mod.code,
|
||||||
name: mod.display_name || mod.name,
|
name: mod.display_name || mod.name,
|
||||||
description: mod.description || '',
|
description: mod.description || '',
|
||||||
defaultSelected: mod.default_selected === true || mod.defaultSelected === true,
|
defaultSelected: mod.default_selected === true || mod.defaultSelected === true,
|
||||||
type: mod.type || 'bmad-org',
|
type: mod.type || 'bmad-org',
|
||||||
npmPackage: mod.npm_package || mod.npmPackage || null,
|
npmPackage: mod.npm_package || mod.npmPackage || null,
|
||||||
|
installTargets: normalizeStringList(installTargets),
|
||||||
|
workerTargets: normalizeStringList(workerTargets),
|
||||||
|
requirements: normalizeStringList(mod.requirements),
|
||||||
|
installNote: mod.install_note || mod['install-note'] || mod.installNote || null,
|
||||||
defaultChannel: normalizeChannelName(mod.default_channel || mod.defaultChannel) || 'stable',
|
defaultChannel: normalizeChannelName(mod.default_channel || mod.defaultChannel) || 'stable',
|
||||||
builtIn: mod.built_in === true,
|
builtIn: mod.built_in === true,
|
||||||
isExternal: mod.built_in !== true,
|
isExternal: mod.built_in !== true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _loadFallbackModules() {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(FALLBACK_CONFIG_PATH, 'utf8');
|
||||||
|
const config = yaml.parse(content);
|
||||||
|
if (Array.isArray(config.modules)) return config.modules.map((mod) => this._normalizeModule(mod));
|
||||||
|
return Object.entries(config.modules || {}).map(([key, mod]) => this._normalizeModule(mod, key));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of available modules from the registry
|
* Get list of available modules from the registry
|
||||||
* @returns {Array<Object>} Array of module info objects
|
* @returns {Array<Object>} Array of module info objects
|
||||||
|
|
@ -145,7 +173,14 @@ class ExternalModuleManager {
|
||||||
|
|
||||||
// Remote format: modules is an array
|
// Remote format: modules is an array
|
||||||
if (Array.isArray(config.modules)) {
|
if (Array.isArray(config.modules)) {
|
||||||
return config.modules.map((mod) => this._normalizeModule(mod));
|
const modules = config.modules.map((mod) => this._normalizeModule(mod));
|
||||||
|
const seenCodes = new Set(modules.map((mod) => mod.code));
|
||||||
|
for (const fallbackMod of await this._loadFallbackModules()) {
|
||||||
|
if (!seenCodes.has(fallbackMod.code)) {
|
||||||
|
modules.push(fallbackMod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return modules;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy bundled format: modules is an object map
|
// Legacy bundled format: modules is an object map
|
||||||
|
|
@ -489,6 +524,19 @@ class ExternalModuleManager {
|
||||||
// Clone the external module repo
|
// Clone the external module repo
|
||||||
const cloneDir = await this.cloneExternalModule(moduleCode, options);
|
const cloneDir = await this.cloneExternalModule(moduleCode, options);
|
||||||
|
|
||||||
|
if (moduleInfo.sourceRoot) {
|
||||||
|
const repoRoot = path.resolve(cloneDir);
|
||||||
|
const sourceRoot = path.resolve(repoRoot, moduleInfo.sourceRoot);
|
||||||
|
const relativeSourceRoot = path.relative(repoRoot, sourceRoot);
|
||||||
|
if (relativeSourceRoot === '..' || relativeSourceRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeSourceRoot)) {
|
||||||
|
throw new Error(`External module '${moduleCode}' source-root escapes repository: ${moduleInfo.sourceRoot}`);
|
||||||
|
}
|
||||||
|
if (!(await fs.pathExists(sourceRoot))) {
|
||||||
|
throw new Error(`External module '${moduleCode}' source-root not found: ${moduleInfo.sourceRoot}`);
|
||||||
|
}
|
||||||
|
return sourceRoot;
|
||||||
|
}
|
||||||
|
|
||||||
// The module-definition specifies the path to module.yaml relative to repo root
|
// The module-definition specifies the path to module.yaml relative to repo root
|
||||||
// We need to return the directory containing module.yaml
|
// We need to return the directory containing module.yaml
|
||||||
const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'skills/module.yaml'
|
const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'skills/module.yaml'
|
||||||
|
|
|
||||||
|
|
@ -903,7 +903,10 @@ class OfficialModules {
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(moduleConfigPath, 'utf8');
|
const content = await fs.readFile(moduleConfigPath, 'utf8');
|
||||||
const moduleConfig = yaml.parse(content);
|
const moduleConfig = yaml.parse(content);
|
||||||
if (moduleConfig) {
|
// Only keep plain object parses. A corrupt config.yaml that parses
|
||||||
|
// to a scalar or array would crash later code that does `key in cfg`
|
||||||
|
// / `Object.keys(cfg)`; treat it the same as a parse error.
|
||||||
|
if (moduleConfig && typeof moduleConfig === 'object' && !Array.isArray(moduleConfig)) {
|
||||||
this._existingConfig[entry.name] = moduleConfig;
|
this._existingConfig[entry.name] = moduleConfig;
|
||||||
foundAny = true;
|
foundAny = true;
|
||||||
}
|
}
|
||||||
|
|
@ -914,9 +917,58 @@ class OfficialModules {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (foundAny) {
|
||||||
|
await this._hoistCoreKeysFromLegacyModuleConfigs();
|
||||||
|
}
|
||||||
|
|
||||||
return foundAny;
|
return foundAny;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate prior answers when a key has moved from a non-core module to core
|
||||||
|
* (e.g. project_name moving from bmm to core in #2279). Without this, the
|
||||||
|
* partition logic in writeCentralConfig drops the value from the bmm bucket
|
||||||
|
* (because it's now a core key) without re-homing it under [core], so the
|
||||||
|
* user's prior answer silently disappears on the next install/quick-update.
|
||||||
|
*/
|
||||||
|
async _hoistCoreKeysFromLegacyModuleConfigs() {
|
||||||
|
const coreSchemaPath = path.join(getSourcePath(), 'core-skills', 'module.yaml');
|
||||||
|
if (!(await fs.pathExists(coreSchemaPath))) return;
|
||||||
|
|
||||||
|
let coreSchema;
|
||||||
|
try {
|
||||||
|
coreSchema = yaml.parse(await fs.readFile(coreSchemaPath, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!coreSchema || typeof coreSchema !== 'object') return;
|
||||||
|
|
||||||
|
const coreKeys = new Set(
|
||||||
|
Object.entries(coreSchema)
|
||||||
|
.filter(([, v]) => v && typeof v === 'object' && 'prompt' in v)
|
||||||
|
.map(([k]) => k),
|
||||||
|
);
|
||||||
|
if (coreKeys.size === 0) return;
|
||||||
|
|
||||||
|
// Belt-and-suspenders: loadExistingConfig already filters non-object parses,
|
||||||
|
// but anyone calling _hoistCoreKeysFromLegacyModuleConfigs in isolation (or
|
||||||
|
// future code paths populating _existingConfig directly) shouldn't be able
|
||||||
|
// to crash this with a scalar / array.
|
||||||
|
const existingCore = this._existingConfig.core;
|
||||||
|
this._existingConfig.core = existingCore && typeof existingCore === 'object' && !Array.isArray(existingCore) ? existingCore : {};
|
||||||
|
|
||||||
|
for (const [moduleName, cfg] of Object.entries(this._existingConfig)) {
|
||||||
|
if (moduleName === 'core' || !cfg || typeof cfg !== 'object' || Array.isArray(cfg)) continue;
|
||||||
|
for (const key of Object.keys(cfg)) {
|
||||||
|
if (!coreKeys.has(key)) continue;
|
||||||
|
if (!(key in this._existingConfig.core)) {
|
||||||
|
this._existingConfig.core[key] = cfg[key];
|
||||||
|
}
|
||||||
|
delete cfg[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-scan module schemas to gather metadata for the configuration gateway prompt.
|
* Pre-scan module schemas to gather metadata for the configuration gateway prompt.
|
||||||
* Returns info about which modules have configurable options.
|
* Returns info about which modules have configurable options.
|
||||||
|
|
|
||||||
|
|
@ -50,3 +50,25 @@ modules:
|
||||||
type: bmad-org
|
type: bmad-org
|
||||||
npmPackage: bmad-method-test-architecture-enterprise
|
npmPackage: bmad-method-test-architecture-enterprise
|
||||||
default_channel: stable
|
default_channel: stable
|
||||||
|
|
||||||
|
bmad-automator:
|
||||||
|
url: https://github.com/bmad-code-org/bmad-automator
|
||||||
|
source-root: payload/.claude/skills
|
||||||
|
code: bma
|
||||||
|
name: "BMad Automator (Experimental)"
|
||||||
|
description: "Experimental pure-skill story automation. Runs only from Claude Code; supports Claude Code and Codex worker sessions; requires tmux on macOS."
|
||||||
|
defaultSelected: false
|
||||||
|
type: experimental
|
||||||
|
npmPackage: bmad-story-automator
|
||||||
|
default_channel: stable
|
||||||
|
install-targets:
|
||||||
|
- claude-code
|
||||||
|
worker-targets:
|
||||||
|
- claude-code
|
||||||
|
- codex
|
||||||
|
requirements:
|
||||||
|
- Claude Code entrypoint
|
||||||
|
- Claude Code or Codex worker sessions
|
||||||
|
- tmux
|
||||||
|
- macOS
|
||||||
|
install-note: "Experimental: BMad Automator only works from Claude Code. It currently supports Claude Code and Codex worker sessions, and requires tmux on macOS."
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,330 @@
|
||||||
|
// `--set <module>.<key>=<value>` is a post-install patch. The installer runs
|
||||||
|
// its normal flow and writes `_bmad/config.toml`, `_bmad/config.user.toml`,
|
||||||
|
// and `_bmad/<module>/config.yaml`; afterwards `applySetOverrides` upserts
|
||||||
|
// each override into those files.
|
||||||
|
//
|
||||||
|
// This is intentionally NOT integrated with the prompt/template/schema
|
||||||
|
// system. Tradeoffs:
|
||||||
|
// - No `result:` template rendering: `--set bmm.project_knowledge=research`
|
||||||
|
// writes "research" verbatim. Pass `--set bmm.project_knowledge='{project-root}/research'`
|
||||||
|
// if you want the rendered form.
|
||||||
|
// - Carry-forward across installs is best-effort: declared schema keys
|
||||||
|
// persist via the existingValue path on the next interactive run; values
|
||||||
|
// for keys outside any module's schema may need to be re-passed on each
|
||||||
|
// install (or edited directly in `_bmad/config.toml`).
|
||||||
|
// - No "key not in schema" validation: whatever you assert, we write.
|
||||||
|
//
|
||||||
|
// Names that, when used as object keys, can mutate `Object.prototype` and
|
||||||
|
// cascade into every plain-object lookup in the process. The `--set` pipeline
|
||||||
|
// assigns into plain `{}` maps keyed by user input, so `--set __proto__.x=1`
|
||||||
|
// would otherwise reach `overrides.__proto__[x] = 1` and pollute every plain
|
||||||
|
// object. We reject the names at parse time and harden the maps in
|
||||||
|
// `parseSetEntries` with `Object.create(null)` for defense-in-depth.
|
||||||
|
const PROTOTYPE_POLLUTING_NAMES = new Set(['__proto__', 'prototype', 'constructor']);
|
||||||
|
|
||||||
|
const path = require('node:path');
|
||||||
|
const fs = require('./fs-native');
|
||||||
|
const yaml = require('yaml');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single `--set <module>.<key>=<value>` entry.
|
||||||
|
* @param {string} entry - raw flag value
|
||||||
|
* @returns {{module: string, key: string, value: string}}
|
||||||
|
* @throws {Error} on malformed input
|
||||||
|
*/
|
||||||
|
function parseSetEntry(entry) {
|
||||||
|
if (typeof entry !== 'string' || entry.length === 0) {
|
||||||
|
throw new Error('--set: empty entry. Expected <module>.<key>=<value>');
|
||||||
|
}
|
||||||
|
const eq = entry.indexOf('=');
|
||||||
|
if (eq === -1) {
|
||||||
|
throw new Error(`--set "${entry}": missing '='. Expected <module>.<key>=<value>`);
|
||||||
|
}
|
||||||
|
const lhs = entry.slice(0, eq);
|
||||||
|
// Note: only the LHS is trimmed. Values may legitimately contain leading
|
||||||
|
// or trailing whitespace (paths with spaces, quoted strings); module / key
|
||||||
|
// names cannot, so it's safe to be strict on the left.
|
||||||
|
const value = entry.slice(eq + 1);
|
||||||
|
const dot = lhs.indexOf('.');
|
||||||
|
if (dot === -1) {
|
||||||
|
throw new Error(`--set "${entry}": missing '.'. Expected <module>.<key>=<value>`);
|
||||||
|
}
|
||||||
|
const moduleCode = lhs.slice(0, dot).trim();
|
||||||
|
const key = lhs.slice(dot + 1).trim();
|
||||||
|
if (!moduleCode || !key) {
|
||||||
|
throw new Error(`--set "${entry}": empty module or key. Expected <module>.<key>=<value>`);
|
||||||
|
}
|
||||||
|
if (PROTOTYPE_POLLUTING_NAMES.has(moduleCode) || PROTOTYPE_POLLUTING_NAMES.has(key)) {
|
||||||
|
throw new Error(
|
||||||
|
`--set "${entry}": '__proto__', 'prototype', and 'constructor' are reserved and cannot be used as a module or key name.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { module: moduleCode, key, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse repeated `--set` entries into a `{ module: { key: value } }` map.
|
||||||
|
* Later entries overwrite earlier ones for the same key. Both the outer
|
||||||
|
* map and the per-module inner maps are `Object.create(null)` so callers
|
||||||
|
* that bypass `parseSetEntry`'s name check still can't pollute prototypes.
|
||||||
|
*
|
||||||
|
* @param {string[]} entries
|
||||||
|
* @returns {Object<string, Object<string, string>>}
|
||||||
|
*/
|
||||||
|
function parseSetEntries(entries) {
|
||||||
|
const overrides = Object.create(null);
|
||||||
|
if (!Array.isArray(entries)) return overrides;
|
||||||
|
for (const entry of entries) {
|
||||||
|
const { module: moduleCode, key, value } = parseSetEntry(entry);
|
||||||
|
if (!overrides[moduleCode]) overrides[moduleCode] = Object.create(null);
|
||||||
|
overrides[moduleCode][key] = value;
|
||||||
|
}
|
||||||
|
return overrides;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a JS string as a TOML basic string (double-quoted with escapes).
|
||||||
|
* @param {string} value
|
||||||
|
*/
|
||||||
|
function tomlString(value) {
|
||||||
|
const s = String(value);
|
||||||
|
// Per the TOML spec, basic strings escape `\`, `"`, and control characters.
|
||||||
|
return (
|
||||||
|
'"' +
|
||||||
|
s
|
||||||
|
.replaceAll('\\', '\\\\')
|
||||||
|
.replaceAll('"', String.raw`\"`)
|
||||||
|
.replaceAll('\b', String.raw`\b`)
|
||||||
|
.replaceAll('\f', String.raw`\f`)
|
||||||
|
.replaceAll('\n', String.raw`\n`)
|
||||||
|
.replaceAll('\r', String.raw`\r`)
|
||||||
|
.replaceAll('\t', String.raw`\t`) +
|
||||||
|
'"'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Section header for a given module code.
|
||||||
|
* - `core` → `[core]`
|
||||||
|
* - `<other>` → `[modules.<other>]`
|
||||||
|
*
|
||||||
|
* Mirrors the layout `manifest-generator.writeCentralConfig` produces.
|
||||||
|
*/
|
||||||
|
function sectionHeader(moduleCode) {
|
||||||
|
return moduleCode === 'core' ? '[core]' : `[modules.${moduleCode}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert or update `key = value` inside a TOML section, returning the new
|
||||||
|
* file content. The format produced by the installer is regular and small
|
||||||
|
* enough that a line scanner is more reliable than pulling in a TOML
|
||||||
|
* round-tripper that would normalize the file's existing whitespace and
|
||||||
|
* comment structure.
|
||||||
|
*
|
||||||
|
* - If `[section]` exists and contains `key`, replace the value on that
|
||||||
|
* line (preserving any inline comment after the value).
|
||||||
|
* - If `[section]` exists but `key` doesn't, append `key = value` at the
|
||||||
|
* end of the section (before the next `[...]` header or EOF, skipping
|
||||||
|
* trailing blank lines so the section stays tidy).
|
||||||
|
* - If `[section]` doesn't exist, append a new section block at EOF.
|
||||||
|
*
|
||||||
|
* @param {string} content existing file content (may be empty)
|
||||||
|
* @param {string} section exact `[section]` header to target
|
||||||
|
* @param {string} key
|
||||||
|
* @param {string} valueToml already TOML-encoded value (e.g. `"foo"`)
|
||||||
|
* @returns {string} new content
|
||||||
|
*/
|
||||||
|
function upsertTomlKey(content, section, key, valueToml) {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
// Track whether the file already ended with a newline so we can preserve
|
||||||
|
// that. `split('\n')` on `"a\n"` yields `['a', '']`, which gives us the
|
||||||
|
// marker we need.
|
||||||
|
const hadTrailingNewline = lines.length > 0 && lines.at(-1) === '';
|
||||||
|
if (hadTrailingNewline) lines.pop();
|
||||||
|
|
||||||
|
// Locate the target section.
|
||||||
|
const sectionStart = lines.findIndex((line) => line.trim() === section);
|
||||||
|
if (sectionStart === -1) {
|
||||||
|
// Section doesn't exist — append a new block. Pad with a blank line if
|
||||||
|
// the file is non-empty so sections stay visually separated.
|
||||||
|
if (lines.length > 0 && lines.at(-1).trim() !== '') lines.push('');
|
||||||
|
lines.push(section, `${key} = ${valueToml}`);
|
||||||
|
return lines.join('\n') + (hadTrailingNewline ? '\n' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the section's end (next `[...]` header or EOF).
|
||||||
|
let sectionEnd = lines.length;
|
||||||
|
for (let i = sectionStart + 1; i < lines.length; i++) {
|
||||||
|
if (/^\s*\[/.test(lines[i])) {
|
||||||
|
sectionEnd = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for the key inside the section. Match `<key> = ...` allowing
|
||||||
|
// optional leading whitespace; preserve the comment tail (`# ...`) if any.
|
||||||
|
const keyPattern = new RegExp(`^(\\s*)${escapeRegExp(key)}\\s*=\\s*(.*)$`);
|
||||||
|
for (let i = sectionStart + 1; i < sectionEnd; i++) {
|
||||||
|
const match = lines[i].match(keyPattern);
|
||||||
|
if (match) {
|
||||||
|
const indent = match[1];
|
||||||
|
// Preserve trailing comment if present. We split on the first `#` that
|
||||||
|
// is preceded by whitespace — TOML strings can't contain unescaped `#`
|
||||||
|
// in basic-string form so this is safe for the values we emit.
|
||||||
|
const tail = match[2];
|
||||||
|
const commentIdx = tail.search(/\s+#/);
|
||||||
|
const commentSuffix = commentIdx === -1 ? '' : tail.slice(commentIdx);
|
||||||
|
lines[i] = `${indent}${key} = ${valueToml}${commentSuffix}`;
|
||||||
|
return lines.join('\n') + (hadTrailingNewline ? '\n' : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section exists but key doesn't. Insert before the next section header,
|
||||||
|
// skipping trailing blank lines inside the current section so the new
|
||||||
|
// entry sits with its siblings.
|
||||||
|
let insertAt = sectionEnd;
|
||||||
|
while (insertAt > sectionStart + 1 && lines[insertAt - 1].trim() === '') {
|
||||||
|
insertAt--;
|
||||||
|
}
|
||||||
|
lines.splice(insertAt, 0, `${key} = ${valueToml}`);
|
||||||
|
return lines.join('\n') + (hadTrailingNewline ? '\n' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(s) {
|
||||||
|
return s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up `[section] key` in a TOML file. Returns true if the file exists,
|
||||||
|
* the section is present, and `key` is set within it. Used by
|
||||||
|
* `applySetOverrides` to route an override to the file that already owns
|
||||||
|
* the key (so user-scope keys land in `config.user.toml`, team-scope keys
|
||||||
|
* land in `config.toml`).
|
||||||
|
*/
|
||||||
|
async function tomlHasKey(filePath, section, key) {
|
||||||
|
if (!(await fs.pathExists(filePath))) return false;
|
||||||
|
const content = await fs.readFile(filePath, 'utf8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const sectionStart = lines.findIndex((line) => line.trim() === section);
|
||||||
|
if (sectionStart === -1) return false;
|
||||||
|
const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
|
||||||
|
for (let i = sectionStart + 1; i < lines.length; i++) {
|
||||||
|
if (/^\s*\[/.test(lines[i])) return false;
|
||||||
|
if (keyPattern.test(lines[i])) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply parsed `--set` overrides to the central TOML files written by the
|
||||||
|
* installer. Called at the end of an install / quick-update.
|
||||||
|
*
|
||||||
|
* Routing per (module, key):
|
||||||
|
* 1. If `_bmad/config.user.toml` already has `[section] key`, update there
|
||||||
|
* (user-scope key like `core.user_name`, `bmm.user_skill_level`).
|
||||||
|
* 2. Otherwise update `_bmad/config.toml` (team scope, the default).
|
||||||
|
*
|
||||||
|
* The schema-correct user/team partition lives in `manifest-generator`. We
|
||||||
|
* intentionally don't re-read module schemas here — the only goal is to
|
||||||
|
* match the file the installer just wrote the key to. For brand-new keys
|
||||||
|
* (not in either file yet), team scope is the safe default.
|
||||||
|
*
|
||||||
|
* @param {Object<string, Object<string, string>>} overrides
|
||||||
|
* @param {string} bmadDir absolute path to `_bmad/`
|
||||||
|
* @returns {Promise<Array<{module:string,key:string,scope:'team'|'user',file:string}>>}
|
||||||
|
* a list of applied entries (for caller logging)
|
||||||
|
*/
|
||||||
|
async function applySetOverrides(overrides, bmadDir) {
|
||||||
|
const applied = [];
|
||||||
|
if (!overrides || typeof overrides !== 'object') return applied;
|
||||||
|
|
||||||
|
const teamPath = path.join(bmadDir, 'config.toml');
|
||||||
|
const userPath = path.join(bmadDir, 'config.user.toml');
|
||||||
|
|
||||||
|
for (const moduleCode of Object.keys(overrides)) {
|
||||||
|
// Skip overrides for modules not actually installed. The installer writes
|
||||||
|
// `_bmad/<module>/config.yaml` for every installed module (including core),
|
||||||
|
// so its presence is a reliable "is this module here?" signal that works
|
||||||
|
// for both fresh installs and quick-updates without coupling to caller-
|
||||||
|
// supplied module lists.
|
||||||
|
const moduleConfigYaml = path.join(bmadDir, moduleCode, 'config.yaml');
|
||||||
|
if (!(await fs.pathExists(moduleConfigYaml))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const section = sectionHeader(moduleCode);
|
||||||
|
const moduleOverrides = overrides[moduleCode] || {};
|
||||||
|
for (const key of Object.keys(moduleOverrides)) {
|
||||||
|
const value = moduleOverrides[key];
|
||||||
|
const valueToml = tomlString(value);
|
||||||
|
|
||||||
|
const userOwnsIt = await tomlHasKey(userPath, section, key);
|
||||||
|
const targetPath = userOwnsIt ? userPath : teamPath;
|
||||||
|
|
||||||
|
// The team file always exists post-install; the user file only exists
|
||||||
|
// if the install wrote at least one user-scope key. If we're routing to
|
||||||
|
// it but it doesn't exist yet, create it with a minimal header so it
|
||||||
|
// has the same shape as installer-written user toml.
|
||||||
|
let content = '';
|
||||||
|
if (await fs.pathExists(targetPath)) {
|
||||||
|
content = await fs.readFile(targetPath, 'utf8');
|
||||||
|
} else {
|
||||||
|
content = '# Personal overrides for _bmad/config.toml.\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = upsertTomlKey(content, section, key, valueToml);
|
||||||
|
await fs.writeFile(targetPath, next, 'utf8');
|
||||||
|
applied.push({
|
||||||
|
module: moduleCode,
|
||||||
|
key,
|
||||||
|
scope: userOwnsIt ? 'user' : 'team',
|
||||||
|
file: path.basename(targetPath),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also patch the per-module yaml (`_bmad/<module>/config.yaml`). The
|
||||||
|
// installer reads this file as `_existingConfig` on subsequent runs and
|
||||||
|
// surfaces declared values as prompt defaults — under `--yes` those
|
||||||
|
// defaults are accepted, so patching here gives `--set` natural
|
||||||
|
// carry-forward for declared keys without needing schema-strict
|
||||||
|
// partition exemptions in the manifest writer. For undeclared keys the
|
||||||
|
// value lives in the per-module yaml but won't be re-emitted into
|
||||||
|
// config.toml on the next install (the schema-strict partition drops
|
||||||
|
// it); re-pass `--set` if you need it sticky.
|
||||||
|
const moduleYamlPath = path.join(bmadDir, moduleCode, 'config.yaml');
|
||||||
|
if (await fs.pathExists(moduleYamlPath)) {
|
||||||
|
try {
|
||||||
|
const text = await fs.readFile(moduleYamlPath, 'utf8');
|
||||||
|
const parsed = yaml.parse(text);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
// Preserve the installer's banner header (everything up to the
|
||||||
|
// first non-comment line) so `_bmad/<module>/config.yaml` keeps
|
||||||
|
// its provenance comments after we round-trip it.
|
||||||
|
const headerLines = [];
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
if (line.startsWith('#') || line.trim() === '') {
|
||||||
|
headerLines.push(line);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(moduleOverrides)) {
|
||||||
|
parsed[key] = moduleOverrides[key];
|
||||||
|
}
|
||||||
|
const body = yaml.stringify(parsed, { indent: 2, lineWidth: 0, minContentWidth: 0 });
|
||||||
|
const header = headerLines.length > 0 ? headerLines.join('\n') + '\n' : '';
|
||||||
|
await fs.writeFile(moduleYamlPath, header + body, 'utf8');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Per-module yaml unparseable — skip silently. The central toml was
|
||||||
|
// already patched above, which is the user-visible state for the
|
||||||
|
// current install. Carry-forward will fail next install but the
|
||||||
|
// current install reflects the override.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { parseSetEntry, parseSetEntries, applySetOverrides, upsertTomlKey, tomlString };
|
||||||
|
|
@ -16,6 +16,7 @@ const {
|
||||||
} = require('./modules/channel-plan');
|
} = require('./modules/channel-plan');
|
||||||
const channelResolver = require('./modules/channel-resolver');
|
const channelResolver = require('./modules/channel-resolver');
|
||||||
const prompts = require('./prompts');
|
const prompts = require('./prompts');
|
||||||
|
const { parseSetEntries } = require('./set-overrides');
|
||||||
|
|
||||||
const manifest = new Manifest();
|
const manifest = new Manifest();
|
||||||
|
|
||||||
|
|
@ -258,6 +259,7 @@ class UI {
|
||||||
} else {
|
} else {
|
||||||
selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
|
selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
|
||||||
}
|
}
|
||||||
|
await this.showSelectedExternalModuleNotes(selectedModules);
|
||||||
|
|
||||||
// Resolve custom sources from --custom-source flag
|
// Resolve custom sources from --custom-source flag
|
||||||
if (options.customSource) {
|
if (options.customSource) {
|
||||||
|
|
@ -286,8 +288,9 @@ class UI {
|
||||||
|
|
||||||
// Get tool selection
|
// Get tool selection
|
||||||
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||||
|
await this.showSelectedModuleIdeWarnings(selectedModules, toolSelection.ides);
|
||||||
|
|
||||||
const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
||||||
...options,
|
...options,
|
||||||
channelOptions,
|
channelOptions,
|
||||||
});
|
});
|
||||||
|
|
@ -313,6 +316,7 @@ class UI {
|
||||||
skipIde: toolSelection.skipIde,
|
skipIde: toolSelection.skipIde,
|
||||||
coreConfig: moduleConfigs.core || {},
|
coreConfig: moduleConfigs.core || {},
|
||||||
moduleConfigs: moduleConfigs,
|
moduleConfigs: moduleConfigs,
|
||||||
|
setOverrides,
|
||||||
skipPrompts: options.yes || false,
|
skipPrompts: options.yes || false,
|
||||||
channelOptions,
|
channelOptions,
|
||||||
};
|
};
|
||||||
|
|
@ -341,6 +345,7 @@ class UI {
|
||||||
} else {
|
} else {
|
||||||
selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
|
selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
|
||||||
}
|
}
|
||||||
|
await this.showSelectedExternalModuleNotes(selectedModules);
|
||||||
|
|
||||||
// Resolve custom sources from --custom-source flag
|
// Resolve custom sources from --custom-source flag
|
||||||
if (options.customSource) {
|
if (options.customSource) {
|
||||||
|
|
@ -364,7 +369,8 @@ class UI {
|
||||||
await this._interactiveChannelGate({ options, channelOptions, selectedModules });
|
await this._interactiveChannelGate({ options, channelOptions, selectedModules });
|
||||||
|
|
||||||
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||||
const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
await this.showSelectedModuleIdeWarnings(selectedModules, toolSelection.ides);
|
||||||
|
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
||||||
...options,
|
...options,
|
||||||
channelOptions,
|
channelOptions,
|
||||||
});
|
});
|
||||||
|
|
@ -390,6 +396,7 @@ class UI {
|
||||||
skipIde: toolSelection.skipIde,
|
skipIde: toolSelection.skipIde,
|
||||||
coreConfig: moduleConfigs.core || {},
|
coreConfig: moduleConfigs.core || {},
|
||||||
moduleConfigs: moduleConfigs,
|
moduleConfigs: moduleConfigs,
|
||||||
|
setOverrides,
|
||||||
skipPrompts: options.yes || false,
|
skipPrompts: options.yes || false,
|
||||||
channelOptions,
|
channelOptions,
|
||||||
};
|
};
|
||||||
|
|
@ -404,6 +411,37 @@ class UI {
|
||||||
* @param {Object} options - Command-line options
|
* @param {Object} options - Command-line options
|
||||||
* @returns {Object} Tool configuration
|
* @returns {Object} Tool configuration
|
||||||
*/
|
*/
|
||||||
|
_parseToolsFlag(toolsArg, allKnownValues) {
|
||||||
|
const selectedIdes = toolsArg
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (selectedIdes.length === 0) {
|
||||||
|
const err = new Error(
|
||||||
|
'--tools was passed empty. Provide at least one tool ID (e.g. --tools claude-code) or run with --list-tools to see valid IDs.',
|
||||||
|
);
|
||||||
|
err.expected = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unknown = selectedIdes.filter((id) => !allKnownValues.has(id));
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
const err = new Error(
|
||||||
|
[
|
||||||
|
`Unknown tool ID${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}`,
|
||||||
|
'',
|
||||||
|
'Run with --list-tools to see all valid IDs.',
|
||||||
|
'Common: claude-code, cursor, copilot, windsurf, cline',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
err.expected = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedIdes;
|
||||||
|
}
|
||||||
|
|
||||||
async promptToolSelection(projectDir, options = {}) {
|
async promptToolSelection(projectDir, options = {}) {
|
||||||
const { ExistingInstall } = require('./core/existing-install');
|
const { ExistingInstall } = require('./core/existing-install');
|
||||||
const { Installer } = require('./core/installer');
|
const { Installer } = require('./core/installer');
|
||||||
|
|
@ -438,15 +476,10 @@ class UI {
|
||||||
const allTools = [...preferredIdes, ...otherIdes];
|
const allTools = [...preferredIdes, ...otherIdes];
|
||||||
|
|
||||||
// Non-interactive: handle --tools and --yes flags before interactive prompt
|
// Non-interactive: handle --tools and --yes flags before interactive prompt
|
||||||
if (options.tools) {
|
// Use !== undefined so an explicit --tools "" falls through to _parseToolsFlag and
|
||||||
if (options.tools.toLowerCase() === 'none') {
|
// gets a specific "passed empty" error instead of being silently ignored.
|
||||||
await prompts.log.info('Skipping tool configuration (--tools none)');
|
if (options.tools !== undefined) {
|
||||||
return { ides: [], skipIde: true };
|
const selectedIdes = this._parseToolsFlag(options.tools, allKnownValues);
|
||||||
}
|
|
||||||
const selectedIdes = options.tools
|
|
||||||
.split(',')
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
await prompts.log.info(`Using tools from command-line: ${selectedIdes.join(', ')}`);
|
await prompts.log.info(`Using tools from command-line: ${selectedIdes.join(', ')}`);
|
||||||
await this.displaySelectedTools(selectedIdes, preferredIdes, allTools);
|
await this.displaySelectedTools(selectedIdes, preferredIdes, allTools);
|
||||||
return { ides: selectedIdes, skipIde: false };
|
return { ides: selectedIdes, skipIde: false };
|
||||||
|
|
@ -522,21 +555,13 @@ class UI {
|
||||||
|
|
||||||
let selectedIdes = [];
|
let selectedIdes = [];
|
||||||
|
|
||||||
// Check if tools are provided via command-line
|
// Check if tools are provided via command-line.
|
||||||
if (options.tools) {
|
// Use !== undefined so an explicit --tools "" still hits _parseToolsFlag's empty-value error.
|
||||||
// Check for explicit "none" value to skip tool installation
|
if (options.tools !== undefined) {
|
||||||
if (options.tools.toLowerCase() === 'none') {
|
selectedIdes = this._parseToolsFlag(options.tools, allKnownValues);
|
||||||
await prompts.log.info('Skipping tool configuration (--tools none)');
|
|
||||||
return { ides: [], skipIde: true };
|
|
||||||
} else {
|
|
||||||
selectedIdes = options.tools
|
|
||||||
.split(',')
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
await prompts.log.info(`Using tools from command-line: ${selectedIdes.join(', ')}`);
|
await prompts.log.info(`Using tools from command-line: ${selectedIdes.join(', ')}`);
|
||||||
await this.displaySelectedTools(selectedIdes, preferredIdes, allTools);
|
await this.displaySelectedTools(selectedIdes, preferredIdes, allTools);
|
||||||
return { ides: selectedIdes, skipIde: false };
|
return { ides: selectedIdes, skipIde: false };
|
||||||
}
|
|
||||||
} else if (options.yes) {
|
} else if (options.yes) {
|
||||||
// If --yes flag is set, skip tool prompt and use previously configured tools or empty
|
// If --yes flag is set, skip tool prompt and use previously configured tools or empty
|
||||||
if (configuredIdes.length > 0) {
|
if (configuredIdes.length > 0) {
|
||||||
|
|
@ -544,8 +569,18 @@ class UI {
|
||||||
await this.displaySelectedTools(configuredIdes, preferredIdes, allTools);
|
await this.displaySelectedTools(configuredIdes, preferredIdes, allTools);
|
||||||
return { ides: configuredIdes, skipIde: false };
|
return { ides: configuredIdes, skipIde: false };
|
||||||
} else {
|
} else {
|
||||||
await prompts.log.info('Skipping tool configuration (--yes flag, no previous tools)');
|
const err = new Error(
|
||||||
return { ides: [], skipIde: true };
|
[
|
||||||
|
'--tools is required for non-interactive install (--yes / -y) when no tools are previously configured.',
|
||||||
|
'',
|
||||||
|
'Common: claude-code, cursor, copilot, windsurf, cline',
|
||||||
|
'See all supported tools: bmad-method install --list-tools',
|
||||||
|
'',
|
||||||
|
'Example: bmad-method install --modules bmm --tools claude-code -y',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
err.expected = true;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -681,6 +716,33 @@ class UI {
|
||||||
*/
|
*/
|
||||||
async collectModuleConfigs(directory, modules, options = {}) {
|
async collectModuleConfigs(directory, modules, options = {}) {
|
||||||
const { OfficialModules } = require('./modules/official-modules');
|
const { OfficialModules } = require('./modules/official-modules');
|
||||||
|
|
||||||
|
// Parse --set up front purely to surface user-error before the install
|
||||||
|
// burns time on the network / filesystem. The actual application happens
|
||||||
|
// in installer.install() as a post-write TOML patch — see
|
||||||
|
// `tools/installer/set-overrides.js`. We also warn about overrides
|
||||||
|
// targeting modules the user didn't include, since those will silently
|
||||||
|
// miss the file the patch step looks for.
|
||||||
|
let setOverrides = {};
|
||||||
|
try {
|
||||||
|
setOverrides = parseSetEntries(options.set || []);
|
||||||
|
} catch (error) {
|
||||||
|
// install.js validated already; rethrow as-is for the user.
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// Drop overrides for modules that aren't in the install set so the
|
||||||
|
// post-install patch step doesn't create orphan sections in config.toml
|
||||||
|
// for modules that were never installed.
|
||||||
|
const selectedModuleSet = new Set(['core', ...modules]);
|
||||||
|
for (const moduleCode of Object.keys(setOverrides)) {
|
||||||
|
if (!selectedModuleSet.has(moduleCode)) {
|
||||||
|
await prompts.log.warn(
|
||||||
|
`--set ${moduleCode}.* — module '${moduleCode}' is not in the install set; values will be ignored. Add it to --modules to apply.`,
|
||||||
|
);
|
||||||
|
delete setOverrides[moduleCode];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const configCollector = new OfficialModules({ channelOptions: options.channelOptions });
|
const configCollector = new OfficialModules({ channelOptions: options.channelOptions });
|
||||||
|
|
||||||
// Seed core config from CLI options if provided
|
// Seed core config from CLI options if provided
|
||||||
|
|
@ -730,6 +792,9 @@ class UI {
|
||||||
const defaultUsername = safeUsername.charAt(0).toUpperCase() + safeUsername.slice(1);
|
const defaultUsername = safeUsername.charAt(0).toUpperCase() + safeUsername.slice(1);
|
||||||
configCollector.collectedConfig.core = {
|
configCollector.collectedConfig.core = {
|
||||||
user_name: defaultUsername,
|
user_name: defaultUsername,
|
||||||
|
// {directory_name} default per src/core-skills/module.yaml — matches what the
|
||||||
|
// interactive flow resolves via buildQuestion()'s {directory_name} placeholder.
|
||||||
|
project_name: path.basename(directory),
|
||||||
communication_language: 'English',
|
communication_language: 'English',
|
||||||
document_output_language: 'English',
|
document_output_language: 'English',
|
||||||
output_folder: '_bmad-output',
|
output_folder: '_bmad-output',
|
||||||
|
|
@ -743,7 +808,7 @@ class UI {
|
||||||
skipPrompts: options.yes || false,
|
skipPrompts: options.yes || false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return configCollector.collectedConfig;
|
return { moduleConfigs: configCollector.collectedConfig, setOverrides };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -893,6 +958,41 @@ class UI {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async showSelectedExternalModuleNotes(selectedModuleIds, externalModules = null) {
|
||||||
|
if (!externalModules) {
|
||||||
|
const externalManager = new ExternalModuleManager();
|
||||||
|
externalModules = await externalManager.listAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
const notes = externalModules
|
||||||
|
.filter((mod) => selectedModuleIds.includes(mod.code) && mod.installNote)
|
||||||
|
.map((mod) => `${mod.name}: ${mod.installNote}`);
|
||||||
|
|
||||||
|
for (const note of notes) {
|
||||||
|
await prompts.log.warn(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async showSelectedModuleIdeWarnings(selectedModuleIds, selectedIdes = []) {
|
||||||
|
const externalManager = new ExternalModuleManager();
|
||||||
|
const externalModules = await externalManager.listAvailable();
|
||||||
|
|
||||||
|
for (const mod of externalModules) {
|
||||||
|
if (!selectedModuleIds.includes(mod.code) || !mod.installTargets || mod.installTargets.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasInstallTarget = mod.installTargets.some((target) => selectedIdes.includes(target));
|
||||||
|
if (!hasInstallTarget) {
|
||||||
|
await prompts.log.warn(
|
||||||
|
`${mod.name}: runnable skills are installed only for ${mod.installTargets.join(
|
||||||
|
', ',
|
||||||
|
)}. Add that tool selection to use this module.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browse and select community modules using category drill-down.
|
* Browse and select community modules using category drill-down.
|
||||||
* Featured/promoted modules appear at the top.
|
* Featured/promoted modules appear at the top.
|
||||||
|
|
|
||||||
|
|
@ -129,13 +129,45 @@ export default defineConfig({
|
||||||
// TEA docs moved to standalone module site; keep BMM sidebar focused.
|
// TEA docs moved to standalone module site; keep BMM sidebar focused.
|
||||||
{
|
{
|
||||||
label: 'BMad Ecosystem',
|
label: 'BMad Ecosystem',
|
||||||
|
translations: { 'vi-VN': 'Hệ sinh thái BMad', 'zh-CN': 'BMad 生态系统', 'fr-FR': 'Écosystème BMad', 'cs-CZ': 'Ekosystém BMad' },
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
items: [
|
items: [
|
||||||
{ label: 'BMad Builder', link: 'https://bmad-builder-docs.bmad-method.org/', attrs: { target: '_blank' } },
|
{
|
||||||
{ label: 'Creative Intelligence Suite', link: 'https://cis-docs.bmad-method.org/', attrs: { target: '_blank' } },
|
label: 'BMad Builder',
|
||||||
{ label: 'Game Dev Studio', link: 'https://game-dev-studio-docs.bmad-method.org/', attrs: { target: '_blank' } },
|
translations: { 'vi-VN': 'BMad Builder', 'zh-CN': 'BMad 构建器', 'fr-FR': 'BMad Builder', 'cs-CZ': 'BMad Builder' },
|
||||||
|
link: 'https://bmad-builder-docs.bmad-method.org/',
|
||||||
|
attrs: { target: '_blank' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Creative Intelligence Suite',
|
||||||
|
translations: {
|
||||||
|
'vi-VN': 'Bộ công cụ Trí tuệ Sáng tạo',
|
||||||
|
'zh-CN': '创意智能套件',
|
||||||
|
'fr-FR': "Suite d'Intelligence Créative",
|
||||||
|
'cs-CZ': 'Sada kreativní inteligence',
|
||||||
|
},
|
||||||
|
link: 'https://cis-docs.bmad-method.org/',
|
||||||
|
attrs: { target: '_blank' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Game Dev Studio',
|
||||||
|
translations: {
|
||||||
|
'vi-VN': 'Xưởng phát triển Game',
|
||||||
|
'zh-CN': '游戏开发工作室',
|
||||||
|
'fr-FR': 'Studio de Développement de Jeux',
|
||||||
|
'cs-CZ': 'Herní vývojové studio',
|
||||||
|
},
|
||||||
|
link: 'https://game-dev-studio-docs.bmad-method.org/',
|
||||||
|
attrs: { target: '_blank' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Test Architect (TEA)',
|
label: 'Test Architect (TEA)',
|
||||||
|
translations: {
|
||||||
|
'vi-VN': 'Kiến trúc sư Kiểm thử (TEA)',
|
||||||
|
'zh-CN': '测试架构师 (TEA)',
|
||||||
|
'fr-FR': 'Architecte de Tests (TEA)',
|
||||||
|
'cs-CZ': 'Testovací architekt (TEA)',
|
||||||
|
},
|
||||||
link: 'https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/',
|
link: 'https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/',
|
||||||
attrs: { target: '_blank' },
|
attrs: { target: '_blank' },
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue