feat(installer): package automator runtime from source

This commit is contained in:
Dicky Moore 2026-05-01 11:32:11 +01:00
parent 9debc165aa
commit da90e1b2e2
11 changed files with 466 additions and 5 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` is experimental and sourced from `bmad-code-org/bmad-automator`. BMAD-METHOD installs the packaged skills; Automator runtime behavior should be changed in the Automator repo.
:::
:::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 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 runtime boundary]
BMad Automator runtime behavior is owned by `bmad-automator`. BMAD-METHOD only registers and packages the module for installer targets. Claude Code remains the supported orchestration path; Codex orchestration is experimental while completion-level validation settles upstream.
:::
**Provides:**
- Story build-cycle automation across story creation, development, QA automation, review, and retrospective
- Resumable tmux orchestration state
- Claude Code plus experimental Codex runtime packaging from the Automator source repo
## 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

@ -18,6 +18,8 @@ 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 { installBmaRuntimePackage } = require('../tools/installer/modules/bma-runtime-package');
const { clearCache, loadPlatformCodes } = require('../tools/installer/ide/platform-codes');
// ANSI colors
@ -85,6 +87,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');
@ -3520,6 +3558,144 @@ 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 tempProjectDir45;
let installedBmadDir45;
let originalLoadExternalModules45;
try {
const yaml45 = require('yaml');
const fallbackConfig45 = yaml45.parse(
await fs.readFile(path.join(__dirname, '..', 'tools', 'installer', 'modules', 'registry-fallback.yaml'), 'utf8'),
);
originalLoadExternalModules45 = ExternalModuleManager.prototype.loadExternalModulesConfig;
ExternalModuleManager.prototype.loadExternalModulesConfig = async function loadFallbackOnlyForTest() {
return fallbackConfig45;
};
const externalManager45 = new ExternalModuleManager();
const automatorInfo45 = await externalManager45.getModuleByCode('bma');
assert(automatorInfo45 !== null, 'BMad Automator is registered as an external module');
assert(automatorInfo45.type === 'experimental', 'BMad Automator is marked experimental');
assert(automatorInfo45.sourceRoot === 'payload/.claude/skills', 'BMad Automator uses source-root for pure skill payload');
assert(
automatorInfo45.installTargets.includes('claude-code') && automatorInfo45.installTargets.includes('codex'),
'BMad Automator declares Claude Code and Codex install targets',
);
const normalizedInfo45 = externalManager45._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(normalizedInfo45.installTargets) && normalizedInfo45.installTargets.includes('claude-code'),
'External module install targets normalize scalar values to arrays',
);
assert(
Array.isArray(normalizedInfo45.workerTargets) && normalizedInfo45.workerTargets.length === 0,
'External module worker targets drop invalid shapes',
);
assert(
normalizedInfo45.requirements.length === 2 &&
normalizedInfo45.requirements.includes('tmux') &&
normalizedInfo45.requirements.includes('false'),
'External module requirements normalize scalar array entries',
);
tempProjectDir45 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-target-'));
installedBmadDir45 = await createAutomatorBmadFixture();
const ideManager45 = new IdeManager();
await ideManager45.ensureInitialized();
const codexResult45 = await ideManager45.setup('codex', tempProjectDir45, installedBmadDir45, {
silent: true,
selectedModules: ['core', 'bma'],
});
assert(codexResult45.success === true, 'Codex setup succeeds with automator module selected');
assert(
await fs.pathExists(path.join(tempProjectDir45, '.agents', 'skills', 'bmad-master', 'SKILL.md')),
'Codex setup installs supported core skills',
);
assert(
await fs.pathExists(path.join(tempProjectDir45, '.agents', 'skills', 'bmad-story-automator', 'SKILL.md')),
'Codex setup installs automator skill because Codex is an explicit target',
);
const escapeRoot45 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-source-root-'));
const escapeRepo45 = path.join(escapeRoot45, 'repo');
await fs.ensureDir(escapeRepo45);
const escapeManager45 = new ExternalModuleManager();
escapeManager45.getModuleByCode = async () => ({
code: 'escape',
builtIn: false,
sourceRoot: '../outside',
});
escapeManager45.cloneExternalModule = async () => escapeRepo45;
let rejectedEscapingSourceRoot45 = false;
try {
await escapeManager45.findExternalModuleSource('escape');
} catch (error) {
rejectedEscapingSourceRoot45 = error.message.includes('source-root escapes repository');
} finally {
await fs.remove(escapeRoot45).catch(() => {});
}
assert(rejectedEscapingSourceRoot45, 'External module source-root cannot escape cloned repository');
const fakeRepo45 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-runtime-package-'));
const fakePayload45 = path.join(fakeRepo45, 'payload', '.claude', 'skills');
const fakeStoryPayload45 = path.join(fakePayload45, 'bmad-story-automator');
const fakeReviewPayload45 = path.join(fakePayload45, 'bmad-story-automator-review');
const fakeSource45 = path.join(fakeRepo45, 'source');
await fs.ensureDir(fakeStoryPayload45);
await fs.ensureDir(fakeReviewPayload45);
await fs.ensureDir(fakeSource45);
await fs.writeFile(path.join(fakeStoryPayload45, 'SKILL.md'), '# bmad-story-automator\n');
await fs.writeFile(path.join(fakeReviewPayload45, 'SKILL.md'), '# bmad-story-automator-review\n');
await fs.writeFile(path.join(fakeSource45, 'pyproject.toml'), '[project]\nname = "story-automator"\n');
await fs.writeFile(path.join(fakeSource45, 'README.md'), '# Automator\n');
await fs.writeFile(path.join(fakeSource45, 'LICENSE'), 'MIT\n');
await fs.ensureDir(path.join(fakeSource45, 'scripts'));
await fs.writeFile(path.join(fakeSource45, 'scripts', 'story-automator'), '#!/usr/bin/env bash\n');
await fs.ensureDir(path.join(fakeSource45, 'src', 'story_automator', 'core'));
await fs.writeFile(path.join(fakeSource45, 'src', 'story_automator', 'core', 'runtime_layout.py'), '# canonical runtime layout\n');
const fakeTarget45 = path.join(fakeRepo45, 'installed', 'bma');
await fs.copy(fakePayload45, fakeTarget45);
const tracked45 = [];
const packaged45 = await installBmaRuntimePackage('bma', fakePayload45, fakeTarget45, (file) => tracked45.push(file));
assert(packaged45 === true, 'BMA runtime package hook runs for bma module');
assert(
(await fs.readFile(
path.join(fakeTarget45, 'bmad-story-automator', 'src', 'story_automator', 'core', 'runtime_layout.py'),
'utf8',
)) === '# canonical runtime layout\n',
'BMA runtime package copies Automator runtime source without patching it',
);
assert(
tracked45.some((file) => file.endsWith(path.join('scripts', 'story-automator'))),
'BMA runtime package tracks copied helper files',
);
await fs.remove(fakeRepo45).catch(() => {});
} catch (error) {
assert(false, `Automator external skill-only module test succeeds: ${error.message}`);
} finally {
if (originalLoadExternalModules45) {
ExternalModuleManager.prototype.loadExternalModulesConfig = originalLoadExternalModules45;
}
if (tempProjectDir45) await fs.remove(tempProjectDir45).catch(() => {});
if (installedBmadDir45) await fs.remove(path.dirname(installedBmadDir45)).catch(() => {});
}
console.log('');
// ============================================================
// Summary
// ============================================================

View File

@ -2,9 +2,14 @@
## 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>/`.
Some modules also need packaging files that live outside the skill payload. BMad Automator (`bma`) is the current example: BMAD-METHOD copies its runtime files from `source/`, but the runtime behavior itself remains owned by `bmad-automator`.
## Post-Install Notes

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

@ -145,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;
@ -248,9 +250,11 @@ 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) {
@ -259,6 +263,7 @@ class ConfigDrivenIdeSetup {
await this.printSummary(results, target_dir, options);
this.skillWriteTracker = null;
this.skippedUnsupported = 0;
return { success: true, results };
}
@ -320,6 +325,10 @@ class ConfigDrivenIdeSetup {
const canonicalId = record.canonicalId;
if (!canonicalId) continue;
if (!(await this.shouldInstallSkillRecord(record))) {
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.
@ -433,6 +442,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
@ -467,6 +481,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);
}
/**
* Print installation summary
* @param {Object} results - Installation results
@ -478,6 +510,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`);
}
const cmd = results.commands;
if (cmd && (cmd.created > 0 || cmd.updated > 0) && this.installerConfig?.commands_target_dir) {
const total = cmd.created + cmd.updated;

View File

@ -0,0 +1,83 @@
const path = require('node:path');
const fs = require('../fs-native');
const STORY_SKILL = 'bmad-story-automator';
const REVIEW_SKILL = 'bmad-story-automator-review';
async function installBmaRuntimePackage(moduleName, sourcePath, targetPath, fileTrackingCallback = null) {
if (moduleName !== 'bma') return false;
const storyTarget = path.join(targetPath, STORY_SKILL);
const reviewTarget = path.join(targetPath, REVIEW_SKILL);
if (!(await fs.pathExists(path.join(storyTarget, 'SKILL.md')))) {
throw new Error(`BMad Automator payload missing installed skill: ${STORY_SKILL}`);
}
if (!(await fs.pathExists(path.join(reviewTarget, 'SKILL.md')))) {
throw new Error(`BMad Automator payload missing installed skill: ${REVIEW_SKILL}`);
}
const repoRoot = await findAutomatorRepoRoot(sourcePath);
const sourceRoot = path.join(repoRoot, 'source');
const runtimeFiles = {
pyproject: path.join(sourceRoot, 'pyproject.toml'),
readme: path.join(sourceRoot, 'README.md'),
license: path.join(sourceRoot, 'LICENSE'),
scripts: path.join(sourceRoot, 'scripts'),
src: path.join(sourceRoot, 'src'),
};
for (const [label, requiredPath] of Object.entries(runtimeFiles)) {
if (!(await fs.pathExists(requiredPath))) {
throw new Error(`BMad Automator runtime ${label} missing: ${requiredPath}`);
}
}
await copyTracked(runtimeFiles.pyproject, path.join(storyTarget, 'pyproject.toml'), fileTrackingCallback);
await copyTracked(runtimeFiles.readme, path.join(storyTarget, 'README.md'), fileTrackingCallback);
await copyTracked(runtimeFiles.license, path.join(storyTarget, 'LICENSE'), fileTrackingCallback);
await copyTracked(runtimeFiles.scripts, path.join(storyTarget, 'scripts'), fileTrackingCallback);
await copyTracked(runtimeFiles.src, path.join(storyTarget, 'src'), fileTrackingCallback);
await fs.chmod(path.join(storyTarget, 'scripts', 'story-automator'), 0o755);
return true;
}
async function findAutomatorRepoRoot(sourcePath) {
let current = path.resolve(sourcePath);
for (let depth = 0; depth < 8; depth += 1) {
if (
(await fs.pathExists(path.join(current, 'source', 'scripts', 'story-automator'))) &&
(await fs.pathExists(path.join(current, 'payload', '.claude', 'skills', STORY_SKILL, 'SKILL.md')))
) {
return current;
}
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
throw new Error(`BMad Automator runtime source not found above: ${sourcePath}`);
}
async function copyTracked(source, target, fileTrackingCallback) {
await fs.remove(target);
await fs.copy(source, target, { overwrite: true });
await trackRecursive(target, fileTrackingCallback);
}
async function trackRecursive(target, fileTrackingCallback) {
if (!fileTrackingCallback) return;
const stat = await fs.stat(target);
if (stat.isFile()) {
fileTrackingCallback(target);
return;
}
if (!stat.isDirectory()) return;
const entries = await fs.readdir(target, { withFileTypes: true });
for (const entry of entries) {
await trackRecursive(path.join(target, entry.name), fileTrackingCallback);
}
}
module.exports = {
installBmaRuntimePackage,
};

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

@ -5,6 +5,7 @@ const prompts = require('../prompts');
const { getProjectRoot, getSourcePath, getModulePath } = require('../project-root');
const { CLIUtils } = require('../cli-utils');
const { ExternalModuleManager } = require('./external-manager');
const { installBmaRuntimePackage } = require('./bma-runtime-package');
class OfficialModules {
constructor(options = {}) {
@ -301,6 +302,7 @@ class OfficialModules {
}
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig);
await installBmaRuntimePackage(moduleName, sourcePath, targetPath, fileTrackingCallback);
if (!options.skipModuleInstaller) {
await this.createModuleDirectories(moduleName, bmadDir, options);

View File

@ -50,3 +50,27 @@ 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. Claude Code remains the supported orchestrator; Codex orchestration is limited to create/dev/review validation while support settles upstream."
defaultSelected: false
type: experimental
npmPackage: bmad-story-automator
default_channel: stable
install-targets:
- claude-code
- codex
worker-targets:
- claude-code
- codex
requirements:
- Claude Code entrypoint for supported orchestration and retrospectives
- Codex entrypoint for experimental create/dev/review validation
- Claude Code or Codex worker sessions
- tmux
- macOS
install-note: "Experimental: Automator runtime support lives in bmad-automator. Codex orchestration should be treated as create/dev/review validation until completion-level behavior is proven."

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,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.