feat(installer): add --custom-source CLI flag for non-interactive installs

Allows installing custom modules from Git URLs or local paths directly
from the command line without interactive prompts:

  npx bmad-method install --custom-source /path/to/module
  npx bmad-method install --custom-source https://gitlab.com/org/repo
  npx bmad-method install --custom-source /path/one,https://host/org/repo

Works alongside --modules and --yes flags. All discovered modules from
each source are auto-selected.
This commit is contained in:
Brian Madison 2026-04-09 18:08:51 -05:00
parent 225e5ee77b
commit 9ff131ac15
2 changed files with 113 additions and 0 deletions

View File

@ -22,6 +22,7 @@ module.exports = {
['--communication-language <lang>', 'Language for agent communication (default: English)'], ['--communication-language <lang>', 'Language for agent communication (default: English)'],
['--document-output-language <lang>', 'Language for document output (default: English)'], ['--document-output-language <lang>', 'Language for document output (default: English)'],
['--output-folder <path>', 'Output folder path relative to project root (default: _bmad-output)'], ['--output-folder <path>', 'Output folder path relative to project root (default: _bmad-output)'],
['--custom-source <sources>', 'Comma-separated Git URLs or local paths to install custom modules from'],
['-y, --yes', 'Accept all defaults and skip prompts where possible'], ['-y, --yes', 'Accept all defaults and skip prompts where possible'],
], ],
action: async (options) => { action: async (options) => {

View File

@ -167,6 +167,14 @@ class UI {
selectedModules = await this.selectAllModules(installedModuleIds); selectedModules = await this.selectAllModules(installedModuleIds);
} }
// Resolve custom sources from --custom-source flag
if (options.customSource) {
const customCodes = await this._resolveCustomSourcesCli(options.customSource);
for (const code of customCodes) {
if (!selectedModules.includes(code)) selectedModules.push(code);
}
}
// Ensure core is in the modules list // Ensure core is in the modules list
if (!selectedModules.includes('core')) { if (!selectedModules.includes('core')) {
selectedModules.unshift('core'); selectedModules.unshift('core');
@ -210,6 +218,14 @@ class UI {
selectedModules = await this.selectAllModules(installedModuleIds); selectedModules = await this.selectAllModules(installedModuleIds);
} }
// Resolve custom sources from --custom-source flag
if (options.customSource) {
const customCodes = await this._resolveCustomSourcesCli(options.customSource);
for (const code of customCodes) {
if (!selectedModules.includes(code)) selectedModules.push(code);
}
}
// Ensure core is in the modules list // Ensure core is in the modules list
if (!selectedModules.includes('core')) { if (!selectedModules.includes('core')) {
selectedModules.unshift('core'); selectedModules.unshift('core');
@ -998,6 +1014,102 @@ class UI {
return selectedModules; return selectedModules;
} }
/**
* Resolve custom sources from --custom-source CLI flag (non-interactive).
* Auto-selects all discovered modules from each source.
* @param {string} sourcesArg - Comma-separated Git URLs or local paths
* @returns {Array} Module codes from all resolved sources
*/
async _resolveCustomSourcesCli(sourcesArg) {
const { CustomModuleManager } = require('./modules/custom-module-manager');
const customMgr = new CustomModuleManager();
const allCodes = [];
const sources = sourcesArg
.split(',')
.map((s) => s.trim())
.filter(Boolean);
for (const source of sources) {
const s = await prompts.spinner();
s.start(`Resolving ${source}...`);
let sourceResult;
try {
sourceResult = await customMgr.resolveSource(source, { skipInstall: true, silent: true });
s.stop(sourceResult.parsed.type === 'local' ? 'Local source resolved' : 'Repository cloned');
} catch (error) {
s.error(`Failed to resolve ${source}`);
await prompts.log.error(` ${error.message}`);
continue;
}
const s2 = await prompts.spinner();
s2.start('Analyzing plugin structure...');
const allResolved = [];
const localPath = sourceResult.parsed.type === 'local' ? sourceResult.rootDir : null;
if (sourceResult.mode === 'discovery') {
try {
const plugins = await customMgr.discoverModules(sourceResult.marketplace, sourceResult.sourceUrl);
const effectiveRepoPath = sourceResult.repoPath || sourceResult.rootDir;
for (const plugin of plugins) {
try {
const resolved = await customMgr.resolvePlugin(effectiveRepoPath, plugin.rawPlugin, sourceResult.sourceUrl, localPath);
if (resolved.length > 0) {
allResolved.push(...resolved);
}
} catch {
// Skip unresolvable plugins
}
}
} catch (discoverError) {
s2.error('Failed to discover modules');
await prompts.log.error(` ${discoverError.message}`);
continue;
}
} else {
// Direct mode: scan for SKILL.md directories
const directPlugin = {
name: sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
source: '.',
skills: [],
};
try {
const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
if (await fs.pathExists(skillMd)) {
directPlugin.skills.push(entry.name);
}
}
}
} catch {
// Skip unreadable directories
}
if (directPlugin.skills.length > 0) {
try {
const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
allResolved.push(...resolved);
} catch {
// Skip unresolvable
}
}
}
s2.stop(`Found ${allResolved.length} module${allResolved.length === 1 ? '' : 's'}`);
for (const mod of allResolved) {
allCodes.push(mod.code);
const versionStr = mod.version ? ` v${mod.version}` : '';
await prompts.log.info(` Custom module: ${mod.name}${versionStr}`);
}
}
return allCodes;
}
/** /**
* Get default modules for non-interactive mode * Get default modules for non-interactive mode
* @param {Set} installedModuleIds - Already installed module IDs * @param {Set} installedModuleIds - Already installed module IDs