bmad-method 6.2.3-next.3 → 6.2.3-next.31
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.
- package/.claude-plugin/marketplace.json +0 -4
- package/README.md +8 -9
- package/README_CN.md +1 -1
- package/README_VN.md +110 -0
- package/package.json +2 -1
- package/removals.txt +17 -0
- package/src/bmm-skills/1-analysis/bmad-agent-analyst/SKILL.md +7 -4
- package/src/bmm-skills/1-analysis/bmad-agent-tech-writer/SKILL.md +6 -4
- package/src/bmm-skills/1-analysis/bmad-document-project/workflow.md +8 -10
- package/src/bmm-skills/1-analysis/bmad-prfaq/SKILL.md +96 -0
- package/src/bmm-skills/1-analysis/bmad-prfaq/agents/artifact-analyzer.md +60 -0
- package/src/bmm-skills/1-analysis/bmad-prfaq/agents/web-researcher.md +49 -0
- package/src/bmm-skills/1-analysis/bmad-prfaq/assets/prfaq-template.md +62 -0
- package/src/bmm-skills/1-analysis/bmad-prfaq/bmad-manifest.json +16 -0
- package/src/bmm-skills/1-analysis/bmad-prfaq/references/customer-faq.md +55 -0
- package/src/bmm-skills/1-analysis/bmad-prfaq/references/internal-faq.md +51 -0
- package/src/bmm-skills/1-analysis/bmad-prfaq/references/press-release.md +60 -0
- package/src/bmm-skills/1-analysis/bmad-prfaq/references/verdict.md +79 -0
- package/src/bmm-skills/1-analysis/bmad-product-brief/SKILL.md +1 -6
- package/src/bmm-skills/1-analysis/bmad-product-brief/bmad-manifest.json +1 -1
- package/src/bmm-skills/1-analysis/research/bmad-domain-research/workflow.md +8 -6
- package/src/bmm-skills/1-analysis/research/bmad-market-research/workflow.md +8 -6
- package/src/bmm-skills/1-analysis/research/bmad-technical-research/workflow.md +8 -6
- package/src/bmm-skills/2-plan-workflows/bmad-agent-pm/SKILL.md +6 -4
- package/src/bmm-skills/2-plan-workflows/bmad-agent-ux-designer/SKILL.md +6 -4
- package/src/bmm-skills/2-plan-workflows/bmad-create-prd/workflow.md +8 -9
- package/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/steps/step-13-responsive-accessibility.md +1 -1
- package/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/workflow.md +8 -9
- package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-01-discovery.md +1 -1
- package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-01b-legacy-conversion.md +1 -1
- package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-02-review.md +1 -1
- package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-03-edit.md +1 -1
- package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-04-complete.md +1 -3
- package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/workflow.md +8 -9
- package/src/bmm-skills/2-plan-workflows/bmad-validate-prd/workflow.md +8 -9
- package/src/bmm-skills/3-solutioning/bmad-agent-architect/SKILL.md +6 -4
- package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/steps/step-01-document-discovery.md +1 -1
- package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/steps/step-02-prd-analysis.md +1 -1
- package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/steps/step-03-epic-coverage-validation.md +1 -1
- package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/workflow.md +9 -11
- package/src/bmm-skills/3-solutioning/bmad-create-architecture/workflow.md +8 -14
- package/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/workflow.md +10 -12
- package/src/bmm-skills/3-solutioning/bmad-generate-project-context/workflow.md +8 -12
- package/src/bmm-skills/4-implementation/bmad-agent-dev/SKILL.md +11 -4
- package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/SKILL.md +29 -0
- package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/generate-trail.md +38 -0
- package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-01-orientation.md +105 -0
- package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-02-walkthrough.md +89 -0
- package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-03-detail-pass.md +106 -0
- package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-04-testing.md +74 -0
- package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-05-wrapup.md +24 -0
- package/src/bmm-skills/4-implementation/bmad-code-review/steps/step-01-gather-context.md +38 -15
- package/src/bmm-skills/4-implementation/bmad-correct-course/checklist.md +2 -2
- package/src/bmm-skills/4-implementation/bmad-correct-course/workflow.md +8 -8
- package/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/checklist.md +1 -1
- package/src/bmm-skills/4-implementation/bmad-quick-dev/compile-epic-context.md +62 -0
- package/src/bmm-skills/4-implementation/bmad-quick-dev/spec-template.md +1 -1
- package/src/bmm-skills/4-implementation/bmad-quick-dev/step-01-clarify-and-route.md +33 -6
- package/src/bmm-skills/4-implementation/bmad-quick-dev/step-02-plan.md +20 -8
- package/src/bmm-skills/4-implementation/bmad-quick-dev/step-03-implement.md +2 -0
- package/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md +16 -4
- package/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md +1 -5
- package/src/bmm-skills/4-implementation/bmad-retrospective/workflow.md +134 -134
- package/src/bmm-skills/4-implementation/bmad-sprint-planning/sprint-status-template.yaml +1 -1
- package/src/bmm-skills/4-implementation/bmad-sprint-planning/workflow.md +3 -3
- package/src/bmm-skills/4-implementation/bmad-sprint-status/workflow.md +2 -2
- package/src/bmm-skills/module-help.csv +4 -1
- package/src/core-skills/bmad-advanced-elicitation/SKILL.md +1 -2
- package/src/core-skills/bmad-distillator/SKILL.md +0 -1
- package/src/core-skills/bmad-distillator/resources/distillate-format-reference.md +9 -9
- package/src/core-skills/bmad-help/SKILL.md +4 -2
- package/src/core-skills/bmad-party-mode/SKILL.md +121 -2
- package/src/core-skills/module-help.csv +1 -0
- package/tools/installer/cli-utils.js +18 -9
- package/tools/installer/commands/install.js +0 -1
- package/tools/installer/core/existing-install.js +2 -8
- package/tools/installer/core/install-paths.js +0 -3
- package/tools/installer/core/installer.js +176 -464
- package/tools/installer/core/manifest-generator.js +4 -12
- package/tools/installer/core/manifest.js +82 -97
- package/tools/installer/ide/_config-driven.js +149 -38
- package/tools/installer/ide/platform-codes.yaml +6 -4
- package/tools/installer/ide/shared/skill-manifest.js +1 -16
- package/tools/installer/install-messages.yaml +19 -26
- package/tools/installer/modules/community-manager.js +377 -0
- package/tools/installer/modules/custom-module-manager.js +308 -0
- package/tools/installer/modules/external-manager.js +65 -49
- package/tools/installer/modules/official-modules.js +37 -65
- package/tools/installer/modules/registry-client.js +66 -0
- package/tools/installer/{external-official-modules.yaml → modules/registry-fallback.yaml} +3 -12
- package/tools/installer/ui.js +340 -672
- package/tools/platform-codes.yaml +6 -0
- package/src/bmm-skills/2-plan-workflows/create-prd/data/domain-complexity.csv +0 -15
- package/src/bmm-skills/2-plan-workflows/create-prd/data/project-types.csv +0 -11
- package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-01-discovery.md +0 -224
- package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-02-format-detection.md +0 -191
- package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-02b-parity-check.md +0 -209
- package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-03-density-validation.md +0 -174
- package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-04-brief-coverage-validation.md +0 -214
- package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-05-measurability-validation.md +0 -228
- package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-06-traceability-validation.md +0 -217
- package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-07-implementation-leakage-validation.md +0 -205
- package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-08-domain-compliance-validation.md +0 -243
- package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-09-project-type-validation.md +0 -263
- package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-10-smart-validation.md +0 -209
- package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-11-holistic-quality-validation.md +0 -264
- package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-12-completeness-validation.md +0 -242
- package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-13-report-complete.md +0 -232
- package/src/bmm-skills/2-plan-workflows/create-prd/workflow-validate-prd.md +0 -65
- package/src/bmm-skills/4-implementation/bmad-agent-qa/SKILL.md +0 -59
- package/src/bmm-skills/4-implementation/bmad-agent-qa/bmad-skill-manifest.yaml +0 -11
- package/src/bmm-skills/4-implementation/bmad-agent-quick-flow-solo-dev/SKILL.md +0 -51
- package/src/bmm-skills/4-implementation/bmad-agent-quick-flow-solo-dev/bmad-skill-manifest.yaml +0 -11
- package/src/bmm-skills/4-implementation/bmad-agent-sm/SKILL.md +0 -53
- package/src/bmm-skills/4-implementation/bmad-agent-sm/bmad-skill-manifest.yaml +0 -11
- package/src/core-skills/bmad-init/SKILL.md +0 -100
- package/src/core-skills/bmad-init/resources/core-module.yaml +0 -25
- package/src/core-skills/bmad-init/scripts/bmad_init.py +0 -624
- package/src/core-skills/bmad-init/scripts/tests/test_bmad_init.py +0 -393
- package/src/core-skills/bmad-party-mode/steps/step-01-agent-loading.md +0 -138
- package/src/core-skills/bmad-party-mode/steps/step-02-discussion-orchestration.md +0 -187
- package/src/core-skills/bmad-party-mode/steps/step-03-graceful-exit.md +0 -167
- package/src/core-skills/bmad-party-mode/workflow.md +0 -190
- package/tools/installer/core/custom-module-cache.js +0 -260
- package/tools/installer/custom-handler.js +0 -112
- package/tools/installer/modules/custom-modules.js +0 -197
- /package/src/bmm-skills/2-plan-workflows/{create-prd → bmad-edit-prd}/data/prd-purpose.md +0 -0
package/tools/installer/ui.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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');
|
|
15
21
|
}
|
|
16
|
-
|
|
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
|
|
29
|
+
}
|
|
30
|
+
return '';
|
|
17
31
|
}
|
|
18
32
|
|
|
19
|
-
|
|
20
|
-
|
|
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 (
|
|
101
|
+
if (existingInstall.installed) {
|
|
82
102
|
choices.push({
|
|
83
|
-
name:
|
|
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
|
}
|
|
@@ -186,120 +167,6 @@ class UI {
|
|
|
186
167
|
selectedModules = await this.selectAllModules(installedModuleIds);
|
|
187
168
|
}
|
|
188
169
|
|
|
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
|
-
}
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Merge any selected custom modules
|
|
299
|
-
if (customModuleResult.selectedCustomModules.length > 0) {
|
|
300
|
-
selectedModules.push(...customModuleResult.selectedCustomModules);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
170
|
// Ensure core is in the modules list
|
|
304
171
|
if (!selectedModules.includes('core')) {
|
|
305
172
|
selectedModules.unshift('core');
|
|
@@ -318,7 +185,6 @@ class UI {
|
|
|
318
185
|
skipIde: toolSelection.skipIde,
|
|
319
186
|
coreConfig: moduleConfigs.core || {},
|
|
320
187
|
moduleConfigs: moduleConfigs,
|
|
321
|
-
customContent: customModuleResult.customContentConfig,
|
|
322
188
|
skipPrompts: options.yes || false,
|
|
323
189
|
};
|
|
324
190
|
}
|
|
@@ -344,84 +210,6 @@ class UI {
|
|
|
344
210
|
selectedModules = await this.selectAllModules(installedModuleIds);
|
|
345
211
|
}
|
|
346
212
|
|
|
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();
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// Add custom content modules if any were selected
|
|
421
|
-
if (customContentConfig && customContentConfig.selectedModuleIds) {
|
|
422
|
-
selectedModules.push(...customContentConfig.selectedModuleIds);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
213
|
// Ensure core is in the modules list
|
|
426
214
|
if (!selectedModules.includes('core')) {
|
|
427
215
|
selectedModules.unshift('core');
|
|
@@ -437,7 +225,6 @@ class UI {
|
|
|
437
225
|
skipIde: toolSelection.skipIde,
|
|
438
226
|
coreConfig: moduleConfigs.core || {},
|
|
439
227
|
moduleConfigs: moduleConfigs,
|
|
440
|
-
customContent: customContentConfig,
|
|
441
228
|
skipPrompts: options.yes || false,
|
|
442
229
|
};
|
|
443
230
|
}
|
|
@@ -776,166 +563,80 @@ class UI {
|
|
|
776
563
|
}
|
|
777
564
|
|
|
778
565
|
/**
|
|
779
|
-
*
|
|
566
|
+
* Select all modules across three tiers: official, community, and custom URL.
|
|
780
567
|
* @param {Set} installedModuleIds - Currently installed module IDs
|
|
781
|
-
* @
|
|
782
|
-
* @returns {Array} Module choices for prompt
|
|
568
|
+
* @returns {Array} Selected module codes (excluding core)
|
|
783
569
|
*/
|
|
784
|
-
async
|
|
785
|
-
|
|
786
|
-
const
|
|
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
|
-
}
|
|
570
|
+
async selectAllModules(installedModuleIds = new Set()) {
|
|
571
|
+
// Phase 1: Official modules
|
|
572
|
+
const officialSelected = await this._selectOfficialModules(installedModuleIds);
|
|
846
573
|
|
|
847
|
-
//
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
574
|
+
// Determine which installed modules are NOT official (community or custom).
|
|
575
|
+
// These must be preserved even if the user declines to browse community/custom.
|
|
576
|
+
const officialCodes = new Set(officialSelected);
|
|
577
|
+
const externalManager = new ExternalModuleManager();
|
|
578
|
+
const registryModules = await externalManager.listAvailable();
|
|
579
|
+
const officialRegistryCodes = new Set(registryModules.map((m) => m.code));
|
|
580
|
+
const installedNonOfficial = [...installedModuleIds].filter((id) => !officialRegistryCodes.has(id));
|
|
581
|
+
|
|
582
|
+
// Phase 2: Community modules (category drill-down)
|
|
583
|
+
// Returns { codes, didBrowse } so we know if the user entered the flow
|
|
584
|
+
const communityResult = await this._browseCommunityModules(installedModuleIds);
|
|
585
|
+
|
|
586
|
+
// Phase 3: Custom URL modules
|
|
587
|
+
const customSelected = await this._addCustomUrlModules(installedModuleIds);
|
|
588
|
+
|
|
589
|
+
// Merge all selections
|
|
590
|
+
const allSelected = new Set([...officialSelected, ...communityResult.codes, ...customSelected]);
|
|
591
|
+
|
|
592
|
+
// Auto-include installed non-official modules that the user didn't get
|
|
593
|
+
// a chance to manage (they declined to browse). If they did browse,
|
|
594
|
+
// trust their selections - they could have deselected intentionally.
|
|
595
|
+
if (!communityResult.didBrowse) {
|
|
596
|
+
for (const code of installedNonOfficial) {
|
|
597
|
+
allSelected.add(code);
|
|
856
598
|
}
|
|
857
599
|
}
|
|
858
600
|
|
|
859
|
-
return
|
|
601
|
+
return [...allSelected];
|
|
860
602
|
}
|
|
861
603
|
|
|
862
604
|
/**
|
|
863
|
-
* Select
|
|
864
|
-
*
|
|
605
|
+
* Select official modules using autocompleteMultiselect.
|
|
606
|
+
* Extracted from the original selectAllModules - unchanged behavior.
|
|
865
607
|
* @param {Set} installedModuleIds - Currently installed module IDs
|
|
866
|
-
* @returns {Array} Selected module codes
|
|
608
|
+
* @returns {Array} Selected official module codes
|
|
867
609
|
*/
|
|
868
|
-
async
|
|
869
|
-
const { OfficialModules } = require('./modules/official-modules');
|
|
870
|
-
const officialModulesSource = new OfficialModules();
|
|
871
|
-
const { modules: localModules } = await officialModulesSource.listAvailable();
|
|
872
|
-
|
|
873
|
-
// Get external modules
|
|
610
|
+
async _selectOfficialModules(installedModuleIds = new Set()) {
|
|
874
611
|
const externalManager = new ExternalModuleManager();
|
|
875
|
-
const
|
|
612
|
+
const registryModules = await externalManager.listAvailable();
|
|
876
613
|
|
|
877
|
-
// Build flat options list with group hints for autocompleteMultiselect
|
|
878
614
|
const allOptions = [];
|
|
879
615
|
const initialValues = [];
|
|
880
616
|
const lockedValues = ['core'];
|
|
881
617
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
// Helper to build module entry with proper sorting and selection
|
|
887
|
-
const buildModuleEntry = (mod, value, group) => {
|
|
888
|
-
const isInstalled = installedModuleIds.has(value);
|
|
618
|
+
const buildModuleEntry = async (mod) => {
|
|
619
|
+
const isInstalled = installedModuleIds.has(mod.code);
|
|
620
|
+
const version = await getMarketplaceVersion(mod.code);
|
|
621
|
+
const label = version ? `${mod.name} (v${version})` : mod.name;
|
|
889
622
|
return {
|
|
890
|
-
label
|
|
891
|
-
value,
|
|
892
|
-
hint: mod.description
|
|
893
|
-
// Pre-select only if already installed (not on fresh install)
|
|
623
|
+
label,
|
|
624
|
+
value: mod.code,
|
|
625
|
+
hint: mod.description,
|
|
894
626
|
selected: isInstalled,
|
|
895
627
|
};
|
|
896
628
|
};
|
|
897
629
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
if (
|
|
902
|
-
|
|
903
|
-
localEntries.push(entry);
|
|
904
|
-
if (entry.selected) {
|
|
905
|
-
initialValues.push(mod.id);
|
|
906
|
-
}
|
|
630
|
+
for (const mod of registryModules) {
|
|
631
|
+
const entry = await buildModuleEntry(mod);
|
|
632
|
+
allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint });
|
|
633
|
+
if (entry.selected) {
|
|
634
|
+
initialValues.push(mod.code);
|
|
907
635
|
}
|
|
908
636
|
}
|
|
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
|
-
}
|
|
920
|
-
}
|
|
921
|
-
}
|
|
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
637
|
|
|
937
638
|
const selected = await prompts.autocompleteMultiselect({
|
|
938
|
-
message: 'Select modules to install:',
|
|
639
|
+
message: 'Select official modules to install:',
|
|
939
640
|
options: allOptions,
|
|
940
641
|
initialValues: initialValues.length > 0 ? initialValues : undefined,
|
|
941
642
|
lockedValues,
|
|
@@ -945,34 +646,275 @@ class UI {
|
|
|
945
646
|
|
|
946
647
|
const result = selected ? [...selected] : [];
|
|
947
648
|
|
|
948
|
-
// Display selected modules as bulleted list
|
|
949
649
|
if (result.length > 0) {
|
|
950
650
|
const moduleLines = result.map((moduleId) => {
|
|
951
651
|
const opt = allOptions.find((o) => o.value === moduleId);
|
|
952
652
|
return ` \u2022 ${opt?.label || moduleId}`;
|
|
953
653
|
});
|
|
954
|
-
await prompts.log.message('Selected modules:\n' + moduleLines.join('\n'));
|
|
654
|
+
await prompts.log.message('Selected official modules:\n' + moduleLines.join('\n'));
|
|
955
655
|
}
|
|
956
656
|
|
|
957
657
|
return result;
|
|
958
658
|
}
|
|
959
659
|
|
|
660
|
+
/**
|
|
661
|
+
* Browse and select community modules using category drill-down.
|
|
662
|
+
* Featured/promoted modules appear at the top.
|
|
663
|
+
* @param {Set} installedModuleIds - Currently installed module IDs
|
|
664
|
+
* @returns {Object} { codes: string[], didBrowse: boolean }
|
|
665
|
+
*/
|
|
666
|
+
async _browseCommunityModules(installedModuleIds = new Set()) {
|
|
667
|
+
const browseCommunity = await prompts.confirm({
|
|
668
|
+
message: 'Would you like to browse community modules?',
|
|
669
|
+
default: false,
|
|
670
|
+
});
|
|
671
|
+
if (!browseCommunity) return { codes: [], didBrowse: false };
|
|
672
|
+
|
|
673
|
+
const { CommunityModuleManager } = require('./modules/community-manager');
|
|
674
|
+
const communityMgr = new CommunityModuleManager();
|
|
675
|
+
|
|
676
|
+
const s = await prompts.spinner();
|
|
677
|
+
s.start('Loading community module catalog...');
|
|
678
|
+
|
|
679
|
+
let categories, featured, allCommunity;
|
|
680
|
+
try {
|
|
681
|
+
[categories, featured, allCommunity] = await Promise.all([
|
|
682
|
+
communityMgr.getCategoryList(),
|
|
683
|
+
communityMgr.listFeatured(),
|
|
684
|
+
communityMgr.listAll(),
|
|
685
|
+
]);
|
|
686
|
+
s.stop(`Community catalog loaded (${allCommunity.length} modules)`);
|
|
687
|
+
} catch (error) {
|
|
688
|
+
s.error('Failed to load community catalog');
|
|
689
|
+
await prompts.log.warn(` ${error.message}`);
|
|
690
|
+
return { codes: [], didBrowse: false };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (allCommunity.length === 0) {
|
|
694
|
+
await prompts.log.info('No community modules are currently available.');
|
|
695
|
+
return { codes: [], didBrowse: false };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const selectedCodes = new Set();
|
|
699
|
+
let browsing = true;
|
|
700
|
+
|
|
701
|
+
while (browsing) {
|
|
702
|
+
const categoryChoices = [];
|
|
703
|
+
|
|
704
|
+
// Featured section at top
|
|
705
|
+
if (featured.length > 0) {
|
|
706
|
+
categoryChoices.push({
|
|
707
|
+
value: '__featured__',
|
|
708
|
+
label: `\u2605 Featured (${featured.length} module${featured.length === 1 ? '' : 's'})`,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Categories with module counts
|
|
713
|
+
for (const cat of categories) {
|
|
714
|
+
categoryChoices.push({
|
|
715
|
+
value: cat.slug,
|
|
716
|
+
label: `${cat.name} (${cat.moduleCount} module${cat.moduleCount === 1 ? '' : 's'})`,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Special actions at bottom
|
|
721
|
+
categoryChoices.push(
|
|
722
|
+
{ value: '__all__', label: '\u25CE View all community modules' },
|
|
723
|
+
{ value: '__search__', label: '\u25CE Search by keyword' },
|
|
724
|
+
{ value: '__done__', label: '\u2713 Done browsing' },
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
const selectedCount = selectedCodes.size;
|
|
728
|
+
const categoryChoice = await prompts.select({
|
|
729
|
+
message: `Browse community modules${selectedCount > 0 ? ` (${selectedCount} selected)` : ''}:`,
|
|
730
|
+
choices: categoryChoices,
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
if (categoryChoice === '__done__') {
|
|
734
|
+
browsing = false;
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
let modulesToShow;
|
|
739
|
+
switch (categoryChoice) {
|
|
740
|
+
case '__featured__': {
|
|
741
|
+
modulesToShow = featured;
|
|
742
|
+
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
case '__all__': {
|
|
746
|
+
modulesToShow = allCommunity;
|
|
747
|
+
|
|
748
|
+
break;
|
|
749
|
+
}
|
|
750
|
+
case '__search__': {
|
|
751
|
+
const query = await prompts.text({
|
|
752
|
+
message: 'Search community modules:',
|
|
753
|
+
placeholder: 'e.g., design, testing, game',
|
|
754
|
+
});
|
|
755
|
+
if (!query || query.trim() === '') continue;
|
|
756
|
+
modulesToShow = await communityMgr.searchByKeyword(query.trim());
|
|
757
|
+
if (modulesToShow.length === 0) {
|
|
758
|
+
await prompts.log.warn('No matching modules found.');
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
default: {
|
|
765
|
+
modulesToShow = await communityMgr.listByCategory(categoryChoice);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Build options for autocompleteMultiselect
|
|
770
|
+
const trustBadge = (tier) => {
|
|
771
|
+
if (tier === 'bmad-certified') return '\u2713';
|
|
772
|
+
if (tier === 'community-reviewed') return '\u25CB';
|
|
773
|
+
return '\u26A0';
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
const options = modulesToShow.map((mod) => {
|
|
777
|
+
const versionStr = mod.version ? ` (v${mod.version})` : '';
|
|
778
|
+
const badge = trustBadge(mod.trustTier);
|
|
779
|
+
return {
|
|
780
|
+
label: `${mod.displayName}${versionStr} [${badge}]`,
|
|
781
|
+
value: mod.code,
|
|
782
|
+
hint: mod.description,
|
|
783
|
+
};
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
// Pre-check modules that are already selected or installed
|
|
787
|
+
const initialValues = modulesToShow.filter((m) => selectedCodes.has(m.code) || installedModuleIds.has(m.code)).map((m) => m.code);
|
|
788
|
+
|
|
789
|
+
const selected = await prompts.autocompleteMultiselect({
|
|
790
|
+
message: 'Select community modules:',
|
|
791
|
+
options,
|
|
792
|
+
initialValues: initialValues.length > 0 ? initialValues : undefined,
|
|
793
|
+
required: false,
|
|
794
|
+
maxItems: Math.min(options.length, 10),
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
// Update accumulated selections: sync with what user selected in this view
|
|
798
|
+
const shownCodes = new Set(modulesToShow.map((m) => m.code));
|
|
799
|
+
for (const code of shownCodes) {
|
|
800
|
+
if (selected && selected.includes(code)) {
|
|
801
|
+
selectedCodes.add(code);
|
|
802
|
+
} else {
|
|
803
|
+
selectedCodes.delete(code);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (selectedCodes.size > 0) {
|
|
809
|
+
const moduleLines = [];
|
|
810
|
+
for (const code of selectedCodes) {
|
|
811
|
+
const mod = await communityMgr.getModuleByCode(code);
|
|
812
|
+
moduleLines.push(` \u2022 ${mod?.displayName || code}`);
|
|
813
|
+
}
|
|
814
|
+
await prompts.log.message('Selected community modules:\n' + moduleLines.join('\n'));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
return { codes: [...selectedCodes], didBrowse: true };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Prompt user to install modules from custom GitHub URLs.
|
|
822
|
+
* @param {Set} installedModuleIds - Currently installed module IDs
|
|
823
|
+
* @returns {Array} Selected custom module code strings
|
|
824
|
+
*/
|
|
825
|
+
async _addCustomUrlModules(installedModuleIds = new Set()) {
|
|
826
|
+
const addCustom = await prompts.confirm({
|
|
827
|
+
message: 'Would you like to install from a custom GitHub URL?',
|
|
828
|
+
default: false,
|
|
829
|
+
});
|
|
830
|
+
if (!addCustom) return [];
|
|
831
|
+
|
|
832
|
+
const { CustomModuleManager } = require('./modules/custom-module-manager');
|
|
833
|
+
const customMgr = new CustomModuleManager();
|
|
834
|
+
const selectedModules = [];
|
|
835
|
+
|
|
836
|
+
let addMore = true;
|
|
837
|
+
while (addMore) {
|
|
838
|
+
const url = await prompts.text({
|
|
839
|
+
message: 'GitHub repository URL:',
|
|
840
|
+
placeholder: 'https://github.com/owner/repo',
|
|
841
|
+
validate: (input) => {
|
|
842
|
+
if (!input || input.trim() === '') return 'URL is required';
|
|
843
|
+
const result = customMgr.validateGitHubUrl(input.trim());
|
|
844
|
+
return result.isValid ? undefined : result.error;
|
|
845
|
+
},
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
const s = await prompts.spinner();
|
|
849
|
+
s.start('Fetching module info...');
|
|
850
|
+
|
|
851
|
+
try {
|
|
852
|
+
const plugins = await customMgr.discoverModules(url.trim());
|
|
853
|
+
s.stop('Module info loaded');
|
|
854
|
+
|
|
855
|
+
await prompts.log.warn(
|
|
856
|
+
'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.',
|
|
857
|
+
);
|
|
858
|
+
|
|
859
|
+
for (const plugin of plugins) {
|
|
860
|
+
const versionStr = plugin.version ? ` v${plugin.version}` : '';
|
|
861
|
+
await prompts.log.info(` ${plugin.name}${versionStr}\n ${plugin.description}\n Author: ${plugin.author}`);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const confirmInstall = await prompts.confirm({
|
|
865
|
+
message: `Install ${plugins.length} plugin${plugins.length === 1 ? '' : 's'} from ${url.trim()}?`,
|
|
866
|
+
default: false,
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
if (confirmInstall) {
|
|
870
|
+
// Pre-clone the repo so it's cached for the install pipeline
|
|
871
|
+
s.start('Cloning repository...');
|
|
872
|
+
try {
|
|
873
|
+
await customMgr.cloneRepo(url.trim());
|
|
874
|
+
s.stop('Repository cloned');
|
|
875
|
+
} catch (cloneError) {
|
|
876
|
+
s.error('Failed to clone repository');
|
|
877
|
+
await prompts.log.error(` ${cloneError.message}`);
|
|
878
|
+
addMore = await prompts.confirm({ message: 'Try another URL?', default: false });
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
for (const plugin of plugins) {
|
|
883
|
+
selectedModules.push(plugin.code);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
} catch (error) {
|
|
887
|
+
s.error('Failed to load module info');
|
|
888
|
+
await prompts.log.error(` ${error.message}`);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
addMore = await prompts.confirm({
|
|
892
|
+
message: 'Add another custom module?',
|
|
893
|
+
default: false,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (selectedModules.length > 0) {
|
|
898
|
+
await prompts.log.message('Selected custom modules:\n' + selectedModules.map((c) => ` \u2022 ${c}`).join('\n'));
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
return selectedModules;
|
|
902
|
+
}
|
|
903
|
+
|
|
960
904
|
/**
|
|
961
905
|
* Get default modules for non-interactive mode
|
|
962
906
|
* @param {Set} installedModuleIds - Already installed module IDs
|
|
963
907
|
* @returns {Array} Default module codes
|
|
964
908
|
*/
|
|
965
909
|
async getDefaultModules(installedModuleIds = new Set()) {
|
|
966
|
-
const
|
|
967
|
-
const
|
|
968
|
-
const { modules: localModules } = await officialModules.listAvailable();
|
|
910
|
+
const externalManager = new ExternalModuleManager();
|
|
911
|
+
const registryModules = await externalManager.listAvailable();
|
|
969
912
|
|
|
970
913
|
const defaultModules = [];
|
|
971
914
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
defaultModules.push(mod.id);
|
|
915
|
+
for (const mod of registryModules) {
|
|
916
|
+
if (mod.defaultSelected || installedModuleIds.has(mod.code)) {
|
|
917
|
+
defaultModules.push(mod.code);
|
|
976
918
|
}
|
|
977
919
|
}
|
|
978
920
|
|
|
@@ -1273,282 +1215,6 @@ class UI {
|
|
|
1273
1215
|
return existingInstall.ides;
|
|
1274
1216
|
}
|
|
1275
1217
|
|
|
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
1218
|
/**
|
|
1553
1219
|
* Display module versions with update availability
|
|
1554
1220
|
* @param {Array} modules - Array of module info objects with version info
|
|
@@ -1558,6 +1224,7 @@ class UI {
|
|
|
1558
1224
|
// Group modules by source
|
|
1559
1225
|
const builtIn = modules.filter((m) => m.source === 'built-in');
|
|
1560
1226
|
const external = modules.filter((m) => m.source === 'external');
|
|
1227
|
+
const community = modules.filter((m) => m.source === 'community');
|
|
1561
1228
|
const custom = modules.filter((m) => m.source === 'custom');
|
|
1562
1229
|
const unknown = modules.filter((m) => m.source === 'unknown');
|
|
1563
1230
|
|
|
@@ -1578,6 +1245,7 @@ class UI {
|
|
|
1578
1245
|
|
|
1579
1246
|
formatGroup(builtIn, 'Built-in Modules');
|
|
1580
1247
|
formatGroup(external, 'External Modules (Official)');
|
|
1248
|
+
formatGroup(community, 'Community Modules');
|
|
1581
1249
|
formatGroup(custom, 'Custom Modules');
|
|
1582
1250
|
formatGroup(unknown, 'Other Modules');
|
|
1583
1251
|
|