bmad-plus 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +108 -30
  2. package/README.md +23 -23
  3. package/SECURITY.md +9 -1
  4. package/osint-agent-package/README.md +13 -0
  5. package/osint-agent-package/SETUP_KEYS.md +13 -0
  6. package/package.json +32 -8
  7. package/readme-international/README.de.md +133 -542
  8. package/readme-international/README.es.md +135 -561
  9. package/readme-international/README.fr.md +134 -558
  10. package/src/bmad-plus/agents/agent-shadow/SKILL.md +6 -0
  11. package/src/bmad-plus/packs/pack-memory/README.md +1 -1
  12. package/src/bmad-plus/packs/pack-memory/memory-orchestrator.md +1 -1
  13. package/src/bmad-plus/packs/pack-memory/shared/memory-protocol.md +1 -1
  14. package/src/bmad-plus/packs/pack-shield/README.md +2 -0
  15. package/src/bmad-plus/packs/pack-shield/SKILL.md +2 -0
  16. package/src/bmad-plus/packs/pack-shield/shared/audit-report-template.md +6 -0
  17. package/src/bmad-plus/packs/pack-shield/shield-orchestrator.md +4 -0
  18. package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +61 -1
  19. package/src/bmad-plus/skills/bmad-plus-uat/ref/uat-results.schema.json +11 -0
  20. package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +455 -137
  21. package/src/bmad-plus/skills/bmad-plus-uat/template/strings.json +230 -20
  22. package/tools/build/adapters.config.js +1 -1
  23. package/tools/build/generate-adapters.js +186 -60
  24. package/tools/build/generate.js +49 -13
  25. package/tools/cli/bmad-plus-cli.js +3 -6
  26. package/tools/cli/commands/autoconfig.js +62 -22
  27. package/tools/cli/commands/install.js +620 -356
  28. package/tools/cli/commands/memory.js +40 -13
  29. package/tools/cli/commands/scan.js +94 -30
  30. package/tools/cli/commands/uat.js +50 -5
  31. package/tools/cli/commands/uninstall.js +65 -16
  32. package/tools/cli/commands/update-check.js +13 -4
  33. package/tools/cli/commands/update-policy.js +14 -5
  34. package/tools/cli/commands/update.js +96 -21
  35. package/tools/cli/i18n.js +120 -68
  36. package/tools/cli/lib/ide-config.js +3 -1
  37. package/tools/cli/lib/install-manifest.js +32 -7
  38. package/tools/cli/lib/installation-health.js +33 -1
  39. package/tools/cli/lib/installed-adapters.js +29 -6
  40. package/tools/cli/lib/memory-init.js +19 -4
  41. package/tools/cli/lib/packs.js +17 -28
  42. package/tools/cli/lib/path-hash.js +5 -1
  43. package/tools/cli/lib/python-provision.js +51 -12
  44. package/tools/cli/lib/stack-detect.js +51 -25
  45. package/tools/cli/lib/uat.js +139 -1
  46. package/tools/cli/lib/update-check.js +132 -34
  47. package/tools/cli/lib/update-dispatch.js +2 -2
  48. package/tools/cli/lib/update-policy.js +57 -19
  49. package/tools/cli/lib/update-transaction.js +439 -85
  50. package/tools/build/README.md +0 -78
  51. package/tools/build/check-counts.js +0 -626
  52. package/tools/build/check-install-contract.js +0 -648
  53. package/tools/build/generated-adapters/.codex/AGENTS.md +0 -134
  54. package/tools/build/generated-adapters/.cursor/rules/bmad-plus.mdc +0 -139
  55. package/tools/build/generated-adapters/.opencode/AGENTS.md +0 -134
  56. package/tools/build/generated-adapters/AGENTS.md +0 -132
  57. package/tools/build/generated-adapters/CLAUDE.md +0 -135
  58. package/tools/build/generated-adapters/CONVENTIONS.md +0 -134
  59. package/tools/build/generated-adapters/GEMINI.md +0 -139
  60. package/tools/build/generated-adapters/README.md +0 -79
  61. package/tools/maintain/upstream-candidate.js +0 -456
  62. package/tools/release/publication-content.js +0 -904
  63. package/tools/release/supply-chain.js +0 -282
@@ -8,13 +8,21 @@
8
8
 
9
9
  const path = require('node:path');
10
10
  const fs = require('node:fs');
11
+ const { isDeepStrictEqual } = require('node:util');
11
12
  const fsExtra = require('fs-extra');
13
+ const semver = require('semver');
12
14
  const clack = require('@clack/prompts');
13
15
  const pc = require('picocolors');
14
- const { t, getLanguageOptions, getCommLanguageOptions } = require('../i18n');
16
+ const { t, LANGUAGES, getLanguageOptions, getCommLanguageOptions } = require('../i18n');
15
17
  const { PACKS, DERIVED } = require('../lib/packs');
16
18
  const { copyPackFiles, copyManagedFile } = require('../lib/pack-copy');
17
- const { acquireProjectLock, inventoryOf, collectManagedFiles } = require('../lib/update-transaction');
19
+ const {
20
+ acquireProjectLock,
21
+ atomicWrite,
22
+ inventoryOf,
23
+ collectManagedFiles,
24
+ } = require('../lib/update-transaction');
25
+ const { isExactVersion } = require('../lib/update-policy');
18
26
  const { initMemory } = require('../lib/memory-init');
19
27
  const { generateUserFiles, IDE_CONFIGS } = require('../../build/generate-adapters');
20
28
  const { writeIDEConfigs, safeAdapterPath } = require('../lib/installed-adapters');
@@ -32,7 +40,10 @@ module.exports = {
32
40
  ['-d, --directory <path>', 'Installation directory (default: current directory)'],
33
41
  ['-p, --packs <packs>', 'Comma-separated pack IDs: core,osint,all (default: interactive)'],
34
42
  ['-y, --yes', 'Accept all defaults, skip prompts'],
35
- ['-l, --lang <code>', 'Language code: en, fr, es, de, pt-br, ru, zh, he, ja, it (overrides auto-detection)'],
43
+ [
44
+ '-l, --lang <code>',
45
+ 'Language code: en, fr, es, de, pt-br, ru, zh, he, ja, it (overrides auto-detection)',
46
+ ],
36
47
  ['--tools <tools>', 'Comma-separated IDE IDs, all, or none (default: auto-detect)'],
37
48
  ['--mode <mode>', 'Execution mode: manual, autopilot, hybrid (default: manual)'],
38
49
  ['--provision-python', 'Provision isolated environments for selected Python packs'],
@@ -50,7 +61,9 @@ module.exports = {
50
61
  const projectDir = path.resolve(options.directory || process.cwd());
51
62
  const bmadSrc = path.join(__dirname, '..', '..', '..', 'src', 'bmad-plus');
52
63
  const previousManifestPath = safeAdapterPath(projectDir, '_bmad/.bmad-plus-install.json');
53
- const previousManifestBytes = fs.existsSync(previousManifestPath) ? fs.readFileSync(previousManifestPath) : null;
64
+ const previousManifestBytes = fs.existsSync(previousManifestPath)
65
+ ? fs.readFileSync(previousManifestPath)
66
+ : null;
54
67
  let previousManifest = null;
55
68
  let manifestError;
56
69
  if (fs.existsSync(previousManifestPath)) {
@@ -60,10 +73,27 @@ module.exports = {
60
73
  manifestError = error;
61
74
  }
62
75
  }
63
- if (options.mode && !['manual', 'autopilot', 'hybrid'].includes(options.mode)) throw new Error('Unknown execution mode: ' + options.mode);
76
+ if (options.mode && !['manual', 'autopilot', 'hybrid'].includes(options.mode))
77
+ throw new Error('Unknown execution mode: ' + options.mode);
78
+ const pkgJson = require('../../../package.json');
79
+
80
+ // A version change must go through `update`: it plans, backs up, and records
81
+ // a receipt. Install only adds packs/tools to (or repairs) the same version.
82
+ const versionConflict = installedVersionConflict(previousManifest?.version, pkgJson.version);
83
+ if (versionConflict) {
84
+ clack.log.error(versionConflict);
85
+ process.exitCode = 1;
86
+ return;
87
+ }
88
+ const existingConfig = readExistingConfig(projectDir);
89
+ const previousPacks = (previousManifest?.packs || []).filter((id) => Object.hasOwn(PACKS, id));
90
+ const previousTools = (
91
+ Array.isArray(previousManifest?.ides) ? previousManifest.ides : []
92
+ ).filter((id) => Object.hasOwn(IDE_CONFIGS, id));
93
+ const requestedPacks = parseRequestedPacks(options.packs);
94
+ const requestedTools = parseRequestedTools(options.tools);
64
95
 
65
96
  // ── Step 0: Language Selection ──
66
- const pkgJson = require('../../../package.json');
67
97
  clack.intro(pc.bgCyan(pc.black(` BMAD+ Installer v${pkgJson.version} `)));
68
98
  clack.log.info(pc.dim('✨ Created by Laurent Rochetta — github.com/lrochetta/BMAD-PLUS'));
69
99
 
@@ -93,26 +123,21 @@ module.exports = {
93
123
  clack.log.info(`${i.installing_to}: ${pc.cyan(projectDir)}`);
94
124
 
95
125
  // ── Step 1: Pack Selection ──
96
- let selectedPacks = ['core']; // Core always included
126
+ // Core is always included; a re-install adds to the packs already installed.
127
+ let chosenPacks = requestedPacks || [];
97
128
 
98
- if (options.packs) {
99
- const requested = options.packs.split(',').map(p => p.trim());
100
- if (requested.includes('all')) {
101
- selectedPacks = Object.keys(PACKS).filter(k => !PACKS[k].disabled);
102
- } else {
103
- selectedPacks = [...new Set(['core', ...requested.filter(p => PACKS[p] && !PACKS[p].disabled)])];
104
- }
105
- } else if (!options.yes) {
129
+ if (!requestedPacks && !options.yes) {
106
130
  const packChoice = await clack.multiselect({
107
131
  message: i.select_packs,
108
132
  options: Object.entries(PACKS)
109
133
  .filter(([, p]) => !p.required)
110
134
  .map(([key, pack]) => ({
111
135
  value: key,
112
- label: `${pack.icon} ${pack.name}`,
113
- hint: pack.disabled ? i.soon : (pack.desc || pack.description || ''),
136
+ label: packLabel(key),
137
+ hint: pack.disabled ? i.soon : pack.desc || pack.description || '',
114
138
  disabled: pack.disabled,
115
139
  })),
140
+ initialValues: previousPacks.filter((id) => !PACKS[id].required),
116
141
  required: false,
117
142
  });
118
143
 
@@ -121,22 +146,19 @@ module.exports = {
121
146
  throw new Error(i.cancelled);
122
147
  }
123
148
 
124
- selectedPacks = [...new Set(['core', ...packChoice])];
149
+ chosenPacks = packChoice;
125
150
  }
151
+ const selectedPacks = DERIVED.packOrder.filter(
152
+ (id) => id === 'core' || previousPacks.includes(id) || chosenPacks.includes(id)
153
+ );
126
154
 
127
- clack.log.success(`${i.selected_packs}: ${selectedPacks.map(p => `${PACKS[p].icon} ${PACKS[p].name}`).join(', ')}`);
155
+ clack.log.success(`${i.selected_packs}: ${selectedPacks.map(packLabel).join(', ')}`);
128
156
 
129
157
  // ── Step 2: IDE Detection ──
130
158
  let detectedIDEs = [];
131
159
 
132
- if (options.tools) {
133
- if (options.tools === 'none' || options.tools === 'skip') {
134
- detectedIDEs = [];
135
- } else if (options.tools === 'all') {
136
- detectedIDEs = Object.keys(IDE_CONFIGS);
137
- } else {
138
- detectedIDEs = options.tools.split(',').map(t => t.trim()).filter(t => IDE_CONFIGS[t]);
139
- }
160
+ if (requestedTools) {
161
+ detectedIDEs = requestedTools;
140
162
  } else {
141
163
  // Auto-detect
142
164
  for (const [id, ide] of Object.entries(IDE_CONFIGS)) {
@@ -164,50 +186,76 @@ module.exports = {
164
186
  }
165
187
  }
166
188
 
167
- // Default to all if --yes
168
- if (detectedIDEs.length === 0 && options.yes) {
169
- detectedIDEs = Object.keys(IDE_CONFIGS);
189
+ // --yes never guesses: without a detected tool, write no adapter.
190
+ if (detectedIDEs.length === 0 && options.yes && previousTools.length === 0) {
191
+ clack.log.info(
192
+ pc.dim(
193
+ 'No AI tool detected; no instruction adapter written. Re-run with --tools <ids> or --tools all to add one.'
194
+ )
195
+ );
170
196
  }
171
197
  }
198
+ // Keep refreshing adapters this installation already owns, unless skipped.
199
+ const skipTools = options.tools === 'none' || options.tools === 'skip';
200
+ if (!skipTools) detectedIDEs = [...new Set([...previousTools, ...detectedIDEs])];
172
201
 
173
202
  if (detectedIDEs.length > 0) {
174
- clack.log.info(`${i.detected_ides}: ${detectedIDEs.map(id => IDE_CONFIGS[id].name).join(', ')}`);
175
- } else if (options.tools === 'none' || options.tools === 'skip') {
203
+ clack.log.info(
204
+ `${i.detected_ides}: ${detectedIDEs.map((id) => IDE_CONFIGS[id].name).join(', ')}`
205
+ );
206
+ } else if (skipTools) {
176
207
  clack.log.info(pc.dim('⏭️ IDE config skipped (--tools none) — existing configs preserved'));
177
208
  }
178
209
 
179
210
  // ── Step 3: User Config ──
180
- let userName = process.env.USER || process.env.USERNAME || 'Developer';
181
- let commLang = 'French';
182
- let execMode = options.mode || 'manual';
211
+ // Existing settings are the defaults; unattended installs never replace them.
212
+ let userName =
213
+ typeof existingConfig?.user_name === 'string' && existingConfig.user_name
214
+ ? existingConfig.user_name
215
+ : process.env.USER || process.env.USERNAME || 'Developer';
216
+ let commLang =
217
+ typeof existingConfig?.communication_language === 'string' &&
218
+ existingConfig.communication_language
219
+ ? existingConfig.communication_language
220
+ : communicationLanguage(options.lang || (options.yes ? undefined : lang));
221
+ let execMode = options.mode || existingConfig?.execution_mode || 'manual';
222
+ if (!['manual', 'autopilot', 'hybrid'].includes(execMode)) execMode = 'manual';
183
223
  let uatEnvironment = '';
224
+ const explicitSettings = {};
225
+ if (options.mode) explicitSettings.execution_mode = options.mode;
184
226
 
185
227
  if (!options.yes) {
186
228
  const userConfig = await clack.group({
187
- userName: () => clack.text({
188
- message: i.enter_name,
189
- placeholder: userName,
190
- defaultValue: userName,
191
- }),
192
- commLang: () => clack.select({
193
- message: i.comm_language,
194
- options: getCommLanguageOptions(),
195
- }),
196
- uatEnvironment: () => clack.text({
197
- message:
198
- i.uat_environment ||
199
- 'Test environment for human acceptance recipes (URL, blank if none)',
200
- placeholder: 'https://demo.example.com',
201
- defaultValue: '',
202
- }),
203
- execMode: () => clack.select({
204
- message: i.exec_mode,
205
- options: [
206
- { value: 'manual', label: i.exec_manual },
207
- { value: 'autopilot', label: i.exec_autopilot },
208
- { value: 'hybrid', label: i.exec_hybrid },
209
- ],
210
- }),
229
+ userName: () =>
230
+ clack.text({
231
+ message: i.enter_name,
232
+ placeholder: userName,
233
+ defaultValue: userName,
234
+ }),
235
+ commLang: () =>
236
+ clack.select({
237
+ message: i.comm_language,
238
+ options: getCommLanguageOptions(),
239
+ initialValue: commLang,
240
+ }),
241
+ uatEnvironment: () =>
242
+ clack.text({
243
+ message:
244
+ i.uat_environment ||
245
+ 'Test environment for human acceptance recipes (URL, blank if none)',
246
+ placeholder: 'https://demo.example.com',
247
+ defaultValue: '',
248
+ }),
249
+ execMode: () =>
250
+ clack.select({
251
+ message: i.exec_mode,
252
+ options: [
253
+ { value: 'manual', label: i.exec_manual },
254
+ { value: 'autopilot', label: i.exec_autopilot },
255
+ { value: 'hybrid', label: i.exec_hybrid },
256
+ ],
257
+ initialValue: execMode,
258
+ }),
211
259
  });
212
260
 
213
261
  if (clack.isCancel(userConfig)) {
@@ -220,343 +268,559 @@ module.exports = {
220
268
  const fb = process.env.USER || process.env.USERNAME || 'Developer';
221
269
  const { name: validatedName, warnings } = validateUserName(rawName, fb);
222
270
  for (const w of warnings) clack.log.warn(w);
271
+ // Answers given in the wizard are explicit and replace existing settings.
272
+ if (userConfig.commLang !== commLang)
273
+ explicitSettings.document_output_language = userConfig.commLang;
223
274
  userName = validatedName;
224
275
  commLang = userConfig.commLang;
225
276
  execMode = options.mode || userConfig.execMode;
226
277
  uatEnvironment = String(userConfig.uatEnvironment || '').trim();
278
+ Object.assign(explicitSettings, {
279
+ user_name: userName,
280
+ communication_language: commLang,
281
+ execution_mode: execMode,
282
+ });
283
+ if (uatEnvironment)
284
+ explicitSettings.uat = { environment: { name: 'TEST', url: uatEnvironment } };
227
285
  }
228
286
 
229
287
  const releaseLock = acquireProjectLock(projectDir);
230
288
  try {
231
- const { safeTarget } = require('../../build/generate-adapters');
232
- const plannedFiles = collectManagedFiles({ projectDir, packs: selectedPacks });
233
- if (detectedIDEs.length) plannedFiles.push(...generateUserFiles(DERIVED, {
234
- packs: selectedPacks, tools: detectedIDEs, userName, language: commLang,
235
- }));
236
- for (const { file } of plannedFiles) safeTarget(projectDir, file);
237
- safeTarget(projectDir, '_bmad/config.yaml');
238
- safeTarget(projectDir, '_bmad/.bmad-plus-install.json');
239
- for (const directory of ['.agents/skills', '.agents/data', '_bmad', '_bmad-output/discovery', '_bmad-output/build', 'docs']) {
240
- safeTarget(projectDir, directory + '/.bmad-preflight');
241
- }
242
- const currentManifestBytes = fs.existsSync(previousManifestPath) ? fs.readFileSync(previousManifestPath) : null;
243
- if (previousManifestBytes === null ? currentManifestBytes !== null :
244
- currentManifestBytes === null || !previousManifestBytes.equals(currentManifestBytes)) {
245
- throw new Error('Installation changed while preparing install. Please retry.');
246
- }
247
- // Preserve the unreadable manifest before repair; do not trust its ownership
248
- // hashes. The adapter writer will retain locally edited user instructions.
249
- if (manifestError) {
250
- const backupBase = '_bmad/.bmad-plus-install.json.corrupt.bak';
251
- let backupFile = backupBase;
252
- let index = 1;
253
- while (fs.existsSync(safeAdapterPath(projectDir, backupFile))) backupFile = `${backupBase}.${index++}`;
254
- const backupPath = safeAdapterPath(projectDir, backupFile);
255
- fs.copyFileSync(safeAdapterPath(projectDir, '_bmad/.bmad-plus-install.json'), backupPath, fs.constants.COPYFILE_EXCL);
256
- clack.log.warn(`${i.manifest_invalid(manifestError.message)}\nBackup: ${backupPath}`);
257
- }
289
+ const { safeTarget } = require('../../build/generate-adapters');
290
+ const plannedFiles = collectManagedFiles({ projectDir, packs: selectedPacks });
291
+ if (detectedIDEs.length)
292
+ plannedFiles.push(
293
+ ...generateUserFiles(DERIVED, {
294
+ packs: selectedPacks,
295
+ tools: detectedIDEs,
296
+ userName,
297
+ language: commLang,
298
+ })
299
+ );
300
+ for (const { file } of plannedFiles) safeTarget(projectDir, file);
301
+ safeTarget(projectDir, '_bmad/config.yaml');
302
+ safeTarget(projectDir, '_bmad/.bmad-plus-install.json');
303
+ for (const directory of [
304
+ '.agents/skills',
305
+ '.agents/data',
306
+ '_bmad',
307
+ '_bmad-output/discovery',
308
+ '_bmad-output/build',
309
+ 'docs',
310
+ ]) {
311
+ safeTarget(projectDir, directory + '/.bmad-preflight');
312
+ }
313
+ const currentManifestBytes = fs.existsSync(previousManifestPath)
314
+ ? fs.readFileSync(previousManifestPath)
315
+ : null;
316
+ if (
317
+ previousManifestBytes === null
318
+ ? currentManifestBytes !== null
319
+ : currentManifestBytes === null || !previousManifestBytes.equals(currentManifestBytes)
320
+ ) {
321
+ throw new Error('Installation changed while preparing install. Please retry.');
322
+ }
323
+ // Preserve the unreadable manifest before repair; do not trust its ownership
324
+ // hashes. The adapter writer will retain locally edited user instructions.
325
+ if (manifestError) {
326
+ const backupBase = '_bmad/.bmad-plus-install.json.corrupt.bak';
327
+ let backupFile = backupBase;
328
+ let index = 1;
329
+ while (fs.existsSync(safeAdapterPath(projectDir, backupFile)))
330
+ backupFile = `${backupBase}.${index++}`;
331
+ const backupPath = safeAdapterPath(projectDir, backupFile);
332
+ fs.copyFileSync(
333
+ safeAdapterPath(projectDir, '_bmad/.bmad-plus-install.json'),
334
+ backupPath,
335
+ fs.constants.COPYFILE_EXCL
336
+ );
337
+ clack.log.warn(`${i.manifest_invalid(manifestError.message)}\nBackup: ${backupPath}`);
338
+ }
258
339
 
259
- // ── Step 4: Install Files ──
260
- const spinner = clack.spinner();
261
- spinner.start(i.installing_files);
262
-
263
- const targetAgentsDir = path.join(projectDir, '.agents', 'skills');
264
- const targetDataDir = path.join(projectDir, '.agents', 'data');
265
- const targetBmadDir = path.join(projectDir, '_bmad');
266
-
267
- // Create directories
268
- fsExtra.ensureDirSync(targetAgentsDir);
269
- fsExtra.ensureDirSync(targetDataDir);
270
- fsExtra.ensureDirSync(targetBmadDir);
271
-
272
- let copiedAgents = 0;
273
- let copiedSkills = 0;
274
- let copiedFiles = 0;
275
- const previousInventory = inventoryOf(previousManifest || {}) || {};
276
- const fileInventory = { ...previousInventory };
277
- let inventoryComplete = true;
278
- const onFileConflict = file => {
279
- if (!previousInventory[file]) inventoryComplete = false;
280
- clack.log.warn(`${file}: locally changed or unowned content preserved.`);
281
- };
282
-
283
- const projectRoot = path.join(bmadSrc, '..', '..');
284
-
285
- for (const packId of selectedPacks) {
286
- const pack = PACKS[packId];
287
- if (!pack || pack.disabled) continue;
288
-
289
- const result = copyPackFiles({
290
- bmadSrc,
291
- targetAgentsDir,
292
- targetDataDir,
293
- projectRoot,
294
- pack,
295
- projectDir, inventory: fileInventory, previousInventory, onConflict: onFileConflict,
296
- });
297
- copiedAgents += result.copiedAgents;
298
- copiedSkills += result.copiedSkills;
299
- copiedFiles += result.copiedFiles;
300
-
301
- // Memory pack: initialize brain with existing brain detection.
302
- // Non-fatal: a read-only or missing home dir (containers, CI) must not
303
- // abort the install mid-flight and leave a manifest-less partial tree.
304
- if (packId === 'memory' && pack.packDir) {
305
- try {
306
- initMemory({ projectDir, bmadSrc, userName, commLang, selectedPacks });
307
- } catch (err) {
308
- clack.log.warn(`Memory/brain initialization failed (non-fatal, install continues): ${err.message}`);
340
+ // ── Step 4: Install Files ──
341
+ const spinner = clack.spinner();
342
+ spinner.start(i.installing_files);
343
+
344
+ const targetAgentsDir = path.join(projectDir, '.agents', 'skills');
345
+ const targetDataDir = path.join(projectDir, '.agents', 'data');
346
+ const targetBmadDir = path.join(projectDir, '_bmad');
347
+
348
+ // Create directories
349
+ fsExtra.ensureDirSync(targetAgentsDir);
350
+ fsExtra.ensureDirSync(targetDataDir);
351
+ fsExtra.ensureDirSync(targetBmadDir);
352
+
353
+ let copiedAgents = 0;
354
+ let copiedSkills = 0;
355
+ let copiedFiles = 0;
356
+ const previousInventory = inventoryOf(previousManifest || {}) || {};
357
+ const fileInventory = { ...previousInventory };
358
+ let inventoryComplete = true;
359
+ const onFileConflict = (file) => {
360
+ if (!previousInventory[file]) inventoryComplete = false;
361
+ clack.log.warn(`${file}: locally changed or unowned content preserved.`);
362
+ };
363
+
364
+ const projectRoot = path.join(bmadSrc, '..', '..');
365
+
366
+ for (const packId of selectedPacks) {
367
+ const pack = PACKS[packId];
368
+ if (!pack || pack.disabled) continue;
369
+
370
+ const result = copyPackFiles({
371
+ bmadSrc,
372
+ targetAgentsDir,
373
+ targetDataDir,
374
+ projectRoot,
375
+ pack,
376
+ projectDir,
377
+ inventory: fileInventory,
378
+ previousInventory,
379
+ onConflict: onFileConflict,
380
+ });
381
+ copiedAgents += result.copiedAgents;
382
+ copiedSkills += result.copiedSkills;
383
+ copiedFiles += result.copiedFiles;
384
+
385
+ // Memory pack: initialize brain with existing brain detection.
386
+ // Non-fatal: a read-only or missing home dir (containers, CI) must not
387
+ // abort the install mid-flight and leave a manifest-less partial tree.
388
+ if (packId === 'memory' && pack.packDir) {
389
+ try {
390
+ initMemory({ projectDir, bmadSrc, userName, commLang, selectedPacks });
391
+ } catch (err) {
392
+ clack.log.warn(
393
+ `Memory/brain initialization failed (non-fatal, install continues): ${err.message}`
394
+ );
395
+ }
309
396
  }
310
397
  }
311
- }
312
398
 
313
- // ── Python provisioning (opt-in) — for packs whose runtime needs Python ──
314
- // Off by default so `install` never spawns Python unless asked. Closes the
315
- // "SEO pack ships prompts that call scripts never installed" gap (PROD-02).
316
- if (options.provisionPython) {
317
- const pythonPacks = selectedPacks.filter((p) => PYTHON_PACKS[p]);
318
- for (const packId of pythonPacks) {
319
- const cfg = PYTHON_PACKS[packId];
320
- const requirementsPath = path.join(projectRoot, ...cfg.requirements);
321
- if (!fs.existsSync(requirementsPath)) {
322
- clack.log.warn(`Python provisioning skipped for ${packId}: ${requirementsPath} not found.`);
323
- continue;
324
- }
325
- const pySpinner = clack.spinner();
326
- pySpinner.start(`Provisioning Python runtime for ${PACKS[packId].name}…`);
327
- const res = provisionPack({
328
- envDir: path.join(projectDir, '.bmad', 'venv', packId),
329
- requirementsPath,
330
- verifyModules: cfg.verifyModules,
331
- });
332
- pySpinner.stop(
333
- res.ok
334
- ? `${PACKS[packId].name}: Python runtime ready (${res.tool}).`
335
- : `${PACKS[packId].name}: Python not provisioned — ${res.messages[res.messages.length - 1] || 'see guidance'}`
336
- );
337
- if (!res.ok) {
338
- clack.log.warn(`Run \`${PACKS[packId].name}\` Python tools after installing Python ≥3.11; the pack's prompts still work, only the local scripts need the runtime.`);
399
+ // ── Python provisioning (opt-in) — for packs whose runtime needs Python ──
400
+ // Off by default so `install` never spawns Python unless asked. Closes the
401
+ // "SEO pack ships prompts that call scripts never installed" gap (PROD-02).
402
+ if (options.provisionPython) {
403
+ const pythonPacks = selectedPacks.filter((p) => PYTHON_PACKS[p]);
404
+ for (const packId of pythonPacks) {
405
+ const cfg = PYTHON_PACKS[packId];
406
+ const requirementsPath = path.join(projectRoot, ...cfg.requirements);
407
+ if (!fs.existsSync(requirementsPath)) {
408
+ clack.log.warn(
409
+ `Python provisioning skipped for ${packId}: ${requirementsPath} not found.`
410
+ );
411
+ continue;
412
+ }
413
+ const pySpinner = clack.spinner();
414
+ pySpinner.start(`Provisioning Python runtime for ${PACKS[packId].name}…`);
415
+ const res = provisionPack({
416
+ envDir: path.join(projectDir, '.bmad', 'venv', packId),
417
+ requirementsPath,
418
+ verifyModules: cfg.verifyModules,
419
+ });
420
+ pySpinner.stop(
421
+ res.ok
422
+ ? `${PACKS[packId].name}: Python runtime ready (${res.tool}).`
423
+ : `${PACKS[packId].name}: Python not provisioned — ${res.messages[res.messages.length - 1] || 'see guidance'}`
424
+ );
425
+ if (!res.ok) {
426
+ clack.log.warn(
427
+ `Run \`${PACKS[packId].name}\` Python tools after installing Python ≥3.11; the pack's prompts still work, only the local scripts need the runtime.`
428
+ );
429
+ }
339
430
  }
340
431
  }
341
- }
342
432
 
343
- // Copy module config
344
- const moduleYaml = path.join(bmadSrc, 'module.yaml');
345
- if (fs.existsSync(moduleYaml)) {
346
- copyManagedFile({ source: moduleYaml, target: path.join(targetBmadDir, 'module.yaml'),
347
- projectDir, inventory: fileInventory, previousInventory, onConflict: onFileConflict });
348
- copiedFiles++;
349
- }
433
+ // Copy module config
434
+ const moduleYaml = path.join(bmadSrc, 'module.yaml');
435
+ if (fs.existsSync(moduleYaml)) {
436
+ copyManagedFile({
437
+ source: moduleYaml,
438
+ target: path.join(targetBmadDir, 'module.yaml'),
439
+ projectDir,
440
+ inventory: fileInventory,
441
+ previousInventory,
442
+ onConflict: onFileConflict,
443
+ });
444
+ copiedFiles++;
445
+ }
350
446
 
351
- const helpCsv = path.join(bmadSrc, 'module-help.csv');
352
- if (fs.existsSync(helpCsv)) {
353
- copyManagedFile({ source: helpCsv, target: path.join(targetBmadDir, 'module-help.csv'),
354
- projectDir, inventory: fileInventory, previousInventory, onConflict: onFileConflict });
355
- copiedFiles++;
356
- }
447
+ const helpCsv = path.join(bmadSrc, 'module-help.csv');
448
+ if (fs.existsSync(helpCsv)) {
449
+ copyManagedFile({
450
+ source: helpCsv,
451
+ target: path.join(targetBmadDir, 'module-help.csv'),
452
+ projectDir,
453
+ inventory: fileInventory,
454
+ previousInventory,
455
+ onConflict: onFileConflict,
456
+ });
457
+ copiedFiles++;
458
+ }
357
459
 
358
- spinner.stop(i.installed_summary(copiedAgents, copiedSkills, copiedFiles));
460
+ spinner.stop(i.installed_summary(copiedAgents, copiedSkills, copiedFiles));
461
+
462
+ // ── Step 5: Generate IDE Configs ──
463
+ let adapterHashes = previousManifest?.adapterHashes || {};
464
+ if (detectedIDEs.length > 0) {
465
+ const ideSpinner = clack.spinner();
466
+ ideSpinner.start(i.configuring_ides);
467
+
468
+ const outcome = writeIDEConfigs({
469
+ projectDir,
470
+ files: generateUserFiles(DERIVED, {
471
+ packs: selectedPacks,
472
+ userName,
473
+ language: commLang,
474
+ tools: detectedIDEs,
475
+ }),
476
+ adapterHashes,
477
+ yes: options.yes,
478
+ });
479
+ adapterHashes = { ...adapterHashes, ...outcome.adapterHashes };
359
480
 
360
- // ── Step 5: Generate IDE Configs ──
361
- let adapterHashes = previousManifest?.adapterHashes || {};
362
- if (detectedIDEs.length > 0) {
363
- const ideSpinner = clack.spinner();
364
- ideSpinner.start(i.configuring_ides);
481
+ ideSpinner.stop(i.ide_configured(detectedIDEs.length));
482
+ }
365
483
 
366
- const outcome = writeIDEConfigs({
484
+ // ── Step 6: Create config.yaml ──
485
+ const configYaml = generateConfigYaml(
486
+ userName,
487
+ commLang,
367
488
  projectDir,
368
- files: generateUserFiles(DERIVED, { packs: selectedPacks, userName, language: commLang, tools: detectedIDEs }),
369
- adapterHashes,
370
- yes: options.yes,
371
- });
372
- adapterHashes = { ...adapterHashes, ...outcome.adapterHashes };
373
-
374
- ideSpinner.stop(i.ide_configured(detectedIDEs.length));
375
- }
376
-
377
- // ── Step 6: Create config.yaml ──
378
- const configYaml = generateConfigYaml(userName, commLang, projectDir, execMode, uatEnvironment);
379
- const configPath = path.join(targetBmadDir, 'config.yaml');
380
- fs.writeFileSync(configPath, configYaml, 'utf8');
381
-
382
- // ── Step 7: Create output directories ──
383
- const outputDir = path.join(projectDir, '_bmad-output');
384
- fsExtra.ensureDirSync(path.join(outputDir, 'discovery'));
385
- fsExtra.ensureDirSync(path.join(outputDir, 'build'));
386
- fsExtra.ensureDirSync(path.join(projectDir, 'docs'));
387
-
388
- // ── Step 8: Write install manifest ──
389
- const pkgVersion = require('../../../package.json').version;
390
- const manifest = {
391
- version: pkgVersion,
392
- uiLanguage: lang,
393
- installed: new Date().toISOString(),
394
- packs: selectedPacks,
395
- ides: detectedIDEs,
396
- adapterHashes,
397
- fileInventory: { schemaVersion: 1, complete: inventoryComplete, files: fileInventory },
398
- executionMode: execMode,
399
- user: userName,
400
- language: commLang,
401
- };
402
- fs.writeFileSync(
403
- path.join(targetBmadDir, '.bmad-plus-install.json'),
404
- JSON.stringify(manifest, null, 2),
405
- 'utf8'
406
- );
407
-
408
- // ── Summary — Contextual Getting Started ──
409
- const agentGuide = [
410
- i.guide_who,
411
- '',
412
- ` ${i.guide_idea.padEnd(28)} → "Atlas, [...]"`,
413
- ` ${i.guide_prd.padEnd(28)} → "Atlas, create PRD"`,
414
- ` ${i.guide_arch.padEnd(28)} → "Forge, propose architecture"`,
415
- ` ${i.guide_code.padEnd(28)} → "Forge, implement story [X]"`,
416
- ` ${i.guide_test.padEnd(28)} → "Sentinel, review module [X]"`,
417
- ` ${i.guide_sprint.padEnd(28)} → "Nexus, create epics"`,
418
- ` ${i.guide_auto.padEnd(28)} → "autopilot"`,
419
- ];
420
-
421
- if (selectedPacks.includes('osint')) {
422
- agentGuide.push(` ${i.guide_osint.padEnd(28)} → "Shadow, investigate [name]"`);
423
- }
424
-
425
- if (selectedPacks.includes('maker')) {
426
- agentGuide.push(` ${i.guide_maker.padEnd(28)} → "Maker, create agent [desc]"`);
427
- }
489
+ execMode,
490
+ uatEnvironment
491
+ );
492
+ const configPath = path.join(targetBmadDir, 'config.yaml');
493
+ if (!fs.existsSync(configPath)) {
494
+ atomicWrite(projectDir, '_bmad/config.yaml', configYaml);
495
+ } else {
496
+ const merged = mergeConfigYaml(
497
+ fs.readFileSync(configPath, 'utf8'),
498
+ configYaml,
499
+ explicitSettings
500
+ );
501
+ if (merged === null)
502
+ clack.log.warn('_bmad/config.yaml could not be parsed; it was left unchanged.');
503
+ else if (merged.changed) atomicWrite(projectDir, '_bmad/config.yaml', merged.content);
504
+ }
428
505
 
429
- if (selectedPacks.includes('seo')) {
430
- agentGuide.push(` ${i.guide_seo.padEnd(28)} → "/seo audit <url>"`);
431
- }
506
+ // ── Step 7: Create output directories ──
507
+ const outputDir = path.join(projectDir, '_bmad-output');
508
+ fsExtra.ensureDirSync(path.join(outputDir, 'discovery'));
509
+ fsExtra.ensureDirSync(path.join(outputDir, 'build'));
510
+ fsExtra.ensureDirSync(path.join(projectDir, 'docs'));
511
+
512
+ // ── Step 8: Write install manifest ──
513
+ const manifest = {
514
+ version: pkgJson.version,
515
+ uiLanguage: lang,
516
+ installed: previousManifest?.installed || new Date().toISOString(),
517
+ ...(previousManifest ? { lastInstalled: new Date().toISOString() } : {}),
518
+ packs: selectedPacks,
519
+ ides: [...new Set([...previousTools, ...detectedIDEs])],
520
+ adapterHashes,
521
+ fileInventory: { schemaVersion: 1, complete: inventoryComplete, files: fileInventory },
522
+ executionMode: execMode,
523
+ user: userName,
524
+ language: commLang,
525
+ };
526
+ atomicWrite(projectDir, '_bmad/.bmad-plus-install.json', JSON.stringify(manifest, null, 2));
527
+
528
+ // ── Summary — Contextual Getting Started ──
529
+ const agentGuide = [
530
+ i.guide_who,
531
+ '',
532
+ ` ${i.guide_idea.padEnd(28)} → "Atlas, [...]"`,
533
+ ` ${i.guide_prd.padEnd(28)} → "Atlas, create PRD"`,
534
+ ` ${i.guide_arch.padEnd(28)} → "Forge, propose architecture"`,
535
+ ` ${i.guide_code.padEnd(28)} → "Forge, implement story [X]"`,
536
+ ` ${i.guide_test.padEnd(28)} → "Sentinel, review module [X]"`,
537
+ ` ${i.guide_sprint.padEnd(28)} → "Nexus, create epics"`,
538
+ ` ${i.guide_auto.padEnd(28)} → "autopilot"`,
539
+ ];
540
+
541
+ if (selectedPacks.includes('osint')) {
542
+ agentGuide.push(` ${i.guide_osint.padEnd(28)} → "Shadow, investigate [name]"`);
543
+ }
432
544
 
433
- if (selectedPacks.includes('backup')) {
434
- agentGuide.push(` ${i.guide_backup.padEnd(28)} → "/backup create"`);
435
- }
545
+ if (selectedPacks.includes('maker')) {
546
+ agentGuide.push(` ${i.guide_maker.padEnd(28)} → "Maker, create agent [desc]"`);
547
+ }
436
548
 
437
- if (selectedPacks.includes('animated')) {
438
- agentGuide.push(` ${i.guide_animated.padEnd(28)} → "/animated build <video>"`);
439
- }
549
+ if (selectedPacks.includes('seo')) {
550
+ agentGuide.push(` ${i.guide_seo.padEnd(28)} → "/seo audit <url>"`);
551
+ }
440
552
 
441
- if (selectedPacks.includes('shield')) {
442
- agentGuide.push(` ${(i.guide_shield || '🛡️ GRC Compliance').padEnd(28)} → "Shield, audit my SaaS for GDPR"`);
443
- }
553
+ if (selectedPacks.includes('backup')) {
554
+ agentGuide.push(` ${i.guide_backup.padEnd(28)} → "/backup create"`);
555
+ }
444
556
 
445
- if (selectedPacks.includes('memory')) {
446
- agentGuide.push(` ${(i.guide_memory || '🧠 Memory Brain').padEnd(28)} → "Zecher, scan projects in [path]"`);
447
- }
557
+ if (selectedPacks.includes('animated')) {
558
+ agentGuide.push(` ${i.guide_animated.padEnd(28)} → "/animated build <video>"`);
559
+ }
448
560
 
449
- if (selectedPacks.includes('dev-studio')) {
450
- agentGuide.push(` ${(i.guide_dev_studio || '🏗️ Dev Studio').padEnd(28)} → "Miriam, brainstorm my app idea"`);
451
- }
561
+ if (selectedPacks.includes('shield')) {
562
+ agentGuide.push(
563
+ ` ${(i.guide_shield || '🛡️ GRC Compliance').padEnd(28)} → "Shield, audit my SaaS for GDPR"`
564
+ );
565
+ }
452
566
 
453
- agentGuide.push(
454
- '',
455
- i.guide_workflow,
456
- ' 1. Atlas (idea → brief → PRD)',
457
- ' 2. Forge (architecture → code)',
458
- ' 3. Sentinel (tests → review)',
459
- '',
460
- i.guide_or_auto,
461
- '',
462
- `${i.guide_output}: _bmad-output/discovery/ & _bmad-output/build/`,
463
- '',
464
- '─'.repeat(50),
465
- '',
466
- `📦 ${i.guide_cli_title || 'CLI Commands'}:`,
467
- ` npx bmad-plus install ${i.guide_cli_install || '— Install agents & skills'}`,
468
- ` npx bmad-plus update ${i.guide_cli_update || '— Update agents (keeps config)'}`,
469
- ` npx bmad-plus doctor ${i.guide_cli_doctor || '— Check installation health'}`,
470
- ` npx bmad-plus uninstall ${i.guide_cli_uninstall || '— Remove BMAD+ from project'}`,
471
- );
567
+ if (selectedPacks.includes('memory')) {
568
+ agentGuide.push(
569
+ ` ${(i.guide_memory || '🧠 Memory Brain').padEnd(28)} → "Zecher, scan projects in [path]"`
570
+ );
571
+ }
472
572
 
473
- // Add pack-specific examples
474
- const examples = [];
475
- if (selectedPacks.includes('seo')) {
476
- examples.push(` ${i.guide_example_seo || '🔍 SEO: "/seo audit https://example.com"'}`);
477
- }
478
- if (selectedPacks.includes('backup')) {
479
- examples.push(` ${i.guide_example_backup || '🗂️ Backup: "/backup create" → ZIP timestamped'}`);
480
- }
481
- if (selectedPacks.includes('animated')) {
482
- examples.push(` ${i.guide_example_animated || '🎬 Animated: "/animated build hero.mp4"'}`);
483
- }
484
- if (selectedPacks.includes('osint')) {
485
- examples.push(` ${i.guide_example_osint || '🔍 OSINT: "Shadow, investigate John Doe"'}`);
486
- }
487
- if (selectedPacks.includes('shield')) {
488
- examples.push(
489
- ` ${i.guide_example_shield_1 || '🛡️ GRC: "Shield, audit my app for GDPR compliance"'}`,
490
- ` ${i.guide_example_shield_2 || '🛡️ GRC: "Shield, gap analysis ISO 27001 vs NIST CSF"'}`,
491
- ` ${i.guide_example_shield_3 || '🛡️ GRC: "Shield, generate SOC 2 evidence checklist"'}`,
492
- );
493
- }
494
- if (selectedPacks.includes('dev-studio')) {
495
- examples.push(
496
- ` ${i.guide_example_dev_studio_1 || '🏗️ Dev Studio: "Miriam, brainstorm a productivity app"'}`,
497
- ` ${i.guide_example_dev_studio_2 || '🏗️ Dev Studio: "Bezalel, design the architecture"'}`,
498
- ` ${i.guide_example_dev_studio_3 || '🏗️ Dev Studio: "Oholiab, implement story S1"'}`,
499
- );
500
- }
501
- if (selectedPacks.includes('memory')) {
502
- examples.push(
503
- ` ${i.guide_example_memory_1 || '🧠 Memory: "Zecher, scan projects in D:\\travail\\DEV"'}`,
504
- ` ${i.guide_example_memory_2 || '🧠 Memory: "Zecher, where were we?"'}`,
505
- ` ${i.guide_example_memory_3 || '🧠 Memory: "Zecher, consolidate memory"'}`,
506
- );
507
- }
573
+ if (selectedPacks.includes('dev-studio')) {
574
+ agentGuide.push(
575
+ ` ${(i.guide_dev_studio || '🏗️ Dev Studio').padEnd(28)} → "Miriam, brainstorm my app idea"`
576
+ );
577
+ }
508
578
 
509
- if (examples.length > 0) {
510
579
  agentGuide.push(
511
580
  '',
512
- `💡 ${i.guide_examples_title || 'Quick Examples'}:`,
513
- ...examples
581
+ i.guide_workflow,
582
+ ' 1. Atlas (idea → brief → PRD)',
583
+ ' 2. Forge (architecture → code)',
584
+ ' 3. Sentinel (tests → review)',
585
+ '',
586
+ i.guide_or_auto,
587
+ '',
588
+ `${i.guide_output}: _bmad-output/discovery/ & _bmad-output/build/`,
589
+ '',
590
+ '─'.repeat(50),
591
+ '',
592
+ `📦 ${i.guide_cli_title || 'CLI Commands'}:`,
593
+ ` npx bmad-plus install ${i.guide_cli_install || '— Install agents & skills'}`,
594
+ ` npx bmad-plus update ${i.guide_cli_update || '— Update agents (keeps config)'}`,
595
+ ` npx bmad-plus doctor ${i.guide_cli_doctor || '— Check installation health'}`,
596
+ ` npx bmad-plus uninstall ${i.guide_cli_uninstall || '— Remove BMAD+ from project'}`
514
597
  );
515
- }
516
598
 
517
- agentGuide.push(
518
- '',
519
- '---',
520
- i.guide_credits
521
- );
599
+ // Add pack-specific examples
600
+ const examples = [];
601
+ if (selectedPacks.includes('seo')) {
602
+ examples.push(` ${i.guide_example_seo || '🔍 SEO: "/seo audit https://example.com"'}`);
603
+ }
604
+ if (selectedPacks.includes('backup')) {
605
+ examples.push(
606
+ ` ${i.guide_example_backup || '🗂️ Backup: "/backup create" → ZIP timestamped'}`
607
+ );
608
+ }
609
+ if (selectedPacks.includes('animated')) {
610
+ examples.push(` ${i.guide_example_animated || '🎬 Animated: "/animated build hero.mp4"'}`);
611
+ }
612
+ if (selectedPacks.includes('osint')) {
613
+ examples.push(` ${i.guide_example_osint || '🔍 OSINT: "Shadow, investigate John Doe"'}`);
614
+ }
615
+ if (selectedPacks.includes('shield')) {
616
+ examples.push(
617
+ ` ${i.guide_example_shield_1 || '🛡️ GRC: "Shield, audit my app for GDPR compliance"'}`,
618
+ ` ${i.guide_example_shield_2 || '🛡️ GRC: "Shield, gap analysis ISO 27001 vs NIST CSF"'}`,
619
+ ` ${i.guide_example_shield_3 || '🛡️ GRC: "Shield, generate SOC 2 evidence checklist"'}`
620
+ );
621
+ }
622
+ if (selectedPacks.includes('dev-studio')) {
623
+ examples.push(
624
+ ` ${i.guide_example_dev_studio_1 || '🏗️ Dev Studio: "Miriam, brainstorm a productivity app"'}`,
625
+ ` ${i.guide_example_dev_studio_2 || '🏗️ Dev Studio: "Bezalel, design the architecture"'}`,
626
+ ` ${i.guide_example_dev_studio_3 || '🏗️ Dev Studio: "Oholiab, implement story S1"'}`
627
+ );
628
+ }
629
+ if (selectedPacks.includes('memory')) {
630
+ examples.push(
631
+ ` ${i.guide_example_memory_1 || '🧠 Memory: "Zecher, scan projects in ~/projects"'}`,
632
+ ` ${i.guide_example_memory_2 || '🧠 Memory: "Zecher, where were we?"'}`,
633
+ ` ${i.guide_example_memory_3 || '🧠 Memory: "Zecher, consolidate memory"'}`
634
+ );
635
+ }
636
+
637
+ if (examples.length > 0) {
638
+ agentGuide.push('', `💡 ${i.guide_examples_title || 'Quick Examples'}:`, ...examples);
639
+ }
522
640
 
523
- clack.note(agentGuide.join('\n'), i.guide_title);
641
+ agentGuide.push('', '---', i.guide_credits);
524
642
 
525
- clack.outro(pc.green(i.guide_ready));
643
+ clack.note(agentGuide.join('\n'), i.guide_title);
644
+
645
+ clack.outro(pc.green(i.guide_ready));
526
646
  } finally {
527
647
  releaseLock();
528
648
  }
529
649
  },
530
650
  // Exported for functional testing (not part of the public API)
531
- _internal: { writeIDEConfigs, generateConfigYaml },
651
+ _internal: {
652
+ writeIDEConfigs,
653
+ generateConfigYaml,
654
+ mergeConfigYaml,
655
+ communicationLanguage,
656
+ installedVersionConflict,
657
+ },
532
658
  };
533
659
 
534
- function generateConfigYaml(userName, language, projectDir, execMode = 'manual', uatEnvironment = '') {
535
- if (!['manual', 'autopilot', 'hybrid'].includes(execMode)) throw new Error('Unknown execution mode: ' + execMode);
536
- return '# BMAD+ Project Configuration\n# Generated by bmad-plus install\n\n' + yaml.dump({
537
- user_name: userName,
538
- communication_language: language,
539
- document_output_language: language,
540
- output_folder: '_bmad-output',
541
- project_name: path.basename(projectDir),
542
- execution_mode: execMode,
543
- auto_role_activation: true,
544
- parallel_execution: true,
545
- checkpoints: {
546
- discovery: 'require_approval',
547
- architecture: 'require_approval',
548
- story: execMode === 'autopilot' ? 'notify_only' : 'require_approval',
549
- delivery: 'require_approval',
550
- },
551
- // Human acceptance recipes. advisory: every delivery produces and offers its page and
552
- // nothing is ever blocked. gate: the delivery waits for a passing "bmad-plus uat gate".
553
- // off: no recipe, and the delivery report says so.
554
- uat: {
555
- mode: 'advisory',
556
- granularity: 'delivery',
557
- environment: { name: uatEnvironment ? 'TEST' : '', url: uatEnvironment || '' },
558
- page_budget: { max_steps: 15, max_minutes: 30 },
559
- dir: '_bmad-output/uat',
560
- },
561
- }, { lineWidth: -1, forceQuotes: true, quotingType: '"' });
660
+ function packLabel(id) {
661
+ const icon = DERIVED.packs[id]?.iconEmoji;
662
+ return (icon ? icon + ' ' : '') + PACKS[id].name;
663
+ }
664
+
665
+ /** Refuse any version change; `update` owns upgrades and downgrades are never silent. */
666
+ function installedVersionConflict(installed, executing) {
667
+ if (
668
+ !installed ||
669
+ installed === executing ||
670
+ !isExactVersion(installed) ||
671
+ !isExactVersion(executing)
672
+ )
673
+ return null;
674
+ if (semver.gt(installed, executing)) {
675
+ return (
676
+ `Downgrade refused: BMAD+ v${installed} is installed and this package is v${executing}. ` +
677
+ 'Run a newer package (npx bmad-plus@latest install), or `bmad-plus update --latest` to check for updates.'
678
+ );
679
+ }
680
+ return (
681
+ `BMAD+ v${installed} is installed; this package is v${executing}. ` +
682
+ 'Run `bmad-plus update` first (it backs up changed files and records a restorable receipt), ' +
683
+ 'then re-run install to add packs or tools.'
684
+ );
685
+ }
686
+
687
+ function parseList(value) {
688
+ return String(value)
689
+ .split(',')
690
+ .map((item) => item.trim())
691
+ .filter(Boolean);
692
+ }
693
+
694
+ /** Explicit pack IDs; unknown IDs fail instead of being silently dropped. */
695
+ function parseRequestedPacks(value) {
696
+ if (value === undefined) return null;
697
+ const requested = parseList(value);
698
+ const available = Object.keys(PACKS).filter((id) => !PACKS[id].disabled);
699
+ if (requested.includes('all')) return available;
700
+ const unknown = requested.filter((id) => id !== 'none' && !available.includes(id));
701
+ if (unknown.length) {
702
+ throw new Error(
703
+ `Unknown pack ID(s): ${unknown.join(', ')}. Available: ${available.join(', ')}, all.`
704
+ );
705
+ }
706
+ return requested.filter((id) => id !== 'none');
707
+ }
708
+
709
+ function parseRequestedTools(value) {
710
+ if (value === undefined) return null;
711
+ if (value === 'none' || value === 'skip') return [];
712
+ const available = Object.keys(IDE_CONFIGS);
713
+ const requested = parseList(value);
714
+ if (requested.includes('all')) return available;
715
+ const unknown = requested.filter((id) => !available.includes(id));
716
+ if (unknown.length) {
717
+ throw new Error(
718
+ `Unknown tool ID(s): ${unknown.join(', ')}. Available: ${available.join(', ')}, all, none.`
719
+ );
720
+ }
721
+ return [...new Set(requested)];
722
+ }
723
+
724
+ /** Communication language name from --lang, then the system locale; English otherwise. */
725
+ function communicationLanguage(code, locale = Intl.DateTimeFormat().resolvedOptions().locale) {
726
+ const candidates = [code, locale, String(locale || '').split('-')[0]]
727
+ .filter(Boolean)
728
+ .map((value) => String(value).toLowerCase());
729
+ for (const candidate of candidates) {
730
+ if (Object.hasOwn(LANGUAGES, candidate)) return LANGUAGES[candidate].name;
731
+ }
732
+ return 'English';
733
+ }
734
+
735
+ function readExistingConfig(projectDir) {
736
+ try {
737
+ const parsed = yaml.load(
738
+ fs.readFileSync(path.join(projectDir, '_bmad', 'config.yaml'), 'utf8')
739
+ );
740
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
741
+ } catch {
742
+ return null;
743
+ }
744
+ }
745
+
746
+ const isPlainObject = (value) =>
747
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value);
748
+
749
+ /** Existing keys win, missing defaults are added; explicit answers replace. */
750
+ function mergeSettings(existing, defaults, explicit) {
751
+ const merged = { ...existing };
752
+ for (const [key, value] of Object.entries(defaults)) {
753
+ if (!(key in merged)) merged[key] = value;
754
+ else if (isPlainObject(merged[key]) && isPlainObject(value))
755
+ merged[key] = mergeSettings(merged[key], value, {});
756
+ }
757
+ for (const [key, value] of Object.entries(explicit)) {
758
+ merged[key] =
759
+ isPlainObject(value) && isPlainObject(merged[key])
760
+ ? mergeSettings(merged[key], {}, value)
761
+ : value;
762
+ }
763
+ return merged;
764
+ }
765
+
766
+ /** Returns null when the existing file is not a YAML mapping (left untouched). */
767
+ function mergeConfigYaml(existingText, generatedText, explicit = {}) {
768
+ let existing;
769
+ try {
770
+ existing = yaml.load(existingText);
771
+ } catch {
772
+ return null;
773
+ }
774
+ if (!isPlainObject(existing)) return null;
775
+ const merged = mergeSettings(existing, yaml.load(generatedText), explicit);
776
+ if (isDeepStrictEqual(merged, existing)) return { changed: false, content: existingText };
777
+ return {
778
+ changed: true,
779
+ content:
780
+ '# BMAD+ Project Configuration\n# Generated by bmad-plus install; existing settings preserved\n\n' +
781
+ yaml.dump(merged, { lineWidth: -1, forceQuotes: true, quotingType: '"' }),
782
+ };
783
+ }
784
+
785
+ function generateConfigYaml(
786
+ userName,
787
+ language,
788
+ projectDir,
789
+ execMode = 'manual',
790
+ uatEnvironment = ''
791
+ ) {
792
+ if (!['manual', 'autopilot', 'hybrid'].includes(execMode))
793
+ throw new Error('Unknown execution mode: ' + execMode);
794
+ return (
795
+ '# BMAD+ Project Configuration\n# Generated by bmad-plus install\n\n' +
796
+ yaml.dump(
797
+ {
798
+ user_name: userName,
799
+ communication_language: language,
800
+ document_output_language: language,
801
+ output_folder: '_bmad-output',
802
+ project_name: path.basename(projectDir),
803
+ execution_mode: execMode,
804
+ auto_role_activation: true,
805
+ parallel_execution: true,
806
+ checkpoints: {
807
+ discovery: 'require_approval',
808
+ architecture: 'require_approval',
809
+ story: execMode === 'autopilot' ? 'notify_only' : 'require_approval',
810
+ delivery: 'require_approval',
811
+ },
812
+ // Human acceptance recipes. advisory: every delivery produces and offers its page and
813
+ // nothing is ever blocked. gate: the delivery waits for a passing "bmad-plus uat gate".
814
+ // off: no recipe, and the delivery report says so.
815
+ uat: {
816
+ mode: 'advisory',
817
+ granularity: 'delivery',
818
+ environment: { name: uatEnvironment ? 'TEST' : '', url: uatEnvironment || '' },
819
+ page_budget: { max_steps: 15, max_minutes: 30 },
820
+ dir: '_bmad-output/uat',
821
+ },
822
+ },
823
+ { lineWidth: -1, forceQuotes: true, quotingType: '"' }
824
+ )
825
+ );
562
826
  }