Compare commits
6 Commits
f9fd7aeb8e
...
4d39db528a
| Author | SHA1 | Date |
|---|---|---|
|
|
4d39db528a | |
|
|
e6cdc93b79 | |
|
|
e174bebc60 | |
|
|
fcf20f1c7b | |
|
|
e011192525 | |
|
|
91a57499e9 |
|
|
@ -13,7 +13,7 @@
|
|||
"name": "bmad-pro-skills",
|
||||
"source": "./",
|
||||
"description": "Next level skills for power users — advanced prompting techniques, agent management, and more.",
|
||||
"version": "6.3.0",
|
||||
"version": "6.6.0",
|
||||
"author": {
|
||||
"name": "Brian (BMad) Madison"
|
||||
},
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
"name": "bmad-method-lifecycle",
|
||||
"source": "./",
|
||||
"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": {
|
||||
"name": "Brian (BMad) Madison"
|
||||
},
|
||||
|
|
|
|||
26
CHANGELOG.md
26
CHANGELOG.md
|
|
@ -1,5 +1,31 @@
|
|||
# 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
|
||||
|
||||
### 🎁 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
|
||||
```
|
||||
|
||||
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/)
|
||||
|
||||
> **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?`
|
||||
|
|
|
|||
|
|
@ -117,21 +117,23 @@ Under `--yes`, patch and minor upgrades apply automatically. Majors stay frozen
|
|||
|
||||
### Flag reference
|
||||
|
||||
| Flag | Purpose |
|
||||
| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
|
||||
| `--yes`, `-y` | Skip all prompts; accept flag values + defaults |
|
||||
| `--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. |
|
||||
| `--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. |
|
||||
| `--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`) |
|
||||
| `--all-stable` | Alias for `--channel=stable` |
|
||||
| `--all-next` | Alias for `--channel=next` |
|
||||
| `--next=<code>` | Put one module on next. 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 |
|
||||
| Flag | Purpose |
|
||||
| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--yes`, `-y` | Skip all prompts; accept flag values + defaults |
|
||||
| `--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. |
|
||||
| `--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. |
|
||||
| `--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`) |
|
||||
| `--all-stable` | Alias for `--channel=stable` |
|
||||
| `--all-next` | Alias for `--channel=next` |
|
||||
| `--next=<code>` | Put one module on next. Repeatable. |
|
||||
| `--pin <code>=<tag>` | Pin one module to a specific tag. Repeatable. |
|
||||
| `--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`).
|
||||
|
||||
|
|
@ -179,6 +181,43 @@ npx bmad-method install --yes --action update \
|
|||
--next=bmb
|
||||
```
|
||||
|
||||
### 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]
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"version": "6.5.0",
|
||||
"version": "6.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bmad-method",
|
||||
"version": "6.5.0",
|
||||
"version": "6.6.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clack/core": "^1.0.0",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "bmad-method",
|
||||
"version": "6.5.0",
|
||||
"version": "6.6.0",
|
||||
"description": "Breakthrough Method of Agile AI-driven Development",
|
||||
"keywords": [
|
||||
"agile",
|
||||
|
|
@ -39,12 +39,13 @@
|
|||
"lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix",
|
||||
"lint:md": "markdownlint-cli2 \"**/*.md\"",
|
||||
"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",
|
||||
"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:install": "node test/test-installation-components.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:skills": "node tools/validate-skills.js --strict"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2983,6 +2983,260 @@ async function runTests() {
|
|||
|
||||
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('');
|
||||
|
||||
// ============================================================
|
||||
// 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);
|
||||
|
|
@ -18,6 +18,16 @@ module.exports = {
|
|||
'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'],
|
||||
['--user-name <name>', 'Name for agents to use (default: system username)'],
|
||||
['--communication-language <lang>', 'Language for agent communication (default: English)'],
|
||||
|
|
@ -47,12 +57,43 @@ module.exports = {
|
|||
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
|
||||
if (options.debug) {
|
||||
process.env.BMAD_DEBUG_MANIFEST = 'true';
|
||||
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);
|
||||
|
||||
// Handle cancel
|
||||
|
|
@ -61,8 +102,13 @@ module.exports = {
|
|||
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') {
|
||||
const { parseSetEntries } = require('../set-overrides');
|
||||
config.setOverrides = parseSetEntries(options.set || []);
|
||||
const result = await installer.quickUpdate(config);
|
||||
await prompts.log.success('Quick update complete!');
|
||||
await prompts.log.info(`Updated ${result.moduleCount} modules with preserved settings (${result.modules.join(', ')})`);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,19 @@
|
|||
* User input comes from either UI answers or headless CLI flags.
|
||||
*/
|
||||
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.modules = Object.freeze([...modules]);
|
||||
this.ides = Object.freeze([...ides]);
|
||||
|
|
@ -15,6 +27,11 @@ class Config {
|
|||
this._quickUpdate = quickUpdate;
|
||||
// channelOptions carry a Map + Set; don't deep-freeze.
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -40,6 +57,7 @@ class Config {
|
|||
moduleConfigs: userInput.moduleConfigs || null,
|
||||
quickUpdate: userInput._quickUpdate || false,
|
||||
channelOptions: userInput.channelOptions || null,
|
||||
setOverrides: userInput.setOverrides || {},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -310,6 +310,19 @@ class Installer {
|
|||
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...');
|
||||
await this.mergeModuleHelpCatalogs(paths.bmadDir, manifestGen.agents);
|
||||
addResult('Help catalog', 'ok');
|
||||
|
|
@ -1283,6 +1296,10 @@ class Installer {
|
|||
ides: configuredIdes,
|
||||
coreConfig: quickModules.collectedConfig.core,
|
||||
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',
|
||||
_quickUpdate: true,
|
||||
_preserveModules: skippedModules,
|
||||
|
|
|
|||
|
|
@ -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,58 +128,102 @@ class CustomModuleManager {
|
|||
};
|
||||
}
|
||||
|
||||
// HTTPS/HTTP URL: https://host/owner/repo[/tree/branch/subdir][.git]
|
||||
const httpsMatch = trimmed.match(/^(https?):\/\/([^/]+)\/([^/]+)\/([^/.]+?)(?:\.git)?(\/.*)?$/);
|
||||
if (httpsMatch) {
|
||||
const [, protocol, host, owner, repo, remainder] = httpsMatch;
|
||||
const cloneUrl = `${protocol}://${host}/${owner}/${repo}`;
|
||||
let subdir = null;
|
||||
let urlRef = null; // branch/tag extracted from /tree/<ref>/subdir
|
||||
// HTTPS/HTTP URL: generic handling for any Git host.
|
||||
// We avoid host-specific parsing — `git clone` will accept whatever URL the
|
||||
// user provides. We only need to (a) separate an optional browser-style
|
||||
// subdir suffix from the clone URL, (b) extract any embedded ref
|
||||
// (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 (remainder) {
|
||||
// Extract subdir from deep path patterns used by various Git hosts
|
||||
if (url && url.host) {
|
||||
const host = url.host;
|
||||
let repoPath = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
let subdir = null;
|
||||
let urlRef = null; // branch/tag/commit extracted from deep-path URLs
|
||||
|
||||
// Detect browser-style deep-path patterns that embed a ref
|
||||
// (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 = [
|
||||
{ regex: /^\/(?:-\/)?tree\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 }, // GitHub, GitLab
|
||||
{ regex: /^\/(?:-\/)?blob\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 },
|
||||
{ regex: /^\/src\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 }, // Gitea/Forgejo
|
||||
/^(.+?)\/(?:-\/)?(?:tree|blob)\/([^/]+)(?:\/(.+))?$/,
|
||||
/^(.+?)\/src\/(?:branch\/|commit\/|tag\/)?([^/]+)(?:\/(.+))?$/,
|
||||
];
|
||||
// Also match `/tree/<ref>` with no subdir
|
||||
const refOnlyPatterns = [/^\/(?:-\/)?tree\/([^/]+?)\/?$/, /^\/(?:-\/)?blob\/([^/]+?)\/?$/, /^\/src\/([^/]+?)\/?$/];
|
||||
|
||||
for (const p of deepPathPatterns) {
|
||||
const match = remainder.match(p.regex);
|
||||
for (const pattern of deepPathPatterns) {
|
||||
const match = repoPath.match(pattern);
|
||||
if (match) {
|
||||
urlRef = match[p.refIdx];
|
||||
subdir = match[p.pathIdx].replace(/\/$/, '');
|
||||
repoPath = match[1];
|
||||
if (match[2]) urlRef = match[2];
|
||||
if (match[3]) {
|
||||
const cleaned = match[3].replace(/\/+$/, '');
|
||||
if (cleaned) subdir = cleaned;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Some hosts use ?path=/subdir on browse links to point at a file or
|
||||
// directory. Honor it when no deep-path marker matched above.
|
||||
if (!subdir) {
|
||||
for (const r of refOnlyPatterns) {
|
||||
const match = remainder.match(r);
|
||||
if (match) {
|
||||
urlRef = match[1];
|
||||
break;
|
||||
}
|
||||
const pathParam = url.searchParams.get('path');
|
||||
if (pathParam) {
|
||||
const cleaned = pathParam.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
if (cleaned) subdir = cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
const version = versionSuffix || urlRef || null;
|
||||
|
||||
return {
|
||||
type: 'url',
|
||||
cloneUrl,
|
||||
subdir,
|
||||
localPath: null,
|
||||
version,
|
||||
rawInput: trimmedRaw,
|
||||
cacheKey,
|
||||
displayName,
|
||||
isValid: true,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Precedence: explicit @version suffix > URL /tree/<ref> path segment.
|
||||
const version = versionSuffix || urlRef || null;
|
||||
|
||||
return {
|
||||
type: 'url',
|
||||
cloneUrl,
|
||||
subdir,
|
||||
localPath: null,
|
||||
version,
|
||||
rawInput: trimmedRaw,
|
||||
cacheKey: `${host}/${owner}/${repo}`,
|
||||
displayName: `${owner}/${repo}`,
|
||||
isValid: true,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
const channelResolver = require('./modules/channel-resolver');
|
||||
const prompts = require('./prompts');
|
||||
const { parseSetEntries } = require('./set-overrides');
|
||||
|
||||
const manifest = new Manifest();
|
||||
|
||||
|
|
@ -287,7 +288,7 @@ class UI {
|
|||
// Get tool selection
|
||||
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||
|
||||
const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
||||
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
||||
...options,
|
||||
channelOptions,
|
||||
});
|
||||
|
|
@ -313,6 +314,7 @@ class UI {
|
|||
skipIde: toolSelection.skipIde,
|
||||
coreConfig: moduleConfigs.core || {},
|
||||
moduleConfigs: moduleConfigs,
|
||||
setOverrides,
|
||||
skipPrompts: options.yes || false,
|
||||
channelOptions,
|
||||
};
|
||||
|
|
@ -364,7 +366,7 @@ class UI {
|
|||
await this._interactiveChannelGate({ options, channelOptions, selectedModules });
|
||||
|
||||
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||
const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
||||
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
||||
...options,
|
||||
channelOptions,
|
||||
});
|
||||
|
|
@ -390,6 +392,7 @@ class UI {
|
|||
skipIde: toolSelection.skipIde,
|
||||
coreConfig: moduleConfigs.core || {},
|
||||
moduleConfigs: moduleConfigs,
|
||||
setOverrides,
|
||||
skipPrompts: options.yes || false,
|
||||
channelOptions,
|
||||
};
|
||||
|
|
@ -709,6 +712,33 @@ class UI {
|
|||
*/
|
||||
async collectModuleConfigs(directory, modules, options = {}) {
|
||||
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 });
|
||||
|
||||
// Seed core config from CLI options if provided
|
||||
|
|
@ -774,7 +804,7 @@ class UI {
|
|||
skipPrompts: options.yes || false,
|
||||
});
|
||||
|
||||
return configCollector.collectedConfig;
|
||||
return { moduleConfigs: configCollector.collectedConfig, setOverrides };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -129,13 +129,45 @@ export default defineConfig({
|
|||
// TEA docs moved to standalone module site; keep BMM sidebar focused.
|
||||
{
|
||||
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,
|
||||
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: 'Game Dev Studio', link: 'https://game-dev-studio-docs.bmad-method.org/', attrs: { target: '_blank' } },
|
||||
{
|
||||
label: 'BMad Builder',
|
||||
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)',
|
||||
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/',
|
||||
attrs: { target: '_blank' },
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue