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
@@ -27,6 +27,9 @@
27
27
  * node tools/build/generate-adapters.js --adopt --force # overwrite root files that
28
28
  * # lack the AUTO-GENERATED marker
29
29
  * node tools/build/generate-adapters.js --out-dir <dir> # write to <dir>
30
+ * node tools/build/generate-adapters.js --target <dir> # user spine + adapters
31
+ * [--packs core,memory] [--tools claude-code,cursor] [--user-name Alice]
32
+ * [--language English] [--force] # explicit foreign overwrite
30
33
  * node tools/build/generate-adapters.js --print # print all adapters to stdout
31
34
  * node tools/build/generate-adapters.js --check # verify preview adapters match
32
35
  * node tools/build/generate-adapters.js --check --adopt # verify ROOT adapters + spine
@@ -39,12 +42,13 @@
39
42
 
40
43
  const fs = require('node:fs');
41
44
  const path = require('node:path');
42
- const { loadRegistry, DEFAULT_REGISTRY_PATH } = require('./generate');
43
- const { PROJECT_INSTRUCTIONS } = require('./adapters.config');
45
+ const { loadRegistry, buildDerived, DEFAULT_REGISTRY_PATH } = require('./generate');
46
+ const { PROJECT_INSTRUCTIONS, MEMORY_INSTRUCTIONS, UPDATE_INSTRUCTIONS, TOOL_METADATA } = require('./adapters.config');
44
47
 
45
48
  const DEFAULT_OUT_DIR = path.join(__dirname, 'generated-adapters');
46
49
  const REPO_ROOT = path.join(__dirname, '..', '..');
47
50
  const GENERATED_MARKER = 'AUTO-GENERATED by tools/build/generate-adapters.js';
51
+ const USER_CONFIG_MARKER = 'BMAD+ installed project configuration';
48
52
 
49
53
  /** Per-tool invocation notes. Unknown tools fall back to buildDefaultNotes(). */
50
54
  const TOOL_NOTES = {
@@ -99,6 +103,7 @@ function validateTargets(registry) {
99
103
  if (typeof t.spine !== 'string' || t.spine === '') {
100
104
  throw new Error('registry.yaml: targets.spine must be a non-empty string');
101
105
  }
106
+ validateRelativeFile(t.spine);
102
107
  if (!Array.isArray(t.adapters) || t.adapters.length === 0) {
103
108
  throw new Error('registry.yaml: targets.adapters must be a non-empty list');
104
109
  }
@@ -106,58 +111,55 @@ function validateTargets(registry) {
106
111
  if (!a || typeof a.tool !== 'string' || a.tool === '' || typeof a.file !== 'string' || a.file === '') {
107
112
  throw new Error(`registry.yaml: targets.adapters[${i}] must have string "tool" and "file"`);
108
113
  }
114
+ validateRelativeFile(a.file);
109
115
  });
110
116
  return registry;
111
117
  }
112
118
 
113
- /** Sum of list lengths for a given key across a pack's categories. */
114
- function sumCategories(pack, key) {
115
- if (!Array.isArray(pack.categories)) return 0;
116
- return pack.categories.reduce((n, c) => n + (Array.isArray(c[key]) ? c[key].length : 0), 0);
117
- }
118
-
119
- /**
120
- * Facts DERIVED from the registry — every number here is computed from the
121
- * declared lists, so adapters can never contradict registry.yaml.
122
- */
123
- function buildRegistryFacts(registry) {
124
- const packEntries = Object.entries(registry.packs).sort((a, b) => a[1].order - b[1].order);
125
- const packs = packEntries.map(([id, p]) => {
126
- const fact = {
127
- id,
128
- name: p.cli.name,
129
- displayName: p.display_name || p.cli.name,
130
- desc: p.cli.desc,
131
- agentCount: p.agents.length,
132
- required: p.required === true,
119
+ /** Convert the canonical derivation into the adapter presentation shape. */
120
+ function factsFromDerived(derived, packs = derived.packOrder) {
121
+ const selected = packs.map(id => {
122
+ const pack = derived.packs[id];
123
+ return {
124
+ id, name: pack.name, displayName: pack.displayName, desc: pack.desc,
125
+ required: pack.required, agentCount: pack.installerAgentCount,
126
+ categoryAgentCount: pack.categoryAgentCount, subAgentCount: pack.subAgentCount,
127
+ workflowCount: pack.workflowCount, frameworkCount: pack.frameworkCount,
128
+ personas: pack.personas,
133
129
  };
134
- const categoryAgents = sumCategories(p, 'agents');
135
- const categoryWorkflows = sumCategories(p, 'workflows');
136
- if (categoryAgents > 0) fact.categoryAgentCount = categoryAgents;
137
- if (Array.isArray(p.sub_agents)) fact.subAgentCount = p.sub_agents.length;
138
- if (Array.isArray(p.workflows)) fact.workflowCount = p.workflows.length;
139
- if (categoryWorkflows > 0) fact.workflowCount = (fact.workflowCount || 0) + categoryWorkflows;
140
- if (Array.isArray(p.compliance_tags) && p.compliance_tags.length > 0) {
141
- fact.frameworkCount = p.compliance_tags.length;
142
- }
143
- return fact;
144
130
  });
145
-
146
131
  return {
147
- product: {
148
- code: registry.product.code,
149
- displayName: registry.product.display_name,
150
- version: registry.product.version,
151
- derivedFrom: registry.product.derived_from,
152
- },
153
- spine: registry.targets.spine,
154
- modelsSupported: [...(registry.targets.models_supported || [])],
155
- packCount: packs.length,
156
- installerAgentCount: packs.reduce((n, p) => n + p.agentCount, 0),
157
- packs,
132
+ product: derived.product,
133
+ spine: derived.targets.spine,
134
+ modelsSupported: derived.targets.models_supported,
135
+ packCount: selected.length,
136
+ installerAgentCount: packs.length === derived.packOrder.length
137
+ ? derived.installerAgents
138
+ : selected.reduce((total, pack) => total + pack.agentCount, 0),
139
+ packs: selected,
158
140
  };
159
141
  }
160
142
 
143
+ /** Counts are calculated only by buildDerived, never from raw lists here. */
144
+ function buildRegistryFacts(registry) {
145
+ return factsFromDerived(buildDerived(registry));
146
+ }
147
+
148
+ /** Render registry-owned persona metadata for the selected packs. */
149
+ function renderRoster(facts) {
150
+ const lines = ['## Agents', '', 'To activate an agent, say its name or persona:', ''];
151
+ for (const pack of facts.packs) {
152
+ for (const persona of pack.personas || []) {
153
+ const identity = persona.alias
154
+ ? '(' + persona.alias + ') — ' + persona.role
155
+ : '(' + persona.role + ')';
156
+ lines.push('- **' + persona.name + '** ' + identity + ' — ' + persona.description +
157
+ (pack.required ? '' : ' (' + pack.name + ')'));
158
+ }
159
+ }
160
+ return lines;
161
+ }
162
+
161
163
  /** Group targets.adapters[] by output file → [{ file, tools: [tool, ...] }]. */
162
164
  function groupAdaptersByFile(registry) {
163
165
  const byFile = new Map();
@@ -209,6 +211,8 @@ function renderProjectInstructions(facts) {
209
211
  '<!-- Hand-authored project instructions — single source: tools/build/adapters.config.js. -->',
210
212
  '<!-- Edit the config, then regenerate; never edit this file directly. -->',
211
213
  '',
214
+ ...renderRoster(facts),
215
+ '',
212
216
  ...PROJECT_INSTRUCTIONS,
213
217
  ];
214
218
  }
@@ -339,18 +343,167 @@ function generateAllFiles(registry) {
339
343
  return [spine, ...generateAllAdapters(registry)];
340
344
  }
341
345
 
346
+ /** Installable tools and paths come from the registry; only detection is local. */
347
+ function buildIDEConfigs(derived) {
348
+ validateTargets({ targets: derived.targets });
349
+ return Object.fromEntries(derived.targets.adapters.map(({ tool, file }) => [
350
+ tool, { name: TOOL_METADATA[tool]?.name || tool, configFile: file,
351
+ detect: [...(TOOL_METADATA[tool]?.detect || [])] },
352
+ ]));
353
+ }
354
+
355
+ function selectedPacks(derived, packs) {
356
+ if (packs !== undefined && !Array.isArray(packs)) throw new Error('packs must be an array of pack IDs');
357
+ const selected = new Set(packs === undefined ? derived.packOrder : packs);
358
+ for (const id of selected) {
359
+ if (!Object.hasOwn(derived.packs, id)) throw new Error('Unknown pack: ' + id);
360
+ }
361
+ for (const id of derived.packOrder) if (derived.packs[id].required) selected.add(id);
362
+ return derived.packOrder.filter(id => selected.has(id));
363
+ }
364
+
365
+ function renderUserSections(derived, { packs, userName = 'Developer', language = 'English' } = {}) {
366
+ const ids = selectedPacks(derived, packs);
367
+ const facts = factsFromDerived(derived, ids);
368
+ const lines = [
369
+ '## Project Context', '',
370
+ 'This project uses ' + facts.product.displayName + ', an augmented AI-driven development framework.',
371
+ 'Based on ' + facts.product.derivedFrom + ' with multi-role agents, autopilot mode, and parallel execution.',
372
+ '', ...renderRoster(facts), '',
373
+ '## Skills', '',
374
+ '- Load skills from `.agents/skills/`.',
375
+ '- Each agent has a SKILL.md with capabilities, activation protocol, and role-switching rules.',
376
+ '- Auto-activation triggers: `.agents/data/role-triggers.yaml`.',
377
+ '', '## Communication', '',
378
+ '- User name: ' + String(userName).replace(/[\r\n]/g, ' '),
379
+ '- Default language: ' + String(language).replace(/[\r\n]/g, ' ') + ' for user-facing content, English for code and technical docs.',
380
+ ];
381
+ if (ids.includes('memory')) lines.push('', ...MEMORY_INSTRUCTIONS);
382
+ lines.push('', ...UPDATE_INSTRUCTIONS, '', ...renderFactsSection(facts), '', ...renderKeyCommands());
383
+ return lines;
384
+ }
385
+
386
+ function userMarkerLines() {
387
+ return [
388
+ '<!-- ' + GENERATED_MARKER + ' — DO NOT EDIT. -->',
389
+ '<!-- ' + USER_CONFIG_MARKER + ' -->',
390
+ '<!-- Refresh installed adapters with: npx bmad-plus update -->',
391
+ ];
392
+ }
393
+
394
+ /** Pure user spine renderer; DERIVED also ships in the npm package. */
395
+ function renderUserConfig(derived, options = {}) {
396
+ validateTargets({ targets: derived.targets });
397
+ return [
398
+ ...userMarkerLines(), '',
399
+ '# ' + derived.product.displayName + ' — Agent Spine', '',
400
+ 'This file is the common project instruction spine for the installed ' + derived.product.displayName + ' tools.', '',
401
+ ...renderUserSections(derived, options), '',
402
+ ].join('\n');
403
+ }
404
+
405
+ /** Pure render of the common spine and every selected registry adapter. */
406
+ function generateUserFiles(derived, options = {}) {
407
+ validateTargets({ targets: derived.targets });
408
+ const available = derived.targets.adapters.map(adapter => adapter.tool);
409
+ const requested = options.tools === undefined ? available : options.tools;
410
+ if (!Array.isArray(requested)) throw new Error('tools must be an array of tool IDs');
411
+ for (const tool of requested) {
412
+ if (!available.includes(tool)) throw new Error('Unknown tool: ' + tool);
413
+ }
414
+ const targets = { ...derived.targets,
415
+ adapters: derived.targets.adapters.filter(adapter => requested.includes(adapter.tool)) };
416
+ const files = [{ file: targets.spine, tools: ['spine'], spine: true,
417
+ content: renderUserConfig(derived, options) }];
418
+ for (const { file, tools } of groupAdaptersByFile({ targets })) {
419
+ if (file === targets.spine) continue;
420
+ const reference = path.posix.relative(path.posix.dirname(file), targets.spine);
421
+ const lines = [];
422
+ if (file.endsWith('.mdc')) lines.push('---',
423
+ 'description: "' + derived.product.displayName + ' agents adapter"', 'alwaysApply: true', '---', '');
424
+ lines.push(...userMarkerLines(), '',
425
+ '# ' + derived.product.displayName + ' — Adapter for ' + tools.join(' + '), '',
426
+ 'Read the project spine first: [' + targets.spine + '](' + reference + ').',
427
+ 'Paths in the shared project instructions below are relative to the project root.', '');
428
+ for (const tool of tools) {
429
+ lines.push('## Tool notes — ' + tool, '');
430
+ for (const note of TOOL_NOTES[tool] || buildDefaultNotes(tool, file)) lines.push('- ' + note);
431
+ lines.push('');
432
+ }
433
+ lines.push(...renderUserSections(derived, options), '');
434
+ files.push({ file, tools, content: lines.join('\n') });
435
+ }
436
+ return files;
437
+ }
438
+
439
+ /** Refuse portable-path escapes even when generated input has been modified. */
440
+ function validateRelativeFile(file) {
441
+ if (typeof file !== 'string' || !file || /[\\:]/.test(file) || file.includes('\0') ||
442
+ path.posix.isAbsolute(file) || file.split('/').some(part => !part || part === '.' || part === '..')) {
443
+ throw new Error('Adapter file must be a relative project path: ' + file);
444
+ }
445
+ }
446
+
447
+ /** Check existing ancestors before any write; Windows junctions are symlinks here. */
448
+ function safeTarget(outDir, file) {
449
+ validateRelativeFile(file);
450
+ const root = path.resolve(outDir);
451
+ const target = path.resolve(root, file);
452
+ const relative = path.relative(root, target);
453
+ if (!relative || relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) {
454
+ throw new Error('Adapter path escapes the target project: ' + file);
455
+ }
456
+ let current = path.parse(target).root;
457
+ for (const part of target.slice(current.length).split(path.sep)) {
458
+ current = path.join(current, part);
459
+ let stat;
460
+ try { stat = fs.lstatSync(current); } catch (error) {
461
+ if (error.code === 'ENOENT') break;
462
+ throw error;
463
+ }
464
+ if (stat.isSymbolicLink()) throw new Error('Adapter path contains a symlink or junction: ' + current);
465
+ if (current === target && !stat.isFile()) throw new Error('Adapter target is not a regular file: ' + current);
466
+ if (current !== target && !stat.isDirectory()) throw new Error('Adapter parent is not a directory: ' + current);
467
+ }
468
+ return target;
469
+ }
470
+
471
+ /** Preflight the whole batch to avoid partial writes on a later unsafe path. */
472
+ function writeFiles(outDir, files) {
473
+ for (const { file } of files) safeTarget(outDir, file);
474
+ return files.map(({ file, content }) => {
475
+ const target = safeTarget(outDir, file);
476
+ fs.mkdirSync(path.dirname(target), { recursive: true });
477
+ safeTarget(outDir, file);
478
+ fs.writeFileSync(target, content, 'utf8');
479
+ return target;
480
+ });
481
+ }
482
+
483
+ function writeUserAdapters({ outDir, derived = require('../cli/lib/packs').DERIVED,
484
+ packs, userName, language, tools, force = false } = {}) {
485
+ if (!outDir) throw new Error('A target project directory is required');
486
+ const files = generateUserFiles(derived, { packs, userName, language, tools });
487
+ const blockers = listAdoptionBlockers(outDir, files, USER_CONFIG_MARKER);
488
+ if (blockers.length && !force) {
489
+ throw new Error('REFUSING to overwrite files not owned by this user configuration: ' +
490
+ blockers.join(', ') + '. Review them and use --force to replace them explicitly.');
491
+ }
492
+ return writeFiles(outDir, files);
493
+ }
494
+
342
495
  /**
343
496
  * Adoption guard: list on-disk targets under outDir that exist but do NOT
344
497
  * carry the AUTO-GENERATED marker (i.e. hand-authored files that would be
345
498
  * clobbered). Overwriting them requires an explicit --force.
346
499
  */
347
- function listAdoptionBlockers(outDir, files) {
500
+ function listAdoptionBlockers(outDir, files, marker = GENERATED_MARKER) {
348
501
  const blockers = [];
349
502
  for (const { file } of files) {
350
- const target = path.join(outDir, file);
503
+ const target = safeTarget(outDir, file);
351
504
  if (!fs.existsSync(target)) continue;
352
505
  const current = fs.readFileSync(target, 'utf8');
353
- if (!current.includes(GENERATED_MARKER)) blockers.push(file);
506
+ if (!current.includes(GENERATED_MARKER) || !current.includes(marker)) blockers.push(file);
354
507
  }
355
508
  return blockers;
356
509
  }
@@ -358,14 +511,7 @@ function listAdoptionBlockers(outDir, files) {
358
511
  /** Write generated spine + adapters under outDir, preserving relative paths. */
359
512
  function writeAdapters({ registryPath = DEFAULT_REGISTRY_PATH, outDir = DEFAULT_OUT_DIR } = {}) {
360
513
  const files = generateAllFiles(loadRegistry(registryPath));
361
- const written = [];
362
- for (const { file, content } of files) {
363
- const target = path.join(outDir, file);
364
- fs.mkdirSync(path.dirname(target), { recursive: true });
365
- fs.writeFileSync(target, content, 'utf8');
366
- written.push(target);
367
- }
368
- return written;
514
+ return writeFiles(outDir, files);
369
515
  }
370
516
 
371
517
  /**
@@ -398,13 +544,20 @@ function check({ registryPath = DEFAULT_REGISTRY_PATH, outDir = DEFAULT_OUT_DIR
398
544
 
399
545
  function parseOutDir(args) {
400
546
  const idx = args.indexOf('--out-dir');
547
+ const targetIdx = args.indexOf('--target');
548
+ if (targetIdx !== -1) {
549
+ if (args.includes('--adopt') || idx !== -1) throw new Error('--target, --adopt and --out-dir are mutually exclusive');
550
+ const value = args[targetIdx + 1];
551
+ if (!value || value.startsWith('--')) throw new Error('--target requires a directory path');
552
+ return path.resolve(value);
553
+ }
401
554
  if (args.includes('--adopt')) {
402
555
  if (idx !== -1) throw new Error('--adopt and --out-dir are mutually exclusive (--adopt targets the repo root)');
403
556
  return REPO_ROOT;
404
557
  }
405
558
  if (idx === -1) return DEFAULT_OUT_DIR;
406
559
  const value = args[idx + 1];
407
- if (!value) throw new Error('--out-dir requires a directory path');
560
+ if (!value || value.startsWith('--')) throw new Error('--out-dir requires a directory path');
408
561
  return path.resolve(value);
409
562
  }
410
563
 
@@ -417,6 +570,42 @@ function main(argv) {
417
570
  console.error(err.message);
418
571
  return 1;
419
572
  }
573
+ if (args.includes('--target')) {
574
+ try {
575
+ const option = (name) => {
576
+ const index = args.indexOf(name);
577
+ if (index === -1) return undefined;
578
+ const value = args[index + 1];
579
+ if (!value || value.startsWith('--')) throw new Error(name + ' requires a value');
580
+ return value;
581
+ };
582
+ const packs = option('--packs');
583
+ const tools = option('--tools');
584
+ const options = { packs: !packs || packs === 'all' ? undefined : packs.split(',').map(value => value.trim()),
585
+ tools: !tools || tools === 'all' ? undefined : tools === 'none' ? [] : tools.split(',').map(value => value.trim()),
586
+ userName: option('--user-name'), language: option('--language') };
587
+ const derived = require('../cli/lib/packs').DERIVED;
588
+ const files = generateUserFiles(derived, options);
589
+ if (args.includes('--print')) {
590
+ for (const { file, content } of files) console.log(file + '\n' + content);
591
+ return 0;
592
+ }
593
+ if (args.includes('--check')) {
594
+ const mismatches = files.filter(({ file, content }) => {
595
+ const target = safeTarget(outDir, file);
596
+ return !fs.existsSync(target) || normalizeEol(fs.readFileSync(target, 'utf8')) !== normalizeEol(content);
597
+ });
598
+ if (mismatches.length) console.error('User adapter drift: ' + mismatches.map(entry => entry.file).join(', '));
599
+ return mismatches.length ? 1 : 0;
600
+ }
601
+ const written = writeUserAdapters({ outDir, derived, ...options, force: args.includes('--force') });
602
+ for (const target of written) console.log('Generated ' + target);
603
+ return 0;
604
+ } catch (error) {
605
+ console.error(error.message);
606
+ return 1;
607
+ }
608
+ }
420
609
  const adopting = args.includes('--adopt');
421
610
  const label = adopting ? 'root adapters + spine' : 'adapters';
422
611
 
@@ -467,6 +656,13 @@ module.exports = {
467
656
  DEFAULT_OUT_DIR,
468
657
  REPO_ROOT,
469
658
  GENERATED_MARKER,
659
+ USER_CONFIG_MARKER,
660
+ buildIDEConfigs,
661
+ get IDE_CONFIGS() { return buildIDEConfigs(require('../cli/lib/packs').DERIVED); },
662
+ renderUserConfig,
663
+ generateUserFiles,
664
+ writeUserAdapters,
665
+ safeTarget,
470
666
  TOOL_NOTES,
471
667
  validateTargets,
472
668
  buildRegistryFacts,