feat(installer): add --set and --list-options for non-interactive config (#1663)

`--set <module>.<key>=<value>` (repeatable) sets any module config option
non-interactively. Scales to every module without growing the CLI surface
per option, and persists into _bmad/config.toml so values survive upgrades.

`--list-options [module]` prints every available --set key for built-in
and locally-cached official modules (community/custom users read their own
module.yaml). Pass a module code to scope the listing.

Validation rules, all non-fatal:
- Module not in --modules → warn and drop the value.
- Key not declared in module.yaml → warn but persist (forward-compat).
  The manifest writer's schema-strict partition exempts these so they
  survive into config.toml even though the schema doesn't know them.
- Malformed --set syntax → exit non-zero up front.

The legacy core shortcuts (--user-name, --output-folder, etc.) remain
supported as aliases for `--set core.<key>=<value>`. --set with
--action quick-update is ignored with a warning since quick-update
preserves the existing answers by design.

Files:
- tools/installer/set-overrides.js (new): parser
- tools/installer/list-options.js (new): discovery + formatter
- tools/installer/commands/install.js: flags + early validation
- tools/installer/ui.js: parse, warn-on-unselected, thread to OfficialModules
- tools/installer/modules/official-modules.js: pre-fill answers, persist unknowns
- tools/installer/core/config.js + installer.js: carry setOverrideKeys through
- tools/installer/core/manifest-generator.js: partition exempts override keys
- test/test-installation-components.js: +15 cases (Suite 44)
- docs/how-to/install-bmad.md, README.md: --set as preferred non-interactive path

Closes #1663
This commit is contained in:
Brian Madison 2026-04-28 09:54:34 -05:00
parent 48a7ec8bff
commit f33d251790
11 changed files with 636 additions and 13 deletions

View File

@ -52,6 +52,15 @@ Follow the installer prompts, then open your AI IDE (Claude Code, Cursor, etc.)
npx bmad-method install --directory /path/to/project --modules bmm --tools claude-code --yes npx bmad-method install --directory /path/to/project --modules bmm --tools claude-code --yes
``` ```
Override any module config option with `--set <module>.<key>=<value>` (repeatable). Run `--list-options` to see every available key:
```bash
npx bmad-method install --yes \
--modules bmm --tools claude-code \
--set bmm.project_knowledge=research \
--set bmm.user_skill_level=expert
```
[See all installation options](https://docs.bmad-method.org/how-to/non-interactive-installation/) [See all installation options](https://docs.bmad-method.org/how-to/non-interactive-installation/)
> **Not sure what to do?** Ask `bmad-help` — it tells you exactly what's next and what's optional. You can also ask questions like `bmad-help I just finished the architecture, what do I do next?` > **Not sure what to do?** Ask `bmad-help` — it tells you exactly what's next and what's optional. You can also ask questions like `bmad-help I just finished the architecture, what do I do next?`

View File

@ -131,7 +131,9 @@ Under `--yes`, patch and minor upgrades apply automatically. Majors stay frozen
| `--all-next` | Alias for `--channel=next` | | `--all-next` | Alias for `--channel=next` |
| `--next=<code>` | Put one module on next. Repeatable. | | `--next=<code>` | Put one module on next. Repeatable. |
| `--pin <code>=<tag>` | Pin one module to a specific tag. Repeatable. | | `--pin <code>=<tag>` | Pin one module to a specific tag. Repeatable. |
| `--user-name`, `--communication-language`, `--document-output-language`, `--output-folder` | Override per-user config defaults | | `--set <module>.<key>=<value>` | Set any module config option non-interactively (preferred — see [Module config overrides](#module-config-overrides)). Repeatable. |
| `--list-options [module]` | Print every `--set` key for built-in and locally-cached official modules, then exit. Pass a module code to scope to one module. |
| `--user-name`, `--communication-language`, `--document-output-language`, `--output-folder` | Legacy shortcuts equivalent to `--set core.<key>=<value>` (still supported) |
Precedence when flags overlap: `--pin` beats `--next=` beats `--channel` / `--all-*` beats the registry default (`stable`). Precedence when flags overlap: `--pin` beats `--next=` beats `--channel` / `--all-*` beats the registry default (`stable`).
@ -179,6 +181,40 @@ npx bmad-method install --yes --action update \
--next=bmb --next=bmb
``` ```
### Module config overrides
`--set <module>.<key>=<value>` is the preferred way to provide answers that would otherwise be asked interactively. It's repeatable, scales to every module, and survives across upgrades because the values land in `_bmad/config.toml` next to the rest of your install state.
**Example — install bmm without using `docs/` for project knowledge:**
```bash
npx bmad-method install --yes \
--modules bmm \
--tools claude-code \
--set bmm.project_knowledge=research \
--set bmm.user_skill_level=expert
```
**Discover available keys for a module:**
```bash
npx bmad-method install --list-options bmm
```
`--list-options` (no argument) lists every key the installer can find locally — built-in modules (`core`, `bmm`) plus any external officials that have been installed at least once on this machine. Community and custom modules aren't enumerated here; read the module's `module.yaml` directly to see what keys it declares.
**Validation rules:**
- `<module>` must be in `--modules` (or core, which is always installed). Setting a value for a module you didn't include prints a warning and the value is ignored.
- `<key>` is matched against the module's `module.yaml` declarations. An unknown key prints a warning but still gets persisted to `config.toml` — useful for forward-compatibility or for community modules whose schema isn't validated here.
- `single-select` values are not validated against the allowed choices. The value lands in config as-is, even if it falls outside the module's enumeration.
The legacy core shortcuts (`--user-name`, `--output-folder`, etc.) still work and remain documented for backward compatibility, but `--set core.user_name=...` is equivalent and uses the same code path.
:::note[Quick-update is unaffected]
`bmad install --action quick-update` preserves the existing `config.toml` answers as-is. `--set` flags passed alongside `--action quick-update` are silently ignored. To change a stored value, run `bmad install --action update` (or just `bmad install`) instead.
:::
:::caution[Rate limit on shared IPs] :::caution[Rate limit on shared IPs]
Anonymous GitHub API calls are capped at 60/hour per IP. A single install hits the API once per external module to resolve the stable tag. Offices behind NAT, CI runner pools, and VPNs can collectively exhaust this. Anonymous GitHub API calls are capped at 60/hour per IP. A single install hits the API once per external module to resolve the stable tag. Offices behind NAT, CI runner pools, and VPNs can collectively exhaust this.

View File

@ -2983,6 +2983,115 @@ async function runTests() {
console.log(''); console.log('');
// ============================================================
// Test Suite 44: --set <module>.<key>=<value> CLI overrides (#1663)
// ============================================================
console.log(`${colors.yellow}Test Suite 44: --set CLI overrides${colors.reset}\n`);
try {
const { parseSetEntry, parseSetEntries } = require('../tools/installer/set-overrides');
const { discoverOfficialModuleYamls, formatOptionsList } = require('../tools/installer/list-options');
// parseSetEntry — happy path
const ok = parseSetEntry('bmm.project_knowledge=research');
assert(
ok.module === 'bmm' && ok.key === 'project_knowledge' && ok.value === 'research',
'parseSetEntry splits <module>.<key>=<value> correctly',
);
// parseSetEntry — value containing '='
const okEq = parseSetEntry('bmm.weird=a=b=c');
assert(okEq.value === 'a=b=c', 'parseSetEntry preserves additional "=" inside the value');
// parseSetEntry — malformed inputs
const badInputs = ['no-equals', 'no-dot=value', '=value', '.=value', 'foo.=value', '.bar=value', ''];
let allBadThrow = true;
for (const bad of badInputs) {
try {
parseSetEntry(bad);
allBadThrow = false;
} catch {
/* expected */
}
}
assert(allBadThrow, `parseSetEntry rejects malformed inputs (${badInputs.length} cases)`);
// parseSetEntries — multiple entries collapse into a {module: {key: value}} map
const multi = parseSetEntries(['bmm.project_knowledge=research', 'bmm.user_skill_level=expert', 'core.user_name=Brian']);
assert(
multi.bmm.project_knowledge === 'research' && multi.bmm.user_skill_level === 'expert' && multi.core.user_name === 'Brian',
'parseSetEntries groups by module',
);
// parseSetEntries — later entry wins for the same key
const later = parseSetEntries(['bmm.x=first', 'bmm.x=second']);
assert(later.bmm.x === 'second', 'parseSetEntries: later --set entry overrides earlier');
// parseSetEntries — non-array / missing input → empty object
const empty = parseSetEntries();
assert(empty && Object.keys(empty).length === 0, 'parseSetEntries() returns empty object when called without args');
// discoverOfficialModuleYamls includes core and bmm built-ins.
const discovered = await discoverOfficialModuleYamls();
const codes = new Set(discovered.map((d) => d.code));
assert(codes.has('core') && codes.has('bmm'), 'discoverOfficialModuleYamls finds core and bmm built-ins');
const coreEntry = discovered.find((d) => d.code === 'core');
assert(coreEntry && coreEntry.source === 'built-in', 'core is reported with source="built-in"');
// formatOptionsList rendering: bmm-only filter shows the project_knowledge key from issue #1663.
const bmmListing = await formatOptionsList('bmm');
assert(bmmListing.includes('bmm.project_knowledge'), '--list-options bmm renders bmm.project_knowledge');
assert(bmmListing.includes('bmm.user_skill_level'), '--list-options bmm renders bmm.user_skill_level');
assert(bmmListing.includes('beginner | intermediate | expert'), '--list-options renders single-select choices');
// formatOptionsList for an unknown module gives a helpful message, not "No modules found".
const unknownListing = await formatOptionsList('definitely-not-a-module');
assert(
unknownListing.includes("No locally-known module.yaml for 'definitely-not-a-module'"),
'--list-options handles unknown module gracefully',
);
// partition() in writeCentralConfig respects setOverrideKeys: an unknown key
// for a known schema must survive when the user asserted it via --set.
const tmp44 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-fixture-44-'));
const bmadDir44 = path.join(tmp44, '_bmad');
await fs.ensureDir(bmadDir44);
const mg = new ManifestGenerator({ ides: [] });
mg.updatedModules = ['core', 'bmm'];
const moduleConfigsForWrite = {
core: { user_name: 'Brian' },
bmm: { project_knowledge: '/proj/research', future_thing: 'pre-seeded' },
};
const setOverrideKeys = { bmm: ['future_thing'] };
await mg.writeCentralConfig(bmadDir44, moduleConfigsForWrite, setOverrideKeys);
const teamToml = await fs.readFile(path.join(bmadDir44, 'config.toml'), 'utf8');
assert(teamToml.includes('project_knowledge = "/proj/research"'), 'writeCentralConfig writes a known schema key');
assert(teamToml.includes('future_thing = "pre-seeded"'), 'writeCentralConfig keeps an unknown key listed in setOverrideKeys');
// Same fixture, no override → unknown key is dropped (control case).
const tmp44b = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-fixture-44b-'));
const bmadDir44b = path.join(tmp44b, '_bmad');
await fs.ensureDir(bmadDir44b);
const mg2 = new ManifestGenerator({ ides: [] });
mg2.updatedModules = ['core', 'bmm'];
await mg2.writeCentralConfig(bmadDir44b, moduleConfigsForWrite, {});
const teamToml2 = await fs.readFile(path.join(bmadDir44b, 'config.toml'), 'utf8');
assert(
!teamToml2.includes('future_thing'),
'writeCentralConfig drops an unknown key when not asserted via --set (schema-strict default holds)',
);
await fs.remove(tmp44).catch(() => {});
await fs.remove(tmp44b).catch(() => {});
} catch (error) {
console.log(`${colors.red}Test Suite 44 setup failed: ${error.message}${colors.reset}`);
console.log(error.stack);
failed++;
}
console.log('');
// ============================================================ // ============================================================
// Summary // Summary
// ============================================================ // ============================================================

View File

@ -18,6 +18,16 @@ module.exports = {
'Comma-separated list of tool/IDE IDs to configure (e.g., "claude-code,cursor"). Required for fresh non-interactive (--yes) installs. Run with --list-tools to see all valid IDs.', 'Comma-separated list of tool/IDE IDs to configure (e.g., "claude-code,cursor"). Required for fresh non-interactive (--yes) installs. Run with --list-tools to see all valid IDs.',
], ],
['--list-tools', 'Print all supported tool/IDE IDs (with target directories) and exit.'], ['--list-tools', 'Print all supported tool/IDE IDs (with target directories) and exit.'],
[
'--set <key=value>',
'Set a module config option non-interactively. Format: <module>.<key>=<value> (e.g. bmm.project_knowledge=research). Repeatable. Run --list-options to see available keys.',
(value, prev) => [...(prev || []), value],
[],
],
[
'--list-options [module]',
'List available --set keys for all locally-known official modules, or for a single module by code, then exit.',
],
['--action <type>', 'Action type for existing installations: install, update, or quick-update'], ['--action <type>', 'Action type for existing installations: install, update, or quick-update'],
['--user-name <name>', 'Name for agents to use (default: system username)'], ['--user-name <name>', 'Name for agents to use (default: system username)'],
['--communication-language <lang>', 'Language for agent communication (default: English)'], ['--communication-language <lang>', 'Language for agent communication (default: English)'],
@ -47,12 +57,32 @@ module.exports = {
process.exit(0); process.exit(0);
} }
if (options.listOptions !== undefined) {
const { formatOptionsList } = require('../list-options');
const moduleArg = options.listOptions === true ? null : options.listOptions;
process.stdout.write((await formatOptionsList(moduleArg)) + '\n');
process.exit(0);
}
// Set debug flag as environment variable for all components // Set debug flag as environment variable for all components
if (options.debug) { if (options.debug) {
process.env.BMAD_DEBUG_MANIFEST = 'true'; process.env.BMAD_DEBUG_MANIFEST = 'true';
await prompts.log.info('Debug mode enabled'); await prompts.log.info('Debug mode enabled');
} }
// Validate --set syntax up-front so malformed entries fail fast,
// before we touch the network or filesystem. Parsed entries are
// re-derived inside ui.js where overrides are seeded.
if (options.set && options.set.length > 0) {
const { parseSetEntries } = require('../set-overrides');
try {
parseSetEntries(options.set);
} catch (error) {
await prompts.log.error(error.message);
process.exit(1);
}
}
const config = await ui.promptInstall(options); const config = await ui.promptInstall(options);
// Handle cancel // Handle cancel
@ -63,6 +93,11 @@ module.exports = {
// Handle quick update separately // Handle quick update separately
if (config.actionType === 'quick-update') { if (config.actionType === 'quick-update') {
if (options.set && options.set.length > 0) {
await prompts.log.warn(
'--set flags are ignored under quick-update (it preserves existing answers). Re-run with --action update to apply them.',
);
}
const result = await installer.quickUpdate(config); const result = await installer.quickUpdate(config);
await prompts.log.success('Quick update complete!'); await prompts.log.success('Quick update complete!');
await prompts.log.info(`Updated ${result.moduleCount} modules with preserved settings (${result.modules.join(', ')})`); await prompts.log.info(`Updated ${result.moduleCount} modules with preserved settings (${result.modules.join(', ')})`);

View File

@ -3,7 +3,19 @@
* User input comes from either UI answers or headless CLI flags. * User input comes from either UI answers or headless CLI flags.
*/ */
class Config { class Config {
constructor({ directory, modules, ides, skipPrompts, verbose, actionType, coreConfig, moduleConfigs, quickUpdate, channelOptions }) { constructor({
directory,
modules,
ides,
skipPrompts,
verbose,
actionType,
coreConfig,
moduleConfigs,
quickUpdate,
channelOptions,
setOverrideKeys,
}) {
this.directory = directory; this.directory = directory;
this.modules = Object.freeze([...modules]); this.modules = Object.freeze([...modules]);
this.ides = Object.freeze([...ides]); this.ides = Object.freeze([...ides]);
@ -15,6 +27,11 @@ class Config {
this._quickUpdate = quickUpdate; this._quickUpdate = quickUpdate;
// channelOptions carry a Map + Set; don't deep-freeze. // channelOptions carry a Map + Set; don't deep-freeze.
this.channelOptions = channelOptions || null; this.channelOptions = channelOptions || null;
// Per-module list of keys originating from `--set <module>.<key>=<value>`
// that are NOT in the module's prompt schema. The manifest writer keeps
// these through the schema-strict partition so user-asserted overrides
// survive into config.toml even when the schema doesn't declare them.
this.setOverrideKeys = setOverrideKeys || {};
Object.freeze(this); Object.freeze(this);
} }
@ -40,6 +57,7 @@ class Config {
moduleConfigs: userInput.moduleConfigs || null, moduleConfigs: userInput.moduleConfigs || null,
quickUpdate: userInput._quickUpdate || false, quickUpdate: userInput._quickUpdate || false,
channelOptions: userInput.channelOptions || null, channelOptions: userInput.channelOptions || null,
setOverrideKeys: userInput.setOverrideKeys || {},
}); });
} }

View File

@ -308,6 +308,7 @@ class Installer {
ides: config.ides || [], ides: config.ides || [],
preservedModules: modulesForCsvPreserve, preservedModules: modulesForCsvPreserve,
moduleConfigs, moduleConfigs,
setOverrideKeys: config.setOverrideKeys || {},
}); });
message('Generating help catalog...'); message('Generating help catalog...');

View File

@ -81,7 +81,11 @@ class ManifestGenerator {
await this.collectAgentsFromModuleYaml(); await this.collectAgentsFromModuleYaml();
// Write manifest files and collect their paths // Write manifest files and collect their paths
const [teamConfigPath, userConfigPath] = await this.writeCentralConfig(bmadDir, options.moduleConfigs || {}); const [teamConfigPath, userConfigPath] = await this.writeCentralConfig(
bmadDir,
options.moduleConfigs || {},
options.setOverrideKeys || {},
);
const manifestFiles = [ const manifestFiles = [
await this.writeMainManifest(cfgDir), await this.writeMainManifest(cfgDir),
await this.writeSkillManifest(cfgDir), await this.writeSkillManifest(cfgDir),
@ -425,7 +429,7 @@ class ManifestGenerator {
* _bmad/custom/config.toml and _bmad/custom/config.user.toml (never touched by installer). * _bmad/custom/config.toml and _bmad/custom/config.user.toml (never touched by installer).
* @returns {string[]} Paths to the written config files * @returns {string[]} Paths to the written config files
*/ */
async writeCentralConfig(bmadDir, moduleConfigs) { async writeCentralConfig(bmadDir, moduleConfigs, setOverrideKeys = {}) {
const teamPath = path.join(bmadDir, 'config.toml'); const teamPath = path.join(bmadDir, 'config.toml');
const userPath = path.join(bmadDir, 'config.user.toml'); const userPath = path.join(bmadDir, 'config.user.toml');
@ -474,17 +478,20 @@ class ManifestGenerator {
// Partition a module's answered config into team vs user buckets. // Partition a module's answered config into team vs user buckets.
// For non-core modules: strip core keys always; when we know the module's // For non-core modules: strip core keys always; when we know the module's
// own schema, also drop keys it doesn't declare. Unknown-schema modules // own schema, also drop keys it doesn't declare — unless the user
// (external / marketplace) fall through with their remaining answers as // asserted them via `--set <module>.<key>=<value>`, in which case we
// team so they don't vanish from the config. // keep them (warn-and-write semantics from issue #1663). Unknown-schema
// modules (external / marketplace) fall through with their remaining
// answers as team so they don't vanish from the config.
const partition = (moduleName, cfg, onlyDeclaredKeys = false) => { const partition = (moduleName, cfg, onlyDeclaredKeys = false) => {
const team = {}; const team = {};
const user = {}; const user = {};
const scopes = scopeByModuleKey[moduleName] || {}; const scopes = scopeByModuleKey[moduleName] || {};
const isCore = moduleName === 'core'; const isCore = moduleName === 'core';
const overrideKeys = new Set(setOverrideKeys[moduleName] || []);
for (const [key, value] of Object.entries(cfg || {})) { for (const [key, value] of Object.entries(cfg || {})) {
if (!isCore && coreKeys.has(key)) continue; if (!isCore && coreKeys.has(key)) continue;
if (onlyDeclaredKeys && !(key in scopes)) continue; if (onlyDeclaredKeys && !(key in scopes) && !overrideKeys.has(key)) continue;
if (scopes[key] === 'user') { if (scopes[key] === 'user') {
user[key] = value; user[key] = value;
} else { } else {

View File

@ -0,0 +1,184 @@
const path = require('node:path');
const fs = require('./fs-native');
const yaml = require('yaml');
const { getProjectRoot, getModulePath, getExternalModuleCachePath } = require('./project-root');
/**
* Read a module.yaml and return its declared `code:` field, or null if missing/unparseable.
*/
async function readModuleCode(yamlPath) {
try {
const parsed = yaml.parse(await fs.readFile(yamlPath, 'utf8'));
if (parsed && typeof parsed === 'object' && typeof parsed.code === 'string') {
return parsed.code;
}
} catch {
// fall through
}
return null;
}
/**
* Discover module.yaml files for officials we can read locally:
* - core, bmm: bundled in src/ (always present)
* - external officials: only if previously cloned to ~/.bmad/cache/external-modules/
*
* Each result's `code` is the `code:` field from the module.yaml when present;
* that's the value `--set <module>.<key>=<value>` matches against.
*
* Community/custom modules are not enumerated; users reference their own
* module.yaml directly per the design (see issue #1663).
*
* @returns {Promise<Array<{code: string, yamlPath: string, source: string}>>}
*/
async function discoverOfficialModuleYamls() {
const found = [];
const seenCodes = new Set();
const addFound = async (yamlPath, source, fallbackCode) => {
const declaredCode = await readModuleCode(yamlPath);
const code = declaredCode || fallbackCode;
if (!code) return;
const lower = code.toLowerCase();
if (seenCodes.has(lower)) return;
seenCodes.add(lower);
found.push({ code, yamlPath, source });
};
// Built-ins.
for (const code of ['core', 'bmm']) {
const yamlPath = path.join(getModulePath(code), 'module.yaml');
if (await fs.pathExists(yamlPath)) {
// Built-ins use their well-known short codes regardless of what the
// module.yaml `code:` says, since the install flow keys on these.
seenCodes.add(code.toLowerCase());
found.push({ code, yamlPath, source: 'built-in' });
}
}
// Bundled in src/modules/<code>/module.yaml (rare, but supported by getModulePath).
const srcModulesDir = path.join(getProjectRoot(), 'src', 'modules');
if (await fs.pathExists(srcModulesDir)) {
const entries = await fs.readdir(srcModulesDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const yamlPath = path.join(srcModulesDir, entry.name, 'module.yaml');
if (await fs.pathExists(yamlPath)) {
await addFound(yamlPath, 'bundled', entry.name);
}
}
}
// External cache (~/.bmad/cache/external-modules/<code>/...).
const cacheRoot = getExternalModuleCachePath('').replace(/\/$/, '');
if (await fs.pathExists(cacheRoot)) {
const rawEntries = await fs.readdir(cacheRoot, { withFileTypes: true });
for (const entry of rawEntries) {
if (!entry.isDirectory()) continue;
const candidates = [
path.join(cacheRoot, entry.name, 'module.yaml'),
path.join(cacheRoot, entry.name, 'src', 'module.yaml'),
path.join(cacheRoot, entry.name, 'skills', 'module.yaml'),
];
for (const candidate of candidates) {
if (await fs.pathExists(candidate)) {
await addFound(candidate, 'cached', entry.name);
break;
}
}
}
}
return found;
}
function formatPromptText(item) {
if (Array.isArray(item.prompt)) return item.prompt.join(' ');
return String(item.prompt || '').trim();
}
function inferType(item) {
if (item['single-select']) return 'single-select';
if (item['multi-select']) return 'multi-select';
if (typeof item.default === 'boolean') return 'boolean';
if (typeof item.default === 'number') return 'number';
return 'string';
}
function formatModuleOptions(code, parsed, source) {
const lines = [];
const header = source === 'built-in' ? code : `${code} (${source})`;
lines.push(header + ':');
let count = 0;
for (const [key, item] of Object.entries(parsed)) {
if (!item || typeof item !== 'object' || !('prompt' in item)) continue;
count++;
const type = inferType(item);
const scope = item.scope === 'user' ? ' [user-scope]' : '';
const defaultStr = item.default === undefined || item.default === null ? '(none)' : String(item.default);
lines.push(` ${code}.${key} (${type}${scope}) default: ${defaultStr}`);
const promptText = formatPromptText(item);
if (promptText) lines.push(` ${promptText}`);
if (item['single-select']) {
const values = item['single-select'].map((v) => (typeof v === 'object' ? v.value : v)).filter((v) => v !== undefined);
if (values.length > 0) lines.push(` values: ${values.join(' | ')}`);
}
lines.push('');
}
if (count === 0) {
lines.push(' (no configurable options)', '');
}
return lines.join('\n');
}
/**
* Render `--list-options` output.
* @param {string|null} moduleCode - if non-null, restrict to this module
* @returns {Promise<string>}
*/
async function formatOptionsList(moduleCode) {
const discovered = await discoverOfficialModuleYamls();
const filtered = moduleCode ? discovered.filter((d) => d.code === moduleCode) : discovered;
if (filtered.length === 0) {
if (moduleCode) {
return [
`No locally-known module.yaml for '${moduleCode}'.`,
'',
'Built-in modules (core, bmm) are always available. External officials',
'appear here after they have been installed at least once on this machine',
'(they are cached under ~/.bmad/cache/external-modules/).',
'',
'For community or custom modules, read the module.yaml file in that',
"module's source repository directly.",
].join('\n');
}
return 'No modules found.';
}
const sections = [];
sections.push('Available --set keys', 'Format: --set <module>.<key>=<value> (repeatable)', '');
for (const { code, yamlPath, source } of filtered) {
let parsed;
try {
parsed = yaml.parse(await fs.readFile(yamlPath, 'utf8'));
} catch {
sections.push(`${code} (${source}): could not parse module.yaml`, '');
continue;
}
if (!parsed || typeof parsed !== 'object') continue;
sections.push(formatModuleOptions(code, parsed, source));
}
if (!moduleCode) {
sections.push(
'Community and custom modules are not listed here — read their module.yaml directly. Unknown keys still persist with a warning.',
);
}
return sections.join('\n');
}
module.exports = { formatOptionsList, discoverOfficialModuleYamls };

View File

@ -20,6 +20,11 @@ class OfficialModules {
// pre-install config collection and the install step agree on which ref // pre-install config collection and the install step agree on which ref
// to clone. // to clone.
this.channelOptions = options.channelOptions || null; this.channelOptions = options.channelOptions || null;
// Per-module CLI overrides from `--set <module>.<key>=<value>`.
// Shape: { moduleCode: { key: rawStringValue } }. Keys matching a
// declared prompt skip the prompt; unknown keys are persisted with
// a warning so future / community modules can opt in.
this.setOverrides = options.setOverrides || {};
} }
/** /**
@ -1497,6 +1502,7 @@ class OfficialModules {
const questions = []; const questions = [];
const staticAnswers = {}; const staticAnswers = {};
const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt'); const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt');
const declaredPromptKeys = new Set();
for (const key of configKeys) { for (const key of configKeys) {
const item = moduleConfig[key]; const item = moduleConfig[key];
@ -1515,6 +1521,7 @@ class OfficialModules {
// Handle interactive values (with prompt) // Handle interactive values (with prompt)
if (item.prompt) { if (item.prompt) {
declaredPromptKeys.add(key);
const question = await this.buildQuestion(moduleName, key, item, moduleConfig); const question = await this.buildQuestion(moduleName, key, item, moduleConfig);
if (question) { if (question) {
questions.push(question); questions.push(question);
@ -1522,8 +1529,49 @@ class OfficialModules {
} }
} }
// Collect all answers (static + prompted) // Apply --set <module>.<key>=<value> overrides for this module.
// - Known prompt key → answer pre-filled, prompt skipped (interactive + --yes).
// - Unknown prompt key → warn, then write directly to collectedConfig at end of
// this method. The corresponding key is also tracked on this.setOverrideKeys
// so writeCentralConfig knows to keep it through the schema-strict partition.
const moduleOverrides = this.setOverrides[moduleName] || {};
const seededOverrideKeys = new Set();
const unknownOverrideKeys = [];
for (const [overrideKey, overrideValue] of Object.entries(moduleOverrides)) {
if (declaredPromptKeys.has(overrideKey)) {
seededOverrideKeys.add(overrideKey);
} else {
unknownOverrideKeys.push([overrideKey, overrideValue]);
}
}
if (unknownOverrideKeys.length > 0) {
for (const [overrideKey] of unknownOverrideKeys) {
await prompts.log.warn(
`--set ${moduleName}.${overrideKey} — '${overrideKey}' is not a declared config key for module '${moduleName}'; persisted but unused by current install.`,
);
}
}
// Collect all answers (static + prompted). Pre-seed override answers
// so the prompt loop and skipPrompts path both see them as already-set.
let allAnswers = { ...staticAnswers }; let allAnswers = { ...staticAnswers };
for (const key of seededOverrideKeys) {
allAnswers[`${moduleName}_${key}`] = moduleOverrides[key];
}
// Drop pre-seeded questions so the user is not re-prompted and so
// skipPrompts mode doesn't overwrite the override with the default.
const remainingQuestions = questions.filter((q) => {
const key = q.name.replace(`${moduleName}_`, '');
return !seededOverrideKeys.has(key);
});
questions.length = 0;
questions.push(...remainingQuestions);
if (seededOverrideKeys.size > 0 && !this._silentConfig) {
const list = [...seededOverrideKeys].map((k) => `${moduleName}.${k}`).join(', ');
await prompts.log.info(`Applying --set overrides: ${list}`);
}
// If there are questions to ask, prompt for accepting defaults vs customizing // If there are questions to ask, prompt for accepting defaults vs customizing
if (questions.length > 0) { if (questions.length > 0) {
@ -1730,6 +1778,19 @@ class OfficialModules {
} }
} }
// Persist any unknown --set keys for this module (warn-and-write semantics).
// The keys are also tracked so writeCentralConfig keeps them through the
// schema-strict partition for officials.
if (unknownOverrideKeys.length > 0) {
if (!this.collectedConfig[moduleName]) this.collectedConfig[moduleName] = {};
if (!this.setOverrideKeys) this.setOverrideKeys = {};
if (!this.setOverrideKeys[moduleName]) this.setOverrideKeys[moduleName] = new Set();
for (const [overrideKey, overrideValue] of unknownOverrideKeys) {
this.collectedConfig[moduleName][overrideKey] = overrideValue;
this.setOverrideKeys[moduleName].add(overrideKey);
}
}
await this.displayModulePostConfigNotes(moduleName, moduleConfig); await this.displayModulePostConfigNotes(moduleName, moduleConfig);
} }

View File

@ -0,0 +1,95 @@
const path = require('node:path');
const fs = require('./fs-native');
const yaml = require('yaml');
const { getModulePath, getExternalModuleCachePath } = require('./project-root');
/**
* Parse a single `--set <module>.<key>=<value>` entry.
* @param {string} entry - raw flag value
* @returns {{module: string, key: string, value: string}}
* @throws {Error} on malformed input
*/
function parseSetEntry(entry) {
if (typeof entry !== 'string' || entry.length === 0) {
throw new Error('--set: empty entry. Expected <module>.<key>=<value>');
}
const eq = entry.indexOf('=');
if (eq === -1) {
throw new Error(`--set "${entry}": missing '='. Expected <module>.<key>=<value>`);
}
const lhs = entry.slice(0, eq);
const value = entry.slice(eq + 1);
const dot = lhs.indexOf('.');
if (dot === -1) {
throw new Error(`--set "${entry}": missing '.'. Expected <module>.<key>=<value>`);
}
const moduleCode = lhs.slice(0, dot).trim();
const key = lhs.slice(dot + 1).trim();
if (!moduleCode || !key) {
throw new Error(`--set "${entry}": empty module or key. Expected <module>.<key>=<value>`);
}
return { module: moduleCode, key, value };
}
/**
* Parse repeated `--set` entries into a `{ module: { key: value } }` map.
* Later entries overwrite earlier ones for the same key.
* @param {string[]} entries
* @returns {Object<string, Object<string, string>>}
*/
function parseSetEntries(entries) {
const overrides = {};
if (!Array.isArray(entries)) return overrides;
for (const entry of entries) {
const { module: moduleCode, key, value } = parseSetEntry(entry);
if (!overrides[moduleCode]) overrides[moduleCode] = {};
overrides[moduleCode][key] = value;
}
return overrides;
}
/**
* Find a module's source `module.yaml` for officials only.
* Returns null for community/custom; we don't validate those.
* @param {string} moduleCode
* @returns {Promise<string|null>}
*/
async function findOfficialModuleYaml(moduleCode) {
const builtIn = path.join(getModulePath(moduleCode), 'module.yaml');
if (await fs.pathExists(builtIn)) return builtIn;
const externalRoot = getExternalModuleCachePath(moduleCode);
if (!(await fs.pathExists(externalRoot))) return null;
const candidates = [
path.join(externalRoot, 'module.yaml'),
path.join(externalRoot, 'src', 'module.yaml'),
path.join(externalRoot, 'skills', 'module.yaml'),
];
for (const candidate of candidates) {
if (await fs.pathExists(candidate)) return candidate;
}
return null;
}
/**
* Read declared config keys (those with a `prompt:`) from a module.yaml.
* Returns a Set of key names, or null if the file can't be read.
*/
async function readDeclaredKeys(moduleYamlPath) {
try {
const parsed = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
if (!parsed || typeof parsed !== 'object') return null;
const keys = new Set();
for (const [key, value] of Object.entries(parsed)) {
if (value && typeof value === 'object' && 'prompt' in value) {
keys.add(key);
}
}
return keys;
} catch {
return null;
}
}
module.exports = { parseSetEntry, parseSetEntries, findOfficialModuleYaml, readDeclaredKeys };

View File

@ -16,6 +16,7 @@ const {
} = require('./modules/channel-plan'); } = require('./modules/channel-plan');
const channelResolver = require('./modules/channel-resolver'); const channelResolver = require('./modules/channel-resolver');
const prompts = require('./prompts'); const prompts = require('./prompts');
const { parseSetEntries } = require('./set-overrides');
const manifest = new Manifest(); const manifest = new Manifest();
@ -287,7 +288,7 @@ class UI {
// Get tool selection // Get tool selection
const toolSelection = await this.promptToolSelection(confirmedDirectory, options); const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, { const { moduleConfigs, setOverrideKeys } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
...options, ...options,
channelOptions, channelOptions,
}); });
@ -313,6 +314,7 @@ class UI {
skipIde: toolSelection.skipIde, skipIde: toolSelection.skipIde,
coreConfig: moduleConfigs.core || {}, coreConfig: moduleConfigs.core || {},
moduleConfigs: moduleConfigs, moduleConfigs: moduleConfigs,
setOverrideKeys,
skipPrompts: options.yes || false, skipPrompts: options.yes || false,
channelOptions, channelOptions,
}; };
@ -364,7 +366,7 @@ class UI {
await this._interactiveChannelGate({ options, channelOptions, selectedModules }); await this._interactiveChannelGate({ options, channelOptions, selectedModules });
let toolSelection = await this.promptToolSelection(confirmedDirectory, options); let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, { const { moduleConfigs, setOverrideKeys } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
...options, ...options,
channelOptions, channelOptions,
}); });
@ -390,6 +392,7 @@ class UI {
skipIde: toolSelection.skipIde, skipIde: toolSelection.skipIde,
coreConfig: moduleConfigs.core || {}, coreConfig: moduleConfigs.core || {},
moduleConfigs: moduleConfigs, moduleConfigs: moduleConfigs,
setOverrideKeys,
skipPrompts: options.yes || false, skipPrompts: options.yes || false,
channelOptions, channelOptions,
}; };
@ -709,7 +712,27 @@ class UI {
*/ */
async collectModuleConfigs(directory, modules, options = {}) { async collectModuleConfigs(directory, modules, options = {}) {
const { OfficialModules } = require('./modules/official-modules'); const { OfficialModules } = require('./modules/official-modules');
const configCollector = new OfficialModules({ channelOptions: options.channelOptions });
// Parse --set entries up front so we can both (a) hand them to the config
// collector to skip prompts, and (b) warn about modules referenced in --set
// that aren't part of this install (those values are dropped, not persisted).
let setOverrides = {};
try {
setOverrides = parseSetEntries(options.set || []);
} catch (error) {
// install.js validated already; rethrow as-is for the user.
throw error;
}
const selectedModuleSet = new Set(['core', ...modules]);
for (const moduleCode of Object.keys(setOverrides)) {
if (!selectedModuleSet.has(moduleCode)) {
await prompts.log.warn(
`--set ${moduleCode}.* — module '${moduleCode}' is not in the install set; values will be ignored. Add it to --modules to apply.`,
);
}
}
const configCollector = new OfficialModules({ channelOptions: options.channelOptions, setOverrides });
// Seed core config from CLI options if provided // Seed core config from CLI options if provided
if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) { if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) {
@ -774,7 +797,52 @@ class UI {
skipPrompts: options.yes || false, skipPrompts: options.yes || false,
}); });
return configCollector.collectedConfig; // Apply --set overrides for `core` AFTER collectAllConfigurations, since
// core is skipped when its config was seeded by `--yes` defaults or by
// legacy core-shortcut flags (--user-name/--output-folder/etc.). Without
// this step those override values would be silently dropped. Core
// result templates are all `{value}` (or `{project-root}/{value}` for
// output_folder, which the existing flag handling also writes raw),
// so writing the raw value matches the legacy shortcut semantics.
const coreOverrides = setOverrides.core || {};
if (Object.keys(coreOverrides).length > 0) {
if (!configCollector.collectedConfig.core) configCollector.collectedConfig.core = {};
for (const [key, value] of Object.entries(coreOverrides)) {
configCollector.collectedConfig.core[key] = value;
}
const yaml = require('yaml');
const { getProjectRoot } = require('./project-root');
const coreSchemaPath = path.join(getProjectRoot(), 'src', 'core-skills', 'module.yaml');
let coreSchema = null;
try {
coreSchema = yaml.parse(await fs.readFile(coreSchemaPath, 'utf8'));
} catch {
// schema unavailable — skip key-existence validation
}
if (coreSchema) {
if (!configCollector.setOverrideKeys) configCollector.setOverrideKeys = {};
if (!configCollector.setOverrideKeys.core) configCollector.setOverrideKeys.core = new Set();
for (const key of Object.keys(coreOverrides)) {
if (!(key in coreSchema)) {
await prompts.log.warn(
`--set core.${key} — '${key}' is not a declared config key for module 'core'; persisted but unused by current install.`,
);
configCollector.setOverrideKeys.core.add(key);
}
}
}
}
// Convert per-module override-key Sets to plain string arrays so the value
// round-trips cleanly through Config.build / freezing.
const setOverrideKeys = {};
if (configCollector.setOverrideKeys) {
for (const [moduleCode, keys] of Object.entries(configCollector.setOverrideKeys)) {
setOverrideKeys[moduleCode] = [...keys];
}
}
return { moduleConfigs: configCollector.collectedConfig, setOverrideKeys };
} }
/** /**