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:
parent
48a7ec8bff
commit
f33d251790
|
|
@ -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?`
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -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(', ')})`);
|
||||||
|
|
|
||||||
|
|
@ -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 || {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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...');
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue