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.
@@ -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: https://host/owner/repo[/tree/branch/subdir][.git]
132
- const httpsMatch = trimmed.match(/^(https?):\/\/([^/]+)\/([^/]+)\/([^/.]+?)(?:\.git)?(\/.*)?$/);
133
- if (httpsMatch) {
134
- const [, protocol, host, owner, repo, remainder] = httpsMatch;
135
- const cloneUrl = `${protocol}://${host}/${owner}/${repo}`;
136
- let subdir = null;
137
- let urlRef = null; // branch/tag extracted from /tree/<ref>/subdir
138
-
139
- if (remainder) {
140
- // Extract subdir from deep path patterns used by various Git hosts
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
- { regex: /^\/(?:-\/)?tree\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 }, // GitHub, GitLab
143
- { regex: /^\/(?:-\/)?blob\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 },
144
- { regex: /^\/src\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 }, // Gitea/Forgejo
160
+ /^(.+?)\/(?:-\/)?(?:tree|blob)\/([^/]+)(?:\/(.+))?$/,
161
+ /^(.+?)\/src\/(?:branch\/|commit\/|tag\/)?([^/]+)(?:\/(.+))?$/,
145
162
  ];
146
- // Also match `/tree/<ref>` with no subdir
147
- const refOnlyPatterns = [/^\/(?:-\/)?tree\/([^/]+?)\/?$/, /^\/(?:-\/)?blob\/([^/]+?)\/?$/, /^\/src\/([^/]+?)\/?$/];
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
- urlRef = match[p.refIdx];
153
- subdir = match[p.pathIdx].replace(/\/$/, '');
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
- for (const r of refOnlyPatterns) {
159
- const match = remainder.match(r);
160
- if (match) {
161
- urlRef = match[1];
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
- // Precedence: explicit @version suffix > URL /tree/<ref> path segment.
169
- const version = versionSuffix || urlRef || null;
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
- return {
172
- type: 'url',
173
- cloneUrl,
174
- subdir,
175
- localPath: null,
176
- version,
177
- rawInput: trimmedRaw,
178
- cacheKey: `${host}/${owner}/${repo}`,
179
- displayName: `${owner}/${repo}`,
180
- isValid: true,
181
- error: null,
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
- if (moduleConfig) {
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.