bmad-plus 0.12.2 → 0.13.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 (44) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +36 -8
  3. package/package.json +6 -4
  4. package/readme-international/README.de.md +37 -8
  5. package/readme-international/README.es.md +38 -9
  6. package/readme-international/README.fr.md +37 -8
  7. package/src/bmad-plus/agents/agent-orchestrator/SKILL.md +2 -0
  8. package/src/bmad-plus/module.yaml +270 -220
  9. package/src/bmad-plus/packs/pack-seo/scripts/seo_apis.py +8 -8
  10. package/src/bmad-plus/packs/pack-seo/scripts/seo_fetch.py +1 -2
  11. package/src/bmad-plus/packs/pack-seo/scripts/seo_report.py +0 -1
  12. package/src/bmad-plus/skills/bmad-plus-autopilot/SKILL.md +1 -1
  13. package/tools/bmad-plus-npx.js +4 -2
  14. package/tools/build/adapters.config.js +60 -51
  15. package/tools/build/check-counts.js +52 -54
  16. package/tools/build/check-install-contract.js +298 -0
  17. package/tools/build/generate-adapters.js +252 -56
  18. package/tools/build/generate.js +187 -10
  19. package/tools/build/generated-adapters/.codex/AGENTS.md +20 -7
  20. package/tools/build/generated-adapters/.cursor/rules/bmad-plus.mdc +20 -7
  21. package/tools/build/generated-adapters/.opencode/AGENTS.md +20 -7
  22. package/tools/build/generated-adapters/AGENTS.md +20 -7
  23. package/tools/build/generated-adapters/CLAUDE.md +20 -7
  24. package/tools/build/generated-adapters/CONVENTIONS.md +20 -7
  25. package/tools/build/generated-adapters/GEMINI.md +20 -7
  26. package/tools/build/module.template.yaml +82 -0
  27. package/tools/cli/bmad-plus-cli.js +16 -1
  28. package/tools/cli/commands/doctor.js +12 -40
  29. package/tools/cli/commands/install.js +108 -163
  30. package/tools/cli/commands/uninstall.js +173 -65
  31. package/tools/cli/commands/update-check.js +31 -0
  32. package/tools/cli/commands/update-policy.js +39 -0
  33. package/tools/cli/commands/update.js +102 -113
  34. package/tools/cli/i18n.js +60 -0
  35. package/tools/cli/lib/ide-config.js +4 -261
  36. package/tools/cli/lib/install-manifest.js +17 -0
  37. package/tools/cli/lib/installed-adapters.js +89 -0
  38. package/tools/cli/lib/npm-runner.js +177 -0
  39. package/tools/cli/lib/pack-copy.js +62 -66
  40. package/tools/cli/lib/packs.js +437 -3
  41. package/tools/cli/lib/update-check.js +153 -0
  42. package/tools/cli/lib/update-dispatch.js +182 -0
  43. package/tools/cli/lib/update-policy.js +90 -0
  44. package/tools/cli/lib/update-transaction.js +334 -0
@@ -12,59 +12,18 @@ const fsExtra = require('fs-extra');
12
12
  const clack = require('@clack/prompts');
13
13
  const pc = require('picocolors');
14
14
  const { t, getLanguageOptions, getCommLanguageOptions } = require('../i18n');
15
- const { PACKS } = require('../lib/packs');
16
- const { copyPackFiles } = require('../lib/pack-copy');
15
+ const { PACKS, DERIVED } = require('../lib/packs');
16
+ const { copyPackFiles, copyManagedFile } = require('../lib/pack-copy');
17
+ const { acquireProjectLock, inventoryOf, collectManagedFiles } = require('../lib/update-transaction');
17
18
  const { initMemory } = require('../lib/memory-init');
18
- const { generateIDEConfig, IDE_CONFIGS } = require('../lib/ide-config');
19
+ const { generateUserFiles, IDE_CONFIGS } = require('../../build/generate-adapters');
20
+ const { writeIDEConfigs, safeAdapterPath } = require('../lib/installed-adapters');
21
+ const { readInstallManifest } = require('../lib/install-manifest');
22
+ const yaml = require('js-yaml');
19
23
  const { validateUserName } = require('../lib/validate');
20
24
  const { provisionPack } = require('../lib/python-provision');
21
25
 
22
- // Packs whose runtime includes Python (registry.yaml: runtime includes "python").
23
- // Provisioned only when the user opts in with --provision-python, so the default
24
- // install (and the test suite) never spawns Python. Kills SEO "broken on arrival".
25
- // Paths are resolved relative to the PACKAGE root (projectRoot below), so they
26
- // must live under a directory shipped in package.json "files". The SEO toolkit
27
- // is embarked in-pack (src/bmad-plus/packs/pack-seo) precisely so npm consumers
28
- // receive the scripts + requirements the SKILL.md commands them to run.
29
- const PYTHON_PACKS = {
30
- seo: {
31
- requirements: ['src', 'bmad-plus', 'packs', 'pack-seo', 'requirements.txt'],
32
- verifyModules: ['requests', 'bs4', 'defusedxml', 'lxml'],
33
- },
34
- memory: {
35
- // NOTE: mcp-server is not in package.json "files" → this still warn-skips for
36
- // npm consumers (memory RAG provisioning is a separate, pre-existing gap).
37
- requirements: ['mcp-server', 'requirements.txt'],
38
- verifyModules: ['chromadb'],
39
- },
40
- };
41
-
42
- // Pack definitions are imported from the shared module: require('../lib/packs').PACKS
43
-
44
- // ── Root-config ownership markers (PROD-01) ──
45
- //
46
- // install writes CLAUDE.md / GEMINI.md / AGENTS.md, but TWO producers own those
47
- // same paths, each stamping its own marker:
48
- // - lib/ide-config.js → INSTALLER_MARKER (this installer's template)
49
- // - tools/build/generate-adapters.js → GENERATED_MARKER (registry-generated spine)
50
- // Recognizing only the first is what let `install --yes` clobber a generated spine.
51
- const INSTALLER_MARKER = 'BMAD+ — AI Agent Configuration';
52
- const GENERATOR_MARKER_FALLBACK = 'AUTO-GENERATED by tools/build/generate-adapters.js';
53
-
54
- /**
55
- * The marker the adapter generator stamps on every root file it owns.
56
- * Read from the generator's own export so the two cannot drift apart — a
57
- * hand-typed copy is how INSTALLER_MARKER drifted from the spine in the first
58
- * place. The fallback only covers trees published without the build tooling;
59
- * tests/unit/install-spine.test.js pins both values to their real producer.
60
- */
61
- function resolveGeneratorMarker() {
62
- try {
63
- return require('../../build/generate-adapters').GENERATED_MARKER || GENERATOR_MARKER_FALLBACK;
64
- } catch {
65
- return GENERATOR_MARKER_FALLBACK;
66
- }
67
- }
26
+ const PYTHON_PACKS = DERIVED.pythonPacks;
68
27
 
69
28
  module.exports = {
70
29
  command: 'install',
@@ -74,12 +33,34 @@ module.exports = {
74
33
  ['-p, --packs <packs>', 'Comma-separated pack IDs: core,osint,all (default: interactive)'],
75
34
  ['-y, --yes', 'Accept all defaults, skip prompts'],
76
35
  ['-l, --lang <code>', 'Language code: en, fr, es, de, pt-br, ru, zh, he, ja, it (overrides auto-detection)'],
77
- ['--tools <tools>', 'Comma-separated IDE IDs (default: auto-detect)'],
36
+ ['--tools <tools>', 'Comma-separated IDE IDs, all, or none (default: auto-detect)'],
37
+ ['--mode <mode>', 'Execution mode: manual, autopilot, hybrid (default: manual)'],
78
38
  ['--provision-python', 'Provision an isolated Python env for packs that need one (seo, memory)'],
79
39
  ],
80
40
  action: async (options) => {
41
+ // A closed/piped stdin cannot answer prompts. Require explicit defaults
42
+ // before creating files, instead of silently exiting halfway through UI.
43
+ if (!options.yes && !process.stdin.isTTY) {
44
+ const i = t(options.lang || 'en');
45
+ clack.log.error(i.noninteractive_requires_yes('install'));
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+
81
50
  const projectDir = path.resolve(options.directory || process.cwd());
82
51
  const bmadSrc = path.join(__dirname, '..', '..', '..', 'src', 'bmad-plus');
52
+ const previousManifestPath = safeAdapterPath(projectDir, '_bmad/.bmad-plus-install.json');
53
+ const previousManifestBytes = fs.existsSync(previousManifestPath) ? fs.readFileSync(previousManifestPath) : null;
54
+ let previousManifest = null;
55
+ let manifestError;
56
+ if (fs.existsSync(previousManifestPath)) {
57
+ try {
58
+ previousManifest = readInstallManifest(previousManifestPath);
59
+ } catch (error) {
60
+ manifestError = error;
61
+ }
62
+ }
63
+ if (options.mode && !['manual', 'autopilot', 'hybrid'].includes(options.mode)) throw new Error('Unknown execution mode: ' + options.mode);
83
64
 
84
65
  // ── Step 0: Language Selection ──
85
66
  const pkgJson = require('../../../package.json');
@@ -151,6 +132,8 @@ module.exports = {
151
132
  if (options.tools) {
152
133
  if (options.tools === 'none' || options.tools === 'skip') {
153
134
  detectedIDEs = [];
135
+ } else if (options.tools === 'all') {
136
+ detectedIDEs = Object.keys(IDE_CONFIGS);
154
137
  } else {
155
138
  detectedIDEs = options.tools.split(',').map(t => t.trim()).filter(t => IDE_CONFIGS[t]);
156
139
  }
@@ -196,6 +179,7 @@ module.exports = {
196
179
  // ── Step 3: User Config ──
197
180
  let userName = process.env.USER || process.env.USERNAME || 'Developer';
198
181
  let commLang = 'French';
182
+ let execMode = options.mode || 'manual';
199
183
 
200
184
  if (!options.yes) {
201
185
  const userConfig = await clack.group({
@@ -230,6 +214,37 @@ module.exports = {
230
214
  for (const w of warnings) clack.log.warn(w);
231
215
  userName = validatedName;
232
216
  commLang = userConfig.commLang;
217
+ execMode = options.mode || userConfig.execMode;
218
+ }
219
+
220
+ const releaseLock = acquireProjectLock(projectDir);
221
+ try {
222
+ const { safeTarget } = require('../../build/generate-adapters');
223
+ const plannedFiles = collectManagedFiles({ projectDir, packs: selectedPacks });
224
+ if (detectedIDEs.length) plannedFiles.push(...generateUserFiles(DERIVED, {
225
+ packs: selectedPacks, tools: detectedIDEs, userName, language: commLang,
226
+ }));
227
+ for (const { file } of plannedFiles) safeTarget(projectDir, file);
228
+ safeTarget(projectDir, '_bmad/config.yaml');
229
+ safeTarget(projectDir, '_bmad/.bmad-plus-install.json');
230
+ for (const directory of ['.agents/skills', '.agents/data', '_bmad', '_bmad-output/discovery', '_bmad-output/build', 'docs']) {
231
+ safeTarget(projectDir, directory + '/.bmad-preflight');
232
+ }
233
+ const currentManifestBytes = fs.existsSync(previousManifestPath) ? fs.readFileSync(previousManifestPath) : null;
234
+ if (previousManifestBytes === null ? currentManifestBytes !== null :
235
+ currentManifestBytes === null || !previousManifestBytes.equals(currentManifestBytes)) {
236
+ throw new Error('Installation changed while preparing install. Please retry.');
237
+ }
238
+ // Preserve the unreadable manifest before repair; do not trust its ownership
239
+ // hashes. The adapter writer will retain locally edited user instructions.
240
+ if (manifestError) {
241
+ const backupBase = '_bmad/.bmad-plus-install.json.corrupt.bak';
242
+ let backupFile = backupBase;
243
+ let index = 1;
244
+ while (fs.existsSync(safeAdapterPath(projectDir, backupFile))) backupFile = `${backupBase}.${index++}`;
245
+ const backupPath = safeAdapterPath(projectDir, backupFile);
246
+ fs.copyFileSync(safeAdapterPath(projectDir, '_bmad/.bmad-plus-install.json'), backupPath, fs.constants.COPYFILE_EXCL);
247
+ clack.log.warn(`${i.manifest_invalid(manifestError.message)}\nBackup: ${backupPath}`);
233
248
  }
234
249
 
235
250
  // ── Step 4: Install Files ──
@@ -248,6 +263,13 @@ module.exports = {
248
263
  let copiedAgents = 0;
249
264
  let copiedSkills = 0;
250
265
  let copiedFiles = 0;
266
+ const previousInventory = inventoryOf(previousManifest || {}) || {};
267
+ const fileInventory = { ...previousInventory };
268
+ let inventoryComplete = true;
269
+ const onFileConflict = file => {
270
+ if (!previousInventory[file]) inventoryComplete = false;
271
+ clack.log.warn(`${file}: locally changed or unowned content preserved.`);
272
+ };
251
273
 
252
274
  const projectRoot = path.join(bmadSrc, '..', '..');
253
275
 
@@ -261,6 +283,7 @@ module.exports = {
261
283
  targetDataDir,
262
284
  projectRoot,
263
285
  pack,
286
+ projectDir, inventory: fileInventory, previousInventory, onConflict: onFileConflict,
264
287
  });
265
288
  copiedAgents += result.copiedAgents;
266
289
  copiedSkills += result.copiedSkills;
@@ -311,35 +334,39 @@ module.exports = {
311
334
  // Copy module config
312
335
  const moduleYaml = path.join(bmadSrc, 'module.yaml');
313
336
  if (fs.existsSync(moduleYaml)) {
314
- fsExtra.copySync(moduleYaml, path.join(targetBmadDir, 'module.yaml'));
337
+ copyManagedFile({ source: moduleYaml, target: path.join(targetBmadDir, 'module.yaml'),
338
+ projectDir, inventory: fileInventory, previousInventory, onConflict: onFileConflict });
315
339
  copiedFiles++;
316
340
  }
317
341
 
318
342
  const helpCsv = path.join(bmadSrc, 'module-help.csv');
319
343
  if (fs.existsSync(helpCsv)) {
320
- fsExtra.copySync(helpCsv, path.join(targetBmadDir, 'module-help.csv'));
344
+ copyManagedFile({ source: helpCsv, target: path.join(targetBmadDir, 'module-help.csv'),
345
+ projectDir, inventory: fileInventory, previousInventory, onConflict: onFileConflict });
321
346
  copiedFiles++;
322
347
  }
323
348
 
324
349
  spinner.stop(i.installed_summary(copiedAgents, copiedSkills, copiedFiles));
325
350
 
326
351
  // ── Step 5: Generate IDE Configs ──
352
+ let adapterHashes = previousManifest?.adapterHashes || {};
327
353
  if (detectedIDEs.length > 0) {
328
354
  const ideSpinner = clack.spinner();
329
355
  ideSpinner.start(i.configuring_ides);
330
356
 
331
- writeIDEConfigs({
357
+ const outcome = writeIDEConfigs({
332
358
  projectDir,
333
- detectedIDEs,
334
- configContent: generateIDEConfig(userName, commLang, selectedPacks),
359
+ files: generateUserFiles(DERIVED, { packs: selectedPacks, userName, language: commLang, tools: detectedIDEs }),
360
+ adapterHashes,
335
361
  yes: options.yes,
336
362
  });
363
+ adapterHashes = { ...adapterHashes, ...outcome.adapterHashes };
337
364
 
338
365
  ideSpinner.stop(i.ide_configured(detectedIDEs.length));
339
366
  }
340
367
 
341
368
  // ── Step 6: Create config.yaml ──
342
- const configYaml = generateConfigYaml(userName, commLang, projectDir);
369
+ const configYaml = generateConfigYaml(userName, commLang, projectDir, execMode);
343
370
  const configPath = path.join(targetBmadDir, 'config.yaml');
344
371
  fs.writeFileSync(configPath, configYaml, 'utf8');
345
372
 
@@ -357,6 +384,9 @@ module.exports = {
357
384
  installed: new Date().toISOString(),
358
385
  packs: selectedPacks,
359
386
  ides: detectedIDEs,
387
+ adapterHashes,
388
+ fileInventory: { schemaVersion: 1, complete: inventoryComplete, files: fileInventory },
389
+ executionMode: execMode,
360
390
  user: userName,
361
391
  language: commLang,
362
392
  };
@@ -484,115 +514,30 @@ module.exports = {
484
514
  clack.note(agentGuide.join('\n'), i.guide_title);
485
515
 
486
516
  clack.outro(pc.green(i.guide_ready));
517
+ } finally {
518
+ releaseLock();
519
+ }
487
520
  },
488
521
  // Exported for functional testing (not part of the public API)
489
- _internal: {
490
- INSTALLER_MARKER,
491
- GENERATOR_MARKER_FALLBACK,
492
- resolveGeneratorMarker,
493
- writeIDEConfigs,
494
- },
522
+ _internal: { writeIDEConfigs, generateConfigYaml },
495
523
  };
496
524
 
497
-
498
- // ── Helpers ──
499
-
500
- /**
501
- * Write the IDE config files, never destroying one this installer does not own.
502
- *
503
- * An existing config falls into one of three classes, tested in this order:
504
- *
505
- * 1. GENERATOR-MANAGED (carries the adapter generator's marker) — SKIPPED, even
506
- * under --yes. Such a file is a pure function of registry.yaml +
507
- * tools/build/adapters.config.js, and the installer template is a strict
508
- * subset of it (no registry facts), so overwriting is a downgrade that also
509
- * breaks the `generate-adapters.js --check` drift gate. A .bak does not
510
- * rescue it: every tool reads the live path, not the backup. The repair is
511
- * regeneration, not merging so install defers and points at the generator.
512
- * 2. HAND-AUTHORED (no BMAD+ marker) — kept as-is interactively; backed up then
513
- * overwritten under --yes (NODE-02).
514
- * 3. INSTALLER-GENERATED (carries INSTALLER_MARKER) refreshed in place.
515
- *
516
- * @param {object} args
517
- * @param {string} args.projectDir - Absolute path to the target project
518
- * @param {string[]} args.detectedIDEs - IDE ids to configure (keys of IDE_CONFIGS)
519
- * @param {string} args.configContent - Rendered config body to write
520
- * @param {boolean} [args.yes] - Non-interactive mode (--yes)
521
- * @returns {{written: string[], skipped: string[], backedUp: string[]}} config file names per outcome
522
- */
523
- function writeIDEConfigs({ projectDir, detectedIDEs, configContent, yes }) {
524
- const generatorMarker = resolveGeneratorMarker();
525
- // Several IDEs share one file (gemini-cli + antigravity → GEMINI.md): dedupe
526
- // outcomes by file name so a shared file is reported — and warned about — once.
527
- const outcome = { written: new Set(), skipped: new Set(), backedUp: new Set() };
528
-
529
- for (const ideId of detectedIDEs) {
530
- const ide = IDE_CONFIGS[ideId];
531
- if (!ide) continue;
532
-
533
- const configPath = path.join(projectDir, ide.configFile);
534
-
535
- if (fs.existsSync(configPath)) {
536
- const existing = fs.readFileSync(configPath, 'utf8');
537
-
538
- // PROD-01: the adapter generator owns this file — defer to it.
539
- if (existing.includes(generatorMarker)) {
540
- if (!outcome.skipped.has(ide.configFile)) {
541
- clack.log.warn(
542
- `⚠️ ${ide.configFile} is generated by tools/build/generate-adapters.js — left untouched. ` +
543
- `Run \`node tools/build/generate-adapters.js --adopt\` to refresh it from registry.yaml.`
544
- );
545
- }
546
- outcome.skipped.add(ide.configFile);
547
- continue;
548
- }
549
-
550
- // NODE-02: never clobber a hand-authored config without protecting it.
551
- if (!existing.includes(INSTALLER_MARKER)) {
552
- if (!yes) {
553
- // Interactive: keep the user's file untouched.
554
- if (!outcome.skipped.has(ide.configFile)) {
555
- clack.log.warn(`⚠️ ${ide.configFile} already exists and was not created by BMAD+ — kept your file (skipped). Re-run with --yes to back it up and overwrite.`);
556
- }
557
- outcome.skipped.add(ide.configFile);
558
- continue;
559
- }
560
- // Non-interactive: back up before overwriting, never destroy data.
561
- if (!outcome.backedUp.has(ide.configFile)) {
562
- let backupPath = `${configPath}.bak`;
563
- if (fs.existsSync(backupPath)) backupPath = `${configPath}.${Date.now()}.bak`;
564
- fs.copyFileSync(configPath, backupPath);
565
- clack.log.warn(`⚠️ Backed up existing ${ide.configFile} → ${path.basename(backupPath)}`);
566
- outcome.backedUp.add(ide.configFile);
567
- }
568
- }
569
- }
570
-
571
- fs.writeFileSync(configPath, configContent, 'utf8');
572
- outcome.written.add(ide.configFile);
573
- }
574
-
575
- return {
576
- written: [...outcome.written],
577
- skipped: [...outcome.skipped],
578
- backedUp: [...outcome.backedUp],
579
- };
580
- }
581
-
582
- function generateConfigYaml(userName, language, projectDir) {
583
- const projectName = path.basename(projectDir);
584
- return `# BMAD+ Project Configuration
585
- # Generated by bmad-plus install
586
-
587
- user_name: "${userName}"
588
- communication_language: "${language}"
589
- document_output_language: "${language}"
590
- output_folder: "_bmad-output"
591
- project_name: "${projectName}"
592
-
593
- # Execution settings
594
- execution_mode: "manual"
595
- auto_role_activation: true
596
- parallel_execution: true
597
- `;
525
+ function generateConfigYaml(userName, language, projectDir, execMode = 'manual') {
526
+ if (!['manual', 'autopilot', 'hybrid'].includes(execMode)) throw new Error('Unknown execution mode: ' + execMode);
527
+ return '# BMAD+ Project Configuration\n# Generated by bmad-plus install\n\n' + yaml.dump({
528
+ user_name: userName,
529
+ communication_language: language,
530
+ document_output_language: language,
531
+ output_folder: '_bmad-output',
532
+ project_name: path.basename(projectDir),
533
+ execution_mode: execMode,
534
+ auto_role_activation: true,
535
+ parallel_execution: true,
536
+ checkpoints: {
537
+ discovery: 'require_approval',
538
+ architecture: 'require_approval',
539
+ story: execMode === 'autopilot' ? 'notify_only' : 'require_approval',
540
+ delivery: 'require_approval',
541
+ },
542
+ }, { lineWidth: -1, forceQuotes: true, quotingType: '"' });
598
543
  }
@@ -1,101 +1,209 @@
1
1
  /**
2
2
  * BMAD+ Uninstall Command
3
- * Removes BMAD+ agents, skills, and configs from the current project
4
- * Internationalized — uses i18n system
5
- *
3
+ * Removes identifiable BMAD+ files while preserving project memory and edits.
6
4
  * Author: Laurent Rochetta
7
5
  */
8
6
 
9
7
  const path = require('node:path');
10
8
  const fs = require('node:fs');
11
- const fsExtra = require('fs-extra');
12
9
  const clack = require('@clack/prompts');
13
10
  const pc = require('picocolors');
14
11
  const { t } = require('../i18n');
12
+ const { PACKS, DERIVED } = require('../lib/packs');
13
+ const { readInstallManifest } = require('../lib/install-manifest');
14
+ const { generateUserFiles, USER_CONFIG_MARKER } = require('../../build/generate-adapters');
15
+ const { contentHash } = require('../lib/installed-adapters');
16
+
17
+ // Never follow a junction/symlink in a path used for deletion, including parents.
18
+ function safeStat(projectDir, target) {
19
+ const relative = path.relative(projectDir, target);
20
+ if (!relative || relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) {
21
+ throw new Error('Path outside the project: ' + target);
22
+ }
23
+ let current = projectDir;
24
+ let stat;
25
+ for (const part of relative.split(path.sep)) {
26
+ current = path.join(current, part);
27
+ try {
28
+ stat = fs.lstatSync(current);
29
+ } catch (err) {
30
+ if (err.code === 'ENOENT') return undefined;
31
+ throw err;
32
+ }
33
+ if (stat.isSymbolicLink()) {
34
+ const error = new Error('Symbolic link or junction: ' + current);
35
+ error.code = 'BMAD_UNSAFE_LINK';
36
+ throw error;
37
+ }
38
+ }
39
+ return stat;
40
+ }
41
+
42
+ function removalPlan(projectDir, manifest, manifestPath) {
43
+ const packageRoot = path.join(__dirname, '..', '..', '..');
44
+ const sourceRoot = path.join(packageRoot, 'src', 'bmad-plus');
45
+ const files = new Map();
46
+ const directories = new Set();
47
+ const targetPath = (...parts) => path.join(projectDir, ...parts);
48
+
49
+ // Old manifests contain pack IDs, not file hashes. Match shipped files byte for
50
+ // byte; anything unknown or modified stays, including files inside a pack.
51
+ function matchSource(source, target) {
52
+ const targetStat = safeStat(projectDir, target);
53
+ if (!targetStat || !fs.existsSync(source)) return;
54
+ const sourceStat = fs.lstatSync(source);
55
+ if (sourceStat.isDirectory() && targetStat.isDirectory()) {
56
+ directories.add(target);
57
+ for (const name of fs.readdirSync(source)) matchSource(path.join(source, name), path.join(target, name));
58
+ } else if (sourceStat.isFile() && targetStat.isFile()) {
59
+ const expected = fs.readFileSync(source);
60
+ if (fs.readFileSync(target).equals(expected)) files.set(target, expected);
61
+ }
62
+ }
63
+
64
+ for (const relative of ['.agents', '.agents/skills', '.agents/data', '_bmad', '_bmad-output']) {
65
+ const target = targetPath(relative);
66
+ if (safeStat(projectDir, target)?.isDirectory()) directories.add(target);
67
+ }
68
+
69
+ for (const packId of manifest.packs) {
70
+ if (!Object.hasOwn(PACKS, packId)) throw new Error('Unknown installed pack: ' + packId);
71
+ const pack = PACKS[packId];
72
+ for (const name of pack.agents || []) matchSource(path.join(sourceRoot, 'agents', name), targetPath('.agents', 'skills', name));
73
+ for (const name of pack.skills || []) matchSource(path.join(sourceRoot, 'skills', name), targetPath('.agents', 'skills', name));
74
+ for (const name of pack.data || []) matchSource(path.join(sourceRoot, 'data', name), targetPath('.agents', 'data', name));
75
+ if (pack.packDir) matchSource(path.join(sourceRoot, pack.packSrcDir || 'agents', pack.packDir), targetPath('.agents', 'skills', pack.packDir));
76
+ if (pack.externalPackage) matchSource(path.join(packageRoot, pack.externalPackage, 'skills'), targetPath('.agents', 'skills'));
77
+ }
78
+ for (const name of ['module.yaml', 'module-help.csv']) matchSource(path.join(sourceRoot, name), targetPath('_bmad', name));
79
+ const renderOptions = {
80
+ packs: manifest.packs,
81
+ userName: typeof manifest.user === 'string' ? manifest.user : 'Developer',
82
+ language: typeof manifest.language === 'string' ? manifest.language : 'English',
83
+ };
84
+ const expectedFiles = new Map(generateUserFiles(DERIVED, renderOptions).map(entry => [entry.file, entry.content]));
85
+ const hashes = manifest.adapterHashes && typeof manifest.adapterHashes === 'object' && !Array.isArray(manifest.adapterHashes)
86
+ ? manifest.adapterHashes : {};
87
+ // Only registry-declared paths can become deletion candidates, even when the
88
+ // manifest contains arbitrary hash keys. Keep previously owned tool targets.
89
+ const claimed = new Set(Object.keys(hashes).filter(file => expectedFiles.has(file)));
90
+ if (Array.isArray(manifest.ides)) {
91
+ const tools = manifest.ides.filter(id => DERIVED.targets.adapters.some(adapter => adapter.tool === id));
92
+ if (tools.length) {
93
+ for (const entry of generateUserFiles(DERIVED, { ...renderOptions, tools })) {
94
+ claimed.add(entry.file);
95
+ expectedFiles.set(entry.file, entry.content);
96
+ }
97
+ }
98
+ }
99
+ const legacyFallback = !Array.isArray(manifest.ides) && Object.keys(hashes).length === 0;
100
+ for (const [file, rendered] of expectedFiles) {
101
+ if (!legacyFallback && !claimed.has(file)) continue;
102
+ const target = targetPath(file);
103
+ let stat;
104
+ try {
105
+ stat = safeStat(projectDir, target);
106
+ } catch (error) {
107
+ // A link with no ownership evidence is user content, not an uninstall target.
108
+ if (legacyFallback && error.code === 'BMAD_UNSAFE_LINK') continue;
109
+ throw error;
110
+ }
111
+ if (stat?.isFile()) {
112
+ const content = fs.readFileSync(target);
113
+ const text = content.toString('utf8');
114
+ const expectedHash = hashes[file];
115
+ const matches = expectedHash ? contentHash(text) === expectedHash : text === rendered;
116
+ if (text.includes(USER_CONFIG_MARKER) && matches) {
117
+ files.set(target, content);
118
+ let parent = path.dirname(target);
119
+ while (parent !== projectDir) {
120
+ directories.add(parent);
121
+ parent = path.dirname(parent);
122
+ }
123
+ }
124
+ }
125
+ }
126
+ // Keep config.yaml: it contains the user's settings and has no ownership hash.
127
+ files.set(manifestPath, fs.readFileSync(manifestPath));
128
+ return { files, directories };
129
+ }
15
130
 
16
131
  module.exports = {
17
132
  command: 'uninstall',
18
133
  description: 'Remove BMAD+ from your project',
19
134
  options: [
135
+ ['-y, --yes', 'Confirm removal without prompting'],
20
136
  ['-l, --lang <code>', 'Language code: en, fr, es, de, pt-br, ru, zh, he, ja, it'],
21
137
  ],
22
138
  action: async (options) => {
23
- const projectDir = process.cwd();
24
-
139
+ const projectDir = fs.realpathSync(process.cwd());
140
+ let i = t(options.lang || 'en');
25
141
  clack.intro(pc.bgRed(pc.white(' BMAD+ Uninstaller ')));
26
142
 
27
- // Check if installed
28
143
  const manifestPath = path.join(projectDir, '_bmad', '.bmad-plus-install.json');
29
- if (!fs.existsSync(manifestPath)) {
30
- clack.log.warn('BMAD+ is not installed in this directory.');
31
- clack.outro('Nothing to remove.');
144
+ let manifest;
145
+ try {
146
+ if (!safeStat(projectDir, manifestPath)) {
147
+ clack.log.warn(i.uninstall_not_installed);
148
+ return;
149
+ }
150
+ manifest = readInstallManifest(manifestPath);
151
+ } catch (err) {
152
+ clack.log.error(i.manifest_invalid(err.message));
153
+ clack.outro(pc.red(i.manifest_repair));
154
+ process.exitCode = 1;
32
155
  return;
33
156
  }
34
-
35
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
36
-
37
- // Use --lang flag, or fall back to manifest language
38
- let lang = options.lang || manifest.uiLanguage || 'en';
39
- const i = t(lang);
40
-
41
- clack.log.info(`BMAD+ v${manifest.version} (${i.installed_on || 'installed'} ${manifest.installed.split('T')[0]})`);
42
- clack.log.info(`${i.selected_packs}: ${(manifest.packs || ['core']).join(', ')}`);
43
-
44
- const confirm = await clack.confirm({
45
- message: i.uninstall_confirm || 'Remove BMAD+ from this project?',
46
- });
47
-
48
- if (!confirm || clack.isCancel(confirm)) {
49
- clack.cancel(i.cancelled);
157
+ i = t(options.lang || manifest.uiLanguage || 'en');
158
+ if (!options.yes && !process.stdin.isTTY) {
159
+ clack.log.error(i.noninteractive_requires_yes('uninstall'));
160
+ process.exitCode = 1;
50
161
  return;
51
162
  }
52
163
 
53
- const spinner = clack.spinner();
54
- spinner.start(i.uninstall_removing || 'Removing...');
55
-
56
- let removed = 0;
57
-
58
- // Remove .agents/skills/ (BMAD+ agents & skills)
59
- const agentsDir = path.join(projectDir, '.agents');
60
- if (fs.existsSync(agentsDir)) {
61
- fsExtra.removeSync(agentsDir);
62
- removed++;
63
- }
64
-
65
- // Remove _bmad/ config
66
- const bmadDir = path.join(projectDir, '_bmad');
67
- if (fs.existsSync(bmadDir)) {
68
- fsExtra.removeSync(bmadDir);
69
- removed++;
164
+ let plan;
165
+ try {
166
+ plan = removalPlan(projectDir, manifest, manifestPath);
167
+ } catch (err) {
168
+ clack.log.error(i.uninstall_aborted(err.message));
169
+ process.exitCode = 1;
170
+ return;
70
171
  }
71
-
72
- // Remove _bmad-output/ (only if empty)
73
- const outputDir = path.join(projectDir, '_bmad-output');
74
- if (fs.existsSync(outputDir)) {
75
- const contents = fs.readdirSync(outputDir, { recursive: true }).filter(f => !f.startsWith('.'));
76
- if (contents.length === 0) {
77
- fsExtra.removeSync(outputDir);
78
- removed++;
79
- } else {
80
- clack.log.info(i.uninstall_output_kept || '📁 _bmad-output/ kept (contains files)');
172
+ clack.log.info('BMAD+ v' + manifest.version + ' (' + i.installed_on + ' ' + manifest.installed.split('T')[0] + ')');
173
+ clack.log.info(i.selected_packs + ': ' + manifest.packs.join(', '));
174
+ if (!options.yes) {
175
+ const confirm = await clack.confirm({ message: i.uninstall_confirm });
176
+ if (!confirm || clack.isCancel(confirm)) {
177
+ clack.cancel(i.cancelled);
178
+ return;
81
179
  }
82
180
  }
83
181
 
84
- // Remove IDE configs (only BMAD+-generated ones)
85
- for (const configFile of ['CLAUDE.md', 'GEMINI.md', 'AGENTS.md', 'OPENCODE.md']) {
86
- const p = path.join(projectDir, configFile);
87
- if (fs.existsSync(p)) {
88
- const content = fs.readFileSync(p, 'utf8');
89
- // NODE-05: only remove a config BMAD+ actually generated (it carries the
90
- // marker below). A hand-authored file that merely mentions "BMAD+" is kept.
91
- if (content.includes('BMAD+ AI Agent Configuration')) {
92
- fs.unlinkSync(p);
182
+ const spinner = clack.spinner();
183
+ spinner.start(i.uninstall_removing);
184
+ let removed = 0;
185
+ try {
186
+ // Revalidate after the prompt, before deleting any file.
187
+ for (const target of [...plan.files.keys(), ...plan.directories]) safeStat(projectDir, target);
188
+ for (const [target, expected] of plan.files) {
189
+ if (safeStat(projectDir, target)?.isFile() && fs.readFileSync(target).equals(expected)) {
190
+ fs.unlinkSync(target);
93
191
  removed++;
94
192
  }
95
193
  }
194
+ // No recursive deletion: hidden files, nested user content, and memory stay.
195
+ for (const target of [...plan.directories].sort((a, b) => b.length - a.length)) {
196
+ if (safeStat(projectDir, target)?.isDirectory() && fs.readdirSync(target).length === 0) {
197
+ fs.rmdirSync(target);
198
+ removed++;
199
+ }
200
+ }
201
+ } catch (err) {
202
+ spinner.stop(i.uninstall_aborted(err.message));
203
+ process.exitCode = 1;
204
+ return;
96
205
  }
97
-
98
- spinner.stop(i.uninstall_done ? i.uninstall_done(removed) : `✅ BMAD+ removed (${removed} items)`);
99
- clack.outro(pc.green(i.guide_ready ? '👋 Done!' : 'Done!'));
206
+ spinner.stop(i.uninstall_done(removed));
207
+ clack.outro(i.uninstall_preserved);
100
208
  },
101
209
  };