Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/how-to/install-bmad.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
| ------------------ | ---------------------------------------------------------------------------- | --------------------------------------- |
Expand Down
18 changes: 18 additions & 0 deletions docs/reference/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
147 changes: 147 additions & 0 deletions test/test-installation-components.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -85,6 +86,42 @@ 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 createSkillCollisionFixture() {
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-skill-collision-'));
const fixtureDir = path.join(fixtureRoot, '_bmad');
Expand Down Expand Up @@ -3237,6 +3274,116 @@ 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;
try {
const externalManager42 = new ExternalModuleManager();
const automatorInfo42 = await externalManager42.getModuleByCode('bma');
assert(automatorInfo42 !== null, 'BMad Automator is registered as an external module');
assert(automatorInfo42.type === 'experimental', 'BMad Automator is marked experimental');
assert(automatorInfo42.sourceRoot === 'payload/.claude/skills', 'BMad Automator uses source-root for pure skill payload');
assert(
automatorInfo42.installTargets.length === 1 && automatorInfo42.installTargets.includes('claude-code'),
'BMad Automator is limited to Claude Code skill installation',
);
const normalizedInfo42 = externalManager42._normalizeModule({
name: 'bad-shapes',
code: 'bad',
repository: 'https://example.com/bad.git',
install_targets: 'claude-code',
worker_targets: { bad: true },
requirements: ['tmux', { bad: true }, false],
});
assert(
Array.isArray(normalizedInfo42.installTargets) && normalizedInfo42.installTargets.includes('claude-code'),
'External module install targets normalize scalar values to arrays',
);
assert(
Array.isArray(normalizedInfo42.workerTargets) && normalizedInfo42.workerTargets.length === 0,
'External module worker targets drop invalid shapes',
);
assert(
normalizedInfo42.requirements.length === 2 &&
normalizedInfo42.requirements.includes('tmux') &&
normalizedInfo42.requirements.includes('false'),
'External module requirements normalize scalar array entries',
);

tempProjectDir42 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-target-'));
installedBmadDir42 = await createAutomatorBmadFixture();

const ideManager42 = new IdeManager();
await ideManager42.ensureInitialized();

const codexResult42 = await ideManager42.setup('codex', tempProjectDir42, installedBmadDir42, {
silent: true,
selectedModules: ['core', 'bma'],
});
assert(codexResult42.success === true, 'Codex setup succeeds with automator module selected');
assert(
await fs.pathExists(path.join(tempProjectDir42, '.agents', 'skills', 'bmad-master', 'SKILL.md')),
'Codex setup still installs supported core skills',
);
assert(
!(await fs.pathExists(path.join(tempProjectDir42, '.agents', 'skills', 'bmad-story-automator', 'SKILL.md'))),
'Codex setup skips Claude Code-only automator skill',
);
Comment thread
bma-d marked this conversation as resolved.
assert(
!(await fs.pathExists(path.join(tempProjectDir42, '.agents', 'skills', 'bmad-story-automator-review', 'SKILL.md'))),
'Codex setup skips Claude Code-only automator review skill',
);

const escapeRoot42 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-source-root-'));
const escapeRepo42 = path.join(escapeRoot42, 'repo');
await fs.ensureDir(escapeRepo42);
const escapeManager42 = new ExternalModuleManager();
escapeManager42.getModuleByCode = async () => ({
code: 'escape',
builtIn: false,
sourceRoot: '../outside',
});
escapeManager42.cloneExternalModule = async () => escapeRepo42;
let rejectedEscapingSourceRoot42 = false;
try {
await escapeManager42.findExternalModuleSource('escape');
} catch (error) {
rejectedEscapingSourceRoot42 = error.message.includes('source-root escapes repository');
} finally {
await fs.remove(escapeRoot42).catch(() => {});
}
assert(rejectedEscapingSourceRoot42, 'External module source-root cannot escape cloned repository');

const claudeResult42 = await ideManager42.setup('claude-code', tempProjectDir42, installedBmadDir42, {
silent: true,
selectedModules: ['core', 'bma'],
});
assert(claudeResult42.success === true, 'Claude Code setup succeeds with automator module selected');
assert(
await fs.pathExists(path.join(tempProjectDir42, '.claude', 'skills', 'bmad-story-automator', 'SKILL.md')),
'Claude Code setup installs automator skill',
);
assert(
await fs.pathExists(path.join(tempProjectDir42, '.claude', 'skills', 'bmad-story-automator-review', 'SKILL.md')),
'Claude Code setup installs automator review skill',
);
assert(
await fs.pathExists(path.join(tempProjectDir42, '.claude', 'skills', 'bmad-story-automator-review', 'workflow.md')),
'Claude Code setup copies automator workflow files',
);
} catch (error) {
assert(false, `Automator external skill-only module test succeeds: ${error.message}`);
} finally {
if (tempProjectDir42) await fs.remove(tempProjectDir42).catch(() => {});
if (installedBmadDir42) await fs.remove(path.dirname(installedBmadDir42)).catch(() => {});
}

console.log('');

// ============================================================
// Summary
// ============================================================
Expand Down
10 changes: 8 additions & 2 deletions tools/installer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
27 changes: 27 additions & 0 deletions tools/installer/core/manifest-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,9 @@ class ManifestGenerator {
for (const moduleName of this.updatedModules) {
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
if (!moduleYamlPath) {
if (await this._isSkillOnlyModule(moduleName)) {
Comment thread
bma-d marked this conversation as resolved.
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.
Expand Down Expand Up @@ -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.`,
Expand Down Expand Up @@ -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);
}
}

/**
Expand Down
31 changes: 31 additions & 0 deletions tools/installer/ide/_config-driven.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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;
Expand Down Expand Up @@ -123,13 +125,16 @@ 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;

await this.printSummary(results, target_dir, options);
this.skillWriteTracker = null;
this.skippedUnsupported = 0;
return { success: true, results };
}

Expand Down Expand Up @@ -162,6 +167,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
Expand Down Expand Up @@ -196,6 +206,24 @@ 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();
const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleName);
const targets = moduleInfo?.installTargets?.length ? moduleInfo.installTargets : null;
this.moduleTargetCache.set(moduleName, targets);

return !targets || targets.includes(this.name);
Comment on lines +218 to +224
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard module metadata lookup failures to avoid hard install aborts.

getModuleByCode() is unguarded here; if it throws (registry/file read/transient failure), the whole IDE install can fail mid-run. In tooling code, this path should handle lookup failure explicitly (e.g., warn and fail-open/fail-closed consistently) instead of crashing the install flow.

Suggested hardening
   const { ExternalModuleManager } = require('../modules/external-manager');
   this.externalModuleManager = this.externalModuleManager || new ExternalModuleManager();
-  const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleName);
+  let moduleInfo = null;
+  try {
+    moduleInfo = await this.externalModuleManager.getModuleByCode(moduleName);
+  } catch {
+    // Metadata lookup is advisory for filtering; avoid aborting entire install.
+    this.moduleTargetCache.set(moduleName, null);
+    return true;
+  }
   const targets = moduleInfo?.installTargets?.length ? moduleInfo.installTargets : null;
   this.moduleTargetCache.set(moduleName, targets);

As per coding guidelines tools/**: Build script/tooling. Check error handling and proper exit codes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/installer/ide/_config-driven.js` around lines 218 - 224, Wrap the call
to ExternalModuleManager.getModuleByCode(moduleName) in a try/catch so lookup
failures don't throw and abort the install; if an error occurs, log a warning
including moduleName and the error, set targets to null (fail-open so the
install proceeds on all targets), cache that null into this.moduleTargetCache
for moduleName, and continue to return the existing expression (!targets ||
targets.includes(this.name)); reference ExternalModuleManager, getModuleByCode,
moduleTargetCache, moduleName and this.name when making the change.

}

/**
* Print installation summary
* @param {Object} results - Installation results
Expand All @@ -207,6 +235,9 @@ class ConfigDrivenIdeSetup {
if (count > 0) {
await prompts.log.success(`${this.name} configured: ${count} skills → ${targetDir}`);
}
if (results.skippedUnsupported > 0) {
await prompts.log.warn(`${this.name}: skipped ${results.skippedUnsupported} skill(s) that do not support this IDE`);
}
}

/**
Expand Down
Loading
Loading