bmad-method 6.2.3-next.9 → 6.3.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 (72) hide show
  1. package/.claude-plugin/marketplace.json +0 -3
  2. package/README.md +8 -9
  3. package/README_CN.md +1 -1
  4. package/README_VN.md +110 -0
  5. package/package.json +1 -1
  6. package/removals.txt +17 -0
  7. package/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/steps/step-13-responsive-accessibility.md +1 -1
  8. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/data/prd-purpose.md +197 -0
  9. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-01-discovery.md +1 -1
  10. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-01b-legacy-conversion.md +1 -1
  11. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-02-review.md +1 -1
  12. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-03-edit.md +1 -1
  13. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-04-complete.md +1 -3
  14. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/steps/step-01-document-discovery.md +1 -1
  15. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/steps/step-02-prd-analysis.md +1 -1
  16. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/steps/step-03-epic-coverage-validation.md +1 -1
  17. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/workflow.md +1 -1
  18. package/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/workflow.md +1 -1
  19. package/src/bmm-skills/4-implementation/bmad-agent-dev/SKILL.md +5 -0
  20. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/SKILL.md +29 -0
  21. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/generate-trail.md +38 -0
  22. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-01-orientation.md +105 -0
  23. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-02-walkthrough.md +89 -0
  24. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-03-detail-pass.md +106 -0
  25. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-04-testing.md +74 -0
  26. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-05-wrapup.md +24 -0
  27. package/src/bmm-skills/4-implementation/bmad-code-review/steps/step-01-gather-context.md +38 -15
  28. package/src/bmm-skills/4-implementation/bmad-correct-course/checklist.md +2 -2
  29. package/src/bmm-skills/4-implementation/bmad-correct-course/workflow.md +8 -8
  30. package/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/checklist.md +1 -1
  31. package/src/bmm-skills/4-implementation/bmad-quick-dev/compile-epic-context.md +62 -0
  32. package/src/bmm-skills/4-implementation/bmad-quick-dev/spec-template.md +1 -1
  33. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-01-clarify-and-route.md +33 -6
  34. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-02-plan.md +20 -8
  35. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-03-implement.md +2 -0
  36. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md +16 -4
  37. package/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md +1 -5
  38. package/src/bmm-skills/4-implementation/bmad-retrospective/workflow.md +134 -134
  39. package/src/bmm-skills/4-implementation/bmad-sprint-planning/sprint-status-template.yaml +1 -1
  40. package/src/bmm-skills/4-implementation/bmad-sprint-planning/workflow.md +3 -3
  41. package/src/bmm-skills/4-implementation/bmad-sprint-status/workflow.md +2 -2
  42. package/src/bmm-skills/module-help.csv +2 -0
  43. package/src/core-skills/bmad-help/SKILL.md +4 -2
  44. package/src/core-skills/bmad-party-mode/SKILL.md +8 -6
  45. package/src/core-skills/module-help.csv +1 -0
  46. package/tools/installer/cli-utils.js +18 -9
  47. package/tools/installer/commands/install.js +1 -1
  48. package/tools/installer/core/existing-install.js +2 -8
  49. package/tools/installer/core/install-paths.js +0 -3
  50. package/tools/installer/core/installer.js +180 -463
  51. package/tools/installer/core/manifest-generator.js +8 -14
  52. package/tools/installer/core/manifest.js +94 -102
  53. package/tools/installer/ide/_config-driven.js +149 -38
  54. package/tools/installer/ide/shared/skill-manifest.js +1 -16
  55. package/tools/installer/install-messages.yaml +19 -26
  56. package/tools/installer/modules/community-manager.js +377 -0
  57. package/tools/installer/modules/custom-module-manager.js +644 -0
  58. package/tools/installer/modules/external-manager.js +65 -49
  59. package/tools/installer/modules/official-modules.js +117 -65
  60. package/tools/installer/modules/plugin-resolver.js +398 -0
  61. package/tools/installer/modules/registry-client.js +66 -0
  62. package/tools/installer/{external-official-modules.yaml → modules/registry-fallback.yaml} +3 -12
  63. package/tools/installer/ui.js +549 -666
  64. package/src/bmm-skills/4-implementation/bmad-agent-qa/SKILL.md +0 -61
  65. package/src/bmm-skills/4-implementation/bmad-agent-qa/bmad-skill-manifest.yaml +0 -11
  66. package/src/bmm-skills/4-implementation/bmad-agent-quick-flow-solo-dev/SKILL.md +0 -53
  67. package/src/bmm-skills/4-implementation/bmad-agent-quick-flow-solo-dev/bmad-skill-manifest.yaml +0 -11
  68. package/src/bmm-skills/4-implementation/bmad-agent-sm/SKILL.md +0 -55
  69. package/src/bmm-skills/4-implementation/bmad-agent-sm/bmad-skill-manifest.yaml +0 -11
  70. package/tools/installer/core/custom-module-cache.js +0 -260
  71. package/tools/installer/custom-handler.js +0 -112
  72. package/tools/installer/modules/custom-modules.js +0 -197
@@ -2,22 +2,50 @@ const path = require('node:path');
2
2
  const os = require('node:os');
3
3
  const fs = require('fs-extra');
4
4
  const { CLIUtils } = require('./cli-utils');
5
- const { CustomHandler } = require('./custom-handler');
6
5
  const { ExternalModuleManager } = require('./modules/external-manager');
6
+ const { getProjectRoot } = require('./project-root');
7
7
  const prompts = require('./prompts');
8
8
 
9
- // Separator class for visual grouping in select/multiselect prompts
10
- // Note: @clack/prompts doesn't support separators natively, they are filtered out
11
- class Separator {
12
- constructor(text = '────────') {
13
- this.line = text;
14
- this.name = text;
9
+ /**
10
+ * Read module version from .claude-plugin/marketplace.json
11
+ * @param {string} moduleCode - Module code (e.g., 'core', 'bmm', 'cis')
12
+ * @returns {string} Version string or empty string
13
+ */
14
+ async function getMarketplaceVersion(moduleCode) {
15
+ let marketplacePath;
16
+ if (moduleCode === 'core' || moduleCode === 'bmm') {
17
+ marketplacePath = path.join(getProjectRoot(), '.claude-plugin', 'marketplace.json');
18
+ } else {
19
+ const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules', moduleCode);
20
+ marketplacePath = path.join(cacheDir, '.claude-plugin', 'marketplace.json');
21
+ }
22
+ try {
23
+ if (await fs.pathExists(marketplacePath)) {
24
+ const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
25
+ return _extractMarketplaceVersion(data);
26
+ }
27
+ } catch {
28
+ // ignore
15
29
  }
16
- type = 'separator';
30
+ return '';
17
31
  }
18
32
 
19
- // Separator for choice lists (compatible interface)
20
- const choiceUtils = { Separator };
33
+ /**
34
+ * Extract the highest version from marketplace.json plugins array.
35
+ * Handles multiple plugins per file safely.
36
+ * @param {Object} data - Parsed marketplace.json
37
+ * @returns {string} Version string or empty string
38
+ */
39
+ function _extractMarketplaceVersion(data) {
40
+ const plugins = data?.plugins;
41
+ if (!Array.isArray(plugins) || plugins.length === 0) return '';
42
+ // Use the highest version across all plugins in the file
43
+ let best = '';
44
+ for (const p of plugins) {
45
+ if (p.version && (!best || p.version > best)) best = p.version;
46
+ }
47
+ return best;
48
+ }
21
49
 
22
50
  /**
23
51
  * UI utilities for the installer
@@ -58,11 +86,6 @@ class UI {
58
86
  // Check if there's an existing BMAD installation
59
87
  const hasExistingInstall = await fs.pathExists(bmadDir);
60
88
 
61
- let customContentConfig = { hasCustomContent: false };
62
- if (!hasExistingInstall) {
63
- customContentConfig._shouldAsk = true;
64
- }
65
-
66
89
  // Track action type (only set if there's an existing installation)
67
90
  let actionType;
68
91
 
@@ -70,17 +93,14 @@ class UI {
70
93
  if (hasExistingInstall) {
71
94
  // Get version information
72
95
  const { existingInstall, bmadDir } = await this.getExistingInstallation(confirmedDirectory);
73
- const packageJsonPath = path.join(__dirname, '../../package.json');
74
- const currentVersion = require(packageJsonPath).version;
75
- const installedVersion = existingInstall.installed ? existingInstall.version || 'unknown' : 'unknown';
76
96
 
77
97
  // Build menu choices dynamically
78
98
  const choices = [];
79
99
 
80
100
  // Always show Quick Update first (allows refreshing installation even on same version)
81
- if (installedVersion !== 'unknown') {
101
+ if (existingInstall.installed) {
82
102
  choices.push({
83
- name: `Quick Update (v${installedVersion} → v${currentVersion})`,
103
+ name: 'Quick Update',
84
104
  value: 'quick-update',
85
105
  });
86
106
  }
@@ -114,48 +134,9 @@ class UI {
114
134
 
115
135
  // Handle quick update separately
116
136
  if (actionType === 'quick-update') {
117
- // Pass --custom-content through so installer can re-cache if cache is missing
118
- let customContentForQuickUpdate = { hasCustomContent: false };
119
- if (options.customContent) {
120
- const paths = options.customContent
121
- .split(',')
122
- .map((p) => p.trim())
123
- .filter(Boolean);
124
- if (paths.length > 0) {
125
- const customPaths = [];
126
- const selectedModuleIds = [];
127
- const sources = [];
128
- for (const customPath of paths) {
129
- const expandedPath = this.expandUserPath(customPath);
130
- const validation = this.validateCustomContentPathSync(expandedPath);
131
- if (validation) continue;
132
- let moduleMeta;
133
- try {
134
- const moduleYamlPath = path.join(expandedPath, 'module.yaml');
135
- moduleMeta = require('yaml').parse(await fs.readFile(moduleYamlPath, 'utf-8'));
136
- } catch {
137
- continue;
138
- }
139
- if (!moduleMeta?.code) continue;
140
- customPaths.push(expandedPath);
141
- selectedModuleIds.push(moduleMeta.code);
142
- sources.push({ path: expandedPath, id: moduleMeta.code, name: moduleMeta.name || moduleMeta.code });
143
- }
144
- if (customPaths.length > 0) {
145
- customContentForQuickUpdate = {
146
- hasCustomContent: true,
147
- selected: true,
148
- sources,
149
- selectedFiles: customPaths.map((p) => path.join(p, 'module.yaml')),
150
- selectedModuleIds,
151
- };
152
- }
153
- }
154
- }
155
137
  return {
156
138
  actionType: 'quick-update',
157
139
  directory: confirmedDirectory,
158
- customContent: customContentForQuickUpdate,
159
140
  skipPrompts: options.yes || false,
160
141
  };
161
142
  }
@@ -177,6 +158,9 @@ class UI {
177
158
  .map((m) => m.trim())
178
159
  .filter(Boolean);
179
160
  await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`);
161
+ } else if (options.customSource) {
162
+ // Custom source without --modules: start with empty list (core added below)
163
+ selectedModules = [];
180
164
  } else if (options.yes) {
181
165
  selectedModules = await this.getDefaultModules(installedModuleIds);
182
166
  await prompts.log.info(
@@ -186,120 +170,14 @@ class UI {
186
170
  selectedModules = await this.selectAllModules(installedModuleIds);
187
171
  }
188
172
 
189
- // After module selection, ask about custom modules
190
- let customModuleResult = { selectedCustomModules: [], customContentConfig: { hasCustomContent: false } };
191
-
192
- if (options.customContent) {
193
- // Use custom content from command-line
194
- const paths = options.customContent
195
- .split(',')
196
- .map((p) => p.trim())
197
- .filter(Boolean);
198
- await prompts.log.info(`Using custom content from command-line: ${paths.join(', ')}`);
199
-
200
- // Build custom content config similar to promptCustomContentSource
201
- const customPaths = [];
202
- const selectedModuleIds = [];
203
- const sources = [];
204
-
205
- for (const customPath of paths) {
206
- const expandedPath = this.expandUserPath(customPath);
207
- const validation = this.validateCustomContentPathSync(expandedPath);
208
- if (validation) {
209
- await prompts.log.warn(`Skipping invalid custom content path: ${customPath} - ${validation}`);
210
- continue;
211
- }
212
-
213
- // Read module metadata
214
- let moduleMeta;
215
- try {
216
- const moduleYamlPath = path.join(expandedPath, 'module.yaml');
217
- const moduleYaml = await fs.readFile(moduleYamlPath, 'utf-8');
218
- const yaml = require('yaml');
219
- moduleMeta = yaml.parse(moduleYaml);
220
- } catch (error) {
221
- await prompts.log.warn(`Skipping custom content path: ${customPath} - failed to read module.yaml: ${error.message}`);
222
- continue;
223
- }
224
-
225
- if (!moduleMeta) {
226
- await prompts.log.warn(`Skipping custom content path: ${customPath} - module.yaml is empty`);
227
- continue;
228
- }
229
-
230
- if (!moduleMeta.code) {
231
- await prompts.log.warn(`Skipping custom content path: ${customPath} - module.yaml missing 'code' field`);
232
- continue;
233
- }
234
-
235
- customPaths.push(expandedPath);
236
- selectedModuleIds.push(moduleMeta.code);
237
- sources.push({
238
- path: expandedPath,
239
- id: moduleMeta.code,
240
- name: moduleMeta.name || moduleMeta.code,
241
- });
242
- }
243
-
244
- if (customPaths.length > 0) {
245
- customModuleResult = {
246
- selectedCustomModules: selectedModuleIds,
247
- customContentConfig: {
248
- hasCustomContent: true,
249
- selected: true,
250
- sources,
251
- selectedFiles: customPaths.map((p) => path.join(p, 'module.yaml')),
252
- selectedModuleIds: selectedModuleIds,
253
- },
254
- };
255
- }
256
- } else if (options.yes) {
257
- // Non-interactive mode: preserve existing custom modules (matches default: false)
258
- const cacheDir = path.join(bmadDir, '_config', 'custom');
259
- if (await fs.pathExists(cacheDir)) {
260
- const entries = await fs.readdir(cacheDir, { withFileTypes: true });
261
- for (const entry of entries) {
262
- if (entry.isDirectory()) {
263
- customModuleResult.selectedCustomModules.push(entry.name);
264
- }
265
- }
266
- await prompts.log.info(
267
- `Non-interactive mode (--yes): preserving ${customModuleResult.selectedCustomModules.length} existing custom module(s)`,
268
- );
269
- } else {
270
- await prompts.log.info('Non-interactive mode (--yes): no existing custom modules found');
271
- }
272
- } else {
273
- const changeCustomModules = await prompts.confirm({
274
- message: 'Modify custom modules, agents, or workflows?',
275
- default: false,
276
- });
277
-
278
- if (changeCustomModules) {
279
- customModuleResult = await this.handleCustomModulesInModifyFlow(confirmedDirectory, selectedModules);
280
- } else {
281
- // Preserve existing custom modules if user doesn't want to modify them
282
- const { Installer } = require('./core/installer');
283
- const installer = new Installer();
284
- const { bmadDir } = await installer.findBmadDir(confirmedDirectory);
285
-
286
- const cacheDir = path.join(bmadDir, '_config', 'custom');
287
- if (await fs.pathExists(cacheDir)) {
288
- const entries = await fs.readdir(cacheDir, { withFileTypes: true });
289
- for (const entry of entries) {
290
- if (entry.isDirectory()) {
291
- customModuleResult.selectedCustomModules.push(entry.name);
292
- }
293
- }
294
- }
173
+ // Resolve custom sources from --custom-source flag
174
+ if (options.customSource) {
175
+ const customCodes = await this._resolveCustomSourcesCli(options.customSource);
176
+ for (const code of customCodes) {
177
+ if (!selectedModules.includes(code)) selectedModules.push(code);
295
178
  }
296
179
  }
297
180
 
298
- // Merge any selected custom modules
299
- if (customModuleResult.selectedCustomModules.length > 0) {
300
- selectedModules.push(...customModuleResult.selectedCustomModules);
301
- }
302
-
303
181
  // Ensure core is in the modules list
304
182
  if (!selectedModules.includes('core')) {
305
183
  selectedModules.unshift('core');
@@ -318,7 +196,6 @@ class UI {
318
196
  skipIde: toolSelection.skipIde,
319
197
  coreConfig: moduleConfigs.core || {},
320
198
  moduleConfigs: moduleConfigs,
321
- customContent: customModuleResult.customContentConfig,
322
199
  skipPrompts: options.yes || false,
323
200
  };
324
201
  }
@@ -336,6 +213,9 @@ class UI {
336
213
  .map((m) => m.trim())
337
214
  .filter(Boolean);
338
215
  await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`);
216
+ } else if (options.customSource) {
217
+ // Custom source without --modules: start with empty list (core added below)
218
+ selectedModules = [];
339
219
  } else if (options.yes) {
340
220
  // Use default modules when --yes flag is set
341
221
  selectedModules = await this.getDefaultModules(installedModuleIds);
@@ -344,84 +224,14 @@ class UI {
344
224
  selectedModules = await this.selectAllModules(installedModuleIds);
345
225
  }
346
226
 
347
- // Ask about custom content (local modules/agents/workflows)
348
- if (options.customContent) {
349
- // Use custom content from command-line
350
- const paths = options.customContent
351
- .split(',')
352
- .map((p) => p.trim())
353
- .filter(Boolean);
354
- await prompts.log.info(`Using custom content from command-line: ${paths.join(', ')}`);
355
-
356
- // Build custom content config similar to promptCustomContentSource
357
- const customPaths = [];
358
- const selectedModuleIds = [];
359
- const sources = [];
360
-
361
- for (const customPath of paths) {
362
- const expandedPath = this.expandUserPath(customPath);
363
- const validation = this.validateCustomContentPathSync(expandedPath);
364
- if (validation) {
365
- await prompts.log.warn(`Skipping invalid custom content path: ${customPath} - ${validation}`);
366
- continue;
367
- }
368
-
369
- // Read module metadata
370
- let moduleMeta;
371
- try {
372
- const moduleYamlPath = path.join(expandedPath, 'module.yaml');
373
- const moduleYaml = await fs.readFile(moduleYamlPath, 'utf-8');
374
- const yaml = require('yaml');
375
- moduleMeta = yaml.parse(moduleYaml);
376
- } catch (error) {
377
- await prompts.log.warn(`Skipping custom content path: ${customPath} - failed to read module.yaml: ${error.message}`);
378
- continue;
379
- }
380
-
381
- if (!moduleMeta) {
382
- await prompts.log.warn(`Skipping custom content path: ${customPath} - module.yaml is empty`);
383
- continue;
384
- }
385
-
386
- if (!moduleMeta.code) {
387
- await prompts.log.warn(`Skipping custom content path: ${customPath} - module.yaml missing 'code' field`);
388
- continue;
389
- }
390
-
391
- customPaths.push(expandedPath);
392
- selectedModuleIds.push(moduleMeta.code);
393
- sources.push({
394
- path: expandedPath,
395
- id: moduleMeta.code,
396
- name: moduleMeta.name || moduleMeta.code,
397
- });
398
- }
399
-
400
- if (customPaths.length > 0) {
401
- customContentConfig = {
402
- hasCustomContent: true,
403
- selected: true,
404
- sources,
405
- selectedFiles: customPaths.map((p) => path.join(p, 'module.yaml')),
406
- selectedModuleIds: selectedModuleIds,
407
- };
408
- }
409
- } else if (!options.yes) {
410
- const wantsCustomContent = await prompts.confirm({
411
- message: 'Add custom modules, agents, or workflows from your computer?',
412
- default: false,
413
- });
414
-
415
- if (wantsCustomContent) {
416
- customContentConfig = await this.promptCustomContentSource();
227
+ // Resolve custom sources from --custom-source flag
228
+ if (options.customSource) {
229
+ const customCodes = await this._resolveCustomSourcesCli(options.customSource);
230
+ for (const code of customCodes) {
231
+ if (!selectedModules.includes(code)) selectedModules.push(code);
417
232
  }
418
233
  }
419
234
 
420
- // Add custom content modules if any were selected
421
- if (customContentConfig && customContentConfig.selectedModuleIds) {
422
- selectedModules.push(...customContentConfig.selectedModuleIds);
423
- }
424
-
425
235
  // Ensure core is in the modules list
426
236
  if (!selectedModules.includes('core')) {
427
237
  selectedModules.unshift('core');
@@ -437,7 +247,6 @@ class UI {
437
247
  skipIde: toolSelection.skipIde,
438
248
  coreConfig: moduleConfigs.core || {},
439
249
  moduleConfigs: moduleConfigs,
440
- customContent: customContentConfig,
441
250
  skipPrompts: options.yes || false,
442
251
  };
443
252
  }
@@ -776,166 +585,80 @@ class UI {
776
585
  }
777
586
 
778
587
  /**
779
- * Get module choices for selection
588
+ * Select all modules across three tiers: official, community, and custom URL.
780
589
  * @param {Set} installedModuleIds - Currently installed module IDs
781
- * @param {Object} customContentConfig - Custom content configuration
782
- * @returns {Array} Module choices for prompt
590
+ * @returns {Array} Selected module codes (excluding core)
783
591
  */
784
- async getModuleChoices(installedModuleIds, customContentConfig = null) {
785
- const color = await prompts.getColor();
786
- const moduleChoices = [];
787
- const isNewInstallation = installedModuleIds.size === 0;
788
-
789
- const customContentItems = [];
790
-
791
- // Add custom content items
792
- if (customContentConfig && customContentConfig.hasCustomContent && customContentConfig.customPath) {
793
- // Existing installation - show from directory
794
- const customHandler = new CustomHandler();
795
- const customFiles = await customHandler.findCustomContent(customContentConfig.customPath);
796
-
797
- for (const customFile of customFiles) {
798
- const customInfo = await customHandler.getCustomInfo(customFile);
799
- if (customInfo) {
800
- customContentItems.push({
801
- name: `${color.cyan('\u2713')} ${customInfo.name} ${color.dim(`(${customInfo.relativePath})`)}`,
802
- value: `__CUSTOM_CONTENT__${customFile}`, // Unique value for each custom content
803
- checked: true, // Default to selected since user chose to provide custom content
804
- path: customInfo.path, // Track path to avoid duplicates
805
- hint: customInfo.description || undefined,
806
- });
807
- }
808
- }
809
- }
810
-
811
- // Add official modules
812
- const { OfficialModules } = require('./modules/official-modules');
813
- const officialModules = new OfficialModules();
814
- const { modules: availableModules, customModules: customModulesFromCache } = await officialModules.listAvailable();
815
-
816
- // First, add all items to appropriate sections
817
- const allCustomModules = [];
818
-
819
- // Add custom content items from directory
820
- allCustomModules.push(...customContentItems);
821
-
822
- // Add custom modules from cache
823
- for (const mod of customModulesFromCache) {
824
- // Skip if this module is already in customContentItems (by path)
825
- const isDuplicate = allCustomModules.some((item) => item.path && mod.path && path.resolve(item.path) === path.resolve(mod.path));
826
-
827
- if (!isDuplicate) {
828
- allCustomModules.push({
829
- name: `${color.cyan('\u2713')} ${mod.name} ${color.dim('(cached)')}`,
830
- value: mod.id,
831
- checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id),
832
- hint: mod.description || undefined,
833
- });
834
- }
835
- }
836
-
837
- // Add separators and modules in correct order
838
- if (allCustomModules.length > 0) {
839
- // Add separator for custom content, all custom modules, and official content separator
840
- moduleChoices.push(
841
- new choiceUtils.Separator('── Custom Content ──'),
842
- ...allCustomModules,
843
- new choiceUtils.Separator('── Official Content ──'),
844
- );
845
- }
592
+ async selectAllModules(installedModuleIds = new Set()) {
593
+ // Phase 1: Official modules
594
+ const officialSelected = await this._selectOfficialModules(installedModuleIds);
846
595
 
847
- // Add official modules (only non-custom ones)
848
- for (const mod of availableModules) {
849
- if (!mod.isCustom) {
850
- moduleChoices.push({
851
- name: mod.name,
852
- value: mod.id,
853
- checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id),
854
- hint: mod.description || undefined,
855
- });
596
+ // Determine which installed modules are NOT official (community or custom).
597
+ // These must be preserved even if the user declines to browse community/custom.
598
+ const officialCodes = new Set(officialSelected);
599
+ const externalManager = new ExternalModuleManager();
600
+ const registryModules = await externalManager.listAvailable();
601
+ const officialRegistryCodes = new Set(registryModules.map((m) => m.code));
602
+ const installedNonOfficial = [...installedModuleIds].filter((id) => !officialRegistryCodes.has(id));
603
+
604
+ // Phase 2: Community modules (category drill-down)
605
+ // Returns { codes, didBrowse } so we know if the user entered the flow
606
+ const communityResult = await this._browseCommunityModules(installedModuleIds);
607
+
608
+ // Phase 3: Custom URL modules
609
+ const customSelected = await this._addCustomUrlModules(installedModuleIds);
610
+
611
+ // Merge all selections
612
+ const allSelected = new Set([...officialSelected, ...communityResult.codes, ...customSelected]);
613
+
614
+ // Auto-include installed non-official modules that the user didn't get
615
+ // a chance to manage (they declined to browse). If they did browse,
616
+ // trust their selections - they could have deselected intentionally.
617
+ if (!communityResult.didBrowse) {
618
+ for (const code of installedNonOfficial) {
619
+ allSelected.add(code);
856
620
  }
857
621
  }
858
622
 
859
- return moduleChoices;
623
+ return [...allSelected];
860
624
  }
861
625
 
862
626
  /**
863
- * Select all modules (official + community) using grouped multiselect.
864
- * Core is shown as locked but filtered from the result since it's always installed separately.
627
+ * Select official modules using autocompleteMultiselect.
628
+ * Extracted from the original selectAllModules - unchanged behavior.
865
629
  * @param {Set} installedModuleIds - Currently installed module IDs
866
- * @returns {Array} Selected module codes (excluding core)
630
+ * @returns {Array} Selected official module codes
867
631
  */
868
- async selectAllModules(installedModuleIds = new Set()) {
869
- const { OfficialModules } = require('./modules/official-modules');
870
- const officialModulesSource = new OfficialModules();
871
- const { modules: localModules } = await officialModulesSource.listAvailable();
872
-
873
- // Get external modules
632
+ async _selectOfficialModules(installedModuleIds = new Set()) {
874
633
  const externalManager = new ExternalModuleManager();
875
- const externalModules = await externalManager.listAvailable();
634
+ const registryModules = await externalManager.listAvailable();
876
635
 
877
- // Build flat options list with group hints for autocompleteMultiselect
878
636
  const allOptions = [];
879
637
  const initialValues = [];
880
638
  const lockedValues = ['core'];
881
639
 
882
- // Core module is always installed — show it locked at the top
883
- allOptions.push({ label: 'BMad Core Module', value: 'core', hint: 'Core configuration and shared resources' });
884
- initialValues.push('core');
885
-
886
- // Helper to build module entry with proper sorting and selection
887
- const buildModuleEntry = (mod, value, group) => {
888
- const isInstalled = installedModuleIds.has(value);
640
+ const buildModuleEntry = async (mod) => {
641
+ const isInstalled = installedModuleIds.has(mod.code);
642
+ const version = await getMarketplaceVersion(mod.code);
643
+ const label = version ? `${mod.name} (v${version})` : mod.name;
889
644
  return {
890
- label: mod.name,
891
- value,
892
- hint: mod.description || group,
893
- // Pre-select only if already installed (not on fresh install)
645
+ label,
646
+ value: mod.code,
647
+ hint: mod.description,
894
648
  selected: isInstalled,
895
649
  };
896
650
  };
897
651
 
898
- // Local modules (BMM, BMB, etc.)
899
- const localEntries = [];
900
- for (const mod of localModules) {
901
- if (!mod.isCustom && mod.id !== 'core') {
902
- const entry = buildModuleEntry(mod, mod.id, 'Local');
903
- localEntries.push(entry);
904
- if (entry.selected) {
905
- initialValues.push(mod.id);
906
- }
907
- }
908
- }
909
- allOptions.push(...localEntries.map(({ label, value, hint }) => ({ label, value, hint })));
910
-
911
- // Group 2: BMad Official Modules (type: bmad-org)
912
- const officialModules = [];
913
- for (const mod of externalModules) {
914
- if (mod.type === 'bmad-org') {
915
- const entry = buildModuleEntry(mod, mod.code, 'Official');
916
- officialModules.push(entry);
917
- if (entry.selected) {
918
- initialValues.push(mod.code);
919
- }
652
+ for (const mod of registryModules) {
653
+ const entry = await buildModuleEntry(mod);
654
+ allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint });
655
+ if (entry.selected) {
656
+ initialValues.push(mod.code);
920
657
  }
921
658
  }
922
- allOptions.push(...officialModules.map(({ label, value, hint }) => ({ label, value, hint })));
923
-
924
- // Group 3: Community Modules (type: community)
925
- const communityModules = [];
926
- for (const mod of externalModules) {
927
- if (mod.type === 'community') {
928
- const entry = buildModuleEntry(mod, mod.code, 'Community');
929
- communityModules.push(entry);
930
- if (entry.selected) {
931
- initialValues.push(mod.code);
932
- }
933
- }
934
- }
935
- allOptions.push(...communityModules.map(({ label, value, hint }) => ({ label, value, hint })));
936
659
 
937
660
  const selected = await prompts.autocompleteMultiselect({
938
- message: 'Select modules to install:',
661
+ message: 'Select official modules to install:',
939
662
  options: allOptions,
940
663
  initialValues: initialValues.length > 0 ? initialValues : undefined,
941
664
  lockedValues,
@@ -945,34 +668,468 @@ class UI {
945
668
 
946
669
  const result = selected ? [...selected] : [];
947
670
 
948
- // Display selected modules as bulleted list
949
671
  if (result.length > 0) {
950
672
  const moduleLines = result.map((moduleId) => {
951
673
  const opt = allOptions.find((o) => o.value === moduleId);
952
674
  return ` \u2022 ${opt?.label || moduleId}`;
953
675
  });
954
- await prompts.log.message('Selected modules:\n' + moduleLines.join('\n'));
676
+ await prompts.log.message('Selected official modules:\n' + moduleLines.join('\n'));
955
677
  }
956
678
 
957
679
  return result;
958
680
  }
959
681
 
682
+ /**
683
+ * Browse and select community modules using category drill-down.
684
+ * Featured/promoted modules appear at the top.
685
+ * @param {Set} installedModuleIds - Currently installed module IDs
686
+ * @returns {Object} { codes: string[], didBrowse: boolean }
687
+ */
688
+ async _browseCommunityModules(installedModuleIds = new Set()) {
689
+ const browseCommunity = await prompts.confirm({
690
+ message: 'Would you like to browse community modules?',
691
+ default: false,
692
+ });
693
+ if (!browseCommunity) return { codes: [], didBrowse: false };
694
+
695
+ const { CommunityModuleManager } = require('./modules/community-manager');
696
+ const communityMgr = new CommunityModuleManager();
697
+
698
+ const s = await prompts.spinner();
699
+ s.start('Loading community module catalog...');
700
+
701
+ let categories, featured, allCommunity;
702
+ try {
703
+ [categories, featured, allCommunity] = await Promise.all([
704
+ communityMgr.getCategoryList(),
705
+ communityMgr.listFeatured(),
706
+ communityMgr.listAll(),
707
+ ]);
708
+ s.stop(`Community catalog loaded (${allCommunity.length} modules)`);
709
+ } catch (error) {
710
+ s.error('Failed to load community catalog');
711
+ await prompts.log.warn(` ${error.message}`);
712
+ return { codes: [], didBrowse: false };
713
+ }
714
+
715
+ if (allCommunity.length === 0) {
716
+ await prompts.log.info('No community modules are currently available.');
717
+ return { codes: [], didBrowse: false };
718
+ }
719
+
720
+ const selectedCodes = new Set();
721
+ let browsing = true;
722
+
723
+ while (browsing) {
724
+ const categoryChoices = [];
725
+
726
+ // Featured section at top
727
+ if (featured.length > 0) {
728
+ categoryChoices.push({
729
+ value: '__featured__',
730
+ label: `\u2605 Featured (${featured.length} module${featured.length === 1 ? '' : 's'})`,
731
+ });
732
+ }
733
+
734
+ // Categories with module counts
735
+ for (const cat of categories) {
736
+ categoryChoices.push({
737
+ value: cat.slug,
738
+ label: `${cat.name} (${cat.moduleCount} module${cat.moduleCount === 1 ? '' : 's'})`,
739
+ });
740
+ }
741
+
742
+ // Special actions at bottom
743
+ categoryChoices.push(
744
+ { value: '__all__', label: '\u25CE View all community modules' },
745
+ { value: '__search__', label: '\u25CE Search by keyword' },
746
+ { value: '__done__', label: '\u2713 Done browsing' },
747
+ );
748
+
749
+ const selectedCount = selectedCodes.size;
750
+ const categoryChoice = await prompts.select({
751
+ message: `Browse community modules${selectedCount > 0 ? ` (${selectedCount} selected)` : ''}:`,
752
+ choices: categoryChoices,
753
+ });
754
+
755
+ if (categoryChoice === '__done__') {
756
+ browsing = false;
757
+ continue;
758
+ }
759
+
760
+ let modulesToShow;
761
+ switch (categoryChoice) {
762
+ case '__featured__': {
763
+ modulesToShow = featured;
764
+
765
+ break;
766
+ }
767
+ case '__all__': {
768
+ modulesToShow = allCommunity;
769
+
770
+ break;
771
+ }
772
+ case '__search__': {
773
+ const query = await prompts.text({
774
+ message: 'Search community modules:',
775
+ placeholder: 'e.g., design, testing, game',
776
+ });
777
+ if (!query || query.trim() === '') continue;
778
+ modulesToShow = await communityMgr.searchByKeyword(query.trim());
779
+ if (modulesToShow.length === 0) {
780
+ await prompts.log.warn('No matching modules found.');
781
+ continue;
782
+ }
783
+
784
+ break;
785
+ }
786
+ default: {
787
+ modulesToShow = await communityMgr.listByCategory(categoryChoice);
788
+ }
789
+ }
790
+
791
+ // Build options for autocompleteMultiselect
792
+ const trustBadge = (tier) => {
793
+ if (tier === 'bmad-certified') return '\u2713';
794
+ if (tier === 'community-reviewed') return '\u25CB';
795
+ return '\u26A0';
796
+ };
797
+
798
+ const options = modulesToShow.map((mod) => {
799
+ const versionStr = mod.version ? ` (v${mod.version})` : '';
800
+ const badge = trustBadge(mod.trustTier);
801
+ return {
802
+ label: `${mod.displayName}${versionStr} [${badge}]`,
803
+ value: mod.code,
804
+ hint: mod.description,
805
+ };
806
+ });
807
+
808
+ // Pre-check modules that are already selected or installed
809
+ const initialValues = modulesToShow.filter((m) => selectedCodes.has(m.code) || installedModuleIds.has(m.code)).map((m) => m.code);
810
+
811
+ const selected = await prompts.autocompleteMultiselect({
812
+ message: 'Select community modules:',
813
+ options,
814
+ initialValues: initialValues.length > 0 ? initialValues : undefined,
815
+ required: false,
816
+ maxItems: Math.min(options.length, 10),
817
+ });
818
+
819
+ // Update accumulated selections: sync with what user selected in this view
820
+ const shownCodes = new Set(modulesToShow.map((m) => m.code));
821
+ for (const code of shownCodes) {
822
+ if (selected && selected.includes(code)) {
823
+ selectedCodes.add(code);
824
+ } else {
825
+ selectedCodes.delete(code);
826
+ }
827
+ }
828
+ }
829
+
830
+ if (selectedCodes.size > 0) {
831
+ const moduleLines = [];
832
+ for (const code of selectedCodes) {
833
+ const mod = await communityMgr.getModuleByCode(code);
834
+ moduleLines.push(` \u2022 ${mod?.displayName || code}`);
835
+ }
836
+ await prompts.log.message('Selected community modules:\n' + moduleLines.join('\n'));
837
+ }
838
+
839
+ return { codes: [...selectedCodes], didBrowse: true };
840
+ }
841
+
842
+ /**
843
+ * Prompt user to install modules from custom sources (Git URLs or local paths).
844
+ * @param {Set} installedModuleIds - Currently installed module IDs
845
+ * @returns {Array} Selected custom module code strings
846
+ */
847
+ async _addCustomUrlModules(installedModuleIds = new Set()) {
848
+ const addCustom = await prompts.confirm({
849
+ message: 'Would you like to install from a custom source (Git URL or local path)?',
850
+ default: false,
851
+ });
852
+ if (!addCustom) return [];
853
+
854
+ const { CustomModuleManager } = require('./modules/custom-module-manager');
855
+ const customMgr = new CustomModuleManager();
856
+ const selectedModules = [];
857
+
858
+ let addMore = true;
859
+ while (addMore) {
860
+ const sourceInput = await prompts.text({
861
+ message: 'Git URL or local path:',
862
+ placeholder: 'https://github.com/owner/repo or /path/to/module',
863
+ validate: (input) => {
864
+ if (!input || input.trim() === '') return 'Source is required';
865
+ const result = customMgr.parseSource(input.trim());
866
+ return result.isValid ? undefined : result.error;
867
+ },
868
+ });
869
+
870
+ const s = await prompts.spinner();
871
+ s.start('Resolving source...');
872
+
873
+ let sourceResult;
874
+ try {
875
+ sourceResult = await customMgr.resolveSource(sourceInput.trim(), { skipInstall: true, silent: true });
876
+ s.stop(sourceResult.parsed.type === 'local' ? 'Local source resolved' : 'Repository cloned');
877
+ } catch (error) {
878
+ s.error('Failed to resolve source');
879
+ await prompts.log.error(` ${error.message}`);
880
+ addMore = await prompts.confirm({ message: 'Try another source?', default: false });
881
+ continue;
882
+ }
883
+
884
+ if (sourceResult.parsed.type === 'local') {
885
+ await prompts.log.info('LOCAL MODULE: Pointing directly at local source (changes take effect on reinstall).');
886
+ } else {
887
+ await prompts.log.warn(
888
+ 'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.',
889
+ );
890
+ }
891
+
892
+ // Resolve plugins based on discovery mode vs direct mode
893
+ s.start('Analyzing plugin structure...');
894
+ const allResolved = [];
895
+ const localPath = sourceResult.parsed.type === 'local' ? sourceResult.rootDir : null;
896
+
897
+ if (sourceResult.mode === 'discovery') {
898
+ // Discovery mode: marketplace.json found, list available plugins
899
+ let plugins;
900
+ try {
901
+ plugins = await customMgr.discoverModules(sourceResult.marketplace, sourceResult.sourceUrl);
902
+ } catch (discoverError) {
903
+ s.error('Failed to discover modules');
904
+ await prompts.log.error(` ${discoverError.message}`);
905
+ addMore = await prompts.confirm({ message: 'Try another source?', default: false });
906
+ continue;
907
+ }
908
+
909
+ const effectiveRepoPath = sourceResult.repoPath || sourceResult.rootDir;
910
+ for (const plugin of plugins) {
911
+ try {
912
+ const resolved = await customMgr.resolvePlugin(effectiveRepoPath, plugin.rawPlugin, sourceResult.sourceUrl, localPath);
913
+ if (resolved.length > 0) {
914
+ allResolved.push(...resolved);
915
+ } else {
916
+ // No skills array or empty - use plugin metadata as-is (legacy)
917
+ allResolved.push({
918
+ code: plugin.code,
919
+ name: plugin.displayName || plugin.name,
920
+ version: plugin.version,
921
+ description: plugin.description,
922
+ strategy: 0,
923
+ pluginName: plugin.name,
924
+ skillPaths: [],
925
+ });
926
+ }
927
+ } catch (resolveError) {
928
+ await prompts.log.warn(` Could not resolve ${plugin.name}: ${resolveError.message}`);
929
+ }
930
+ }
931
+ } else {
932
+ // Direct mode: no marketplace.json, scan directory for skills and resolve
933
+ const directPlugin = {
934
+ name: sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
935
+ source: '.',
936
+ skills: [],
937
+ };
938
+
939
+ // Scan for SKILL.md directories to populate skills array
940
+ try {
941
+ const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
942
+ for (const entry of entries) {
943
+ if (entry.isDirectory()) {
944
+ const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
945
+ if (await fs.pathExists(skillMd)) {
946
+ directPlugin.skills.push(entry.name);
947
+ }
948
+ }
949
+ }
950
+ } catch (scanError) {
951
+ s.error('Failed to scan directory');
952
+ await prompts.log.error(` ${scanError.message}`);
953
+ addMore = await prompts.confirm({ message: 'Try another source?', default: false });
954
+ continue;
955
+ }
956
+
957
+ if (directPlugin.skills.length > 0) {
958
+ try {
959
+ const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
960
+ allResolved.push(...resolved);
961
+ } catch (resolveError) {
962
+ await prompts.log.warn(` Could not resolve: ${resolveError.message}`);
963
+ }
964
+ }
965
+ }
966
+ s.stop(`Found ${allResolved.length} installable module${allResolved.length === 1 ? '' : 's'}`);
967
+
968
+ if (allResolved.length === 0) {
969
+ await prompts.log.warn('No installable modules found in this source.');
970
+ addMore = await prompts.confirm({ message: 'Try another source?', default: false });
971
+ continue;
972
+ }
973
+
974
+ // Build multiselect choices
975
+ // Already-installed modules are pre-checked (update). New modules are unchecked (opt-in).
976
+ // Unchecking an installed module means "skip update" - removal is handled elsewhere.
977
+ const choices = allResolved.map((mod) => {
978
+ const versionStr = mod.version ? ` v${mod.version}` : '';
979
+ const skillCount = mod.skillPaths ? mod.skillPaths.length : 0;
980
+ const skillStr = skillCount > 0 ? ` (${skillCount} skill${skillCount === 1 ? '' : 's'})` : '';
981
+ const alreadyInstalled = installedModuleIds.has(mod.code);
982
+ const hint = alreadyInstalled ? 'update' : undefined;
983
+
984
+ return {
985
+ name: `${mod.name}${versionStr}${skillStr}`,
986
+ value: mod.code,
987
+ hint,
988
+ checked: alreadyInstalled,
989
+ };
990
+ });
991
+
992
+ // Show descriptions before the multiselect
993
+ for (const mod of allResolved) {
994
+ const versionStr = mod.version ? ` v${mod.version}` : '';
995
+ await prompts.log.info(` ${mod.name}${versionStr}\n ${mod.description}`);
996
+ }
997
+
998
+ const selected = await prompts.multiselect({
999
+ message: 'Select modules to install:',
1000
+ choices,
1001
+ required: false,
1002
+ });
1003
+
1004
+ if (selected && selected.length > 0) {
1005
+ for (const code of selected) {
1006
+ selectedModules.push(code);
1007
+ }
1008
+ }
1009
+
1010
+ addMore = await prompts.confirm({
1011
+ message: 'Add another custom source?',
1012
+ default: false,
1013
+ });
1014
+ }
1015
+
1016
+ if (selectedModules.length > 0) {
1017
+ await prompts.log.message('Selected custom modules:\n' + selectedModules.map((c) => ` \u2022 ${c}`).join('\n'));
1018
+ }
1019
+
1020
+ return selectedModules;
1021
+ }
1022
+
1023
+ /**
1024
+ * Resolve custom sources from --custom-source CLI flag (non-interactive).
1025
+ * Auto-selects all discovered modules from each source.
1026
+ * @param {string} sourcesArg - Comma-separated Git URLs or local paths
1027
+ * @returns {Array} Module codes from all resolved sources
1028
+ */
1029
+ async _resolveCustomSourcesCli(sourcesArg) {
1030
+ const { CustomModuleManager } = require('./modules/custom-module-manager');
1031
+ const customMgr = new CustomModuleManager();
1032
+ const allCodes = [];
1033
+
1034
+ const sources = sourcesArg
1035
+ .split(',')
1036
+ .map((s) => s.trim())
1037
+ .filter(Boolean);
1038
+
1039
+ for (const source of sources) {
1040
+ const s = await prompts.spinner();
1041
+ s.start(`Resolving ${source}...`);
1042
+
1043
+ let sourceResult;
1044
+ try {
1045
+ sourceResult = await customMgr.resolveSource(source, { skipInstall: true, silent: true });
1046
+ s.stop(sourceResult.parsed.type === 'local' ? 'Local source resolved' : 'Repository cloned');
1047
+ } catch (error) {
1048
+ s.error(`Failed to resolve ${source}`);
1049
+ await prompts.log.error(` ${error.message}`);
1050
+ continue;
1051
+ }
1052
+
1053
+ const s2 = await prompts.spinner();
1054
+ s2.start('Analyzing plugin structure...');
1055
+ const allResolved = [];
1056
+ const localPath = sourceResult.parsed.type === 'local' ? sourceResult.rootDir : null;
1057
+
1058
+ if (sourceResult.mode === 'discovery') {
1059
+ try {
1060
+ const plugins = await customMgr.discoverModules(sourceResult.marketplace, sourceResult.sourceUrl);
1061
+ const effectiveRepoPath = sourceResult.repoPath || sourceResult.rootDir;
1062
+ for (const plugin of plugins) {
1063
+ try {
1064
+ const resolved = await customMgr.resolvePlugin(effectiveRepoPath, plugin.rawPlugin, sourceResult.sourceUrl, localPath);
1065
+ if (resolved.length > 0) {
1066
+ allResolved.push(...resolved);
1067
+ }
1068
+ } catch {
1069
+ // Skip unresolvable plugins
1070
+ }
1071
+ }
1072
+ } catch (discoverError) {
1073
+ s2.error('Failed to discover modules');
1074
+ await prompts.log.error(` ${discoverError.message}`);
1075
+ continue;
1076
+ }
1077
+ } else {
1078
+ // Direct mode: scan for SKILL.md directories
1079
+ const directPlugin = {
1080
+ name: sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
1081
+ source: '.',
1082
+ skills: [],
1083
+ };
1084
+ try {
1085
+ const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
1086
+ for (const entry of entries) {
1087
+ if (entry.isDirectory()) {
1088
+ const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
1089
+ if (await fs.pathExists(skillMd)) {
1090
+ directPlugin.skills.push(entry.name);
1091
+ }
1092
+ }
1093
+ }
1094
+ } catch {
1095
+ // Skip unreadable directories
1096
+ }
1097
+
1098
+ if (directPlugin.skills.length > 0) {
1099
+ try {
1100
+ const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
1101
+ allResolved.push(...resolved);
1102
+ } catch {
1103
+ // Skip unresolvable
1104
+ }
1105
+ }
1106
+ }
1107
+ s2.stop(`Found ${allResolved.length} module${allResolved.length === 1 ? '' : 's'}`);
1108
+
1109
+ for (const mod of allResolved) {
1110
+ allCodes.push(mod.code);
1111
+ const versionStr = mod.version ? ` v${mod.version}` : '';
1112
+ await prompts.log.info(` Custom module: ${mod.name}${versionStr}`);
1113
+ }
1114
+ }
1115
+
1116
+ return allCodes;
1117
+ }
1118
+
960
1119
  /**
961
1120
  * Get default modules for non-interactive mode
962
1121
  * @param {Set} installedModuleIds - Already installed module IDs
963
1122
  * @returns {Array} Default module codes
964
1123
  */
965
1124
  async getDefaultModules(installedModuleIds = new Set()) {
966
- const { OfficialModules } = require('./modules/official-modules');
967
- const officialModules = new OfficialModules();
968
- const { modules: localModules } = await officialModules.listAvailable();
1125
+ const externalManager = new ExternalModuleManager();
1126
+ const registryModules = await externalManager.listAvailable();
969
1127
 
970
1128
  const defaultModules = [];
971
1129
 
972
- // Add default-selected local modules (typically BMM)
973
- for (const mod of localModules) {
974
- if (mod.defaultSelected === true || installedModuleIds.has(mod.id)) {
975
- defaultModules.push(mod.id);
1130
+ for (const mod of registryModules) {
1131
+ if (mod.defaultSelected || installedModuleIds.has(mod.code)) {
1132
+ defaultModules.push(mod.code);
976
1133
  }
977
1134
  }
978
1135
 
@@ -1273,282 +1430,6 @@ class UI {
1273
1430
  return existingInstall.ides;
1274
1431
  }
1275
1432
 
1276
- /**
1277
- * Validate custom content path synchronously
1278
- * @param {string} input - User input path
1279
- * @returns {string|undefined} Error message or undefined if valid
1280
- */
1281
- validateCustomContentPathSync(input) {
1282
- // Allow empty input to cancel
1283
- if (!input || input.trim() === '') {
1284
- return; // Allow empty to exit
1285
- }
1286
-
1287
- try {
1288
- // Expand the path
1289
- const expandedPath = this.expandUserPath(input.trim());
1290
-
1291
- // Check if path exists
1292
- if (!fs.pathExistsSync(expandedPath)) {
1293
- return 'Path does not exist';
1294
- }
1295
-
1296
- // Check if it's a directory
1297
- const stat = fs.statSync(expandedPath);
1298
- if (!stat.isDirectory()) {
1299
- return 'Path must be a directory';
1300
- }
1301
-
1302
- // Check for module.yaml in the root
1303
- const moduleYamlPath = path.join(expandedPath, 'module.yaml');
1304
- if (!fs.pathExistsSync(moduleYamlPath)) {
1305
- return 'Directory must contain a module.yaml file in the root';
1306
- }
1307
-
1308
- // Try to parse the module.yaml to get the module ID
1309
- try {
1310
- const yaml = require('yaml');
1311
- const content = fs.readFileSync(moduleYamlPath, 'utf8');
1312
- const moduleData = yaml.parse(content);
1313
- if (!moduleData.code) {
1314
- return 'module.yaml must contain a "code" field for the module ID';
1315
- }
1316
- } catch (error) {
1317
- return 'Invalid module.yaml file: ' + error.message;
1318
- }
1319
-
1320
- return; // Valid
1321
- } catch (error) {
1322
- return 'Error validating path: ' + error.message;
1323
- }
1324
- }
1325
-
1326
- /**
1327
- * Prompt user for custom content source location
1328
- * @returns {Object} Custom content configuration
1329
- */
1330
- async promptCustomContentSource() {
1331
- const customContentConfig = { hasCustomContent: true, sources: [] };
1332
-
1333
- // Keep asking for more sources until user is done
1334
- while (true) {
1335
- // First ask if user wants to add another module or continue
1336
- if (customContentConfig.sources.length > 0) {
1337
- const action = await prompts.select({
1338
- message: 'Would you like to:',
1339
- choices: [
1340
- { name: 'Add another custom module', value: 'add' },
1341
- { name: 'Continue with installation', value: 'continue' },
1342
- ],
1343
- default: 'continue',
1344
- });
1345
-
1346
- if (action === 'continue') {
1347
- break;
1348
- }
1349
- }
1350
-
1351
- let sourcePath;
1352
- let isValid = false;
1353
-
1354
- while (!isValid) {
1355
- // Use sync validation because @clack/prompts doesn't support async validate
1356
- const inputPath = await prompts.text({
1357
- message: 'Path to custom module folder (press Enter to skip):',
1358
- validate: (input) => this.validateCustomContentPathSync(input),
1359
- });
1360
-
1361
- // If user pressed Enter without typing anything, exit the loop
1362
- if (!inputPath || inputPath.trim() === '') {
1363
- // If we have no modules yet, return false for no custom content
1364
- if (customContentConfig.sources.length === 0) {
1365
- return { hasCustomContent: false };
1366
- }
1367
- return customContentConfig;
1368
- }
1369
-
1370
- sourcePath = this.expandUserPath(inputPath);
1371
- isValid = true;
1372
- }
1373
-
1374
- // Read module.yaml to get module info
1375
- const yaml = require('yaml');
1376
- const moduleYamlPath = path.join(sourcePath, 'module.yaml');
1377
- const moduleContent = await fs.readFile(moduleYamlPath, 'utf8');
1378
- const moduleData = yaml.parse(moduleContent);
1379
-
1380
- // Add to sources
1381
- customContentConfig.sources.push({
1382
- path: sourcePath,
1383
- id: moduleData.code,
1384
- name: moduleData.name || moduleData.code,
1385
- });
1386
-
1387
- await prompts.log.success(`Confirmed local custom module: ${moduleData.name || moduleData.code}`);
1388
- }
1389
-
1390
- // Ask if user wants to add these to the installation
1391
- const shouldInstall = await prompts.confirm({
1392
- message: `Install these ${customContentConfig.sources.length} custom modules?`,
1393
- default: true,
1394
- });
1395
-
1396
- if (shouldInstall) {
1397
- customContentConfig.selected = true;
1398
- // Store paths to module.yaml files, not directories
1399
- customContentConfig.selectedFiles = customContentConfig.sources.map((s) => path.join(s.path, 'module.yaml'));
1400
- // Also include module IDs for installation
1401
- customContentConfig.selectedModuleIds = customContentConfig.sources.map((s) => s.id);
1402
- }
1403
-
1404
- return customContentConfig;
1405
- }
1406
-
1407
- /**
1408
- * Handle custom modules in the modify flow
1409
- * @param {string} directory - Installation directory
1410
- * @param {Array} selectedModules - Currently selected modules
1411
- * @returns {Object} Result with selected custom modules and custom content config
1412
- */
1413
- async handleCustomModulesInModifyFlow(directory, selectedModules) {
1414
- // Get existing installation to find custom modules
1415
- const { existingInstall } = await this.getExistingInstallation(directory);
1416
-
1417
- // Check if there are any custom modules in cache
1418
- const { Installer } = require('./core/installer');
1419
- const installer = new Installer();
1420
- const { bmadDir } = await installer.findBmadDir(directory);
1421
-
1422
- const cacheDir = path.join(bmadDir, '_config', 'custom');
1423
- const cachedCustomModules = [];
1424
-
1425
- if (await fs.pathExists(cacheDir)) {
1426
- const entries = await fs.readdir(cacheDir, { withFileTypes: true });
1427
- for (const entry of entries) {
1428
- if (entry.isDirectory()) {
1429
- const moduleYamlPath = path.join(cacheDir, entry.name, 'module.yaml');
1430
- if (await fs.pathExists(moduleYamlPath)) {
1431
- const yaml = require('yaml');
1432
- const content = await fs.readFile(moduleYamlPath, 'utf8');
1433
- const moduleData = yaml.parse(content);
1434
-
1435
- cachedCustomModules.push({
1436
- id: entry.name,
1437
- name: moduleData.name || entry.name,
1438
- description: moduleData.description || 'Custom module from cache',
1439
- checked: selectedModules.includes(entry.name),
1440
- fromCache: true,
1441
- });
1442
- }
1443
- }
1444
- }
1445
- }
1446
-
1447
- const result = {
1448
- selectedCustomModules: [],
1449
- customContentConfig: { hasCustomContent: false },
1450
- };
1451
-
1452
- // Ask user about custom modules
1453
- await prompts.log.info('Custom Modules');
1454
- if (cachedCustomModules.length > 0) {
1455
- await prompts.log.message('Found custom modules in your installation:');
1456
- } else {
1457
- await prompts.log.message('No custom modules currently installed.');
1458
- }
1459
-
1460
- // Build choices dynamically based on whether we have existing modules
1461
- const choices = [];
1462
- if (cachedCustomModules.length > 0) {
1463
- choices.push(
1464
- { name: 'Keep all existing custom modules', value: 'keep' },
1465
- { name: 'Select which custom modules to keep', value: 'select' },
1466
- { name: 'Add new custom modules', value: 'add' },
1467
- { name: 'Remove all custom modules', value: 'remove' },
1468
- );
1469
- } else {
1470
- choices.push({ name: 'Add new custom modules', value: 'add' }, { name: 'Cancel (no custom modules)', value: 'cancel' });
1471
- }
1472
-
1473
- const customAction = await prompts.select({
1474
- message: cachedCustomModules.length > 0 ? 'Manage custom modules?' : 'Add custom modules?',
1475
- choices: choices,
1476
- default: cachedCustomModules.length > 0 ? 'keep' : 'add',
1477
- });
1478
-
1479
- switch (customAction) {
1480
- case 'keep': {
1481
- // Keep all existing custom modules
1482
- result.selectedCustomModules = cachedCustomModules.map((m) => m.id);
1483
- await prompts.log.message(`Keeping ${result.selectedCustomModules.length} custom module(s)`);
1484
- break;
1485
- }
1486
-
1487
- case 'select': {
1488
- // Let user choose which to keep
1489
- const selectChoices = cachedCustomModules.map((m) => ({
1490
- name: `${m.name} (${m.id})`,
1491
- value: m.id,
1492
- checked: m.checked,
1493
- }));
1494
-
1495
- // Add "None / I changed my mind" option at the end
1496
- const choicesWithSkip = [
1497
- ...selectChoices,
1498
- {
1499
- name: '⚠ None / I changed my mind - keep no custom modules',
1500
- value: '__NONE__',
1501
- checked: false,
1502
- },
1503
- ];
1504
-
1505
- const keepModules = await prompts.multiselect({
1506
- message: 'Select custom modules to keep (use arrow keys, space to toggle):',
1507
- choices: choicesWithSkip,
1508
- required: true,
1509
- });
1510
-
1511
- // If user selected both "__NONE__" and other modules, honor the "None" choice
1512
- if (keepModules && keepModules.includes('__NONE__') && keepModules.length > 1) {
1513
- await prompts.log.warn('"None / I changed my mind" was selected, so no custom modules will be kept.');
1514
- result.selectedCustomModules = [];
1515
- } else {
1516
- // Filter out the special '__NONE__' value
1517
- result.selectedCustomModules = keepModules ? keepModules.filter((m) => m !== '__NONE__') : [];
1518
- }
1519
- break;
1520
- }
1521
-
1522
- case 'add': {
1523
- // By default, keep existing modules when adding new ones
1524
- // User chose "Add new" not "Replace", so we assume they want to keep existing
1525
- result.selectedCustomModules = cachedCustomModules.map((m) => m.id);
1526
-
1527
- // Then prompt for new ones (reuse existing method)
1528
- const newCustomContent = await this.promptCustomContentSource();
1529
- if (newCustomContent.hasCustomContent && newCustomContent.selected) {
1530
- result.selectedCustomModules.push(...newCustomContent.selectedModuleIds);
1531
- result.customContentConfig = newCustomContent;
1532
- }
1533
- break;
1534
- }
1535
-
1536
- case 'remove': {
1537
- // Remove all custom modules
1538
- await prompts.log.warn('All custom modules will be removed from the installation');
1539
- break;
1540
- }
1541
-
1542
- case 'cancel': {
1543
- // User cancelled - no custom modules
1544
- await prompts.log.message('No custom modules will be added');
1545
- break;
1546
- }
1547
- }
1548
-
1549
- return result;
1550
- }
1551
-
1552
1433
  /**
1553
1434
  * Display module versions with update availability
1554
1435
  * @param {Array} modules - Array of module info objects with version info
@@ -1558,6 +1439,7 @@ class UI {
1558
1439
  // Group modules by source
1559
1440
  const builtIn = modules.filter((m) => m.source === 'built-in');
1560
1441
  const external = modules.filter((m) => m.source === 'external');
1442
+ const community = modules.filter((m) => m.source === 'community');
1561
1443
  const custom = modules.filter((m) => m.source === 'custom');
1562
1444
  const unknown = modules.filter((m) => m.source === 'unknown');
1563
1445
 
@@ -1578,6 +1460,7 @@ class UI {
1578
1460
 
1579
1461
  formatGroup(builtIn, 'Built-in Modules');
1580
1462
  formatGroup(external, 'External Modules (Official)');
1463
+ formatGroup(community, 'Community Modules');
1581
1464
  formatGroup(custom, 'Custom Modules');
1582
1465
  formatGroup(unknown, 'Other Modules');
1583
1466