From da90e1b2e2b939f4b600e894f9d9467241492bfd Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Fri, 1 May 2026 11:32:11 +0100 Subject: [PATCH] feat(installer): package automator runtime from source --- docs/how-to/install-bmad.md | 8 +- docs/reference/modules.md | 18 ++ test/test-installation-components.js | 176 ++++++++++++++++++ tools/installer/README.md | 9 +- tools/installer/core/manifest-generator.js | 27 +++ tools/installer/ide/_config-driven.js | 35 ++++ .../installer/modules/bma-runtime-package.js | 83 +++++++++ tools/installer/modules/external-manager.js | 50 ++++- tools/installer/modules/official-modules.js | 2 + .../installer/modules/registry-fallback.yaml | 24 +++ tools/installer/ui.js | 39 ++++ 11 files changed, 466 insertions(+), 5 deletions(-) create mode 100644 tools/installer/modules/bma-runtime-package.js diff --git a/docs/how-to/install-bmad.md b/docs/how-to/install-bmad.md index 224704a47..2e40aea0a 100644 --- a/docs/how-to/install-bmad.md +++ b/docs/how-to/install-bmad.md @@ -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 | | ------------------ | ---------------------------------------------------------------------------- | --------------------------------------- | diff --git a/docs/reference/modules.md b/docs/reference/modules.md index 6bdc64190..4da130364 100644 --- a/docs/reference/modules.md +++ b/docs/reference/modules.md @@ -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. diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 0a5ebed5b..33ec30625 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -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 // ============================================================ diff --git a/tools/installer/README.md b/tools/installer/README.md index 9e943d689..e17e80330 100644 --- a/tools/installer/README.md +++ b/tools/installer/README.md @@ -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//`. + +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 diff --git a/tools/installer/core/manifest-generator.js b/tools/installer/core/manifest-generator.js index f7b5d0084..1b081f35f 100644 --- a/tools/installer/core/manifest-generator.js +++ b/tools/installer/core/manifest-generator.js @@ -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); + } } /** diff --git a/tools/installer/ide/_config-driven.js b/tools/installer/ide/_config-driven.js index bf6fffbc5..59de98dd8 100644 --- a/tools/installer/ide/_config-driven.js +++ b/tools/installer/ide/_config-driven.js @@ -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; diff --git a/tools/installer/modules/bma-runtime-package.js b/tools/installer/modules/bma-runtime-package.js new file mode 100644 index 000000000..e9cc4ad2e --- /dev/null +++ b/tools/installer/modules/bma-runtime-package.js @@ -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, +}; diff --git a/tools/installer/modules/external-manager.js b/tools/installer/modules/external-manager.js index 7d2add4fb..37f404515 100644 --- a/tools/installer/modules/external-manager.js +++ b/tools/installer/modules/external-manager.js @@ -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} 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' diff --git a/tools/installer/modules/official-modules.js b/tools/installer/modules/official-modules.js index 615daba86..93c0e9f04 100644 --- a/tools/installer/modules/official-modules.js +++ b/tools/installer/modules/official-modules.js @@ -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); diff --git a/tools/installer/modules/registry-fallback.yaml b/tools/installer/modules/registry-fallback.yaml index 52bc4b4fc..2c0559c4a 100644 --- a/tools/installer/modules/registry-fallback.yaml +++ b/tools/installer/modules/registry-fallback.yaml @@ -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." diff --git a/tools/installer/ui.js b/tools/installer/ui.js index 5770206ef..8dd6be3cb 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -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.