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
```
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/)
> **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` |
| `--next=<code>` | Put one module on next. 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`).
@ -179,6 +181,40 @@ npx bmad-method install --yes --action update \
--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]
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('');
// ============================================================
// 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
// ============================================================

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.',
],
['--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'],
['--user-name <name>', 'Name for agents to use (default: system username)'],
['--communication-language <lang>', 'Language for agent communication (default: English)'],
@ -47,12 +57,32 @@ module.exports = {
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
if (options.debug) {
process.env.BMAD_DEBUG_MANIFEST = 'true';
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);
// Handle cancel
@ -63,6 +93,11 @@ module.exports = {
// Handle quick update separately
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);
await prompts.log.success('Quick update complete!');
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.
*/
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.modules = Object.freeze([...modules]);
this.ides = Object.freeze([...ides]);
@ -15,6 +27,11 @@ class Config {
this._quickUpdate = quickUpdate;
// channelOptions carry a Map + Set; don't deep-freeze.
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);
}
@ -40,6 +57,7 @@ class Config {
moduleConfigs: userInput.moduleConfigs || null,
quickUpdate: userInput._quickUpdate || false,
channelOptions: userInput.channelOptions || null,
setOverrideKeys: userInput.setOverrideKeys || {},
});
}

View File

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

View File

@ -81,7 +81,11 @@ class ManifestGenerator {
await this.collectAgentsFromModuleYaml();
// 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 = [
await this.writeMainManifest(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).
* @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 userPath = path.join(bmadDir, 'config.user.toml');
@ -474,17 +478,20 @@ class ManifestGenerator {
// 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
// own schema, also drop keys it doesn't declare. Unknown-schema modules
// (external / marketplace) fall through with their remaining answers as
// team so they don't vanish from the config.
// own schema, also drop keys it doesn't declare — unless the user
// asserted them via `--set <module>.<key>=<value>`, in which case we
// 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 team = {};
const user = {};
const scopes = scopeByModuleKey[moduleName] || {};
const isCore = moduleName === 'core';
const overrideKeys = new Set(setOverrideKeys[moduleName] || []);
for (const [key, value] of Object.entries(cfg || {})) {
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') {
user[key] = value;
} 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
// to clone.
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 staticAnswers = {};
const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt');
const declaredPromptKeys = new Set();
for (const key of configKeys) {
const item = moduleConfig[key];
@ -1515,6 +1521,7 @@ class OfficialModules {
// Handle interactive values (with prompt)
if (item.prompt) {
declaredPromptKeys.add(key);
const question = await this.buildQuestion(moduleName, key, item, moduleConfig);
if (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 };
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 (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);
}

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');
const channelResolver = require('./modules/channel-resolver');
const prompts = require('./prompts');
const { parseSetEntries } = require('./set-overrides');
const manifest = new Manifest();
@ -287,7 +288,7 @@ class UI {
// Get tool selection
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
const { moduleConfigs, setOverrideKeys } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
...options,
channelOptions,
});
@ -313,6 +314,7 @@ class UI {
skipIde: toolSelection.skipIde,
coreConfig: moduleConfigs.core || {},
moduleConfigs: moduleConfigs,
setOverrideKeys,
skipPrompts: options.yes || false,
channelOptions,
};
@ -364,7 +366,7 @@ class UI {
await this._interactiveChannelGate({ options, channelOptions, selectedModules });
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
const { moduleConfigs, setOverrideKeys } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
...options,
channelOptions,
});
@ -390,6 +392,7 @@ class UI {
skipIde: toolSelection.skipIde,
coreConfig: moduleConfigs.core || {},
moduleConfigs: moduleConfigs,
setOverrideKeys,
skipPrompts: options.yes || false,
channelOptions,
};
@ -709,7 +712,27 @@ class UI {
*/
async collectModuleConfigs(directory, modules, options = {}) {
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
if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) {
@ -774,7 +797,52 @@ class UI {
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 };
}
/**