diff --git a/README.md b/README.md index c9fb503e2..f64b7186c 100644 --- a/README.md +++ b/README.md @@ -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 .=` (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?` diff --git a/docs/how-to/install-bmad.md b/docs/how-to/install-bmad.md index 6651143d6..4fbca560b 100644 --- a/docs/how-to/install-bmad.md +++ b/docs/how-to/install-bmad.md @@ -131,7 +131,9 @@ Under `--yes`, patch and minor upgrades apply automatically. Majors stay frozen | `--all-next` | Alias for `--channel=next` | | `--next=` | Put one module on next. Repeatable. | | `--pin =` | Pin one module to a specific tag. Repeatable. | -| `--user-name`, `--communication-language`, `--document-output-language`, `--output-folder` | Override per-user config defaults | +| `--set .=` | 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.=` (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 .=` 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:** + +- `` 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. +- `` 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. diff --git a/test/test-installation-components.js b/test/test-installation-components.js index a8bf77756..2facf8aed 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -2983,6 +2983,115 @@ async function runTests() { console.log(''); + // ============================================================ + // Test Suite 44: --set .= 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 .= 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 // ============================================================ diff --git a/tools/installer/commands/install.js b/tools/installer/commands/install.js index 55adcfb9c..d752b0471 100644 --- a/tools/installer/commands/install.js +++ b/tools/installer/commands/install.js @@ -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 ', + 'Set a module config option non-interactively. Format: .= (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 ', 'Action type for existing installations: install, update, or quick-update'], ['--user-name ', 'Name for agents to use (default: system username)'], ['--communication-language ', '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(', ')})`); diff --git a/tools/installer/core/config.js b/tools/installer/core/config.js index bc359fed9..6b5907461 100644 --- a/tools/installer/core/config.js +++ b/tools/installer/core/config.js @@ -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 .=` + // 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 || {}, }); } diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index b91ba6bb7..748b1b6f5 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -308,6 +308,7 @@ class Installer { ides: config.ides || [], preservedModules: modulesForCsvPreserve, moduleConfigs, + setOverrideKeys: config.setOverrideKeys || {}, }); message('Generating help catalog...'); diff --git a/tools/installer/core/manifest-generator.js b/tools/installer/core/manifest-generator.js index f7b5d0084..8993b1d40 100644 --- a/tools/installer/core/manifest-generator.js +++ b/tools/installer/core/manifest-generator.js @@ -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 .=`, 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 { diff --git a/tools/installer/list-options.js b/tools/installer/list-options.js new file mode 100644 index 000000000..37742143e --- /dev/null +++ b/tools/installer/list-options.js @@ -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 .=` matches against. + * + * Community/custom modules are not enumerated; users reference their own + * module.yaml directly per the design (see issue #1663). + * + * @returns {Promise>} + */ +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//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//...). + 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} + */ +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 .= (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 }; diff --git a/tools/installer/modules/official-modules.js b/tools/installer/modules/official-modules.js index 615daba86..964e5d248 100644 --- a/tools/installer/modules/official-modules.js +++ b/tools/installer/modules/official-modules.js @@ -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 .=`. + // 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 .= 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); } diff --git a/tools/installer/set-overrides.js b/tools/installer/set-overrides.js new file mode 100644 index 000000000..b5db532ae --- /dev/null +++ b/tools/installer/set-overrides.js @@ -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 .=` 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 .='); + } + const eq = entry.indexOf('='); + if (eq === -1) { + throw new Error(`--set "${entry}": missing '='. Expected .=`); + } + 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 .=`); + } + 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 .=`); + } + 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>} + */ +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} + */ +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 }; diff --git a/tools/installer/ui.js b/tools/installer/ui.js index 12501b3f2..22b94ec7e 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -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 }; } /**