Compare commits

...

8 Commits

Author SHA1 Message Date
Dicky Moore c792ef169d
fix(installer): include automator runtime from source-root
Co-authored-by: bmad <bmad.directory@gmail.com>
2026-05-06 09:29:19 -03:00
bmad f1b9679232
fix(installer): guard automator registry lookups 2026-05-06 09:29:19 -03:00
bmad 42988f598a
fix(installer): address automator review feedback 2026-05-06 09:29:18 -03:00
bmad 3372e95b62
feat(installer): install automator skill module 2026-05-06 09:29:18 -03:00
bmad 6c1f9164d0
feat(installer): register automator external module 2026-05-06 09:28:36 -03:00
Alex Verkhovsky e36f219c81
refactor(catalog): rename after/before to preceded-by/followed-by (#2360)
* refactor(catalog): rename after/before columns to preceded-by/followed-by

The bare prepositions `after` and `before` had no subject anchor, leaving
the dependency direction ambiguous: "X has Y in its `after` column" reads
plausibly as either "Y comes after X" or "X comes after Y". An LLM
catalog consumer just got the direction wrong because of this.

`preceded-by` / `followed-by` are passive-voice participles whose grammar
locks the subject (the skill in this row) and forces a single reading:
"X is preceded by Y" can only mean Y comes first.

Rename applied to:
- module-help.csv headers (bmm-skills, core-skills)
- bmad-help SKILL.md schema doc + descriptions
- installer.js mergeModuleHelpCatalogs header string
- plugin-resolver.js _buildSynthesizedHelpCsv header string
- bmad-manifest.json keys (bmad-product-brief, bmad-prfaq)
- distillate-format-reference.md example manifest

The separate `required` column continues to carry hard-gate semantics;
the renamed columns are pure soft sequencing hints, as already documented
in bmad-help.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(installer): wrap long header strings per prettier

* feat(installer): warn on non-canonical module-help.csv headers

mergeModuleHelpCatalogs now compares each per-module file's header
against the canonical schema and emits a one-shot prompts.log.warn per
module on drift, naming both the expected and actual header. Data
continues to load positionally so external modules built against the
old after/before schema still install cleanly — the warning is the
maintainer signal to rename their columns.

Centralize the canonical header in modules/module-help-schema.js so the
merger and the synthesizer (PluginResolver._buildSynthesizedHelpCsv)
read the same source of truth; future column renames are one edit.

Verified by installing all four bmad-org external modules
(bmb, cis, gds, tea) — every one ships the legacy after/before header
today and now fires an advisory warning while still merging cleanly
into _bmad/_config/bmad-help.csv with the canonical column names.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:28:50 -07:00
jheyworth 9debc165aa
fix(installer): remove bmad-help from Copilot Custom Agents picker (#2359)
* fix(installer): remove bmad-help from Copilot Custom Agents picker

Per @BMadCode's feedback after #2324 merged: every persona agent's
activation message already advertises bmad-help, so its picker entry is
redundant AND confusing (looks like a peer agent when it's actually the
meta-help). Removes the ALWAYS_AGENT_IDS allowlist exception that put it
there.

The toml-driven filter (the mechanism BMadCode endorsed in his PR review)
remains the sole signal: a skill is a persona iff its source
customize.toml has an [agent] section. bmad-help has no customize.toml,
so under the cleaned-up filter it's correctly excluded.

Tests: replaces the inclusion assertion in Suite 17 with an exclusion
assertion. Suite still covers persona / non-conventional persona /
workflow / meta-skill-with-`-agent-`-in-name cases.

Refs #2324

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: clarify Suite 17 fixture comment per PR review

The fixture creates no customize.toml at all for bmad-help, so the
exclusion path being exercised is the missing-file branch — not the
file-without-[agent]-section branch. Reword the comment accordingly.

Per @augmentcode review on #2359.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:03:02 -05:00
jheyworth 65b810a11f
fix(installer): generate slash-command and Agent pointer files (OpenCode + GitHub Copilot) (#2324)
* fix(installer): generate OpenCode /<skill> slash commands

Adds .opencode/commands/<canonicalId>.md pointer files for each installed
skill so users can invoke skills directly (e.g. /bmad-quick-dev) instead
of going through the /skills menu.

- platform-codes.yaml: add commands_target_dir field for opencode
- _config-driven.js: installCommandPointers() with skip-if-exists default,
  reserved-name collision guard, YAML-safe description quoting
- _config-driven.js: cleanupCommandPointers() for symmetric uninstall
- test-installation-components.js: extend OpenCode suite with assertions
  covering pointer creation, content, and idempotency

OpenCode-only and opt-in via the new yaml field; other adapters unchanged.

Refs #2267

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(installer): address PR #2324 review feedback

Six fixes from CodeRabbit + Augment review on the OpenCode command
pointer generation:

- skipTarget no longer suppresses installCommandPointers in multi-IDE
  shared-target_dir batches. Pointers live in a per-IDE directory and
  are not deduped across peers, so OpenCode must still generate them
  even when a peer (e.g. openhands) won the .agents/skills write race.
- skipTarget no longer suppresses cleanupCommandPointers either, so
  partial uninstalls leave no stale pointers when a peer remains.
- canonicalId is validated as a safe basename before being interpolated
  into a file path (defense in depth against a malformed manifest entry
  writing outside commands_target_dir).
- yamlSafeSingleLine now quotes descriptions starting with `[` or `{`
  so YAML doesn't parse them as a sequence/map.
- Per-record fs.writeFile failures are caught and counted (writeFailures)
  rather than aborting the whole IDE install — pointer files are a
  non-essential adjunct to the skill copy.
- Generator-shaped pointer files are refreshed when the manifest
  description changes; hand-modified files (body diverges from the
  generator pattern) are still preserved unless forceCommands is set.

Tests: extends Suite 8 with description-update propagation; adds new
Suite 40c covering OpenCode + openhands batches in both orderings plus
partial-IDE uninstall pointer cleanup. 308 tests pass (was 296).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(installer): address PR #2324 follow-up nitpicks

Four nitpicks from CodeRabbit's original review that were missed in the
first triage pass:

- Hand-edited pointers now survive the production install flow.
  cleanupCommandPointers spares pointers for canonicalIds that are still
  in the new manifest when called from the install/update flow (signal:
  options.previousSkillIds is set). Uninstall and partial-IDE removal
  flows still wipe pointers as before. The previous behavior wiped every
  pointer in removalSet before installCommandPointers could run, so its
  skip-if-exists guard never fired and hand edits were lost on every
  reinstall — contradicting the docstring's preservation claim.
- RESERVED_OPENCODE_COMMANDS is now gated on this.name === 'opencode'
  so future adapters opting into commands_target_dir don't silently
  inherit OpenCode's reserved-name set.
- printSummary now surfaces results.commands so users see how many
  pointers were created/refreshed/skipped per install, plus a warning
  for any per-file write failures.
- Dropped a dead `typeof entry !== 'string'` check; fs.readdir without
  withFileTypes always yields strings.

Tests: extends Suite 8 with a hand-edit-preservation regression that
calls setup with previousSkillIds (the production shape) and asserts a
sentinel byte sequence in the pointer body survives. 310 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(installer): extend command-pointer generation to Copilot Custom Agents

Re-scopes #2324 to cover the second user-facing pain: GitHub Copilot's
Custom Agents picker, where installed BMAD skills currently don't show up
even though slash commands work natively.

Generalizes the per-platform pointer-file mechanism so the same
installCommandPointers / cleanupCommandPointers code path serves both
OpenCode (slash commands palette) and Copilot (Custom Agents picker), with
all platform-specific shape pushed into platform-codes.yaml as data:

- commands_target_dir       — where pointer files live (existing)
- commands_extension        — file extension (default '.md'; Copilot uses
                              '.agent.md' per VS Code Custom Agents docs)
- commands_body_template    — pointer body, supports {canonicalId} and
                              {target_dir} placeholders. Default matches
                              OpenCode's `@skills/<id>` resolver. Copilot
                              has no such resolver, so its template uses
                              the {project-root}/<target_dir>/<id>/SKILL.md
                              LOAD pattern (consistent with PR #1769).

OpenCode behavior is unchanged. Copilot users now get a per-skill
.github/agents/<canonicalId>.agent.md file that surfaces the skill in the
Custom Agents picker — addressing the "agents being gone" complaint
flagged by enterprise users.

Tests: extends Suite 17 with assertions for Copilot agent pointer
creation, body content (LOAD pattern with {project-root}-rooted path),
and idempotency. 318 tests pass (was 310).

Refs #2267

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(installer): filter Copilot Custom Agents picker to persona agents only

Earlier commit naively wrote a `.github/agents/<id>.agent.md` for every
installed skill, which would clutter the Custom Agents picker with 90+
workflow/tool entries that don't belong there.

Adds an `agents-only` filter that gates the per-skill emission on whether
the canonical id signals a persona agent:

- Primary rule: id contains `-agent-` (e.g. `bmad-agent-pm`,
  `gds-agent-game-dev`, `wds-agent-freya-ux`,
  `bmad-cis-agent-storyteller`).
- Allowlist: `bmad-tea` — TEA's Murat persona uses the bare module code
  rather than the `-agent-` convention. Listed explicitly so the rule
  still surfaces it.

Verified against the full installed manifest (114 skills): catches all
20 description-confirmed personas across BMM, CIS, GDS, WDS, TEA;
excludes all 94 workflows/tools.

Wired through a new yaml field on github-copilot:

  commands_filter: agents-only

OpenCode is unaffected — it has no `commands_filter` set, so the loop
behaves as before (every skill becomes a slash command).

Tests: extends Suite 17 with a multi-skill manifest fixture covering
persona/agent + bmad-tea + workflow cases; asserts persona agents and
bmad-tea get .agent.md files while workflows do not. 322 tests pass.

Refs #2267

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(installer): detect personas via customize.toml [agent] section

Per maintainer review on PR #2324: the `-agent-` naming convention isn't
a load-bearing contract anywhere else in the codebase, and the bmad-tea
allowlist already shows it starting to break. A future persona that
doesn't follow the convention would silently disappear from the Copilot
Custom Agents picker.

Replaces the name-based filter with a behavior-based signal: read each
skill's source `customize.toml` and check for an `[agent]` section. This
is the actual configuration source of truth — every BMAD persona is
configured under `[agent]`, every workflow under `[workflow]`, every
standalone skill has no customize.toml.

Verified on disk against the full installed manifest (114 skills):

- 20 personas detected — exactly the description-confirmed count across
  BMM, CIS, GDS, WDS, TEA. bmad-tea is caught natively (no allowlist).
- 94 workflows/tools correctly excluded.
- `bmad-agent-builder` (meta-skill that builds agent skills) is now
  CORRECTLY excluded — its canonical id contains `-agent-` but its
  customize.toml has [workflow], not [agent], because it isn't a
  persona itself. The previous naming-based filter was including it in
  the agents picker, which would have been a silent UX bug.

`NON_CONVENTIONAL_AGENT_IDS` constant is removed entirely — the toml
signal subsumes it.

Tests: extends Suite 17 with a 4-skill fixture that covers persona +
non-conventional persona + workflow + meta-skill cases. 388 tests pass.

Refs #2267

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(installer): always include bmad-help in Copilot agents picker

Adds a single, deliberate exception to the toml-based agents-only filter:
`bmad-help` is the structural meta-skill across BMAD — the orientation
helper that points users at every other skill. Users invoke it
persona-style ("ask the helper") even though it has no `[agent]`
customize.toml of its own (it isn't a configurable persona).

Implemented as a one-element ALWAYS_AGENT_IDS set rather than a hardcode
in the function body so the exception is named, documented, and
discoverable. The skill is structurally unique — there is no second
meta-help skill — so this is not the start of a growing allowlist; it's
a one-off for the one orientation surface BMAD ships.

Verified on disk: agents picker now shows 21 entries (20 personas via
[agent] in customize.toml + bmad-help). bmad-agent-builder stays
correctly excluded (its customize.toml has [workflow], not [agent]).

Tests: extends Suite 17 with a `bmad-help` fixture (no customize.toml,
must still appear in agents picker). 389 tests pass.

Refs #2267

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Brian <bmadcode@gmail.com>
2026-04-29 22:13:06 -05:00
20 changed files with 1178 additions and 22 deletions

View File

@ -31,13 +31,17 @@ npx bmad-method install
The interactive flow asks you five things:
1. Installation directory (defaults to the current working directory)
2. Which modules to install (checkboxes for core, bmm, bmb, cis, gds, tea)
2. Which modules to install (checkboxes for core, bmm, bmb, cis, gds, tea, bma)
3. **"Ready to install (all stable)?"** — Yes accepts the latest released tag for every external module
4. Which AI tools/IDEs to integrate with (claude-code, cursor, and others)
5. Per-module config (name, language, output folder)
Accept the defaults and you land on the latest stable release of every module, configured for your chosen tool.
:::caution[BMad Automator constraints]
`bma` installs runnable Automator skills only for the Claude Code entrypoint. Codex is supported as a worker target only, and worker sessions currently require `tmux` on macOS.
:::
:::tip[Just want the newest prerelease?]
```bash
@ -53,7 +57,7 @@ Two independent axes control what ends up on disk.
### Axis 1: external module channels
Every external module — bmb, cis, gds, tea, and any community module — installs on one of three channels:
Every external module — bmb, cis, gds, tea, bma, and any community module — installs on one of three channels:
| Channel | What gets installed | Who picks this |
| ------------------ | ---------------------------------------------------------------------------- | --------------------------------------- |

View File

@ -71,6 +71,24 @@ Enterprise-grade test strategy, automation guidance, and release gate decisions
- NFR assessment, CI setup, and framework scaffolding
- P0-P3 prioritization with optional Playwright Utils and MCP integrations
## BMad Automator (Experimental)
Automates the BMad story build loop with a pure skill bundle sourced from the separate Automator repository.
- **Code:** `bma`
- **npm:** [`bmad-story-automator`](https://www.npmjs.com/package/bmad-story-automator)
- **GitHub:** [bmad-code-org/bmad-automator](https://github.com/bmad-code-org/bmad-automator)
:::caution[Experimental Claude Code-only entrypoint]
BMad Automator only runs from Claude Code. It currently supports Claude Code and Codex worker sessions, and requires tmux on macOS.
:::
**Provides:**
- Story build-cycle automation across story creation, development, QA automation, review, and retrospective
- Resumable tmux orchestration state
- Claude Code entry skill plus Claude Code/Codex worker-session coordination
## Community Modules
Community modules and a module marketplace are coming. Check the [BMad GitHub organization](https://github.com/bmad-code-org) for updates.

View File

@ -7,8 +7,8 @@
"description": "Produces battle-tested PRFAQ document and optional LLM distillate for PRD input.",
"supports-headless": true,
"phase-name": "1-analysis",
"after": ["brainstorming", "perform-research"],
"before": ["create-prd"],
"preceded-by": ["brainstorming", "perform-research"],
"followed-by": ["create-prd"],
"is-required": false,
"output-location": "{planning_artifacts}"
}

View File

@ -8,8 +8,8 @@
"description": "Produces executive product brief and optional LLM distillate for PRD input.",
"supports-headless": true,
"phase-name": "1-analysis",
"after": ["brainstorming", "perform-research"],
"before": ["create-prd"],
"preceded-by": ["brainstorming", "perform-research"],
"followed-by": ["create-prd"],
"is-required": true,
"output-location": "{planning_artifacts}"
}

View File

@ -1,4 +1,4 @@
module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs
module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs
BMad Method,_meta,,,,,,,,,false,https://docs.bmad-method.org/llms.txt,
BMad Method,bmad-document-project,Document Project,DP,Analyze an existing project to produce useful documentation.,,,anytime,,,false,project-knowledge,*
BMad Method,bmad-generate-project-context,Generate Project Context,GPC,Scan existing codebase to generate a lean LLM-optimized project-context.md. Essential for brownfield projects.,,,anytime,,,false,output_folder,project context

1 module skill display-name menu-code description action args phase after preceded-by before followed-by required output-location outputs
2 BMad Method _meta false https://docs.bmad-method.org/llms.txt
3 BMad Method bmad-document-project Document Project DP Analyze an existing project to produce useful documentation. anytime false project-knowledge *
4 BMad Method bmad-generate-project-context Generate Project Context GPC Scan existing codebase to generate a lean LLM-optimized project-context.md. Essential for brownfield projects. anytime false output_folder project context

View File

@ -139,7 +139,7 @@ parts: 1
## Solution Architecture
- Plugins: skill bundles with Anthropic plugin standard as base format + bmad-manifest.json extending for BMAD-specific metadata (installer options, capabilities, help integration, phase ordering, dependencies)
- Existing manifest example: `{"module-code":"bmm","replaces-skill":"bmad-create-product-brief","capabilities":[{"name":"create-brief","menu-code":"CB","supports-headless":true,"phase-name":"1-analysis","after":["brainstorming"],"before":["create-prd"],"is-required":true}]}`
- Existing manifest example: `{"module-code":"bmm","replaces-skill":"bmad-create-product-brief","capabilities":[{"name":"create-brief","menu-code":"CB","supports-headless":true,"phase-name":"1-analysis","preceded-by":["brainstorming"],"followed-by":["create-prd"],"is-required":true}]}`
- Vercel skills CLI handles platform translation; integration pattern (wrap/fork/call) is PRD decision
- bmad-setup: global skill scanning installed bmad-manifest.json files, registering capabilities, configuring project settings; always included as base skill in every bundle (solves bootstrapping)
- bmad-update: plugin update path without full reinstall; technical approach (diff/replace/preserve customizations) is PRD decision

View File

@ -33,16 +33,16 @@ When this skill completes, the user should:
The catalog uses this format:
```
module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs
module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs
```
**Phases** determine the high-level flow:
- `anytime` — available regardless of workflow state
- Numbered phases (`1-analysis`, `2-planning`, etc.) flow in order; naming varies by module
**Dependencies** determine ordering within and across phases:
- `after` — skills that should ideally complete before this one
- `before` — skills that should run after this one
**Sequencing** determines recommended ordering within and across phases (these are soft suggestions, not hard gates — see `required` for gating):
- `preceded-by` — skills that should ideally complete before this one
- `followed-by` — skills that should ideally run after this one
- Format: `skill-name` for single-action skills, `skill-name:action` for multi-action skills
**Required gates**:

View File

@ -1,4 +1,4 @@
module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs
module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs
Core,_meta,,,,,,,,,false,https://docs.bmad-method.org/llms.txt,
Core,bmad-brainstorming,Brainstorming,BSP,Use early in ideation or when stuck generating ideas.,,,anytime,,,false,{output_folder}/brainstorming,brainstorming session
Core,bmad-party-mode,Party Mode,PM,Orchestrate multi-agent discussions when you need multiple perspectives or want agents to collaborate.,,,anytime,,,false,,

1 module skill display-name menu-code description action args phase after preceded-by before followed-by required output-location outputs
2 Core _meta false https://docs.bmad-method.org/llms.txt
3 Core bmad-brainstorming Brainstorming BSP Use early in ideation or when stuck generating ideas. anytime false {output_folder}/brainstorming brainstorming session
4 Core bmad-party-mode Party Mode PM Orchestrate multi-agent discussions when you need multiple perspectives or want agents to collaborate. anytime false

View File

@ -18,6 +18,7 @@ const { Installer } = require('../tools/installer/core/installer');
const { ManifestGenerator } = require('../tools/installer/core/manifest-generator');
const { OfficialModules } = require('../tools/installer/modules/official-modules');
const { IdeManager } = require('../tools/installer/ide/manager');
const { ExternalModuleManager } = require('../tools/installer/modules/external-manager');
const { clearCache, loadPlatformCodes } = require('../tools/installer/ide/platform-codes');
// ANSI colors
@ -85,6 +86,63 @@ async function createTestBmadFixture() {
return fixtureDir;
}
async function createAutomatorBmadFixture() {
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-fixture-'));
const fixtureDir = path.join(fixtureRoot, '_bmad');
await fs.ensureDir(path.join(fixtureDir, '_config'));
await fs.writeFile(
path.join(fixtureDir, '_config', 'skill-manifest.csv'),
[
'canonicalId,name,description,module,path',
'"bmad-master","bmad-master","Minimal core skill","core","_bmad/core/bmad-master/SKILL.md"',
'"bmad-story-automator","bmad-story-automator","Automator skill","bma","_bmad/bma/bmad-story-automator/SKILL.md"',
'"bmad-story-automator-review","bmad-story-automator-review","Automator review skill","bma","_bmad/bma/bmad-story-automator-review/SKILL.md"',
'',
].join('\n'),
);
const coreSkillDir = path.join(fixtureDir, 'core', 'bmad-master');
await fs.ensureDir(coreSkillDir);
await fs.writeFile(
path.join(coreSkillDir, 'SKILL.md'),
['---', 'name: bmad-master', 'description: Minimal core skill', '---', '', 'Core skill body.'].join('\n'),
);
for (const skillName of ['bmad-story-automator', 'bmad-story-automator-review']) {
const skillDir = path.join(fixtureDir, 'bma', skillName);
await fs.ensureDir(skillDir);
await fs.writeFile(
path.join(skillDir, 'SKILL.md'),
['---', `name: ${skillName}`, 'description: Automator skill', '---', '', 'Automator body.'].join('\n'),
);
await fs.writeFile(path.join(skillDir, 'workflow.md'), `# ${skillName}\n\nAutomator workflow body.\n`);
}
return fixtureDir;
}
async function createAutomatorSourceRootFixture() {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-source-'));
const sourceRoot = path.join(repoRoot, 'payload', '.claude', 'skills');
for (const skillName of ['bmad-story-automator', 'bmad-story-automator-review']) {
const skillDir = path.join(sourceRoot, skillName);
await fs.ensureDir(skillDir);
await fs.writeFile(
path.join(skillDir, 'SKILL.md'),
['---', `name: ${skillName}`, 'description: Automator skill', '---', '', 'Automator body.'].join('\n'),
);
}
await fs.ensureDir(path.join(repoRoot, 'source', 'scripts'));
await fs.writeFile(path.join(repoRoot, 'source', 'scripts', 'story-automator'), '#!/usr/bin/env bash\n');
await fs.ensureDir(path.join(repoRoot, 'source', 'src', 'story_automator'));
await fs.writeFile(path.join(repoRoot, 'source', 'src', 'story_automator', 'cli.py'), 'def main():\n return 0\n');
return { repoRoot, sourceRoot };
}
async function createSkillCollisionFixture() {
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-skill-collision-'));
const fixtureDir = path.join(fixtureRoot, '_bmad');
@ -285,6 +343,10 @@ async function runTests() {
const opencodeInstaller = platformCodes.platforms.opencode?.installer;
assert(opencodeInstaller?.target_dir === '.agents/skills', 'OpenCode target_dir uses native skills path');
assert(
opencodeInstaller?.commands_target_dir === '.opencode/commands',
'OpenCode commands_target_dir is configured for /<skill> slash commands',
);
const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-opencode-test-'));
const installedBmadDir = await createTestBmadFixture();
@ -301,6 +363,55 @@ async function runTests() {
const skillFile = path.join(tempProjectDir, '.agents', 'skills', 'bmad-master', 'SKILL.md');
assert(await fs.pathExists(skillFile), 'OpenCode install writes SKILL.md directory output');
// Command pointer assertions: a /<canonicalId> slash command should exist
// for each installed skill so users can invoke skills directly without
// going through the /skills menu.
const commandFile = path.join(tempProjectDir, '.opencode', 'commands', 'bmad-master.md');
assert(await fs.pathExists(commandFile), 'OpenCode install writes per-skill command pointer file');
const commandContent = await fs.readFile(commandFile, 'utf8');
assert(commandContent.includes('@skills/bmad-master'), 'Command pointer body references the skill via @skills/<canonicalId>');
assert(commandContent.includes('description:'), 'Command pointer carries a description in YAML frontmatter');
// Idempotency: re-running install must not duplicate or rewrite pointers.
const result2 = await ideManager.setup('opencode', tempProjectDir, installedBmadDir, {
silent: true,
selectedModules: ['bmm'],
});
assert(result2.success === true, 'Second OpenCode install succeeds (idempotent)');
assert(await fs.pathExists(commandFile), 'Command pointer survives a second install pass');
// Description-update propagation: when the manifest description changes
// and the on-disk pointer still matches the generator pattern, refresh
// the file so users see the updated description.
const csvPath = path.join(installedBmadDir, '_config', 'skill-manifest.csv');
const updatedCsv =
'canonicalId,name,description,module,path\n' +
'"bmad-master","bmad-master","UPDATED description for the test agent","core","_bmad/core/bmad-master/SKILL.md"\n';
await fs.writeFile(csvPath, updatedCsv);
const result3 = await ideManager.setup('opencode', tempProjectDir, installedBmadDir, {
silent: true,
selectedModules: ['bmm'],
});
assert(result3.success === true, 'Third OpenCode install succeeds after description update');
const refreshed = await fs.readFile(commandFile, 'utf8');
assert(refreshed.includes('UPDATED description'), 'Generator-shaped pointer is refreshed when manifest description changes');
// Hand-edit preservation across the production install flow. The
// installer passes previousSkillIds — without the cleanup-side spare,
// hand edits would be wiped here.
const SENTINEL = 'HAND_EDITED_BY_USER_SHOULD_SURVIVE';
const handEditedBody = `---\ndescription: my custom description\n---\n\n${SENTINEL}\n`;
await fs.writeFile(commandFile, handEditedBody);
const result4 = await ideManager.setup('opencode', tempProjectDir, installedBmadDir, {
silent: true,
selectedModules: ['bmm'],
previousSkillIds: new Set(['bmad-master']),
});
assert(result4.success === true, 'Fourth OpenCode install succeeds with hand-edited pointer present');
const afterReinstall = await fs.readFile(commandFile, 'utf8');
assert(afterReinstall.includes(SENTINEL), 'Hand-edited pointer survives a routine reinstall (cleanup spares active-manifest IDs)');
await fs.remove(tempProjectDir);
await fs.remove(path.dirname(installedBmadDir));
} catch (error) {
@ -504,10 +615,83 @@ async function runTests() {
const copilotInstaller = platformCodes17.platforms['github-copilot']?.installer;
assert(copilotInstaller?.target_dir === '.agents/skills', 'GitHub Copilot target_dir uses native skills path');
assert(
copilotInstaller?.commands_target_dir === '.github/agents',
'GitHub Copilot commands_target_dir is configured for the Custom Agents picker',
);
assert(copilotInstaller?.commands_extension === '.agent.md', 'GitHub Copilot uses .agent.md extension for Custom Agents files');
assert(
typeof copilotInstaller?.commands_body_template === 'string' && copilotInstaller.commands_body_template.includes('{canonicalId}'),
'GitHub Copilot defines a commands_body_template with {canonicalId} placeholder',
);
assert(
copilotInstaller?.commands_filter === 'agents-only',
'GitHub Copilot filters Custom Agents picker to persona agents only (agents-only)',
);
const tempProjectDir17 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-copilot-test-'));
const installedBmadDir17 = await createTestBmadFixture();
// Extend the fixture to exercise the agents-only filter, which detects
// persona agents by the `[agent]` section in each skill's source
// customize.toml. Five skill types covered:
//
// 1. Persona agent — has customize.toml with [agent] → INCLUDED
// 2. Persona with non-conventional id — also has [agent] → INCLUDED
// (verifies the filter doesn't depend on `-agent-` naming)
// 3. Meta-skill whose id contains `-agent-` but isn't a
// persona — has customize.toml with [workflow] → EXCLUDED
// (mirrors `bmad-agent-builder` in the real manifest)
// 4. Workflow skill — no customize.toml at all → EXCLUDED
// 5. `bmad-help` — meta-help skill with no customize.toml;
// every persona agent's activation already advertises it,
// so it's correctly excluded from the picker as redundant → EXCLUDED
const fixtureCsvPath17 = path.join(installedBmadDir17, '_config', 'skill-manifest.csv');
await fs.writeFile(
fixtureCsvPath17,
[
'canonicalId,name,description,module,path',
'"bmad-master","bmad-master","Workflow with no customize.toml — should NOT appear in Copilot agents picker","core","_bmad/core/bmad-master/SKILL.md"',
'"bmad-agent-fixture","bmad-agent-fixture","Persona agent — customize.toml has [agent], SHOULD appear","core","_bmad/core/bmad-agent-fixture/SKILL.md"',
'"bmad-tea","bmad-tea","Non-conventional id but [agent] in customize.toml — SHOULD appear","core","_bmad/core/bmad-tea/SKILL.md"',
'"bmad-agent-builder","bmad-agent-builder","Skill-builder workflow — id contains -agent- but customize.toml has [workflow] — should NOT appear","core","_bmad/core/bmad-agent-builder/SKILL.md"',
'"bmad-help","bmad-help","Meta-help skill — no customize.toml; SHOULD NOT appear in agents picker (toml-driven filter)","core","_bmad/core/bmad-help/SKILL.md"',
'',
].join('\n'),
);
// Materialise the source skill directories so the agents-only filter
// can read their customize.toml. The bmad-master and bmad-agent-builder
// SKILL.md files were already populated by createTestBmadFixture (they
// share the bmad-master target_dir layout); only the customize.toml
// and the new agent fixtures need to be created here.
for (const id of ['bmad-agent-fixture', 'bmad-tea', 'bmad-agent-builder', 'bmad-help']) {
const dir17 = path.join(installedBmadDir17, 'core', id);
await fs.ensureDir(dir17);
await fs.writeFile(
path.join(dir17, 'SKILL.md'),
['---', `name: ${id}`, `description: fixture for ${id}`, '---', '', `Body of ${id}.`].join('\n'),
);
}
// Note: bmad-help intentionally has NO customize.toml — it exercises
// the toml-driven filter's exclusion path (a skill with no
// customize.toml is correctly kept out of the Copilot agents picker).
// [agent] customize.toml for the two persona fixtures.
await fs.writeFile(
path.join(installedBmadDir17, 'core', 'bmad-agent-fixture', 'customize.toml'),
['[agent]', 'name = "Fixture Agent"', 'title = "Test Persona"', ''].join('\n'),
);
await fs.writeFile(
path.join(installedBmadDir17, 'core', 'bmad-tea', 'customize.toml'),
['[agent]', 'name = "Murat"', 'title = "Test Architect"', ''].join('\n'),
);
// [workflow] customize.toml for the meta-skill — its id contains `-agent-`
// but it is NOT a persona (mirrors bmad-agent-builder in production).
await fs.writeFile(
path.join(installedBmadDir17, 'core', 'bmad-agent-builder', 'customize.toml'),
['[workflow]', '', '# Meta-skill that builds agents but is not itself a persona.', ''].join('\n'),
);
const copilotInstructionsPath17 = path.join(tempProjectDir17, '.github', 'copilot-instructions.md');
await fs.ensureDir(path.dirname(copilotInstructionsPath17));
await fs.writeFile(
@ -543,6 +727,56 @@ async function runTests() {
'GitHub Copilot setup preserves user content in copilot-instructions.md',
);
// Custom Agents picker integration: persona agents (those with [agent]
// in their source customize.toml) get .agent.md files in
// .github/agents/. Workflows and meta-skills with [workflow] (or no
// customize.toml at all) do NOT — the agents-only filter keeps the
// picker uncluttered and the signal is naming-independent.
const agentsDir17 = path.join(tempProjectDir17, '.github', 'agents');
const agentFileForPersona17 = path.join(agentsDir17, 'bmad-agent-fixture.agent.md');
const agentFileForTea17 = path.join(agentsDir17, 'bmad-tea.agent.md');
const agentFileForWorkflow17 = path.join(agentsDir17, 'bmad-master.agent.md');
const agentFileForMetaSkill17 = path.join(agentsDir17, 'bmad-agent-builder.agent.md');
const agentFileForBmadHelp17 = path.join(agentsDir17, 'bmad-help.agent.md');
assert(
await fs.pathExists(agentFileForPersona17),
'Persona agent ([agent] in customize.toml) gets a .agent.md file in .github/agents/',
);
assert(await fs.pathExists(agentFileForTea17), 'Non-conventional id with [agent] in customize.toml is included (no allowlist needed)');
assert(!(await fs.pathExists(agentFileForWorkflow17)), 'Workflow skill (no customize.toml) is FILTERED OUT of .github/agents/');
assert(
!(await fs.pathExists(agentFileForBmadHelp17)),
'bmad-help is excluded from Copilot agents picker (no customize.toml; allowlist removed per maintainer feedback)',
);
assert(
!(await fs.pathExists(agentFileForMetaSkill17)),
'Meta-skill with -agent- in id but [workflow] in customize.toml is FILTERED OUT (signal is behavior, not naming)',
);
// Body content of the persona agent file: frontmatter description +
// LOAD pattern referencing the skill's SKILL.md path under target_dir.
const personaAgentContent17 = await fs.readFile(agentFileForPersona17, 'utf8');
assert(
personaAgentContent17.includes('description:'),
'Copilot agent pointer carries a description in YAML frontmatter (drives the agents picker label)',
);
assert(
personaAgentContent17.includes('{project-root}/.agents/skills/bmad-agent-fixture/SKILL.md'),
'Copilot agent pointer body resolves to the skill via LOAD {project-root}/<target_dir>/<id>/SKILL.md',
);
// Idempotency: re-running setup must not duplicate or rewrite the agent
// pointer when the source manifest is unchanged, AND must not start
// emitting workflow-skill agent files.
const result17b = await ideManager17.setup('github-copilot', tempProjectDir17, installedBmadDir17, {
silent: true,
selectedModules: ['bmm'],
});
assert(result17b.success === true, 'Second GitHub Copilot install succeeds (idempotent)');
assert(await fs.pathExists(agentFileForPersona17), 'Persona agent pointer survives a second install pass');
assert(!(await fs.pathExists(agentFileForWorkflow17)), 'Workflow skill remains filtered out of agents picker on second install');
await fs.remove(tempProjectDir17);
await fs.remove(path.dirname(installedBmadDir17));
} catch (error) {
@ -2737,6 +2971,113 @@ async function runTests() {
console.log('');
// ============================================================
// Test Suite 40c: OpenCode command pointers in multi-IDE batches
// ============================================================
// Regression: when OpenCode is the *peer* in a setupBatch sharing
// .agents/skills (e.g. with openhands), the skill write is dedup-skipped
// but the per-IDE .opencode/commands/ pointers must still be generated.
// Symmetrically, partial uninstall while a peer remains must still clean
// up OpenCode's own command pointers.
console.log(`${colors.yellow}Test Suite 40c: OpenCode command pointers in shared-target batches${colors.reset}\n`);
try {
clearCache();
const platformCodes40c = await loadPlatformCodes();
const opencodeTarget40c = platformCodes40c.platforms.opencode?.installer?.target_dir;
const openhandsTarget40c = platformCodes40c.platforms.openhands?.installer?.target_dir;
assert(
opencodeTarget40c === '.agents/skills' && openhandsTarget40c === '.agents/skills',
'OpenCode and OpenHands share .agents/skills target_dir',
);
// Order A: opencode first → opencode is the writer.
const projA = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-opencode-batch-a-'));
const bmadA = await createTestBmadFixture();
const mgrA = new IdeManager();
await mgrA.ensureInitialized();
const resultsA = await mgrA.setupBatch(['opencode', 'openhands'], projA, bmadA, {
silent: true,
selectedModules: ['core'],
});
const cmdA = path.join(projA, '.opencode', 'commands', 'bmad-master.md');
assert(
resultsA.every((r) => r.success === true),
'opencode-first batch: all platforms succeed',
);
assert(await fs.pathExists(cmdA), 'opencode-first batch: command pointer is created');
// Order B: openhands first → opencode is the peer (skipTarget=true).
// Without the fix, the early-return would bypass installCommandPointers.
const projB = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-opencode-batch-b-'));
const bmadB = await createTestBmadFixture();
const mgrB = new IdeManager();
await mgrB.ensureInitialized();
const resultsB = await mgrB.setupBatch(['openhands', 'opencode'], projB, bmadB, {
silent: true,
selectedModules: ['core'],
});
const cmdB = path.join(projB, '.opencode', 'commands', 'bmad-master.md');
const opencodeResultB = resultsB.find((r) => r.ide === 'opencode');
assert(
resultsB.every((r) => r.success === true),
'openhands-first batch: all platforms succeed',
);
assert(
opencodeResultB?.handlerResult?.results?.sharedTargetHandledByPeer === true,
'openhands-first batch: opencode is marked sharedTargetHandledByPeer (skill write deduped)',
);
assert(await fs.pathExists(cmdB), 'openhands-first batch: command pointer is generated even when skill write is deduped');
// Cleanup symmetry: uninstall opencode while openhands remains.
// Uses an in-project bmadDir so the cleanup path can compute removalSet
// from the manifest (the production layout). The cross-temp-dir fixture
// above can't exercise this — same constraint Test Suite 40 documents.
const projC = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-opencode-batch-c-'));
const bmadC = path.join(projC, '_bmad');
await fs.ensureDir(path.join(bmadC, '_config'));
await fs.writeFile(
path.join(bmadC, '_config', 'skill-manifest.csv'),
'canonicalId,name,description,module,path\n' +
'"bmad-master","bmad-master","Minimal test agent fixture","core","_bmad/core/bmad-master/SKILL.md"\n',
);
const skillC = path.join(bmadC, 'core', 'bmad-master');
await fs.ensureDir(skillC);
await fs.writeFile(
path.join(skillC, 'SKILL.md'),
['---', 'name: bmad-master', 'description: Minimal test agent fixture', '---', '', 'You are a test agent.'].join('\n'),
);
const mgrC = new IdeManager();
await mgrC.ensureInitialized();
await mgrC.setupBatch(['openhands', 'opencode'], projC, bmadC, {
silent: true,
selectedModules: ['core'],
});
const cmdC = path.join(projC, '.opencode', 'commands', 'bmad-master.md');
assert(await fs.pathExists(cmdC), 'in-project fixture: pointer is generated for opencode peer');
const cleanupResultsC = await mgrC.cleanupByList(projC, ['opencode'], {
silent: true,
remainingIdes: ['openhands'],
});
assert(cleanupResultsC[0].success !== false, 'opencode partial-uninstall reports success');
const sharedSurvivesC = await fs.pathExists(path.join(projC, '.agents', 'skills', 'bmad-master', 'SKILL.md'));
assert(sharedSurvivesC, 'shared .agents/skills/ survives partial uninstall (peer still uses it)');
assert(!(await fs.pathExists(cmdC)), 'opencode command pointer is removed on partial uninstall even when peer remains');
await fs.remove(projA).catch(() => {});
await fs.remove(path.dirname(bmadA)).catch(() => {});
await fs.remove(projB).catch(() => {});
await fs.remove(path.dirname(bmadB)).catch(() => {});
await fs.remove(projC).catch(() => {});
} catch (error) {
console.log(`${colors.red}Test Suite 40c setup failed: ${error.message}${colors.reset}`);
failed++;
}
console.log('');
// ============================================================
// Test Suite 41: Custom-module skill ownership (non-bmad prefix)
// ============================================================
@ -3237,6 +3578,137 @@ async function runTests() {
console.log('');
// ============================================================
// Test Suite 45: Automator External Skill-Only Module
// ============================================================
console.log(`${colors.yellow}Test Suite 45: Automator External Skill-Only Module${colors.reset}\n`);
let tempProjectDir42;
let installedBmadDir42;
let automatorSourceFixture42;
let runtimeTargetRoot42;
try {
const externalManager42 = new ExternalModuleManager();
const automatorInfo42 = await externalManager42.getModuleByCode('bma');
assert(automatorInfo42 !== null, 'BMad Automator is registered as an external module');
assert(automatorInfo42.type === 'experimental', 'BMad Automator is marked experimental');
assert(automatorInfo42.sourceRoot === 'payload/.claude/skills', 'BMad Automator uses source-root for pure skill payload');
assert(
automatorInfo42.installTargets.length === 1 && automatorInfo42.installTargets.includes('claude-code'),
'BMad Automator is limited to Claude Code skill installation',
);
const normalizedInfo42 = externalManager42._normalizeModule({
name: 'bad-shapes',
code: 'bad',
repository: 'https://example.com/bad.git',
install_targets: 'claude-code',
worker_targets: { bad: true },
requirements: ['tmux', { bad: true }, false],
});
assert(
Array.isArray(normalizedInfo42.installTargets) && normalizedInfo42.installTargets.includes('claude-code'),
'External module install targets normalize scalar values to arrays',
);
assert(
Array.isArray(normalizedInfo42.workerTargets) && normalizedInfo42.workerTargets.length === 0,
'External module worker targets drop invalid shapes',
);
assert(
normalizedInfo42.requirements.length === 2 &&
normalizedInfo42.requirements.includes('tmux') &&
normalizedInfo42.requirements.includes('false'),
'External module requirements normalize scalar array entries',
);
automatorSourceFixture42 = await createAutomatorSourceRootFixture();
runtimeTargetRoot42 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-runtime-target-'));
const runtimeBmadDir42 = path.join(runtimeTargetRoot42, '_bmad');
const officialModules42 = new OfficialModules();
officialModules42.findModuleSource = async () => automatorSourceFixture42.sourceRoot;
await officialModules42.install('bma', runtimeBmadDir42, null, { skipModuleInstaller: true, silent: true });
assert(
await fs.pathExists(path.join(runtimeBmadDir42, 'bma', 'bmad-story-automator', 'scripts', 'story-automator')),
'BMad Automator source-root install includes runtime helper',
);
assert(
await fs.pathExists(path.join(runtimeBmadDir42, 'bma', 'bmad-story-automator', 'src', 'story_automator', 'cli.py')),
'BMad Automator source-root install includes Python runtime source',
);
await fs.remove(runtimeTargetRoot42).catch(() => {});
runtimeTargetRoot42 = null;
tempProjectDir42 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-target-'));
installedBmadDir42 = await createAutomatorBmadFixture();
const ideManager42 = new IdeManager();
await ideManager42.ensureInitialized();
const codexResult42 = await ideManager42.setup('codex', tempProjectDir42, installedBmadDir42, {
silent: true,
selectedModules: ['core', 'bma'],
});
assert(codexResult42.success === true, 'Codex setup succeeds with automator module selected');
assert(
await fs.pathExists(path.join(tempProjectDir42, '.agents', 'skills', 'bmad-master', 'SKILL.md')),
'Codex setup still installs supported core skills',
);
assert(
!(await fs.pathExists(path.join(tempProjectDir42, '.agents', 'skills', 'bmad-story-automator', 'SKILL.md'))),
'Codex setup skips Claude Code-only automator skill',
);
assert(
!(await fs.pathExists(path.join(tempProjectDir42, '.agents', 'skills', 'bmad-story-automator-review', 'SKILL.md'))),
'Codex setup skips Claude Code-only automator review skill',
);
const escapeRoot42 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-source-root-'));
const escapeRepo42 = path.join(escapeRoot42, 'repo');
await fs.ensureDir(escapeRepo42);
const escapeManager42 = new ExternalModuleManager();
escapeManager42.getModuleByCode = async () => ({
code: 'escape',
builtIn: false,
sourceRoot: '../outside',
});
escapeManager42.cloneExternalModule = async () => escapeRepo42;
let rejectedEscapingSourceRoot42 = false;
try {
await escapeManager42.findExternalModuleSource('escape');
} catch (error) {
rejectedEscapingSourceRoot42 = error.message.includes('source-root escapes repository');
} finally {
await fs.remove(escapeRoot42).catch(() => {});
}
assert(rejectedEscapingSourceRoot42, 'External module source-root cannot escape cloned repository');
const claudeResult42 = await ideManager42.setup('claude-code', tempProjectDir42, installedBmadDir42, {
silent: true,
selectedModules: ['core', 'bma'],
});
assert(claudeResult42.success === true, 'Claude Code setup succeeds with automator module selected');
assert(
await fs.pathExists(path.join(tempProjectDir42, '.claude', 'skills', 'bmad-story-automator', 'SKILL.md')),
'Claude Code setup installs automator skill',
);
assert(
await fs.pathExists(path.join(tempProjectDir42, '.claude', 'skills', 'bmad-story-automator-review', 'SKILL.md')),
'Claude Code setup installs automator review skill',
);
assert(
await fs.pathExists(path.join(tempProjectDir42, '.claude', 'skills', 'bmad-story-automator-review', 'workflow.md')),
'Claude Code setup copies automator workflow files',
);
} catch (error) {
assert(false, `Automator external skill-only module test succeeds: ${error.message}`);
} finally {
if (tempProjectDir42) await fs.remove(tempProjectDir42).catch(() => {});
if (installedBmadDir42) await fs.remove(path.dirname(installedBmadDir42)).catch(() => {});
if (automatorSourceFixture42) await fs.remove(automatorSourceFixture42.repoRoot).catch(() => {});
if (runtimeTargetRoot42) await fs.remove(runtimeTargetRoot42).catch(() => {});
}
console.log('');
// ============================================================
// Summary
// ============================================================

View File

@ -2,9 +2,15 @@
## Installing external repo BMad official modules
For external official modules to be discoverable during install, ensure an entry for the external repo is added to external-official-modules.yaml.
For external official modules to be discoverable during install, ensure an entry for the external repo is added to the marketplace `registry/official.yaml` source of truth. Add the same entry to `modules/registry-fallback.yaml` only when BMAD-METHOD needs a bundled fallback or a staged registry supplement.
For community modules - this will be handled in a different way. This file is only for registration of modules under the bmad-code-org.
For community modules - this is handled through the marketplace community registry.
Use `module-definition` for conventional module repos with `module.yaml`.
Use `source-root` for pure skill bundles that should be copied directly into `_bmad/<module-code>/`.
This keeps the external repo as the source of truth and avoids vendoring generated skill payloads into BMAD-METHOD.
Experimental modules can set `type: experimental` and `install-targets` to limit which IDE integrations receive their skills.
## Post-Install Notes

View File

@ -12,6 +12,7 @@ const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
const { InstallPaths } = require('./install-paths');
const { ExternalModuleManager } = require('../modules/external-manager');
const { resolveModuleVersion } = require('../modules/version-resolver');
const { MODULE_HELP_CSV_HEADER } = require('../modules/module-help-schema');
const { ExistingInstall } = require('./existing-install');
const { warnPreNativeSkillsLegacy } = require('./legacy-warnings');
@ -942,7 +943,7 @@ class Installer {
*/
async mergeModuleHelpCatalogs(bmadDir, _agentEntries = []) {
const allRows = [];
const headerRow = 'module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs';
const headerRow = MODULE_HELP_CSV_HEADER;
const COLUMN_COUNT = 13;
const PHASE_INDEX = 7;
@ -975,9 +976,19 @@ class Installer {
const content = await fs.readFile(helpFilePath, 'utf8');
const lines = content.split('\n').filter((line) => line.trim() && !line.startsWith('#'));
let headerWarned = false;
for (const line of lines) {
// Skip header row
// Header row: warn on drift from canonical schema, then skip.
// Data rows are loaded positionally regardless, so the warning
// is advisory — the maintainer should rename their columns.
if (line.startsWith('module,')) {
if (!headerWarned && line.trim() !== headerRow) {
await prompts.log.warn(
` ${moduleName}/module-help.csv header does not match canonical schema. ` +
`Expected: ${headerRow} | Found: ${line.trim()} | Data loaded positionally.`,
);
headerWarned = true;
}
continue;
}

View File

@ -246,6 +246,9 @@ class ManifestGenerator {
for (const moduleName of this.updatedModules) {
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
if (!moduleYamlPath) {
if (await this._isSkillOnlyModule(moduleName)) {
continue;
}
// External modules live in ~/.bmad/cache/external-modules, not src/modules.
// Warn rather than silently skip so missing agent rosters don't vanish
// from config.toml without notice.
@ -441,6 +444,9 @@ class ManifestGenerator {
for (const moduleName of this.updatedModules) {
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
if (!moduleYamlPath) {
if (await this._isSkillOnlyModule(moduleName)) {
continue;
}
console.warn(
`[warn] writeCentralConfig: could not locate module.yaml for '${moduleName}'. ` +
`Answers from this module will default to team scope — user-scoped keys may mis-file into config.toml.`,
@ -799,6 +805,27 @@ class ManifestGenerator {
return false;
}
async _isSkillOnlyModule(moduleName) {
const modulePath = path.join(this.bmadDir, moduleName);
if (!(await fs.pathExists(modulePath))) return false;
if (await fs.pathExists(path.join(modulePath, 'module.yaml'))) return false;
if (!(await this._moduleUsesSourceRoot(moduleName))) return false;
return this._hasSkillMdRecursive(modulePath);
}
async _moduleUsesSourceRoot(moduleName) {
if (!this.sourceRootModuleCodes) {
try {
const { ExternalModuleManager } = require('../modules/external-manager');
const externalModules = await new ExternalModuleManager().listAvailable();
this.sourceRootModuleCodes = new Set(externalModules.filter((mod) => mod.sourceRoot).map((mod) => mod.code));
} catch {
this.sourceRootModuleCodes = new Set();
}
}
return this.sourceRootModuleCodes.has(moduleName);
}
}
/**

View File

@ -6,6 +6,125 @@ const csv = require('csv-parse/sync');
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
const { getInstalledCanonicalIds, isBmadOwnedEntry } = require('./shared/installed-skills');
// Reserved OpenCode slash commands. A skill whose canonicalId collides with
// one of these is skipped during command-pointer generation so it doesn't
// shadow a built-in.
const RESERVED_OPENCODE_COMMANDS = new Set([
'review',
'commit',
'init',
'help',
'skills',
'fast',
'compact',
'clear',
'undo',
'redo',
'edit',
'editor',
'exit',
'quit',
'theme',
'config',
'model',
'session',
]);
// Wrap a description for safe insertion into single-line YAML frontmatter.
// Leaves plain values untouched; double-quotes (and escapes) anything that
// could break YAML parsing or span multiple lines.
function yamlSafeSingleLine(value) {
const collapsed = String(value)
.replaceAll(/[\r\n]+/g, ' ')
.trim();
const needsQuoting = /[:#'"\\]/.test(collapsed) || /^[!&*?|>%@`[{]/.test(collapsed);
if (!needsQuoting) return collapsed;
const escaped = collapsed.replaceAll('\\', '\\\\').replaceAll('"', String.raw`\"`);
return `"${escaped}"`;
}
// Validate that a canonicalId is a safe basename — no path separators, no
// parent-dir traversal, no leading dots, only the character set we expect.
// Defense-in-depth: the manifest is trusted today, but the value flows
// directly into a file path and a malformed entry should not write outside
// the commands directory.
function isSafeCanonicalId(value) {
return typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(value) && !value.includes('..');
}
// Default body template for command pointer files. Used when a platform's
// installer config doesn't override `commands_body_template`. Matches
// OpenCode's native `@skills/<id>` skill-reference syntax.
const DEFAULT_COMMANDS_BODY_TEMPLATE = '@skills/{canonicalId}';
// Is this skill a persona agent (vs. a workflow/tool/standalone skill)?
// Used by platforms that surface only persona agents (e.g. Copilot's Custom
// Agents picker). Signal: the skill's source `customize.toml` has an
// `[agent]` section. This is the actual configuration source of truth —
// every BMAD persona is configured via [agent] in its customize.toml,
// every workflow uses [workflow], every standalone skill has no
// customize.toml at all. Verified against the full installed manifest:
// catches exactly the 20 description-confirmed personas across BMM, CIS,
// GDS, WDS, TEA, and correctly excludes meta-skills like
// `bmad-agent-builder` (a skill-builder workflow whose canonical id
// contains `-agent-` but which has no [agent] section because it isn't a
// persona itself).
//
// Reading the source toml — at install time the source skill directory
// (resolved from manifest record.path) still exists; cleanup runs later
// in the install flow.
async function isAgentSkill(record, bmadDir) {
if (!record?.path || !bmadDir) return false;
const bmadFolderName = path.basename(bmadDir);
const bmadPrefix = bmadFolderName + '/';
const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path;
const tomlPath = path.join(bmadDir, path.dirname(relativePath), 'customize.toml');
if (!(await fs.pathExists(tomlPath))) return false;
try {
const content = await fs.readFile(tomlPath, 'utf8');
return /^\[agent\]/m.test(content);
} catch {
return false;
}
}
// Resolve placeholders in a body template. Supported placeholders:
// {canonicalId} — the skill's canonical id
// {target_dir} — the platform's skill install directory (e.g. .agents/skills)
// {project-root} — left as a literal placeholder for the model/tool to expand
// at runtime; consistent with PR #1769's templates.
function expandBodyTemplate(template, { canonicalId, targetDir }) {
return template.replaceAll('{canonicalId}', canonicalId).replaceAll('{target_dir}', targetDir);
}
// The exact body the installer would generate for a given description and
// canonicalId, given the platform's body template. Centralised so both the
// write and the freshness-check paths agree on the canonical form.
function buildCommandPointerBody(description, canonicalId, { template, targetDir }) {
const bodyText = expandBodyTemplate(template, { canonicalId, targetDir });
return `---\ndescription: ${yamlSafeSingleLine(description)}\n---\n\n${bodyText}\n`;
}
// Heuristic: does an existing pointer file look like our generator's output
// (and therefore safe to refresh) versus a user-modified file (which we
// preserve)? We check the body shape rather than full equality so that
// description-only edits in the manifest can propagate without trampling
// hand edits to the body.
function looksLikeGeneratorOutput(content, canonicalId, { template, targetDir }) {
if (typeof content !== 'string') return false;
const trimmed = content.trim();
const expectedTail = expandBodyTemplate(template, { canonicalId, targetDir }).trim();
// Must end with the exact body our generator writes (post-expansion).
if (!trimmed.endsWith(expectedTail)) return false;
// Must start with frontmatter containing exactly one description: line.
const fmMatch = trimmed.match(/^---\n([\S\s]*?)\n---\n/);
if (!fmMatch) return false;
const fmLines = fmMatch[1].split('\n').filter((l) => l.length > 0);
if (fmLines.length !== 1) return false;
if (!fmLines[0].startsWith('description:')) return false;
return true;
}
/**
* Config-driven IDE setup handler
*
@ -26,6 +145,8 @@ class ConfigDrivenIdeSetup {
this.platformConfig = platformConfig;
this.installerConfig = platformConfig.installer || null;
this.bmadFolderName = BMAD_FOLDER_NAME;
this.externalModuleManager = null;
this.moduleTargetCache = new Map();
// Set configDir from target_dir so detect() works
this.configDir = this.installerConfig?.target_dir || null;
@ -97,9 +218,15 @@ class ConfigDrivenIdeSetup {
}
// When a peer platform in the same install batch owns this target_dir,
// skip the skill write — the peer has already populated it.
// skip the skill write — the peer has already populated it. Command
// pointers, however, write to a separate per-IDE directory and must
// still be generated for this IDE; they are not deduped across peers.
if (options.skipTarget) {
return { success: true, results: { skills: 0, sharedTargetHandledByPeer: true } };
const results = { skills: 0, sharedTargetHandledByPeer: true };
if (this.installerConfig.commands_target_dir) {
results.commands = await this.installCommandPointers(projectDir, bmadDir, this.installerConfig, options);
}
return { success: true, results };
}
if (this.installerConfig.target_dir) {
@ -123,16 +250,165 @@ class ConfigDrivenIdeSetup {
await fs.ensureDir(targetPath);
this.skillWriteTracker = new Set();
this.skippedUnsupported = 0;
const results = { skills: 0 };
results.skills = await this.installVerbatimSkills(projectDir, bmadDir, targetPath, config);
results.skippedUnsupported = this.skippedUnsupported || 0;
results.skillDirectories = this.skillWriteTracker.size;
if (config.commands_target_dir) {
results.commands = await this.installCommandPointers(projectDir, bmadDir, config, options);
}
await this.printSummary(results, target_dir, options);
this.skillWriteTracker = null;
this.skippedUnsupported = 0;
return { success: true, results };
}
/**
* Generate per-skill command pointer files for IDEs that surface commands
* separately from skills (e.g. OpenCode's `.opencode/commands/<name>.md`).
*
* Each pointer is a tiny markdown file whose body is `@skills/<canonicalId>`
* so invoking `/<canonicalId>` routes the user straight to the skill instead
* of forcing them through a `/skills` menu.
*
* Skips:
* - Names that collide with reserved built-in slash commands.
* - canonicalIds that aren't safe basename-only identifiers (defense
* against path traversal even though the manifest is currently trusted).
* - Existing files whose body looks user-modified (preserves hand edits);
* pointer files matching the generator pattern get overwritten so that
* description changes in skill-manifest.csv propagate on re-install.
*
* Per-file write failures are recorded and reported but do not abort the
* rest of the install pointer files are a non-essential adjunct to the
* skill copy that already succeeded.
*
* @param {string} projectDir
* @param {string} bmadDir
* @param {Object} config - Installer config; reads commands_target_dir.
* @param {Object} options - Setup options. forceCommands overwrites existing
* files unconditionally (including hand-modified ones).
* @returns {Promise<Object>} { created, updated, skippedExisting, skippedCollision, skippedInvalidId, writeFailures, fallbackDescription }
*/
async installCommandPointers(projectDir, bmadDir, config, options = {}) {
const result = {
created: 0,
updated: 0,
skippedExisting: 0,
skippedCollision: 0,
skippedInvalidId: 0,
skippedFiltered: 0,
writeFailures: 0,
fallbackDescription: 0,
};
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
if (!(await fs.pathExists(csvPath))) return result;
const commandsPath = path.join(projectDir, config.commands_target_dir);
await fs.ensureDir(commandsPath);
// Per-platform pointer-file shape, all overrideable in platform-codes.yaml.
const extension = config.commands_extension || '.md';
const template = config.commands_body_template || DEFAULT_COMMANDS_BODY_TEMPLATE;
const targetDir = config.target_dir;
const filter = config.commands_filter || null;
const csvContent = await fs.readFile(csvPath, 'utf8');
const records = csv.parse(csvContent, { columns: true, skip_empty_lines: true });
for (const record of records) {
const canonicalId = record.canonicalId;
if (!canonicalId) continue;
// Defensive basename validation. canonicalId comes from a trusted
// manifest today, but the value flows directly into a file path —
// reject anything that could escape commands_target_dir.
if (!isSafeCanonicalId(canonicalId)) {
result.skippedInvalidId++;
continue;
}
// Optional per-platform filter: surfaces that should only show
// persona agents (e.g. Copilot's Custom Agents picker) skip
// workflow/tool skills here so the picker isn't cluttered with
// 90+ unrelated entries.
if (filter === 'agents-only' && !(await isAgentSkill(record, bmadDir))) {
result.skippedFiltered++;
continue;
}
// Reserved-name guard is OpenCode-specific. Other adapters that opt
// into commands_target_dir later should declare their own reserved
// set rather than inheriting OpenCode's.
if (this.name === 'opencode' && RESERVED_OPENCODE_COMMANDS.has(canonicalId)) {
result.skippedCollision++;
continue;
}
let description = (record.description || '').trim();
if (!description) {
description = `Run the ${canonicalId} skill`;
result.fallbackDescription++;
}
const body = buildCommandPointerBody(description, canonicalId, { template, targetDir });
const commandFile = path.join(commandsPath, `${canonicalId}${extension}`);
// If a pointer file already exists, decide whether to overwrite based
// on whether it looks like generator output (description-only diff) or
// a user-modified file. forceCommands overrides this protection.
if (!options.forceCommands && (await fs.pathExists(commandFile))) {
let existing;
try {
existing = await fs.readFile(commandFile, 'utf8');
} catch {
// Treat unreadable as user-owned and skip — safer than overwriting.
result.skippedExisting++;
continue;
}
if (existing === body) {
// No-op idempotent re-run.
result.skippedExisting++;
continue;
}
if (looksLikeGeneratorOutput(existing, canonicalId, { template, targetDir })) {
// Description (or other generated bit) has changed; refresh in place.
try {
await fs.writeFile(commandFile, body, 'utf8');
result.updated++;
} catch (error) {
result.writeFailures++;
if (!options.silent) {
await prompts.log.warn(`Failed to update command pointer ${canonicalId}${extension}: ${error.message}`);
}
}
continue;
}
// Hand-modified pointer — preserve it.
result.skippedExisting++;
continue;
}
try {
await fs.writeFile(commandFile, body, 'utf8');
result.created++;
} catch (error) {
result.writeFailures++;
if (!options.silent) {
await prompts.log.warn(`Failed to write command pointer ${canonicalId}${extension}: ${error.message}`);
}
}
}
return result;
}
/**
* Install verbatim native SKILL.md directories from skill-manifest.csv.
* Copies the entire source directory as-is into the IDE skill directory.
@ -162,6 +438,11 @@ class ConfigDrivenIdeSetup {
const canonicalId = record.canonicalId;
if (!canonicalId) continue;
if (!(await this.shouldInstallSkillRecord(record))) {
this.skippedUnsupported = (this.skippedUnsupported || 0) + 1;
continue;
}
// Derive source directory from path column
// path is like "_bmad/bmm/workflows/bmad-quick-flow/bmad-quick-dev-new-preview/SKILL.md"
// Strip bmadFolderName prefix and join with bmadDir, then get dirname
@ -196,6 +477,31 @@ class ConfigDrivenIdeSetup {
return count;
}
async shouldInstallSkillRecord(record) {
const moduleName = record.module;
if (!moduleName) return true;
if (this.moduleTargetCache.has(moduleName)) {
const targets = this.moduleTargetCache.get(moduleName);
return !targets || targets.includes(this.name);
}
const { ExternalModuleManager } = require('../modules/external-manager');
this.externalModuleManager = this.externalModuleManager || new ExternalModuleManager();
let targets = null;
try {
const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleName);
targets = moduleInfo?.installTargets?.length ? moduleInfo.installTargets : null;
} catch (error) {
await prompts.log.warn(
`ExternalModuleManager.getModuleByCode failed for module '${moduleName}' while installing ${this.name}; installing skill anyway. ${error.message}`,
);
}
this.moduleTargetCache.set(moduleName, targets);
return !targets || targets.includes(this.name);
}
/**
* Print installation summary
* @param {Object} results - Installation results
@ -207,6 +513,21 @@ class ConfigDrivenIdeSetup {
if (count > 0) {
await prompts.log.success(`${this.name} configured: ${count} skills → ${targetDir}`);
}
const cmd = results.commands;
if (cmd && (cmd.created > 0 || cmd.updated > 0) && this.installerConfig?.commands_target_dir) {
const total = cmd.created + cmd.updated;
const detail = cmd.updated > 0 ? `${cmd.created} new, ${cmd.updated} refreshed` : `${total}`;
await prompts.log.success(`${this.name} commands: ${detail}${this.installerConfig.commands_target_dir}`);
if (cmd.skippedCollision > 0) {
await prompts.log.message(` (${cmd.skippedCollision} skipped — name collides with reserved slash command)`);
}
if (cmd.writeFailures > 0) {
await prompts.log.warn(` (${cmd.writeFailures} pointer writes failed — see warnings above)`);
}
}
if (results.skippedUnsupported > 0) {
await prompts.log.warn(`${this.name}: skipped ${results.skippedUnsupported} skill(s) that do not support this IDE`);
}
}
/**
@ -247,6 +568,36 @@ class ConfigDrivenIdeSetup {
await this.cleanupRovoDevPrompts(projectDir, options);
}
// Clean generated command pointer files in commands_target_dir.
// Mirrors target_dir cleanup so uninstalls and skill removals don't
// leave dangling /<canonicalId> commands pointing at missing skills.
// Runs regardless of skipTarget — command pointers live in a per-IDE
// directory and are not deduped across peers, so a peer-owned shared
// skills directory does not protect this IDE's command pointers from
// cleanup. The "currently active" set is passed so install-flow cleanup
// (where removalSet contains skills that will be re-added moments later)
// doesn't trample hand-edited pointers; install-flow cleanup will only
// delete pointers for skills that are not in the new manifest.
if (this.installerConfig?.commands_target_dir) {
// In the install/update flow (signal: previousSkillIds was passed),
// spare pointers whose canonicalId is still in the manifest so hand
// edits survive a routine reinstall. In the uninstall flow (no
// previousSkillIds — full uninstall or per-IDE removal via
// cleanupByList), don't spare anything; the IDE itself is going away,
// so its pointers should go with it.
const isInstallFlow = options.previousSkillIds && options.previousSkillIds.size > 0;
const activeSkillIds = isInstallFlow ? await this._readActiveSkillIds(resolvedBmadDir) : new Set();
const extension = this.installerConfig.commands_extension || '.md';
await this.cleanupCommandPointers(
projectDir,
this.installerConfig.commands_target_dir,
options,
removalSet,
activeSkillIds,
extension,
);
}
// Skip target_dir cleanup when a peer platform owns this directory
// (set during dedup'd install or when uninstalling one of several
// platforms that share the same target_dir).
@ -346,6 +697,97 @@ class ConfigDrivenIdeSetup {
}
}
/**
* Cleanup generated command pointer files for entries in removalSet.
* Symmetric counterpart to installCommandPointers removes
* `<canonicalId><extension>` files whose canonicalId is in the set. Removes
* the commands directory entirely if it ends up empty.
* @param {string} projectDir
* @param {string} commandsTargetDir - Relative dir (e.g. .opencode/commands)
* @param {Object} options
* @param {Set<string>} removalSet - canonicalIds whose pointer files to remove
* @param {Set<string>} [activeSkillIds] - canonicalIds present in the
* current manifest. Pointers for IDs in this set are spared so an
* install-flow cleanup (where removalSet === previousSkillIds and the
* same skills are about to be re-installed) doesn't wipe hand-edited
* pointer files. Pass an empty set or omit to delete every match in
* removalSet (uninstall flow).
* @param {string} [extension] - Pointer file extension (default '.md');
* matches the platform's commands_extension config value so cleanup
* correctly identifies pointer files for IDEs whose convention isn't .md
* (e.g. Copilot's `.agent.md`).
*/
async cleanupCommandPointers(
projectDir,
commandsTargetDir,
options = {},
removalSet = new Set(),
activeSkillIds = new Set(),
extension = '.md',
) {
if (!removalSet || removalSet.size === 0) return;
const commandsPath = path.join(projectDir, commandsTargetDir);
if (!(await fs.pathExists(commandsPath))) return;
let entries;
try {
entries = await fs.readdir(commandsPath);
} catch {
return;
}
for (const entry of entries) {
if (!entry.endsWith(extension)) continue;
const canonicalId = entry.slice(0, -extension.length);
if (!removalSet.has(canonicalId)) continue;
// Spare pointers for skills that are still in the manifest; the
// install pass will refresh them in place if their content has gone
// stale, while preserving hand edits.
if (activeSkillIds.has(canonicalId)) continue;
try {
await fs.remove(path.join(commandsPath, entry));
} catch {
// Skip files we can't remove.
}
}
// Remove the commands directory if we emptied it.
try {
const remaining = await fs.readdir(commandsPath);
if (remaining.length === 0) {
await fs.remove(commandsPath);
}
} catch {
// Directory may already be gone.
}
}
/**
* Read the canonicalIds currently present in the skill-manifest.csv.
* Used by cleanup to distinguish "re-install of an existing skill"
* (preserve pointer) from "skill truly being removed" (delete pointer).
* @param {string|null} bmadDir
* @returns {Promise<Set<string>>}
*/
async _readActiveSkillIds(bmadDir) {
const ids = new Set();
if (!bmadDir) return ids;
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
if (!(await fs.pathExists(csvPath))) return ids;
try {
const content = await fs.readFile(csvPath, 'utf8');
const records = csv.parse(content, { columns: true, skip_empty_lines: true });
for (const record of records) {
if (record.canonicalId) ids.add(record.canonicalId);
}
} catch {
// Manifest unreadable — return an empty set so cleanup falls back to
// the conservative "delete what removalSet says" behavior.
}
return ids;
}
/**
* Cleanup a specific target directory.
* When removalSet is provided, only removes entries in that set.

View File

@ -132,6 +132,21 @@ platforms:
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
commands_target_dir: .github/agents
commands_extension: .agent.md
commands_body_template: "LOAD the FULL {project-root}/{target_dir}/{canonicalId}/SKILL.md, READ its entire contents and follow its directions exactly!"
# The Custom Agents picker should only show persona agents (not
# workflows/tools). Detected by reading each skill's source
# `customize.toml` and checking for an `[agent]` section — that's
# the actual configuration source of truth: every BMAD persona is
# configured under `[agent]`, every workflow under `[workflow]`,
# every standalone skill has no customize.toml. This signal is
# naming-independent, so personas like `bmad-tea` (which doesn't
# follow the `-agent-` convention) are still included, and
# meta-skills like `bmad-agent-builder` (which contains `-agent-`
# but is a skill-builder workflow, not a persona) are correctly
# excluded.
commands_filter: agents-only
goose:
name: "Block Goose"
@ -222,6 +237,7 @@ platforms:
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
commands_target_dir: .opencode/commands
openhands:
name: "OpenHands"

View File

@ -16,6 +16,15 @@ function normalizeChannelName(raw) {
return VALID_CHANNELS.has(lower) ? lower : null;
}
function normalizeStringList(raw) {
if (raw == null || raw === '') return [];
const values = Array.isArray(raw) ? raw : [raw];
return values
.filter((value) => ['string', 'number', 'boolean'].includes(typeof value))
.map((value) => String(value).trim())
.filter(Boolean);
}
/**
* Conservative quoting for tag names passed to git commands. Tags are
* user-typed (--pin) or come from the GitHub API. Only allow the semver
@ -120,22 +129,41 @@ class ExternalModuleManager {
* @returns {Object} Normalized module info
*/
_normalizeModule(mod, key) {
const installTargets = mod.install_targets ?? mod['install-targets'] ?? mod.installTargets;
const workerTargets = mod.worker_targets ?? mod['worker-targets'] ?? mod.workerTargets;
return {
key: key || mod.name,
url: mod.repository || mod.url,
moduleDefinition: mod.module_definition || mod['module-definition'],
sourceRoot: mod.source_root || mod['source-root'] || mod.sourceRoot || null,
code: mod.code,
name: mod.display_name || mod.name,
description: mod.description || '',
defaultSelected: mod.default_selected === true || mod.defaultSelected === true,
type: mod.type || 'bmad-org',
npmPackage: mod.npm_package || mod.npmPackage || null,
installTargets: normalizeStringList(installTargets),
workerTargets: normalizeStringList(workerTargets),
requirements: normalizeStringList(mod.requirements),
installNote: mod.install_note || mod['install-note'] || mod.installNote || null,
defaultChannel: normalizeChannelName(mod.default_channel || mod.defaultChannel) || 'stable',
builtIn: mod.built_in === true,
isExternal: mod.built_in !== true,
};
}
async _loadFallbackModules() {
try {
const content = await fs.readFile(FALLBACK_CONFIG_PATH, 'utf8');
const config = yaml.parse(content);
if (Array.isArray(config.modules)) return config.modules.map((mod) => this._normalizeModule(mod));
return Object.entries(config.modules || {}).map(([key, mod]) => this._normalizeModule(mod, key));
} catch {
return [];
}
}
/**
* Get list of available modules from the registry
* @returns {Array<Object>} Array of module info objects
@ -145,7 +173,14 @@ class ExternalModuleManager {
// Remote format: modules is an array
if (Array.isArray(config.modules)) {
return config.modules.map((mod) => this._normalizeModule(mod));
const modules = config.modules.map((mod) => this._normalizeModule(mod));
const seenCodes = new Set(modules.map((mod) => mod.code));
for (const fallbackMod of await this._loadFallbackModules()) {
if (!seenCodes.has(fallbackMod.code)) {
modules.push(fallbackMod);
}
}
return modules;
}
// Legacy bundled format: modules is an object map
@ -489,6 +524,19 @@ class ExternalModuleManager {
// Clone the external module repo
const cloneDir = await this.cloneExternalModule(moduleCode, options);
if (moduleInfo.sourceRoot) {
const repoRoot = path.resolve(cloneDir);
const sourceRoot = path.resolve(repoRoot, moduleInfo.sourceRoot);
const relativeSourceRoot = path.relative(repoRoot, sourceRoot);
if (relativeSourceRoot === '..' || relativeSourceRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeSourceRoot)) {
throw new Error(`External module '${moduleCode}' source-root escapes repository: ${moduleInfo.sourceRoot}`);
}
if (!(await fs.pathExists(sourceRoot))) {
throw new Error(`External module '${moduleCode}' source-root not found: ${moduleInfo.sourceRoot}`);
}
return sourceRoot;
}
// The module-definition specifies the path to module.yaml relative to repo root
// We need to return the directory containing module.yaml
const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'skills/module.yaml'

View File

@ -0,0 +1,13 @@
/**
* Canonical schema for per-module `module-help.csv` files.
*
* Both the merger (`Installer.mergeModuleHelpCatalogs`) and the synthesizer
* (`PluginResolver._buildSynthesizedHelpCsv`) emit this exact header. The
* merger compares each per-module file's header against this string and
* warns on drift, so any rename here must be matched in external module
* authors' CSVs (or accepted as a positional fall-through with a warning).
*/
const MODULE_HELP_CSV_HEADER =
'module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs';
module.exports = { MODULE_HELP_CSV_HEADER };

View File

@ -301,6 +301,7 @@ class OfficialModules {
}
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig);
await this.copyAutomatorRuntimeIfNeeded(moduleName, sourcePath, targetPath, fileTrackingCallback);
if (!options.skipModuleInstaller) {
await this.createModuleDirectories(moduleName, bmadDir, options);
@ -572,6 +573,29 @@ class OfficialModules {
}
}
async copyAutomatorRuntimeIfNeeded(moduleName, sourcePath, targetPath, fileTrackingCallback = null) {
if (moduleName !== 'bma') return;
const storyTarget = path.join(targetPath, 'bmad-story-automator');
if (!(await fs.pathExists(path.join(storyTarget, 'SKILL.md')))) return;
const repoRoot = path.resolve(sourcePath, '..', '..', '..');
const runtimeRoot = path.join(repoRoot, 'source');
const runtimeParts = [
['scripts', 'scripts'],
['src', 'src'],
];
for (const [sourceRel, targetRel] of runtimeParts) {
const sourceDir = path.join(runtimeRoot, sourceRel);
const targetDir = path.join(storyTarget, targetRel);
if (!(await fs.pathExists(sourceDir))) {
throw new Error(`BMad Automator runtime source missing: source/${sourceRel}`);
}
await this.copyModuleWithFiltering(sourceDir, targetDir, fileTrackingCallback);
}
}
/**
* Create directories declared in module.yaml's `directories` key
* This replaces the security-risky module installer pattern with declarative config

View File

@ -1,6 +1,7 @@
const fs = require('../fs-native');
const path = require('node:path');
const yaml = require('yaml');
const { MODULE_HELP_CSV_HEADER } = require('./module-help-schema');
/**
* Resolves how to install a plugin from marketplace.json by analyzing
@ -338,8 +339,7 @@ class PluginResolver {
* @returns {string} CSV content
*/
_buildSynthesizedHelpCsv(moduleName, skillInfos) {
const header = 'module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs';
const rows = [header];
const rows = [MODULE_HELP_CSV_HEADER];
for (const info of skillInfos) {
const displayName = this._formatDisplayName(info.name || info.dirName);

View File

@ -50,3 +50,25 @@ modules:
type: bmad-org
npmPackage: bmad-method-test-architecture-enterprise
default_channel: stable
bmad-automator:
url: https://github.com/bmad-code-org/bmad-automator
source-root: payload/.claude/skills
code: bma
name: "BMad Automator (Experimental)"
description: "Experimental pure-skill story automation. Runs only from Claude Code; supports Claude Code and Codex worker sessions; requires tmux on macOS."
defaultSelected: false
type: experimental
npmPackage: bmad-story-automator
default_channel: stable
install-targets:
- claude-code
worker-targets:
- claude-code
- codex
requirements:
- Claude Code entrypoint
- Claude Code or Codex worker sessions
- tmux
- macOS
install-note: "Experimental: BMad Automator only works from Claude Code. It currently supports Claude Code and Codex worker sessions, and requires tmux on macOS."

View File

@ -259,6 +259,7 @@ class UI {
} else {
selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
}
await this.showSelectedExternalModuleNotes(selectedModules);
// Resolve custom sources from --custom-source flag
if (options.customSource) {
@ -287,6 +288,7 @@ class UI {
// Get tool selection
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
await this.showSelectedModuleIdeWarnings(selectedModules, toolSelection.ides);
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
...options,
@ -343,6 +345,7 @@ class UI {
} else {
selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
}
await this.showSelectedExternalModuleNotes(selectedModules);
// Resolve custom sources from --custom-source flag
if (options.customSource) {
@ -366,6 +369,7 @@ class UI {
await this._interactiveChannelGate({ options, channelOptions, selectedModules });
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
await this.showSelectedModuleIdeWarnings(selectedModules, toolSelection.ides);
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
...options,
channelOptions,
@ -954,6 +958,55 @@ class UI {
return result;
}
async showSelectedExternalModuleNotes(selectedModuleIds, externalModules = null) {
if (!externalModules) {
const externalManager = new ExternalModuleManager();
try {
externalModules = await externalManager.listAvailable();
} catch (error) {
await prompts.log.warn(
`ExternalModuleManager.listAvailable failed while loading module notes; continuing without external module notes. ${error.message}`,
);
externalModules = [];
}
}
const notes = externalModules
.filter((mod) => selectedModuleIds.includes(mod.code) && mod.installNote)
.map((mod) => `${mod.name}: ${mod.installNote}`);
for (const note of notes) {
await prompts.log.warn(note);
}
}
async showSelectedModuleIdeWarnings(selectedModuleIds, selectedIdes = []) {
const externalManager = new ExternalModuleManager();
let externalModules = [];
try {
externalModules = await externalManager.listAvailable();
} catch (error) {
await prompts.log.warn(
`ExternalModuleManager.listAvailable failed while loading IDE compatibility warnings; continuing without external module warnings. ${error.message}`,
);
}
for (const mod of externalModules) {
if (!selectedModuleIds.includes(mod.code) || !mod.installTargets || mod.installTargets.length === 0) {
continue;
}
const hasInstallTarget = mod.installTargets.some((target) => selectedIdes.includes(target));
if (!hasInstallTarget) {
await prompts.log.warn(
`${mod.name}: runnable skills are installed only for ${mod.installTargets.join(
', ',
)}. Add that tool selection to use this module.`,
);
}
}
}
/**
* Browse and select community modules using category drill-down.
* Featured/promoted modules appear at the top.