bmad-method 6.2.2 → 6.2.3-next.1

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 (77) hide show
  1. package/.claude-plugin/marketplace.json +78 -0
  2. package/package.json +8 -8
  3. package/src/core-skills/bmad-distillator/resources/distillate-format-reference.md +1 -1
  4. package/src/core-skills/bmad-init/scripts/bmad_init.py +35 -4
  5. package/src/core-skills/bmad-init/scripts/tests/test_bmad_init.py +64 -0
  6. package/tools/{cli → installer}/bmad-cli.js +3 -1
  7. package/tools/{cli/lib → installer}/cli-utils.js +3 -4
  8. package/tools/{cli → installer}/commands/install.js +3 -3
  9. package/tools/{cli → installer}/commands/status.js +4 -4
  10. package/tools/{cli → installer}/commands/uninstall.js +5 -5
  11. package/tools/installer/core/config.js +52 -0
  12. package/tools/{cli/installers/lib → installer}/core/custom-module-cache.js +1 -1
  13. package/tools/installer/core/existing-install.js +127 -0
  14. package/tools/installer/core/install-paths.js +129 -0
  15. package/tools/installer/core/installer.js +1790 -0
  16. package/tools/{cli/installers/lib → installer}/core/manifest-generator.js +3 -3
  17. package/tools/{cli/installers/lib → installer}/core/manifest.js +2 -2
  18. package/tools/{cli/installers/lib/custom/handler.js → installer/custom-handler.js} +1 -1
  19. package/tools/{cli/installers/lib → installer}/ide/_config-driven.js +30 -397
  20. package/tools/{cli/installers/lib → installer}/ide/manager.js +1 -53
  21. package/tools/installer/ide/platform-codes.js +37 -0
  22. package/tools/installer/ide/platform-codes.yaml +190 -0
  23. package/tools/{cli/installers/lib → installer}/ide/shared/module-injections.js +1 -1
  24. package/tools/{cli/installers/lib → installer}/message-loader.js +2 -2
  25. package/tools/installer/modules/custom-modules.js +197 -0
  26. package/tools/installer/modules/external-manager.js +323 -0
  27. package/tools/{cli/installers/lib/core/config-collector.js → installer/modules/official-modules.js} +714 -43
  28. package/tools/{cli/lib → installer}/ui.js +65 -299
  29. package/tools/javascript-conventions.md +5 -0
  30. package/tools/bmad-npx-wrapper.js +0 -38
  31. package/tools/cli/installers/lib/core/dependency-resolver.js +0 -743
  32. package/tools/cli/installers/lib/core/detector.js +0 -223
  33. package/tools/cli/installers/lib/core/ide-config-manager.js +0 -157
  34. package/tools/cli/installers/lib/core/installer.js +0 -3002
  35. package/tools/cli/installers/lib/ide/_base-ide.js +0 -657
  36. package/tools/cli/installers/lib/ide/platform-codes.js +0 -100
  37. package/tools/cli/installers/lib/ide/platform-codes.yaml +0 -341
  38. package/tools/cli/installers/lib/modules/external-manager.js +0 -136
  39. package/tools/cli/installers/lib/modules/manager.js +0 -928
  40. package/tools/cli/lib/config.js +0 -213
  41. package/tools/cli/lib/platform-codes.js +0 -116
  42. package/tools/lib/xml-utils.js +0 -13
  43. /package/tools/{cli → installer}/README.md +0 -0
  44. /package/tools/{cli → installer}/external-official-modules.yaml +0 -0
  45. /package/tools/{cli/lib → installer}/file-ops.js +0 -0
  46. /package/tools/{cli/installers/lib → installer}/ide/shared/agent-command-generator.js +0 -0
  47. /package/tools/{cli/installers/lib → installer}/ide/shared/bmad-artifacts.js +0 -0
  48. /package/tools/{cli/installers/lib → installer}/ide/shared/path-utils.js +0 -0
  49. /package/tools/{cli/installers/lib → installer}/ide/shared/skill-manifest.js +0 -0
  50. /package/tools/{cli/installers/lib → installer}/ide/templates/agent-command-template.md +0 -0
  51. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/antigravity.md +0 -0
  52. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/default-agent.md +0 -0
  53. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/default-task.md +0 -0
  54. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/default-tool.md +0 -0
  55. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/default-workflow.md +0 -0
  56. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/gemini-agent.toml +0 -0
  57. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/gemini-task.toml +0 -0
  58. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/gemini-tool.toml +0 -0
  59. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/gemini-workflow-yaml.toml +0 -0
  60. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/gemini-workflow.toml +0 -0
  61. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/kiro-agent.md +0 -0
  62. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/kiro-task.md +0 -0
  63. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/kiro-tool.md +0 -0
  64. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/kiro-workflow.md +0 -0
  65. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/opencode-agent.md +0 -0
  66. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/opencode-task.md +0 -0
  67. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/opencode-tool.md +0 -0
  68. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/opencode-workflow-yaml.md +0 -0
  69. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/opencode-workflow.md +0 -0
  70. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/rovodev.md +0 -0
  71. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/trae.md +0 -0
  72. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/windsurf-workflow.md +0 -0
  73. /package/tools/{cli/installers/lib → installer}/ide/templates/split/.gitkeep +0 -0
  74. /package/tools/{cli/installers → installer}/install-messages.yaml +0 -0
  75. /package/tools/{cli/lib → installer}/project-root.js +0 -0
  76. /package/tools/{cli/lib → installer}/prompts.js +0 -0
  77. /package/tools/{cli/lib → installer}/yaml-format.js +0 -0
@@ -1,30 +1,701 @@
1
1
  const path = require('node:path');
2
2
  const fs = require('fs-extra');
3
3
  const yaml = require('yaml');
4
- const { getProjectRoot, getModulePath } = require('../../../lib/project-root');
5
- const { CLIUtils } = require('../../../lib/cli-utils');
6
- const prompts = require('../../../lib/prompts');
7
-
8
- class ConfigCollector {
9
- constructor() {
4
+ const prompts = require('../prompts');
5
+ const { getProjectRoot, getSourcePath, getModulePath } = require('../project-root');
6
+ const { CLIUtils } = require('../cli-utils');
7
+ const { ExternalModuleManager } = require('./external-manager');
8
+
9
+ class OfficialModules {
10
+ constructor(options = {}) {
11
+ this.externalModuleManager = new ExternalModuleManager();
12
+ // Config collection state (merged from ConfigCollector)
10
13
  this.collectedConfig = {};
11
- this.existingConfig = null;
14
+ this._existingConfig = null;
12
15
  this.currentProjectDir = null;
13
- this._moduleManagerInstance = null;
14
16
  }
15
17
 
16
18
  /**
17
- * Get or create a cached ModuleManager instance (lazy initialization)
18
- * @returns {Object} ModuleManager instance
19
+ * Module configurations collected during install.
20
+ */
21
+ get moduleConfigs() {
22
+ return this.collectedConfig;
23
+ }
24
+
25
+ /**
26
+ * Existing module configurations read from a previous installation.
27
+ */
28
+ get existingConfig() {
29
+ return this._existingConfig;
30
+ }
31
+
32
+ /**
33
+ * Build a configured OfficialModules instance from install config.
34
+ * @param {Object} config - Clean install config (from Config.build)
35
+ * @param {Object} paths - InstallPaths instance
36
+ * @returns {OfficialModules}
37
+ */
38
+ static async build(config, paths) {
39
+ const instance = new OfficialModules();
40
+
41
+ // Pre-collected by UI or quickUpdate — store and load existing for path-change detection
42
+ if (config.moduleConfigs) {
43
+ instance.collectedConfig = config.moduleConfigs;
44
+ await instance.loadExistingConfig(paths.projectRoot);
45
+ return instance;
46
+ }
47
+
48
+ // Headless collection (--yes flag from CLI without UI, tests)
49
+ if (config.hasCoreConfig()) {
50
+ instance.collectedConfig.core = config.coreConfig;
51
+ instance.allAnswers = {};
52
+ for (const [key, value] of Object.entries(config.coreConfig)) {
53
+ instance.allAnswers[`core_${key}`] = value;
54
+ }
55
+ }
56
+
57
+ const toCollect = config.hasCoreConfig() ? config.modules.filter((m) => m !== 'core') : [...config.modules];
58
+
59
+ await instance.collectAllConfigurations(toCollect, paths.projectRoot, {
60
+ skipPrompts: config.skipPrompts,
61
+ });
62
+
63
+ return instance;
64
+ }
65
+
66
+ /**
67
+ * Copy a file to the target location
68
+ * @param {string} sourcePath - Source file path
69
+ * @param {string} targetPath - Target file path
70
+ * @param {boolean} overwrite - Whether to overwrite existing files (default: true)
71
+ */
72
+ async copyFile(sourcePath, targetPath, overwrite = true) {
73
+ await fs.copy(sourcePath, targetPath, { overwrite });
74
+ }
75
+
76
+ /**
77
+ * Copy a directory recursively
78
+ * @param {string} sourceDir - Source directory path
79
+ * @param {string} targetDir - Target directory path
80
+ * @param {boolean} overwrite - Whether to overwrite existing files (default: true)
81
+ */
82
+ async copyDirectory(sourceDir, targetDir, overwrite = true) {
83
+ await fs.ensureDir(targetDir);
84
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true });
85
+
86
+ for (const entry of entries) {
87
+ const sourcePath = path.join(sourceDir, entry.name);
88
+ const targetPath = path.join(targetDir, entry.name);
89
+
90
+ if (entry.isDirectory()) {
91
+ await this.copyDirectory(sourcePath, targetPath, overwrite);
92
+ } else {
93
+ await this.copyFile(sourcePath, targetPath, overwrite);
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * List all available built-in modules (core and bmm).
100
+ * All other modules come from external-official-modules.yaml
101
+ * @returns {Object} Object with modules array and customModules array
102
+ */
103
+ async listAvailable() {
104
+ const modules = [];
105
+ const customModules = [];
106
+
107
+ // Add built-in core module (directly under src/core-skills)
108
+ const corePath = getSourcePath('core-skills');
109
+ if (await fs.pathExists(corePath)) {
110
+ const coreInfo = await this.getModuleInfo(corePath, 'core', 'src/core-skills');
111
+ if (coreInfo) {
112
+ modules.push(coreInfo);
113
+ }
114
+ }
115
+
116
+ // Add built-in bmm module (directly under src/bmm-skills)
117
+ const bmmPath = getSourcePath('bmm-skills');
118
+ if (await fs.pathExists(bmmPath)) {
119
+ const bmmInfo = await this.getModuleInfo(bmmPath, 'bmm', 'src/bmm-skills');
120
+ if (bmmInfo) {
121
+ modules.push(bmmInfo);
122
+ }
123
+ }
124
+
125
+ return { modules, customModules };
126
+ }
127
+
128
+ /**
129
+ * Get module information from a module path
130
+ * @param {string} modulePath - Path to the module directory
131
+ * @param {string} defaultName - Default name for the module
132
+ * @param {string} sourceDescription - Description of where the module was found
133
+ * @returns {Object|null} Module info or null if not a valid module
134
+ */
135
+ async getModuleInfo(modulePath, defaultName, sourceDescription) {
136
+ // Check for module structure (module.yaml OR custom.yaml)
137
+ const moduleConfigPath = path.join(modulePath, 'module.yaml');
138
+ const rootCustomConfigPath = path.join(modulePath, 'custom.yaml');
139
+ let configPath = null;
140
+
141
+ if (await fs.pathExists(moduleConfigPath)) {
142
+ configPath = moduleConfigPath;
143
+ } else if (await fs.pathExists(rootCustomConfigPath)) {
144
+ configPath = rootCustomConfigPath;
145
+ }
146
+
147
+ // Skip if this doesn't look like a module
148
+ if (!configPath) {
149
+ return null;
150
+ }
151
+
152
+ // Mark as custom if it's using custom.yaml OR if it's outside src/bmm or src/core
153
+ const isCustomSource =
154
+ sourceDescription !== 'src/bmm-skills' && sourceDescription !== 'src/core-skills' && sourceDescription !== 'src/modules';
155
+ const moduleInfo = {
156
+ id: defaultName,
157
+ path: modulePath,
158
+ name: defaultName
159
+ .split('-')
160
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
161
+ .join(' '),
162
+ description: 'BMAD Module',
163
+ version: '5.0.0',
164
+ source: sourceDescription,
165
+ isCustom: configPath === rootCustomConfigPath || isCustomSource,
166
+ };
167
+
168
+ // Read module config for metadata
169
+ try {
170
+ const configContent = await fs.readFile(configPath, 'utf8');
171
+ const config = yaml.parse(configContent);
172
+
173
+ // Use the code property as the id if available
174
+ if (config.code) {
175
+ moduleInfo.id = config.code;
176
+ }
177
+
178
+ moduleInfo.name = config.name || moduleInfo.name;
179
+ moduleInfo.description = config.description || moduleInfo.description;
180
+ moduleInfo.version = config.version || moduleInfo.version;
181
+ moduleInfo.dependencies = config.dependencies || [];
182
+ moduleInfo.defaultSelected = config.default_selected === undefined ? false : config.default_selected;
183
+ } catch (error) {
184
+ await prompts.log.warn(`Failed to read config for ${defaultName}: ${error.message}`);
185
+ }
186
+
187
+ return moduleInfo;
188
+ }
189
+
190
+ /**
191
+ * Find the source path for a module by searching all possible locations
192
+ * @param {string} moduleCode - Code of the module to find (from module.yaml)
193
+ * @returns {string|null} Path to the module source or null if not found
194
+ */
195
+ async findModuleSource(moduleCode, options = {}) {
196
+ const projectRoot = getProjectRoot();
197
+
198
+ // Check for core module (directly under src/core-skills)
199
+ if (moduleCode === 'core') {
200
+ const corePath = getSourcePath('core-skills');
201
+ if (await fs.pathExists(corePath)) {
202
+ return corePath;
203
+ }
204
+ }
205
+
206
+ // Check for built-in bmm module (directly under src/bmm-skills)
207
+ if (moduleCode === 'bmm') {
208
+ const bmmPath = getSourcePath('bmm-skills');
209
+ if (await fs.pathExists(bmmPath)) {
210
+ return bmmPath;
211
+ }
212
+ }
213
+
214
+ // Check external official modules
215
+ const externalSource = await this.externalModuleManager.findExternalModuleSource(moduleCode, options);
216
+ if (externalSource) {
217
+ return externalSource;
218
+ }
219
+
220
+ return null;
221
+ }
222
+
223
+ /**
224
+ * Install a module
225
+ * @param {string} moduleName - Code of the module to install (from module.yaml)
226
+ * @param {string} bmadDir - Target bmad directory
227
+ * @param {Function} fileTrackingCallback - Optional callback to track installed files
228
+ * @param {Object} options - Additional installation options
229
+ * @param {Array<string>} options.installedIDEs - Array of IDE codes that were installed
230
+ * @param {Object} options.moduleConfig - Module configuration from config collector
231
+ * @param {Object} options.logger - Logger instance for output
232
+ */
233
+ async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) {
234
+ const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent });
235
+ const targetPath = path.join(bmadDir, moduleName);
236
+
237
+ if (!sourcePath) {
238
+ throw new Error(
239
+ `Source for module '${moduleName}' is not available. It will be retained but cannot be updated without its source files.`,
240
+ );
241
+ }
242
+
243
+ if (await fs.pathExists(targetPath)) {
244
+ await fs.remove(targetPath);
245
+ }
246
+
247
+ await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig);
248
+
249
+ if (!options.skipModuleInstaller) {
250
+ await this.createModuleDirectories(moduleName, bmadDir, options);
251
+ }
252
+
253
+ const { Manifest } = require('../core/manifest');
254
+ const manifestObj = new Manifest();
255
+ const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, bmadDir, sourcePath);
256
+
257
+ await manifestObj.addModule(bmadDir, moduleName, {
258
+ version: versionInfo.version,
259
+ source: versionInfo.source,
260
+ npmPackage: versionInfo.npmPackage,
261
+ repoUrl: versionInfo.repoUrl,
262
+ });
263
+
264
+ return { success: true, module: moduleName, path: targetPath, versionInfo };
265
+ }
266
+
267
+ /**
268
+ * Update an existing module
269
+ * @param {string} moduleName - Name of the module to update
270
+ * @param {string} bmadDir - Target bmad directory
19
271
  */
20
- _getModuleManager() {
21
- if (!this._moduleManagerInstance) {
22
- const { ModuleManager } = require('../modules/manager');
23
- this._moduleManagerInstance = new ModuleManager();
272
+ async update(moduleName, bmadDir) {
273
+ const sourcePath = await this.findModuleSource(moduleName);
274
+ const targetPath = path.join(bmadDir, moduleName);
275
+
276
+ if (!sourcePath) {
277
+ throw new Error(`Module '${moduleName}' not found in any source location`);
278
+ }
279
+
280
+ if (!(await fs.pathExists(targetPath))) {
281
+ throw new Error(`Module '${moduleName}' is not installed`);
24
282
  }
25
- return this._moduleManagerInstance;
283
+
284
+ await this.syncModule(sourcePath, targetPath);
285
+
286
+ return {
287
+ success: true,
288
+ module: moduleName,
289
+ path: targetPath,
290
+ };
26
291
  }
27
292
 
293
+ /**
294
+ * Remove a module
295
+ * @param {string} moduleName - Name of the module to remove
296
+ * @param {string} bmadDir - Target bmad directory
297
+ */
298
+ async remove(moduleName, bmadDir) {
299
+ const targetPath = path.join(bmadDir, moduleName);
300
+
301
+ if (!(await fs.pathExists(targetPath))) {
302
+ throw new Error(`Module '${moduleName}' is not installed`);
303
+ }
304
+
305
+ await fs.remove(targetPath);
306
+
307
+ return {
308
+ success: true,
309
+ module: moduleName,
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Check if a module is installed
315
+ * @param {string} moduleName - Name of the module
316
+ * @param {string} bmadDir - Target bmad directory
317
+ * @returns {boolean} True if module is installed
318
+ */
319
+ async isInstalled(moduleName, bmadDir) {
320
+ const targetPath = path.join(bmadDir, moduleName);
321
+ return await fs.pathExists(targetPath);
322
+ }
323
+
324
+ /**
325
+ * Get installed module info
326
+ * @param {string} moduleName - Name of the module
327
+ * @param {string} bmadDir - Target bmad directory
328
+ * @returns {Object|null} Module info or null if not installed
329
+ */
330
+ async getInstalledInfo(moduleName, bmadDir) {
331
+ const targetPath = path.join(bmadDir, moduleName);
332
+
333
+ if (!(await fs.pathExists(targetPath))) {
334
+ return null;
335
+ }
336
+
337
+ const configPath = path.join(targetPath, 'config.yaml');
338
+ const moduleInfo = {
339
+ id: moduleName,
340
+ path: targetPath,
341
+ installed: true,
342
+ };
343
+
344
+ if (await fs.pathExists(configPath)) {
345
+ try {
346
+ const configContent = await fs.readFile(configPath, 'utf8');
347
+ const config = yaml.parse(configContent);
348
+ Object.assign(moduleInfo, config);
349
+ } catch (error) {
350
+ await prompts.log.warn(`Failed to read installed module config: ${error.message}`);
351
+ }
352
+ }
353
+
354
+ return moduleInfo;
355
+ }
356
+
357
+ /**
358
+ * Copy module with filtering for localskip agents and conditional content
359
+ * @param {string} sourcePath - Source module path
360
+ * @param {string} targetPath - Target module path
361
+ * @param {Function} fileTrackingCallback - Optional callback to track installed files
362
+ * @param {Object} moduleConfig - Module configuration with conditional flags
363
+ */
364
+ async copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback = null, moduleConfig = {}) {
365
+ // Get all files in source
366
+ const sourceFiles = await this.getFileList(sourcePath);
367
+
368
+ for (const file of sourceFiles) {
369
+ // Skip sub-modules directory - these are IDE-specific and handled separately
370
+ if (file.startsWith('sub-modules/')) {
371
+ continue;
372
+ }
373
+
374
+ // Skip sidecar directories - these contain agent-specific assets not needed at install time
375
+ const isInSidecarDirectory = path
376
+ .dirname(file)
377
+ .split('/')
378
+ .some((dir) => dir.toLowerCase().endsWith('-sidecar'));
379
+
380
+ if (isInSidecarDirectory) {
381
+ continue;
382
+ }
383
+
384
+ // Skip module.yaml at root - it's only needed at install time
385
+ if (file === 'module.yaml') {
386
+ continue;
387
+ }
388
+
389
+ // Skip module root config.yaml only - generated by config collector with actual values
390
+ // Workflow-level config.yaml (e.g. workflows/orchestrate-story/config.yaml) must be copied
391
+ // for custom modules that use workflow-specific configuration
392
+ if (file === 'config.yaml') {
393
+ continue;
394
+ }
395
+
396
+ const sourceFile = path.join(sourcePath, file);
397
+ const targetFile = path.join(targetPath, file);
398
+
399
+ // Check if this is an agent file
400
+ if (file.startsWith('agents/') && file.endsWith('.md')) {
401
+ // Read the file to check for localskip
402
+ const content = await fs.readFile(sourceFile, 'utf8');
403
+
404
+ // Check for localskip="true" in the agent tag
405
+ const agentMatch = content.match(/<agent[^>]*\slocalskip="true"[^>]*>/);
406
+ if (agentMatch) {
407
+ await prompts.log.message(` Skipping web-only agent: ${path.basename(file)}`);
408
+ continue; // Skip this agent
409
+ }
410
+ }
411
+
412
+ // Copy the file with placeholder replacement
413
+ await this.copyFile(sourceFile, targetFile);
414
+
415
+ // Track the file if callback provided
416
+ if (fileTrackingCallback) {
417
+ fileTrackingCallback(targetFile);
418
+ }
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Find all .md agent files recursively in a directory
424
+ * @param {string} dir - Directory to search
425
+ * @returns {Array} List of .md agent file paths
426
+ */
427
+ async findAgentMdFiles(dir) {
428
+ const agentFiles = [];
429
+
430
+ async function searchDirectory(searchDir) {
431
+ const entries = await fs.readdir(searchDir, { withFileTypes: true });
432
+
433
+ for (const entry of entries) {
434
+ const fullPath = path.join(searchDir, entry.name);
435
+
436
+ if (entry.isFile() && entry.name.endsWith('.md')) {
437
+ agentFiles.push(fullPath);
438
+ } else if (entry.isDirectory()) {
439
+ await searchDirectory(fullPath);
440
+ }
441
+ }
442
+ }
443
+
444
+ await searchDirectory(dir);
445
+ return agentFiles;
446
+ }
447
+
448
+ /**
449
+ * Create directories declared in module.yaml's `directories` key
450
+ * This replaces the security-risky module installer pattern with declarative config
451
+ * During updates, if a directory path changed, moves the old directory to the new path
452
+ * @param {string} moduleName - Name of the module
453
+ * @param {string} bmadDir - Target bmad directory
454
+ * @param {Object} options - Installation options
455
+ * @param {Object} options.moduleConfig - Module configuration from config collector
456
+ * @param {Object} options.existingModuleConfig - Previous module config (for detecting path changes during updates)
457
+ * @param {Object} options.coreConfig - Core configuration
458
+ * @returns {Promise<{createdDirs: string[], movedDirs: string[], createdWdsFolders: string[]}>} Created directories info
459
+ */
460
+ async createModuleDirectories(moduleName, bmadDir, options = {}) {
461
+ const moduleConfig = options.moduleConfig || {};
462
+ const existingModuleConfig = options.existingModuleConfig || {};
463
+ const projectRoot = path.dirname(bmadDir);
464
+ const emptyResult = { createdDirs: [], movedDirs: [], createdWdsFolders: [] };
465
+
466
+ // Special handling for core module - it's in src/core-skills not src/modules
467
+ let sourcePath;
468
+ if (moduleName === 'core') {
469
+ sourcePath = getSourcePath('core-skills');
470
+ } else {
471
+ sourcePath = await this.findModuleSource(moduleName, { silent: true });
472
+ if (!sourcePath) {
473
+ return emptyResult; // No source found, skip
474
+ }
475
+ }
476
+
477
+ // Read module.yaml to find the `directories` key
478
+ const moduleYamlPath = path.join(sourcePath, 'module.yaml');
479
+ if (!(await fs.pathExists(moduleYamlPath))) {
480
+ return emptyResult; // No module.yaml, skip
481
+ }
482
+
483
+ let moduleYaml;
484
+ try {
485
+ const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
486
+ moduleYaml = yaml.parse(yamlContent);
487
+ } catch (error) {
488
+ await prompts.log.warn(`Invalid module.yaml for ${moduleName}: ${error.message}`);
489
+ return emptyResult;
490
+ }
491
+
492
+ if (!moduleYaml || !moduleYaml.directories) {
493
+ return emptyResult; // No directories declared, skip
494
+ }
495
+
496
+ const directories = moduleYaml.directories;
497
+ const wdsFolders = moduleYaml.wds_folders || [];
498
+ const createdDirs = [];
499
+ const movedDirs = [];
500
+ const createdWdsFolders = [];
501
+
502
+ for (const dirRef of directories) {
503
+ // Parse variable reference like "{design_artifacts}"
504
+ const varMatch = dirRef.match(/^\{([^}]+)\}$/);
505
+ if (!varMatch) {
506
+ // Not a variable reference, skip
507
+ continue;
508
+ }
509
+
510
+ const configKey = varMatch[1];
511
+ const dirValue = moduleConfig[configKey];
512
+ if (!dirValue || typeof dirValue !== 'string') {
513
+ continue; // No value or not a string, skip
514
+ }
515
+
516
+ // Strip {project-root}/ prefix if present
517
+ let dirPath = dirValue.replace(/^\{project-root\}\/?/, '');
518
+
519
+ // Handle remaining {project-root} anywhere in the path
520
+ dirPath = dirPath.replaceAll('{project-root}', '');
521
+
522
+ // Resolve to absolute path
523
+ const fullPath = path.join(projectRoot, dirPath);
524
+
525
+ // Validate path is within project root (prevent directory traversal)
526
+ const normalizedPath = path.normalize(fullPath);
527
+ const normalizedRoot = path.normalize(projectRoot);
528
+ if (!normalizedPath.startsWith(normalizedRoot + path.sep) && normalizedPath !== normalizedRoot) {
529
+ const color = await prompts.getColor();
530
+ await prompts.log.warn(color.yellow(`${configKey} path escapes project root, skipping: ${dirPath}`));
531
+ continue;
532
+ }
533
+
534
+ // Check if directory path changed from previous config (update/modify scenario)
535
+ const oldDirValue = existingModuleConfig[configKey];
536
+ let oldFullPath = null;
537
+ let oldDirPath = null;
538
+ if (oldDirValue && typeof oldDirValue === 'string') {
539
+ // F3: Normalize both values before comparing to avoid false negatives
540
+ // from trailing slashes, separator differences, or prefix format variations
541
+ let normalizedOld = oldDirValue.replace(/^\{project-root\}\/?/, '');
542
+ normalizedOld = path.normalize(normalizedOld.replaceAll('{project-root}', ''));
543
+ const normalizedNew = path.normalize(dirPath);
544
+
545
+ if (normalizedOld !== normalizedNew) {
546
+ oldDirPath = normalizedOld;
547
+ oldFullPath = path.join(projectRoot, oldDirPath);
548
+ const normalizedOldAbsolute = path.normalize(oldFullPath);
549
+ if (!normalizedOldAbsolute.startsWith(normalizedRoot + path.sep) && normalizedOldAbsolute !== normalizedRoot) {
550
+ oldFullPath = null; // Old path escapes project root, ignore it
551
+ }
552
+
553
+ // F13: Prevent parent/child move (e.g. docs/planning → docs/planning/v2)
554
+ if (oldFullPath) {
555
+ const normalizedNewAbsolute = path.normalize(fullPath);
556
+ if (
557
+ normalizedOldAbsolute.startsWith(normalizedNewAbsolute + path.sep) ||
558
+ normalizedNewAbsolute.startsWith(normalizedOldAbsolute + path.sep)
559
+ ) {
560
+ const color = await prompts.getColor();
561
+ await prompts.log.warn(
562
+ color.yellow(
563
+ `${configKey}: cannot move between parent/child paths (${oldDirPath} / ${dirPath}), creating new directory instead`,
564
+ ),
565
+ );
566
+ oldFullPath = null;
567
+ }
568
+ }
569
+ }
570
+ }
571
+
572
+ const dirName = configKey.replaceAll('_', ' ');
573
+
574
+ if (oldFullPath && (await fs.pathExists(oldFullPath)) && !(await fs.pathExists(fullPath))) {
575
+ // Path changed and old dir exists → move old to new location
576
+ // F1: Use fs.move() instead of fs.rename() for cross-device/volume support
577
+ // F2: Wrap in try/catch — fallback to creating new dir on failure
578
+ try {
579
+ await fs.ensureDir(path.dirname(fullPath));
580
+ await fs.move(oldFullPath, fullPath);
581
+ movedDirs.push(`${dirName}: ${oldDirPath} → ${dirPath}`);
582
+ } catch (moveError) {
583
+ const color = await prompts.getColor();
584
+ await prompts.log.warn(
585
+ color.yellow(
586
+ `Failed to move ${oldDirPath} → ${dirPath}: ${moveError.message}\n Creating new directory instead. Please move contents from the old directory manually.`,
587
+ ),
588
+ );
589
+ await fs.ensureDir(fullPath);
590
+ createdDirs.push(`${dirName}: ${dirPath}`);
591
+ }
592
+ } else if (oldFullPath && (await fs.pathExists(oldFullPath)) && (await fs.pathExists(fullPath))) {
593
+ // F5: Both old and new directories exist — warn user about potential orphaned documents
594
+ const color = await prompts.getColor();
595
+ await prompts.log.warn(
596
+ color.yellow(
597
+ `${dirName}: path changed but both directories exist:\n Old: ${oldDirPath}\n New: ${dirPath}\n Old directory may contain orphaned documents — please review and merge manually.`,
598
+ ),
599
+ );
600
+ } else if (!(await fs.pathExists(fullPath))) {
601
+ // New directory doesn't exist yet → create it
602
+ createdDirs.push(`${dirName}: ${dirPath}`);
603
+ await fs.ensureDir(fullPath);
604
+ }
605
+
606
+ // Create WDS subfolders if this is the design_artifacts directory
607
+ if (configKey === 'design_artifacts' && wdsFolders.length > 0) {
608
+ for (const subfolder of wdsFolders) {
609
+ const subPath = path.join(fullPath, subfolder);
610
+ if (!(await fs.pathExists(subPath))) {
611
+ await fs.ensureDir(subPath);
612
+ createdWdsFolders.push(subfolder);
613
+ }
614
+ }
615
+ }
616
+ }
617
+
618
+ return { createdDirs, movedDirs, createdWdsFolders };
619
+ }
620
+
621
+ /**
622
+ * Private: Process module configuration
623
+ * @param {string} modulePath - Path to installed module
624
+ * @param {string} moduleName - Module name
625
+ */
626
+ async processModuleConfig(modulePath, moduleName) {
627
+ const configPath = path.join(modulePath, 'config.yaml');
628
+
629
+ if (await fs.pathExists(configPath)) {
630
+ try {
631
+ let configContent = await fs.readFile(configPath, 'utf8');
632
+
633
+ // Replace path placeholders
634
+ configContent = configContent.replaceAll('{project-root}', `bmad/${moduleName}`);
635
+ configContent = configContent.replaceAll('{module}', moduleName);
636
+
637
+ await fs.writeFile(configPath, configContent, 'utf8');
638
+ } catch (error) {
639
+ await prompts.log.warn(`Failed to process module config: ${error.message}`);
640
+ }
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Private: Sync module files (preserving user modifications)
646
+ * @param {string} sourcePath - Source module path
647
+ * @param {string} targetPath - Target module path
648
+ */
649
+ async syncModule(sourcePath, targetPath) {
650
+ // Get list of all source files
651
+ const sourceFiles = await this.getFileList(sourcePath);
652
+
653
+ for (const file of sourceFiles) {
654
+ const sourceFile = path.join(sourcePath, file);
655
+ const targetFile = path.join(targetPath, file);
656
+
657
+ // Check if target file exists and has been modified
658
+ if (await fs.pathExists(targetFile)) {
659
+ const sourceStats = await fs.stat(sourceFile);
660
+ const targetStats = await fs.stat(targetFile);
661
+
662
+ // Skip if target is newer (user modified)
663
+ if (targetStats.mtime > sourceStats.mtime) {
664
+ continue;
665
+ }
666
+ }
667
+
668
+ // Copy file with placeholder replacement
669
+ await this.copyFile(sourceFile, targetFile);
670
+ }
671
+ }
672
+
673
+ /**
674
+ * Private: Get list of all files in a directory
675
+ * @param {string} dir - Directory path
676
+ * @param {string} baseDir - Base directory for relative paths
677
+ * @returns {Array} List of relative file paths
678
+ */
679
+ async getFileList(dir, baseDir = dir) {
680
+ const files = [];
681
+ const entries = await fs.readdir(dir, { withFileTypes: true });
682
+
683
+ for (const entry of entries) {
684
+ const fullPath = path.join(dir, entry.name);
685
+
686
+ if (entry.isDirectory()) {
687
+ const subFiles = await this.getFileList(fullPath, baseDir);
688
+ files.push(...subFiles);
689
+ } else {
690
+ files.push(path.relative(baseDir, fullPath));
691
+ }
692
+ }
693
+
694
+ return files;
695
+ }
696
+
697
+ // ─── Config collection methods (merged from ConfigCollector) ───
698
+
28
699
  /**
29
700
  * Find the bmad installation directory in a project
30
701
  * V6+ installations can use ANY folder name but ALWAYS have _config/manifest.yaml
@@ -95,7 +766,7 @@ class ConfigCollector {
95
766
  * @param {string} projectDir - Target project directory
96
767
  */
97
768
  async loadExistingConfig(projectDir) {
98
- this.existingConfig = {};
769
+ this._existingConfig = {};
99
770
 
100
771
  // Check if project directory exists first
101
772
  if (!(await fs.pathExists(projectDir))) {
@@ -129,7 +800,7 @@ class ConfigCollector {
129
800
  const content = await fs.readFile(moduleConfigPath, 'utf8');
130
801
  const moduleConfig = yaml.parse(content);
131
802
  if (moduleConfig) {
132
- this.existingConfig[entry.name] = moduleConfig;
803
+ this._existingConfig[entry.name] = moduleConfig;
133
804
  foundAny = true;
134
805
  }
135
806
  } catch {
@@ -153,7 +824,7 @@ class ConfigCollector {
153
824
  const results = [];
154
825
 
155
826
  for (const moduleName of modules) {
156
- // Resolve module.yaml path - custom paths first, then standard location, then ModuleManager search
827
+ // Resolve module.yaml path - custom paths first, then standard location, then OfficialModules search
157
828
  let moduleConfigPath = null;
158
829
  const customPath = this.customModulePaths?.get(moduleName);
159
830
  if (customPath) {
@@ -163,7 +834,7 @@ class ConfigCollector {
163
834
  if (await fs.pathExists(standardPath)) {
164
835
  moduleConfigPath = standardPath;
165
836
  } else {
166
- const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
837
+ const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true });
167
838
  if (moduleSourcePath) {
168
839
  moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
169
840
  }
@@ -349,7 +1020,7 @@ class ConfigCollector {
349
1020
  this.currentProjectDir = projectDir;
350
1021
 
351
1022
  // Load existing config if not already loaded
352
- if (!this.existingConfig) {
1023
+ if (!this._existingConfig) {
353
1024
  await this.loadExistingConfig(projectDir);
354
1025
  }
355
1026
 
@@ -364,7 +1035,7 @@ class ConfigCollector {
364
1035
 
365
1036
  // If not found in src/modules, we need to find it by searching the project
366
1037
  if (!(await fs.pathExists(moduleConfigPath))) {
367
- const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
1038
+ const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true });
368
1039
 
369
1040
  if (moduleSourcePath) {
370
1041
  moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
@@ -378,7 +1049,7 @@ class ConfigCollector {
378
1049
  configPath = moduleConfigPath;
379
1050
  } else {
380
1051
  // Check if this is a custom module with custom.yaml
381
- const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
1052
+ const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true });
382
1053
 
383
1054
  if (moduleSourcePath) {
384
1055
  const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml');
@@ -391,11 +1062,11 @@ class ConfigCollector {
391
1062
  }
392
1063
 
393
1064
  // No config schema for this module - use existing values
394
- if (this.existingConfig && this.existingConfig[moduleName]) {
1065
+ if (this._existingConfig && this._existingConfig[moduleName]) {
395
1066
  if (!this.collectedConfig[moduleName]) {
396
1067
  this.collectedConfig[moduleName] = {};
397
1068
  }
398
- this.collectedConfig[moduleName] = { ...this.existingConfig[moduleName] };
1069
+ this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] };
399
1070
  }
400
1071
  return false;
401
1072
  }
@@ -409,7 +1080,7 @@ class ConfigCollector {
409
1080
 
410
1081
  // Compare schema with existing config to find new/missing fields
411
1082
  const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt');
412
- const existingKeys = this.existingConfig && this.existingConfig[moduleName] ? Object.keys(this.existingConfig[moduleName]) : [];
1083
+ const existingKeys = this._existingConfig && this._existingConfig[moduleName] ? Object.keys(this._existingConfig[moduleName]) : [];
413
1084
 
414
1085
  // Check if this module has no configuration keys at all (like CIS)
415
1086
  // Filter out metadata fields and only count actual config objects
@@ -440,11 +1111,11 @@ class ConfigCollector {
440
1111
 
441
1112
  // If in silent mode and no new keys (neither interactive nor static), use existing config and skip prompts
442
1113
  if (silentMode && newKeys.length === 0 && newStaticKeys.length === 0) {
443
- if (this.existingConfig && this.existingConfig[moduleName]) {
1114
+ if (this._existingConfig && this._existingConfig[moduleName]) {
444
1115
  if (!this.collectedConfig[moduleName]) {
445
1116
  this.collectedConfig[moduleName] = {};
446
1117
  }
447
- this.collectedConfig[moduleName] = { ...this.existingConfig[moduleName] };
1118
+ this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] };
448
1119
 
449
1120
  // Special handling for user_name: ensure it has a value
450
1121
  if (
@@ -455,7 +1126,7 @@ class ConfigCollector {
455
1126
  }
456
1127
 
457
1128
  // Also populate allAnswers for cross-referencing
458
- for (const [key, value] of Object.entries(this.existingConfig[moduleName])) {
1129
+ for (const [key, value] of Object.entries(this._existingConfig[moduleName])) {
459
1130
  // Ensure user_name is properly set in allAnswers too
460
1131
  let finalValue = value;
461
1132
  if (moduleName === 'core' && key === 'user_name' && (!value || value === '[USER_NAME]')) {
@@ -519,8 +1190,8 @@ class ConfigCollector {
519
1190
 
520
1191
  // Process all answers (both static and prompted)
521
1192
  // First, copy existing config to preserve values that aren't being updated
522
- if (this.existingConfig && this.existingConfig[moduleName]) {
523
- this.collectedConfig[moduleName] = { ...this.existingConfig[moduleName] };
1193
+ if (this._existingConfig && this._existingConfig[moduleName]) {
1194
+ this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] };
524
1195
  } else {
525
1196
  this.collectedConfig[moduleName] = {};
526
1197
  }
@@ -545,11 +1216,11 @@ class ConfigCollector {
545
1216
  }
546
1217
 
547
1218
  // Copy over existing values for fields that weren't prompted
548
- if (this.existingConfig && this.existingConfig[moduleName]) {
1219
+ if (this._existingConfig && this._existingConfig[moduleName]) {
549
1220
  if (!this.collectedConfig[moduleName]) {
550
1221
  this.collectedConfig[moduleName] = {};
551
1222
  }
552
- for (const [key, value] of Object.entries(this.existingConfig[moduleName])) {
1223
+ for (const [key, value] of Object.entries(this._existingConfig[moduleName])) {
553
1224
  if (!this.collectedConfig[moduleName][key]) {
554
1225
  this.collectedConfig[moduleName][key] = value;
555
1226
  this.allAnswers[`${moduleName}_${key}`] = value;
@@ -652,7 +1323,7 @@ class ConfigCollector {
652
1323
  async collectModuleConfig(moduleName, projectDir, skipLoadExisting = false, skipCompletion = false) {
653
1324
  this.currentProjectDir = projectDir;
654
1325
  // Load existing config if needed and not already loaded
655
- if (!skipLoadExisting && !this.existingConfig) {
1326
+ if (!skipLoadExisting && !this._existingConfig) {
656
1327
  await this.loadExistingConfig(projectDir);
657
1328
  }
658
1329
 
@@ -674,7 +1345,7 @@ class ConfigCollector {
674
1345
 
675
1346
  // If not found in src/modules or custom paths, search the project
676
1347
  if (!(await fs.pathExists(moduleConfigPath))) {
677
- const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
1348
+ const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true });
678
1349
 
679
1350
  if (moduleSourcePath) {
680
1351
  moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
@@ -994,8 +1665,8 @@ class ConfigCollector {
994
1665
  }
995
1666
 
996
1667
  // Prefer the current module's persisted value when re-prompting an existing install
997
- if (!configValue && currentModule && this.existingConfig?.[currentModule]?.[configKey] !== undefined) {
998
- configValue = this.existingConfig[currentModule][configKey];
1668
+ if (!configValue && currentModule && this._existingConfig?.[currentModule]?.[configKey] !== undefined) {
1669
+ configValue = this._existingConfig[currentModule][configKey];
999
1670
  }
1000
1671
 
1001
1672
  // Check in already collected config
@@ -1009,10 +1680,10 @@ class ConfigCollector {
1009
1680
  }
1010
1681
 
1011
1682
  // Fall back to other existing module config values
1012
- if (!configValue && this.existingConfig) {
1013
- for (const mod of Object.keys(this.existingConfig)) {
1014
- if (mod !== '_meta' && this.existingConfig[mod] && this.existingConfig[mod][configKey]) {
1015
- configValue = this.existingConfig[mod][configKey];
1683
+ if (!configValue && this._existingConfig) {
1684
+ for (const mod of Object.keys(this._existingConfig)) {
1685
+ if (mod !== '_meta' && this._existingConfig[mod] && this._existingConfig[mod][configKey]) {
1686
+ configValue = this._existingConfig[mod][configKey];
1016
1687
  break;
1017
1688
  }
1018
1689
  }
@@ -1083,8 +1754,8 @@ class ConfigCollector {
1083
1754
 
1084
1755
  // Check for existing value
1085
1756
  let existingValue = null;
1086
- if (this.existingConfig && this.existingConfig[moduleName]) {
1087
- existingValue = this.existingConfig[moduleName][key];
1757
+ if (this._existingConfig && this._existingConfig[moduleName]) {
1758
+ existingValue = this._existingConfig[moduleName][key];
1088
1759
  existingValue = this.normalizeExistingValueForPrompt(existingValue, moduleName, item, moduleConfig);
1089
1760
  }
1090
1761
 
@@ -1369,4 +2040,4 @@ class ConfigCollector {
1369
2040
  }
1370
2041
  }
1371
2042
 
1372
- module.exports = { ConfigCollector };
2043
+ module.exports = { OfficialModules };