diff --git a/README.md b/README.md index c9fb503e2..ea7ba5254 100644 --- a/README.md +++ b/README.md @@ -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 .=` (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?` diff --git a/docs/how-to/install-bmad.md b/docs/how-to/install-bmad.md index 6651143d6..224704a47 100644 --- a/docs/how-to/install-bmad.md +++ b/docs/how-to/install-bmad.md @@ -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 ` | Install into this directory (default: current working dir) | -| `--modules ` | Exact module set. Core is auto-added. Not a delta — list everything you want kept. | -| `--tools ` | 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 ` | `install`, `update`, or `quick-update`. Defaults based on existing install state. | -| `--custom-source ` | Install custom modules from Git URLs or local paths | -| `--channel ` | Apply to all externals (aliased as `--all-stable` / `--all-next`) | -| `--all-stable` | Alias for `--channel=stable` | -| `--all-next` | Alias for `--channel=next` | -| `--next=` | Put one module on next. Repeatable. | -| `--pin =` | 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 ` | Install into this directory (default: current working dir) | +| `--modules ` | Exact module set. Core is auto-added. Not a delta — list everything you want kept. | +| `--tools ` | 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 ` | `install`, `update`, or `quick-update`. Defaults based on existing install state. | +| `--custom-source ` | Install custom modules from Git URLs or local paths | +| `--channel ` | Apply to all externals (aliased as `--all-stable` / `--all-next`) | +| `--all-stable` | Alias for `--channel=stable` | +| `--all-next` | Alias for `--channel=next` | +| `--next=` | Put one module on next. Repeatable. | +| `--pin =` | Pin one module to a specific tag. Repeatable. | +| `--set .=` | 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.=` (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 .=` 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//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.] ` (or `[core] `) 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//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. diff --git a/docs/zh-cn/explanation/named-agents.md b/docs/zh-cn/explanation/named-agents.md new file mode 100644 index 000000000..595b27930 --- /dev/null +++ b/docs/zh-cn/explanation/named-agents.md @@ -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,咱们来头脑风暴",她直接上手干活时,留意一下哪些事情**没有**发生。没有斜杠命令,没有菜单要翻,没有尴尬的功能介绍。这种"无感",正是设计本身。 diff --git a/docs/zh-cn/how-to/expand-bmad-for-your-org.md b/docs/zh-cn/how-to/expand-bmad-for-your-org.md new file mode 100644 index 000000000..a17c8d5e2 --- /dev/null +++ b/docs/zh-cn/how-to/expand-bmad-for-your-org.md @@ -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: ". +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 + +``` + +一句话,每次会话加载。它与 `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 服务器未连接时不会生效。 + +**方案不适用于你的场景?** 以上方案是示例性的。底层机制(三层合并、结构化规则、智能体贯穿工作流)支持更多模式,按需组合即可。 diff --git a/package.json b/package.json index 023b3c41f..a307fa748 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/test/test-installation-components.js b/test/test-installation-components.js index e92f7a757..0c1ca206c 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -3210,6 +3210,260 @@ async function runTests() { console.log(''); + // ============================================================ + // Test Suite 44: --set .= 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 .= 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 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 // ============================================================ diff --git a/test/test-parse-source-urls.js b/test/test-parse-source-urls.js new file mode 100644 index 000000000..9d01e7f53 --- /dev/null +++ b/test/test-parse-source-urls.js @@ -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 ////. + // 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/ 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/ 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/ 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); diff --git a/tools/installer/commands/install.js b/tools/installer/commands/install.js index 55adcfb9c..1dfe6fb70 100644 --- a/tools/installer/commands/install.js +++ b/tools/installer/commands/install.js @@ -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 ', + 'Set a module config option non-interactively. Spec format: .= (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 ', 'Action type for existing installations: install, update, or quick-update'], ['--user-name ', 'Name for agents to use (default: system username)'], ['--communication-language ', '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(', ')})`); diff --git a/tools/installer/core/config.js b/tools/installer/core/config.js index bc359fed9..39617de4c 100644 --- a/tools/installer/core/config.js +++ b/tools/installer/core/config.js @@ -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 .=` 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 || {}, }); } diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index b91ba6bb7..4952c89e1 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -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, diff --git a/tools/installer/list-options.js b/tools/installer/list-options.js new file mode 100644 index 000000000..d06be8b06 --- /dev/null +++ b/tools/installer/list-options.js @@ -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 .=` matches against. + * + * Community/custom modules are not enumerated; users reference their own + * module.yaml directly per the design (see issue #1663). + * + * @returns {Promise>} + */ +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//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//...). + 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 ` 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 .= (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 }; diff --git a/tools/installer/modules/custom-module-manager.js b/tools/installer/modules/custom-module-manager.js index ca3e52325..9dd9e8b6d 100644 --- a/tools/installer/modules/custom-module-manager.js +++ b/tools/installer/modules/custom-module-manager.js @@ -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//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 //tree|blob/[/] + // GitLab //-/tree|blob/[/] + // Gitea //src/[/] + // Gitea //src/(branch|commit|tag)/[/] + // 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/` 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 "/" 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/ 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/ 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 { diff --git a/tools/installer/set-overrides.js b/tools/installer/set-overrides.js new file mode 100644 index 000000000..9349ee2d6 --- /dev/null +++ b/tools/installer/set-overrides.js @@ -0,0 +1,330 @@ +// `--set .=` is a post-install patch. The installer runs +// its normal flow and writes `_bmad/config.toml`, `_bmad/config.user.toml`, +// and `_bmad//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 .=` 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 .='); + } + const eq = entry.indexOf('='); + if (eq === -1) { + throw new Error(`--set "${entry}": missing '='. Expected .=`); + } + 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 .=`); + } + 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 .=`); + } + 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>} + */ +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]` + * - `` → `[modules.]` + * + * 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 ` = ...` 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>} overrides + * @param {string} bmadDir absolute path to `_bmad/` + * @returns {Promise>} + * 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//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//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//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 }; diff --git a/tools/installer/ui.js b/tools/installer/ui.js index 12501b3f2..5770206ef 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -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 }; } /** diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 64ea3e0d9..67828bbec 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -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' }, },