Compare commits
2 Commits
6b60599e3b
...
adcffe6dc2
| Author | SHA1 | Date |
|---|---|---|
|
|
adcffe6dc2 | |
|
|
da470fd2ec |
|
|
@ -31,7 +31,7 @@ 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)
|
||||
|
|
@ -53,7 +53,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 |
|
||||
| ------------------ | ---------------------------------------------------------------------------- | --------------------------------------- |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,41 @@ 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'),
|
||||
);
|
||||
}
|
||||
|
||||
return fixtureDir;
|
||||
}
|
||||
|
||||
async function createSkillCollisionFixture() {
|
||||
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-skill-collision-'));
|
||||
const fixtureDir = path.join(fixtureRoot, '_bmad');
|
||||
|
|
@ -2773,6 +2809,66 @@ async function runTests() {
|
|||
|
||||
console.log('');
|
||||
|
||||
// ============================================================
|
||||
// Test Suite 42: Automator External Skill-Only Module
|
||||
// ============================================================
|
||||
console.log(`${colors.yellow}Test Suite 42: 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',
|
||||
);
|
||||
|
||||
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',
|
||||
);
|
||||
|
||||
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',
|
||||
);
|
||||
} 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
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,13 @@ 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;
|
||||
return this._hasSkillMdRecursive(modulePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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 || null;
|
||||
this.moduleTargetCache.set(moduleName, targets);
|
||||
|
||||
return !targets || targets.includes(this.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Print installation summary
|
||||
* @param {Object} results - Installation results
|
||||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -124,18 +124,34 @@ class ExternalModuleManager {
|
|||
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: mod.install_targets || mod['install-targets'] || mod.installTargets || null,
|
||||
workerTargets: mod.worker_targets || mod['worker-targets'] || mod.workerTargets || [],
|
||||
requirements: 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 +161,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 +512,14 @@ class ExternalModuleManager {
|
|||
// Clone the external module repo
|
||||
const cloneDir = await this.cloneExternalModule(moduleCode, options);
|
||||
|
||||
if (moduleInfo.sourceRoot) {
|
||||
const sourceRoot = path.join(cloneDir, 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'
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -258,6 +258,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) {
|
||||
|
|
@ -286,6 +287,7 @@ class UI {
|
|||
|
||||
// Get tool selection
|
||||
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||
await this.showSelectedModuleIdeWarnings(selectedModules, toolSelection.ides);
|
||||
|
||||
const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
||||
...options,
|
||||
|
|
@ -341,6 +343,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) {
|
||||
|
|
@ -364,6 +367,7 @@ class UI {
|
|||
await this._interactiveChannelGate({ options, channelOptions, selectedModules });
|
||||
|
||||
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||
await this.showSelectedModuleIdeWarnings(selectedModules, toolSelection.ides);
|
||||
const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
||||
...options,
|
||||
channelOptions,
|
||||
|
|
@ -893,6 +897,41 @@ class UI {
|
|||
return result;
|
||||
}
|
||||
|
||||
async showSelectedExternalModuleNotes(selectedModuleIds, externalModules = null) {
|
||||
if (!externalModules) {
|
||||
const externalManager = new ExternalModuleManager();
|
||||
externalModules = await externalManager.listAvailable();
|
||||
}
|
||||
|
||||
const notes = externalModules
|
||||
.filter((mod) => selectedModuleIds.includes(mod.code) && mod.installNote)
|
||||
.map((mod) => `${mod.name}: ${mod.installNote}`);
|
||||
|
||||
for (const note of notes) {
|
||||
await prompts.log.warn(note);
|
||||
}
|
||||
}
|
||||
|
||||
async showSelectedModuleIdeWarnings(selectedModuleIds, selectedIdes = []) {
|
||||
const externalManager = new ExternalModuleManager();
|
||||
const externalModules = await externalManager.listAvailable();
|
||||
|
||||
for (const mod of externalModules) {
|
||||
if (!selectedModuleIds.includes(mod.code) || !mod.installTargets || mod.installTargets.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasInstallTarget = mod.installTargets.some((target) => selectedIdes.includes(target));
|
||||
if (!hasInstallTarget) {
|
||||
await prompts.log.warn(
|
||||
`${mod.name}: runnable skills are installed only for ${mod.installTargets.join(
|
||||
', ',
|
||||
)}. Add that tool selection to use this module.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse and select community modules using category drill-down.
|
||||
* Featured/promoted modules appear at the top.
|
||||
|
|
|
|||
Loading…
Reference in New Issue