bmad-method 6.5.1-next.8 → 6.6.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/README.md +9 -0
- package/package.json +4 -3
- package/src/bmm-skills/module-help.csv +21 -21
- package/src/bmm-skills/module.yaml +1 -5
- package/src/core-skills/module-help.csv +11 -11
- package/src/core-skills/module.yaml +5 -0
- package/tools/installer/commands/install.js +47 -1
- package/tools/installer/core/config.js +19 -1
- package/tools/installer/core/installer.js +47 -99
- package/tools/installer/list-options.js +210 -0
- package/tools/installer/modules/custom-module-manager.js +85 -41
- package/tools/installer/modules/official-modules.js +53 -1
- package/tools/installer/set-overrides.js +330 -0
- package/tools/installer/ui.js +36 -3
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
const fs = require('./fs-native');
|
|
3
|
+
const yaml = require('yaml');
|
|
4
|
+
const { getProjectRoot, getModulePath, getExternalModuleCachePath } = require('./project-root');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Read a module.yaml and return its declared `code:` field, or null if missing/unparseable.
|
|
8
|
+
*/
|
|
9
|
+
async function readModuleCode(yamlPath) {
|
|
10
|
+
try {
|
|
11
|
+
const parsed = yaml.parse(await fs.readFile(yamlPath, 'utf8'));
|
|
12
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.code === 'string') {
|
|
13
|
+
return parsed.code;
|
|
14
|
+
}
|
|
15
|
+
} catch {
|
|
16
|
+
// fall through
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Discover module.yaml files for officials we can read locally:
|
|
23
|
+
* - core, bmm: bundled in src/ (always present)
|
|
24
|
+
* - external officials: only if previously cloned to ~/.bmad/cache/external-modules/
|
|
25
|
+
*
|
|
26
|
+
* Each result's `code` is the `code:` field from the module.yaml when present;
|
|
27
|
+
* that's the value `--set <module>.<key>=<value>` matches against.
|
|
28
|
+
*
|
|
29
|
+
* Community/custom modules are not enumerated; users reference their own
|
|
30
|
+
* module.yaml directly per the design (see issue #1663).
|
|
31
|
+
*
|
|
32
|
+
* @returns {Promise<Array<{code: string, yamlPath: string, source: string}>>}
|
|
33
|
+
*/
|
|
34
|
+
async function discoverOfficialModuleYamls() {
|
|
35
|
+
const found = [];
|
|
36
|
+
// Dedupe is case-insensitive because module caches occasionally retain a
|
|
37
|
+
// legacy UPPERCASE-named directory alongside the canonical lowercase one
|
|
38
|
+
// (same module, different cache key from an older schema). We pick whichever
|
|
39
|
+
// entry we see first and skip the alternate-case duplicate. NOTE: `--set`
|
|
40
|
+
// matching itself is case-sensitive (it keys on `moduleName` from the install
|
|
41
|
+
// flow's selected list, which is always lowercase short codes), so the
|
|
42
|
+
// surfaced `code` here is what users should type. Don't change to
|
|
43
|
+
// case-sensitive dedupe without revisiting that contract.
|
|
44
|
+
const seenCodes = new Set();
|
|
45
|
+
|
|
46
|
+
const addFound = async (yamlPath, source, fallbackCode) => {
|
|
47
|
+
const declaredCode = await readModuleCode(yamlPath);
|
|
48
|
+
const code = declaredCode || fallbackCode;
|
|
49
|
+
if (!code) return;
|
|
50
|
+
const lower = code.toLowerCase();
|
|
51
|
+
if (seenCodes.has(lower)) return;
|
|
52
|
+
seenCodes.add(lower);
|
|
53
|
+
found.push({ code, yamlPath, source });
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Built-ins.
|
|
57
|
+
for (const code of ['core', 'bmm']) {
|
|
58
|
+
const yamlPath = path.join(getModulePath(code), 'module.yaml');
|
|
59
|
+
if (await fs.pathExists(yamlPath)) {
|
|
60
|
+
// Built-ins use their well-known short codes regardless of what the
|
|
61
|
+
// module.yaml `code:` says, since the install flow keys on these.
|
|
62
|
+
seenCodes.add(code.toLowerCase());
|
|
63
|
+
found.push({ code, yamlPath, source: 'built-in' });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Bundled in src/modules/<code>/module.yaml (rare, but supported by getModulePath).
|
|
68
|
+
const srcModulesDir = path.join(getProjectRoot(), 'src', 'modules');
|
|
69
|
+
if (await fs.pathExists(srcModulesDir)) {
|
|
70
|
+
const entries = await fs.readdir(srcModulesDir, { withFileTypes: true });
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (!entry.isDirectory()) continue;
|
|
73
|
+
const yamlPath = path.join(srcModulesDir, entry.name, 'module.yaml');
|
|
74
|
+
if (await fs.pathExists(yamlPath)) {
|
|
75
|
+
await addFound(yamlPath, 'bundled', entry.name);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// External cache (~/.bmad/cache/external-modules/<code>/...).
|
|
81
|
+
const cacheRoot = getExternalModuleCachePath('').replace(/\/$/, '');
|
|
82
|
+
if (await fs.pathExists(cacheRoot)) {
|
|
83
|
+
const rawEntries = await fs.readdir(cacheRoot, { withFileTypes: true });
|
|
84
|
+
for (const entry of rawEntries) {
|
|
85
|
+
if (!entry.isDirectory()) continue;
|
|
86
|
+
const candidates = [
|
|
87
|
+
path.join(cacheRoot, entry.name, 'module.yaml'),
|
|
88
|
+
path.join(cacheRoot, entry.name, 'src', 'module.yaml'),
|
|
89
|
+
path.join(cacheRoot, entry.name, 'skills', 'module.yaml'),
|
|
90
|
+
];
|
|
91
|
+
for (const candidate of candidates) {
|
|
92
|
+
if (await fs.pathExists(candidate)) {
|
|
93
|
+
await addFound(candidate, 'cached', entry.name);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return found;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatPromptText(item) {
|
|
104
|
+
if (Array.isArray(item.prompt)) return item.prompt.join(' ');
|
|
105
|
+
return String(item.prompt || '').trim();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function inferType(item) {
|
|
109
|
+
if (item['single-select']) return 'single-select';
|
|
110
|
+
if (item['multi-select']) return 'multi-select';
|
|
111
|
+
if (typeof item.default === 'boolean') return 'boolean';
|
|
112
|
+
if (typeof item.default === 'number') return 'number';
|
|
113
|
+
return 'string';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function formatModuleOptions(code, parsed, source) {
|
|
117
|
+
const lines = [];
|
|
118
|
+
const header = source === 'built-in' ? code : `${code} (${source})`;
|
|
119
|
+
lines.push(header + ':');
|
|
120
|
+
|
|
121
|
+
let count = 0;
|
|
122
|
+
for (const [key, item] of Object.entries(parsed)) {
|
|
123
|
+
if (!item || typeof item !== 'object' || !('prompt' in item)) continue;
|
|
124
|
+
count++;
|
|
125
|
+
const type = inferType(item);
|
|
126
|
+
const scope = item.scope === 'user' ? ' [user-scope]' : '';
|
|
127
|
+
const defaultStr = item.default === undefined || item.default === null ? '(none)' : String(item.default);
|
|
128
|
+
lines.push(` ${code}.${key} (${type}${scope}) default: ${defaultStr}`);
|
|
129
|
+
const promptText = formatPromptText(item);
|
|
130
|
+
if (promptText) lines.push(` ${promptText}`);
|
|
131
|
+
if (Array.isArray(item['single-select'])) {
|
|
132
|
+
const values = item['single-select'].map((v) => (typeof v === 'object' ? v.value : v)).filter((v) => v !== undefined);
|
|
133
|
+
if (values.length > 0) lines.push(` values: ${values.join(' | ')}`);
|
|
134
|
+
}
|
|
135
|
+
lines.push('');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (count === 0) {
|
|
139
|
+
lines.push(' (no configurable options)', '');
|
|
140
|
+
}
|
|
141
|
+
return lines.join('\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Render `--list-options` output.
|
|
146
|
+
*
|
|
147
|
+
* Returns `{ text, ok }` so callers can surface a non-zero exit code on
|
|
148
|
+
* a typo'd module-code lookup. Discovery dedupes case-insensitively, so
|
|
149
|
+
* the lookup is also case-insensitive — typing `--list-options BMM` and
|
|
150
|
+
* `--list-options bmm` both find the bmm built-in.
|
|
151
|
+
*
|
|
152
|
+
* @param {string|null} moduleCode - if non-null, restrict to this module
|
|
153
|
+
* @returns {Promise<{text: string, ok: boolean}>}
|
|
154
|
+
*/
|
|
155
|
+
async function formatOptionsList(moduleCode) {
|
|
156
|
+
const discovered = await discoverOfficialModuleYamls();
|
|
157
|
+
const needle = moduleCode ? moduleCode.toLowerCase() : null;
|
|
158
|
+
const filtered = needle ? discovered.filter((d) => d.code.toLowerCase() === needle) : discovered;
|
|
159
|
+
|
|
160
|
+
if (filtered.length === 0) {
|
|
161
|
+
if (moduleCode) {
|
|
162
|
+
const text = [
|
|
163
|
+
`No locally-known module.yaml for '${moduleCode}'.`,
|
|
164
|
+
'',
|
|
165
|
+
'Built-in modules (core, bmm) are always available. External officials',
|
|
166
|
+
'appear here after they have been installed at least once on this machine',
|
|
167
|
+
'(they are cached under ~/.bmad/cache/external-modules/).',
|
|
168
|
+
'',
|
|
169
|
+
'For community or custom modules, read the module.yaml file in that',
|
|
170
|
+
"module's source repository directly.",
|
|
171
|
+
].join('\n');
|
|
172
|
+
return { text, ok: false };
|
|
173
|
+
}
|
|
174
|
+
return { text: 'No modules found.', ok: false };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const sections = [];
|
|
178
|
+
// Track when a module-scoped lookup couldn't actually be rendered (yaml
|
|
179
|
+
// unparseable or empty after parse). The full `--list-options` output is
|
|
180
|
+
// tolerant of one bad entry, but `--list-options <module>` against a single
|
|
181
|
+
// unreadable module should still fail tooling so a CI script catches it.
|
|
182
|
+
let moduleScopedFailure = false;
|
|
183
|
+
sections.push('Available --set keys', 'Format: --set <module>.<key>=<value> (repeatable)', '');
|
|
184
|
+
for (const { code, yamlPath, source } of filtered) {
|
|
185
|
+
let parsed;
|
|
186
|
+
try {
|
|
187
|
+
parsed = yaml.parse(await fs.readFile(yamlPath, 'utf8'));
|
|
188
|
+
} catch {
|
|
189
|
+
sections.push(`${code} (${source}): could not parse module.yaml`, '');
|
|
190
|
+
if (moduleCode) moduleScopedFailure = true;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
194
|
+
sections.push(`${code} (${source}): module.yaml is not a valid object (got ${Array.isArray(parsed) ? 'array' : typeof parsed})`, '');
|
|
195
|
+
if (moduleCode) moduleScopedFailure = true;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
sections.push(formatModuleOptions(code, parsed, source));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!moduleCode) {
|
|
202
|
+
sections.push(
|
|
203
|
+
'Community and custom modules are not listed here — read their module.yaml directly. Unknown keys still persist with a warning.',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { text: sections.join('\n'), ok: !moduleScopedFailure };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = { formatOptionsList, discoverOfficialModuleYamls };
|
|
@@ -128,58 +128,102 @@ class CustomModuleManager {
|
|
|
128
128
|
};
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
// HTTPS/HTTP URL:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
131
|
+
// HTTPS/HTTP URL: generic handling for any Git host.
|
|
132
|
+
// We avoid host-specific parsing — `git clone` will accept whatever URL the
|
|
133
|
+
// user provides. We only need to (a) separate an optional browser-style
|
|
134
|
+
// subdir suffix from the clone URL, (b) extract any embedded ref
|
|
135
|
+
// (branch/tag) from deep-path URLs, and (c) derive a cache key / display
|
|
136
|
+
// name from the path. The original protocol (http or https) is preserved.
|
|
137
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
138
|
+
let url;
|
|
139
|
+
try {
|
|
140
|
+
url = new URL(trimmed);
|
|
141
|
+
} catch {
|
|
142
|
+
url = null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (url && url.host) {
|
|
146
|
+
const host = url.host;
|
|
147
|
+
let repoPath = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
148
|
+
let subdir = null;
|
|
149
|
+
let urlRef = null; // branch/tag/commit extracted from deep-path URLs
|
|
150
|
+
|
|
151
|
+
// Detect browser-style deep-path patterns that embed a ref
|
|
152
|
+
// (branch/tag/commit) and optional subdirectory. These appear
|
|
153
|
+
// across many hosts:
|
|
154
|
+
// GitHub /<repo>/tree|blob/<ref>[/<subdir>]
|
|
155
|
+
// GitLab /<repo>/-/tree|blob/<ref>[/<subdir>]
|
|
156
|
+
// Gitea /<repo>/src/<ref>[/<subdir>]
|
|
157
|
+
// Gitea /<repo>/src/(branch|commit|tag)/<ref>[/<subdir>]
|
|
158
|
+
// Group 1 = repo path prefix, Group 2 = ref, Group 3 = subdir (optional).
|
|
141
159
|
const deepPathPatterns = [
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
{ regex: /^\/src\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 }, // Gitea/Forgejo
|
|
160
|
+
/^(.+?)\/(?:-\/)?(?:tree|blob)\/([^/]+)(?:\/(.+))?$/,
|
|
161
|
+
/^(.+?)\/src\/(?:branch\/|commit\/|tag\/)?([^/]+)(?:\/(.+))?$/,
|
|
145
162
|
];
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
for (const p of deepPathPatterns) {
|
|
150
|
-
const match = remainder.match(p.regex);
|
|
163
|
+
for (const pattern of deepPathPatterns) {
|
|
164
|
+
const match = repoPath.match(pattern);
|
|
151
165
|
if (match) {
|
|
152
|
-
|
|
153
|
-
|
|
166
|
+
repoPath = match[1];
|
|
167
|
+
if (match[2]) urlRef = match[2];
|
|
168
|
+
if (match[3]) {
|
|
169
|
+
const cleaned = match[3].replace(/\/+$/, '');
|
|
170
|
+
if (cleaned) subdir = cleaned;
|
|
171
|
+
}
|
|
154
172
|
break;
|
|
155
173
|
}
|
|
156
174
|
}
|
|
175
|
+
|
|
176
|
+
// Some hosts use ?path=/subdir on browse links to point at a file or
|
|
177
|
+
// directory. Honor it when no deep-path marker matched above.
|
|
157
178
|
if (!subdir) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
break;
|
|
163
|
-
}
|
|
179
|
+
const pathParam = url.searchParams.get('path');
|
|
180
|
+
if (pathParam) {
|
|
181
|
+
const cleaned = pathParam.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
182
|
+
if (cleaned) subdir = cleaned;
|
|
164
183
|
}
|
|
165
184
|
}
|
|
166
|
-
}
|
|
167
185
|
|
|
168
|
-
|
|
169
|
-
|
|
186
|
+
// Strip a single trailing .git for a stable cacheKey/displayName.
|
|
187
|
+
const repoPathClean = repoPath.replace(/\.git$/i, '');
|
|
188
|
+
if (!repoPathClean) {
|
|
189
|
+
return {
|
|
190
|
+
type: null,
|
|
191
|
+
cloneUrl: null,
|
|
192
|
+
subdir: null,
|
|
193
|
+
localPath: null,
|
|
194
|
+
cacheKey: null,
|
|
195
|
+
displayName: null,
|
|
196
|
+
isValid: false,
|
|
197
|
+
error: 'Not a valid Git URL or local path',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
170
200
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
displayName
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
201
|
+
const cloneUrl = `${url.protocol}//${host}/${repoPathClean}`;
|
|
202
|
+
const cacheKey = `${host}/${repoPathClean}`;
|
|
203
|
+
|
|
204
|
+
// Display name: prefer "<owner>/<repo>" using the last two meaningful
|
|
205
|
+
// path segments.
|
|
206
|
+
const segments = repoPathClean.split('/').filter(Boolean);
|
|
207
|
+
const repoSeg = segments.at(-1);
|
|
208
|
+
const ownerSeg = segments.at(-2);
|
|
209
|
+
const displayName = ownerSeg ? `${ownerSeg}/${repoSeg}` : repoSeg;
|
|
210
|
+
|
|
211
|
+
// Precedence: explicit @version suffix > URL /tree/<ref> path segment.
|
|
212
|
+
const version = versionSuffix || urlRef || null;
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
type: 'url',
|
|
216
|
+
cloneUrl,
|
|
217
|
+
subdir,
|
|
218
|
+
localPath: null,
|
|
219
|
+
version,
|
|
220
|
+
rawInput: trimmedRaw,
|
|
221
|
+
cacheKey,
|
|
222
|
+
displayName,
|
|
223
|
+
isValid: true,
|
|
224
|
+
error: null,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
183
227
|
}
|
|
184
228
|
|
|
185
229
|
return {
|
|
@@ -903,7 +903,10 @@ class OfficialModules {
|
|
|
903
903
|
try {
|
|
904
904
|
const content = await fs.readFile(moduleConfigPath, 'utf8');
|
|
905
905
|
const moduleConfig = yaml.parse(content);
|
|
906
|
-
|
|
906
|
+
// Only keep plain object parses. A corrupt config.yaml that parses
|
|
907
|
+
// to a scalar or array would crash later code that does `key in cfg`
|
|
908
|
+
// / `Object.keys(cfg)`; treat it the same as a parse error.
|
|
909
|
+
if (moduleConfig && typeof moduleConfig === 'object' && !Array.isArray(moduleConfig)) {
|
|
907
910
|
this._existingConfig[entry.name] = moduleConfig;
|
|
908
911
|
foundAny = true;
|
|
909
912
|
}
|
|
@@ -914,9 +917,58 @@ class OfficialModules {
|
|
|
914
917
|
}
|
|
915
918
|
}
|
|
916
919
|
|
|
920
|
+
if (foundAny) {
|
|
921
|
+
await this._hoistCoreKeysFromLegacyModuleConfigs();
|
|
922
|
+
}
|
|
923
|
+
|
|
917
924
|
return foundAny;
|
|
918
925
|
}
|
|
919
926
|
|
|
927
|
+
/**
|
|
928
|
+
* Migrate prior answers when a key has moved from a non-core module to core
|
|
929
|
+
* (e.g. project_name moving from bmm to core in #2279). Without this, the
|
|
930
|
+
* partition logic in writeCentralConfig drops the value from the bmm bucket
|
|
931
|
+
* (because it's now a core key) without re-homing it under [core], so the
|
|
932
|
+
* user's prior answer silently disappears on the next install/quick-update.
|
|
933
|
+
*/
|
|
934
|
+
async _hoistCoreKeysFromLegacyModuleConfigs() {
|
|
935
|
+
const coreSchemaPath = path.join(getSourcePath(), 'core-skills', 'module.yaml');
|
|
936
|
+
if (!(await fs.pathExists(coreSchemaPath))) return;
|
|
937
|
+
|
|
938
|
+
let coreSchema;
|
|
939
|
+
try {
|
|
940
|
+
coreSchema = yaml.parse(await fs.readFile(coreSchemaPath, 'utf8'));
|
|
941
|
+
} catch {
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
if (!coreSchema || typeof coreSchema !== 'object') return;
|
|
945
|
+
|
|
946
|
+
const coreKeys = new Set(
|
|
947
|
+
Object.entries(coreSchema)
|
|
948
|
+
.filter(([, v]) => v && typeof v === 'object' && 'prompt' in v)
|
|
949
|
+
.map(([k]) => k),
|
|
950
|
+
);
|
|
951
|
+
if (coreKeys.size === 0) return;
|
|
952
|
+
|
|
953
|
+
// Belt-and-suspenders: loadExistingConfig already filters non-object parses,
|
|
954
|
+
// but anyone calling _hoistCoreKeysFromLegacyModuleConfigs in isolation (or
|
|
955
|
+
// future code paths populating _existingConfig directly) shouldn't be able
|
|
956
|
+
// to crash this with a scalar / array.
|
|
957
|
+
const existingCore = this._existingConfig.core;
|
|
958
|
+
this._existingConfig.core = existingCore && typeof existingCore === 'object' && !Array.isArray(existingCore) ? existingCore : {};
|
|
959
|
+
|
|
960
|
+
for (const [moduleName, cfg] of Object.entries(this._existingConfig)) {
|
|
961
|
+
if (moduleName === 'core' || !cfg || typeof cfg !== 'object' || Array.isArray(cfg)) continue;
|
|
962
|
+
for (const key of Object.keys(cfg)) {
|
|
963
|
+
if (!coreKeys.has(key)) continue;
|
|
964
|
+
if (!(key in this._existingConfig.core)) {
|
|
965
|
+
this._existingConfig.core[key] = cfg[key];
|
|
966
|
+
}
|
|
967
|
+
delete cfg[key];
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
920
972
|
/**
|
|
921
973
|
* Pre-scan module schemas to gather metadata for the configuration gateway prompt.
|
|
922
974
|
* Returns info about which modules have configurable options.
|