Skip to content

Commit adcffe6

Browse files
committed
feat(installer): install automator skill module
1 parent da470fd commit adcffe6

8 files changed

Lines changed: 239 additions & 5 deletions

File tree

docs/how-to/install-bmad.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ npx bmad-method install
3131
The interactive flow asks you five things:
3232

3333
1. Installation directory (defaults to the current working directory)
34-
2. Which modules to install (checkboxes for core, bmm, bmb, cis, gds, tea)
34+
2. Which modules to install (checkboxes for core, bmm, bmb, cis, gds, tea, bma)
3535
3. **"Ready to install (all stable)?"** — Yes accepts the latest released tag for every external module
3636
4. Which AI tools/IDEs to integrate with (claude-code, cursor, and others)
3737
5. Per-module config (name, language, output folder)
@@ -53,7 +53,7 @@ Two independent axes control what ends up on disk.
5353

5454
### Axis 1: external module channels
5555

56-
Every external module — bmb, cis, gds, tea, and any community module — installs on one of three channels:
56+
Every external module — bmb, cis, gds, tea, bma, and any community module — installs on one of three channels:
5757

5858
| Channel | What gets installed | Who picks this |
5959
| ------------------ | ---------------------------------------------------------------------------- | --------------------------------------- |

docs/reference/modules.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,24 @@ Enterprise-grade test strategy, automation guidance, and release gate decisions
7171
- NFR assessment, CI setup, and framework scaffolding
7272
- P0-P3 prioritization with optional Playwright Utils and MCP integrations
7373

74+
## BMad Automator (Experimental)
75+
76+
Automates the BMad story build loop with a pure skill bundle sourced from the separate Automator repository.
77+
78+
- **Code:** `bma`
79+
- **npm:** [`bmad-story-automator`](https://www.npmjs.com/package/bmad-story-automator)
80+
- **GitHub:** [bmad-code-org/bmad-automator](https://github.com/bmad-code-org/bmad-automator)
81+
82+
:::caution[Experimental Claude Code-only entrypoint]
83+
BMad Automator only runs from Claude Code. It currently supports Claude Code and Codex worker sessions, and requires tmux on macOS.
84+
:::
85+
86+
**Provides:**
87+
88+
- Story build-cycle automation across story creation, development, QA automation, review, and retrospective
89+
- Resumable tmux orchestration state
90+
- Claude Code entry skill plus Claude Code/Codex worker-session coordination
91+
7492
## Community Modules
7593

7694
Community modules and a module marketplace are coming. Check the [BMad GitHub organization](https://github.com/bmad-code-org) for updates.

test/test-installation-components.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const { Installer } = require('../tools/installer/core/installer');
1818
const { ManifestGenerator } = require('../tools/installer/core/manifest-generator');
1919
const { OfficialModules } = require('../tools/installer/modules/official-modules');
2020
const { IdeManager } = require('../tools/installer/ide/manager');
21+
const { ExternalModuleManager } = require('../tools/installer/modules/external-manager');
2122
const { clearCache, loadPlatformCodes } = require('../tools/installer/ide/platform-codes');
2223

2324
// ANSI colors
@@ -85,6 +86,41 @@ async function createTestBmadFixture() {
8586
return fixtureDir;
8687
}
8788

89+
async function createAutomatorBmadFixture() {
90+
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-fixture-'));
91+
const fixtureDir = path.join(fixtureRoot, '_bmad');
92+
await fs.ensureDir(path.join(fixtureDir, '_config'));
93+
94+
await fs.writeFile(
95+
path.join(fixtureDir, '_config', 'skill-manifest.csv'),
96+
[
97+
'canonicalId,name,description,module,path',
98+
'"bmad-master","bmad-master","Minimal core skill","core","_bmad/core/bmad-master/SKILL.md"',
99+
'"bmad-story-automator","bmad-story-automator","Automator skill","bma","_bmad/bma/bmad-story-automator/SKILL.md"',
100+
'"bmad-story-automator-review","bmad-story-automator-review","Automator review skill","bma","_bmad/bma/bmad-story-automator-review/SKILL.md"',
101+
'',
102+
].join('\n'),
103+
);
104+
105+
const coreSkillDir = path.join(fixtureDir, 'core', 'bmad-master');
106+
await fs.ensureDir(coreSkillDir);
107+
await fs.writeFile(
108+
path.join(coreSkillDir, 'SKILL.md'),
109+
['---', 'name: bmad-master', 'description: Minimal core skill', '---', '', 'Core skill body.'].join('\n'),
110+
);
111+
112+
for (const skillName of ['bmad-story-automator', 'bmad-story-automator-review']) {
113+
const skillDir = path.join(fixtureDir, 'bma', skillName);
114+
await fs.ensureDir(skillDir);
115+
await fs.writeFile(
116+
path.join(skillDir, 'SKILL.md'),
117+
['---', `name: ${skillName}`, 'description: Automator skill', '---', '', 'Automator body.'].join('\n'),
118+
);
119+
}
120+
121+
return fixtureDir;
122+
}
123+
88124
async function createSkillCollisionFixture() {
89125
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-skill-collision-'));
90126
const fixtureDir = path.join(fixtureRoot, '_bmad');
@@ -2773,6 +2809,66 @@ async function runTests() {
27732809

27742810
console.log('');
27752811

2812+
// ============================================================
2813+
// Test Suite 42: Automator External Skill-Only Module
2814+
// ============================================================
2815+
console.log(`${colors.yellow}Test Suite 42: Automator External Skill-Only Module${colors.reset}\n`);
2816+
2817+
let tempProjectDir42;
2818+
let installedBmadDir42;
2819+
try {
2820+
const externalManager42 = new ExternalModuleManager();
2821+
const automatorInfo42 = await externalManager42.getModuleByCode('bma');
2822+
assert(automatorInfo42 !== null, 'BMad Automator is registered as an external module');
2823+
assert(automatorInfo42.type === 'experimental', 'BMad Automator is marked experimental');
2824+
assert(automatorInfo42.sourceRoot === 'payload/.claude/skills', 'BMad Automator uses source-root for pure skill payload');
2825+
assert(
2826+
automatorInfo42.installTargets.length === 1 && automatorInfo42.installTargets.includes('claude-code'),
2827+
'BMad Automator is limited to Claude Code skill installation',
2828+
);
2829+
2830+
tempProjectDir42 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-target-'));
2831+
installedBmadDir42 = await createAutomatorBmadFixture();
2832+
2833+
const ideManager42 = new IdeManager();
2834+
await ideManager42.ensureInitialized();
2835+
2836+
const codexResult42 = await ideManager42.setup('codex', tempProjectDir42, installedBmadDir42, {
2837+
silent: true,
2838+
selectedModules: ['core', 'bma'],
2839+
});
2840+
assert(codexResult42.success === true, 'Codex setup succeeds with automator module selected');
2841+
assert(
2842+
await fs.pathExists(path.join(tempProjectDir42, '.agents', 'skills', 'bmad-master', 'SKILL.md')),
2843+
'Codex setup still installs supported core skills',
2844+
);
2845+
assert(
2846+
!(await fs.pathExists(path.join(tempProjectDir42, '.agents', 'skills', 'bmad-story-automator', 'SKILL.md'))),
2847+
'Codex setup skips Claude Code-only automator skill',
2848+
);
2849+
2850+
const claudeResult42 = await ideManager42.setup('claude-code', tempProjectDir42, installedBmadDir42, {
2851+
silent: true,
2852+
selectedModules: ['core', 'bma'],
2853+
});
2854+
assert(claudeResult42.success === true, 'Claude Code setup succeeds with automator module selected');
2855+
assert(
2856+
await fs.pathExists(path.join(tempProjectDir42, '.claude', 'skills', 'bmad-story-automator', 'SKILL.md')),
2857+
'Claude Code setup installs automator skill',
2858+
);
2859+
assert(
2860+
await fs.pathExists(path.join(tempProjectDir42, '.claude', 'skills', 'bmad-story-automator-review', 'SKILL.md')),
2861+
'Claude Code setup installs automator review skill',
2862+
);
2863+
} catch (error) {
2864+
assert(false, 'Automator external skill-only module test succeeds', error.message);
2865+
} finally {
2866+
if (tempProjectDir42) await fs.remove(tempProjectDir42).catch(() => {});
2867+
if (installedBmadDir42) await fs.remove(path.dirname(installedBmadDir42)).catch(() => {});
2868+
}
2869+
2870+
console.log('');
2871+
27762872
// ============================================================
27772873
// Summary
27782874
// ============================================================

tools/installer/README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@
22

33
## Installing external repo BMad official modules
44

5-
For external official modules to be discoverable during install, ensure an entry for the external repo is added to external-official-modules.yaml.
5+
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.
66

7-
For community modules - this will be handled in a different way. This file is only for registration of modules under the bmad-code-org.
7+
For community modules - this is handled through the marketplace community registry.
8+
9+
Use `module-definition` for conventional module repos with `module.yaml`.
10+
Use `source-root` for pure skill bundles that should be copied directly into `_bmad/<module-code>/`.
11+
This keeps the external repo as the source of truth and avoids vendoring generated skill payloads into BMAD-METHOD.
12+
13+
Experimental modules can set `type: experimental` and `install-targets` to limit which IDE integrations receive their skills.
814

915
## Post-Install Notes
1016

tools/installer/core/manifest-generator.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,9 @@ class ManifestGenerator {
246246
for (const moduleName of this.updatedModules) {
247247
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
248248
if (!moduleYamlPath) {
249+
if (await this._isSkillOnlyModule(moduleName)) {
250+
continue;
251+
}
249252
// External modules live in ~/.bmad/cache/external-modules, not src/modules.
250253
// Warn rather than silently skip so missing agent rosters don't vanish
251254
// from config.toml without notice.
@@ -441,6 +444,9 @@ class ManifestGenerator {
441444
for (const moduleName of this.updatedModules) {
442445
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
443446
if (!moduleYamlPath) {
447+
if (await this._isSkillOnlyModule(moduleName)) {
448+
continue;
449+
}
444450
console.warn(
445451
`[warn] writeCentralConfig: could not locate module.yaml for '${moduleName}'. ` +
446452
`Answers from this module will default to team scope — user-scoped keys may mis-file into config.toml.`,
@@ -799,6 +805,13 @@ class ManifestGenerator {
799805

800806
return false;
801807
}
808+
809+
async _isSkillOnlyModule(moduleName) {
810+
const modulePath = path.join(this.bmadDir, moduleName);
811+
if (!(await fs.pathExists(modulePath))) return false;
812+
if (await fs.pathExists(path.join(modulePath, 'module.yaml'))) return false;
813+
return this._hasSkillMdRecursive(modulePath);
814+
}
802815
}
803816

804817
/**

tools/installer/ide/_config-driven.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ class ConfigDrivenIdeSetup {
2626
this.platformConfig = platformConfig;
2727
this.installerConfig = platformConfig.installer || null;
2828
this.bmadFolderName = BMAD_FOLDER_NAME;
29+
this.externalModuleManager = null;
30+
this.moduleTargetCache = new Map();
2931

3032
// Set configDir from target_dir so detect() works
3133
this.configDir = this.installerConfig?.target_dir || null;
@@ -123,13 +125,16 @@ class ConfigDrivenIdeSetup {
123125
await fs.ensureDir(targetPath);
124126

125127
this.skillWriteTracker = new Set();
128+
this.skippedUnsupported = 0;
126129
const results = { skills: 0 };
127130

128131
results.skills = await this.installVerbatimSkills(projectDir, bmadDir, targetPath, config);
132+
results.skippedUnsupported = this.skippedUnsupported || 0;
129133
results.skillDirectories = this.skillWriteTracker.size;
130134

131135
await this.printSummary(results, target_dir, options);
132136
this.skillWriteTracker = null;
137+
this.skippedUnsupported = 0;
133138
return { success: true, results };
134139
}
135140

@@ -162,6 +167,11 @@ class ConfigDrivenIdeSetup {
162167
const canonicalId = record.canonicalId;
163168
if (!canonicalId) continue;
164169

170+
if (!(await this.shouldInstallSkillRecord(record))) {
171+
this.skippedUnsupported = (this.skippedUnsupported || 0) + 1;
172+
continue;
173+
}
174+
165175
// Derive source directory from path column
166176
// path is like "_bmad/bmm/workflows/bmad-quick-flow/bmad-quick-dev-new-preview/SKILL.md"
167177
// Strip bmadFolderName prefix and join with bmadDir, then get dirname
@@ -196,6 +206,24 @@ class ConfigDrivenIdeSetup {
196206
return count;
197207
}
198208

209+
async shouldInstallSkillRecord(record) {
210+
const moduleName = record.module;
211+
if (!moduleName) return true;
212+
213+
if (this.moduleTargetCache.has(moduleName)) {
214+
const targets = this.moduleTargetCache.get(moduleName);
215+
return !targets || targets.includes(this.name);
216+
}
217+
218+
const { ExternalModuleManager } = require('../modules/external-manager');
219+
this.externalModuleManager = this.externalModuleManager || new ExternalModuleManager();
220+
const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleName);
221+
const targets = moduleInfo?.installTargets || null;
222+
this.moduleTargetCache.set(moduleName, targets);
223+
224+
return !targets || targets.includes(this.name);
225+
}
226+
199227
/**
200228
* Print installation summary
201229
* @param {Object} results - Installation results
@@ -207,6 +235,9 @@ class ConfigDrivenIdeSetup {
207235
if (count > 0) {
208236
await prompts.log.success(`${this.name} configured: ${count} skills → ${targetDir}`);
209237
}
238+
if (results.skippedUnsupported > 0) {
239+
await prompts.log.warn(`${this.name}: skipped ${results.skippedUnsupported} skill(s) that do not support this IDE`);
240+
}
210241
}
211242

212243
/**

tools/installer/modules/external-manager.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,18 +124,34 @@ class ExternalModuleManager {
124124
key: key || mod.name,
125125
url: mod.repository || mod.url,
126126
moduleDefinition: mod.module_definition || mod['module-definition'],
127+
sourceRoot: mod.source_root || mod['source-root'] || mod.sourceRoot || null,
127128
code: mod.code,
128129
name: mod.display_name || mod.name,
129130
description: mod.description || '',
130131
defaultSelected: mod.default_selected === true || mod.defaultSelected === true,
131132
type: mod.type || 'bmad-org',
132133
npmPackage: mod.npm_package || mod.npmPackage || null,
134+
installTargets: mod.install_targets || mod['install-targets'] || mod.installTargets || null,
135+
workerTargets: mod.worker_targets || mod['worker-targets'] || mod.workerTargets || [],
136+
requirements: mod.requirements || [],
137+
installNote: mod.install_note || mod['install-note'] || mod.installNote || null,
133138
defaultChannel: normalizeChannelName(mod.default_channel || mod.defaultChannel) || 'stable',
134139
builtIn: mod.built_in === true,
135140
isExternal: mod.built_in !== true,
136141
};
137142
}
138143

144+
async _loadFallbackModules() {
145+
try {
146+
const content = await fs.readFile(FALLBACK_CONFIG_PATH, 'utf8');
147+
const config = yaml.parse(content);
148+
if (Array.isArray(config.modules)) return config.modules.map((mod) => this._normalizeModule(mod));
149+
return Object.entries(config.modules || {}).map(([key, mod]) => this._normalizeModule(mod, key));
150+
} catch {
151+
return [];
152+
}
153+
}
154+
139155
/**
140156
* Get list of available modules from the registry
141157
* @returns {Array<Object>} Array of module info objects
@@ -145,7 +161,14 @@ class ExternalModuleManager {
145161

146162
// Remote format: modules is an array
147163
if (Array.isArray(config.modules)) {
148-
return config.modules.map((mod) => this._normalizeModule(mod));
164+
const modules = config.modules.map((mod) => this._normalizeModule(mod));
165+
const seenCodes = new Set(modules.map((mod) => mod.code));
166+
for (const fallbackMod of await this._loadFallbackModules()) {
167+
if (!seenCodes.has(fallbackMod.code)) {
168+
modules.push(fallbackMod);
169+
}
170+
}
171+
return modules;
149172
}
150173

151174
// Legacy bundled format: modules is an object map
@@ -489,6 +512,14 @@ class ExternalModuleManager {
489512
// Clone the external module repo
490513
const cloneDir = await this.cloneExternalModule(moduleCode, options);
491514

515+
if (moduleInfo.sourceRoot) {
516+
const sourceRoot = path.join(cloneDir, moduleInfo.sourceRoot);
517+
if (!(await fs.pathExists(sourceRoot))) {
518+
throw new Error(`External module '${moduleCode}' source-root not found: ${moduleInfo.sourceRoot}`);
519+
}
520+
return sourceRoot;
521+
}
522+
492523
// The module-definition specifies the path to module.yaml relative to repo root
493524
// We need to return the directory containing module.yaml
494525
const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'skills/module.yaml'

0 commit comments

Comments
 (0)