bmad-method 6.2.3-next.30 → 6.2.3-next.32

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.
@@ -135,6 +135,22 @@ class OfficialModules {
135
135
  const moduleConfigPath = path.join(modulePath, 'module.yaml');
136
136
 
137
137
  if (!(await fs.pathExists(moduleConfigPath))) {
138
+ // Check resolution cache for strategy 5 modules (no module.yaml on disk)
139
+ const { CustomModuleManager } = require('./custom-module-manager');
140
+ const customMgr = new CustomModuleManager();
141
+ const resolved = customMgr.getResolution(defaultName);
142
+ if (resolved && resolved.synthesizedModuleYaml) {
143
+ return {
144
+ id: resolved.code,
145
+ path: modulePath,
146
+ name: resolved.name,
147
+ description: resolved.description,
148
+ version: resolved.version || '1.0.0',
149
+ source: sourceDescription,
150
+ dependencies: [],
151
+ defaultSelected: false,
152
+ };
153
+ }
138
154
  return null;
139
155
  }
140
156
 
@@ -232,6 +248,14 @@ class OfficialModules {
232
248
  * @param {Object} options.logger - Logger instance for output
233
249
  */
234
250
  async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) {
251
+ // Check if this module has a plugin resolution (custom marketplace install)
252
+ const { CustomModuleManager } = require('./custom-module-manager');
253
+ const customMgr = new CustomModuleManager();
254
+ const resolved = customMgr.getResolution(moduleName);
255
+ if (resolved) {
256
+ return this.installFromResolution(resolved, bmadDir, fileTrackingCallback, options);
257
+ }
258
+
235
259
  const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent });
236
260
  const targetPath = path.join(bmadDir, moduleName);
237
261
 
@@ -265,6 +289,62 @@ class OfficialModules {
265
289
  return { success: true, module: moduleName, path: targetPath, versionInfo };
266
290
  }
267
291
 
292
+ /**
293
+ * Install a module from a PluginResolver resolution result.
294
+ * Copies specific skill directories and places module-help.csv at the target root.
295
+ * @param {Object} resolved - ResolvedModule from PluginResolver
296
+ * @param {string} bmadDir - Target bmad directory
297
+ * @param {Function} fileTrackingCallback - Optional callback to track installed files
298
+ * @param {Object} options - Installation options
299
+ */
300
+ async installFromResolution(resolved, bmadDir, fileTrackingCallback = null, options = {}) {
301
+ const targetPath = path.join(bmadDir, resolved.code);
302
+
303
+ if (await fs.pathExists(targetPath)) {
304
+ await fs.remove(targetPath);
305
+ }
306
+
307
+ await fs.ensureDir(targetPath);
308
+
309
+ // Copy each skill directory, flattened by leaf name
310
+ for (const skillPath of resolved.skillPaths) {
311
+ const skillDirName = path.basename(skillPath);
312
+ const skillTarget = path.join(targetPath, skillDirName);
313
+ await this.copyModuleWithFiltering(skillPath, skillTarget, fileTrackingCallback, options.moduleConfig);
314
+ }
315
+
316
+ // Place module-help.csv at the module root
317
+ if (resolved.moduleHelpCsvPath) {
318
+ // Strategies 1-4: copy the existing file
319
+ const helpTarget = path.join(targetPath, 'module-help.csv');
320
+ await fs.copy(resolved.moduleHelpCsvPath, helpTarget, { overwrite: true });
321
+ if (fileTrackingCallback) fileTrackingCallback(helpTarget);
322
+ } else if (resolved.synthesizedHelpCsv) {
323
+ // Strategy 5: write synthesized content
324
+ const helpTarget = path.join(targetPath, 'module-help.csv');
325
+ await fs.writeFile(helpTarget, resolved.synthesizedHelpCsv, 'utf8');
326
+ if (fileTrackingCallback) fileTrackingCallback(helpTarget);
327
+ }
328
+
329
+ // Create directories declared in module.yaml (strategies 1-4 may have these)
330
+ if (!options.skipModuleInstaller) {
331
+ await this.createModuleDirectories(resolved.code, bmadDir, options);
332
+ }
333
+
334
+ // Update manifest
335
+ const { Manifest } = require('../core/manifest');
336
+ const manifestObj = new Manifest();
337
+
338
+ await manifestObj.addModule(bmadDir, resolved.code, {
339
+ version: resolved.version || null,
340
+ source: 'custom',
341
+ npmPackage: null,
342
+ repoUrl: resolved.repoUrl || null,
343
+ });
344
+
345
+ return { success: true, module: resolved.code, path: targetPath, versionInfo: { version: resolved.version || '' } };
346
+ }
347
+
268
348
  /**
269
349
  * Update an existing module
270
350
  * @param {string} moduleName - Name of the module to update
@@ -0,0 +1,398 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('node:path');
3
+ const yaml = require('yaml');
4
+
5
+ /**
6
+ * Resolves how to install a plugin from marketplace.json by analyzing
7
+ * where module.yaml and module-help.csv live relative to the listed skills.
8
+ *
9
+ * Five strategies, tried in order:
10
+ * 1. Root module files at the common parent of all skills
11
+ * 2. A -setup skill with assets/module.yaml + assets/module-help.csv
12
+ * 3. Single standalone skill with both files in its assets/
13
+ * 4. Multiple standalone skills, each with both files in assets/
14
+ * 5. Fallback: synthesize from marketplace.json + SKILL.md frontmatter
15
+ */
16
+ class PluginResolver {
17
+ /**
18
+ * Resolve a plugin to one or more installable module definitions.
19
+ * @param {string} repoPath - Absolute path to the cloned repository root
20
+ * @param {Object} plugin - Plugin object from marketplace.json
21
+ * @param {string} plugin.name - Plugin identifier
22
+ * @param {string} [plugin.source] - Relative path from repo root
23
+ * @param {string} [plugin.version] - Semantic version
24
+ * @param {string} [plugin.description] - Plugin description
25
+ * @param {string[]} [plugin.skills] - Relative paths to skill directories
26
+ * @returns {Promise<ResolvedModule[]>} Array of resolved module definitions
27
+ */
28
+ async resolve(repoPath, plugin) {
29
+ const skillRelPaths = plugin.skills || [];
30
+
31
+ // No skills array: legacy behavior - caller should use existing findModuleSource
32
+ if (skillRelPaths.length === 0) {
33
+ return [];
34
+ }
35
+
36
+ // Resolve skill paths to absolute, constrain to repo root, filter non-existent
37
+ const repoRoot = path.resolve(repoPath);
38
+ const skillPaths = [];
39
+ for (const rel of skillRelPaths) {
40
+ const normalized = rel.replace(/^\.\//, '');
41
+ const abs = path.resolve(repoPath, normalized);
42
+ // Guard against path traversal (.. segments, absolute paths in marketplace.json)
43
+ if (!abs.startsWith(repoRoot + path.sep) && abs !== repoRoot) {
44
+ continue;
45
+ }
46
+ if (await fs.pathExists(abs)) {
47
+ skillPaths.push(abs);
48
+ }
49
+ }
50
+
51
+ if (skillPaths.length === 0) {
52
+ return [];
53
+ }
54
+
55
+ // Try each strategy in order
56
+ const result =
57
+ (await this._tryRootModuleFiles(repoPath, plugin, skillPaths)) ||
58
+ (await this._trySetupSkill(repoPath, plugin, skillPaths)) ||
59
+ (await this._trySingleStandalone(repoPath, plugin, skillPaths)) ||
60
+ (await this._tryMultipleStandalone(repoPath, plugin, skillPaths)) ||
61
+ (await this._synthesizeFallback(repoPath, plugin, skillPaths));
62
+
63
+ return result;
64
+ }
65
+
66
+ // ─── Strategy 1: Root Module Files ──────────────────────────────────────────
67
+
68
+ /**
69
+ * Check if module.yaml + module-help.csv exist at the common parent of all skills.
70
+ */
71
+ async _tryRootModuleFiles(repoPath, plugin, skillPaths) {
72
+ const commonParent = this._computeCommonParent(skillPaths);
73
+ const moduleYamlPath = path.join(commonParent, 'module.yaml');
74
+ const moduleHelpPath = path.join(commonParent, 'module-help.csv');
75
+
76
+ if (!(await fs.pathExists(moduleYamlPath)) || !(await fs.pathExists(moduleHelpPath))) {
77
+ return null;
78
+ }
79
+
80
+ const moduleData = await this._readModuleYaml(moduleYamlPath);
81
+ if (!moduleData) return null;
82
+
83
+ return [
84
+ {
85
+ code: moduleData.code || plugin.name,
86
+ name: moduleData.name || plugin.name,
87
+ version: plugin.version || moduleData.module_version || null,
88
+ description: moduleData.description || plugin.description || '',
89
+ strategy: 1,
90
+ pluginName: plugin.name,
91
+ moduleYamlPath,
92
+ moduleHelpCsvPath: moduleHelpPath,
93
+ skillPaths,
94
+ synthesizedModuleYaml: null,
95
+ synthesizedHelpCsv: null,
96
+ },
97
+ ];
98
+ }
99
+
100
+ // ─── Strategy 2: Setup Skill ────────────────────────────────────────────────
101
+
102
+ /**
103
+ * Search for a skill ending in -setup with assets/module.yaml + assets/module-help.csv.
104
+ */
105
+ async _trySetupSkill(repoPath, plugin, skillPaths) {
106
+ for (const skillPath of skillPaths) {
107
+ const dirName = path.basename(skillPath);
108
+ if (!dirName.endsWith('-setup')) continue;
109
+
110
+ const moduleYamlPath = path.join(skillPath, 'assets', 'module.yaml');
111
+ const moduleHelpPath = path.join(skillPath, 'assets', 'module-help.csv');
112
+
113
+ if (!(await fs.pathExists(moduleYamlPath)) || !(await fs.pathExists(moduleHelpPath))) {
114
+ continue;
115
+ }
116
+
117
+ const moduleData = await this._readModuleYaml(moduleYamlPath);
118
+ if (!moduleData) continue;
119
+
120
+ return [
121
+ {
122
+ code: moduleData.code || plugin.name,
123
+ name: moduleData.name || plugin.name,
124
+ version: plugin.version || moduleData.module_version || null,
125
+ description: moduleData.description || plugin.description || '',
126
+ strategy: 2,
127
+ pluginName: plugin.name,
128
+ moduleYamlPath,
129
+ moduleHelpCsvPath: moduleHelpPath,
130
+ skillPaths,
131
+ synthesizedModuleYaml: null,
132
+ synthesizedHelpCsv: null,
133
+ },
134
+ ];
135
+ }
136
+
137
+ return null;
138
+ }
139
+
140
+ // ─── Strategy 3: Single Standalone Skill ────────────────────────────────────
141
+
142
+ /**
143
+ * One skill listed, with assets/module.yaml + assets/module-help.csv.
144
+ */
145
+ async _trySingleStandalone(repoPath, plugin, skillPaths) {
146
+ if (skillPaths.length !== 1) return null;
147
+
148
+ const skillPath = skillPaths[0];
149
+ const moduleYamlPath = path.join(skillPath, 'assets', 'module.yaml');
150
+ const moduleHelpPath = path.join(skillPath, 'assets', 'module-help.csv');
151
+
152
+ if (!(await fs.pathExists(moduleYamlPath)) || !(await fs.pathExists(moduleHelpPath))) {
153
+ return null;
154
+ }
155
+
156
+ const moduleData = await this._readModuleYaml(moduleYamlPath);
157
+ if (!moduleData) return null;
158
+
159
+ return [
160
+ {
161
+ code: moduleData.code || plugin.name,
162
+ name: moduleData.name || plugin.name,
163
+ version: plugin.version || moduleData.module_version || null,
164
+ description: moduleData.description || plugin.description || '',
165
+ strategy: 3,
166
+ pluginName: plugin.name,
167
+ moduleYamlPath,
168
+ moduleHelpCsvPath: moduleHelpPath,
169
+ skillPaths,
170
+ synthesizedModuleYaml: null,
171
+ synthesizedHelpCsv: null,
172
+ },
173
+ ];
174
+ }
175
+
176
+ // ─── Strategy 4: Multiple Standalone Skills ─────────────────────────────────
177
+
178
+ /**
179
+ * Multiple skills, each with assets/module.yaml + assets/module-help.csv.
180
+ * Each becomes its own installable module.
181
+ */
182
+ async _tryMultipleStandalone(repoPath, plugin, skillPaths) {
183
+ if (skillPaths.length < 2) return null;
184
+
185
+ const resolved = [];
186
+
187
+ for (const skillPath of skillPaths) {
188
+ const moduleYamlPath = path.join(skillPath, 'assets', 'module.yaml');
189
+ const moduleHelpPath = path.join(skillPath, 'assets', 'module-help.csv');
190
+
191
+ if (!(await fs.pathExists(moduleYamlPath)) || !(await fs.pathExists(moduleHelpPath))) {
192
+ continue;
193
+ }
194
+
195
+ const moduleData = await this._readModuleYaml(moduleYamlPath);
196
+ if (!moduleData) continue;
197
+
198
+ resolved.push({
199
+ code: moduleData.code || path.basename(skillPath),
200
+ name: moduleData.name || path.basename(skillPath),
201
+ version: plugin.version || moduleData.module_version || null,
202
+ description: moduleData.description || '',
203
+ strategy: 4,
204
+ pluginName: plugin.name,
205
+ moduleYamlPath,
206
+ moduleHelpCsvPath: moduleHelpPath,
207
+ skillPaths: [skillPath],
208
+ synthesizedModuleYaml: null,
209
+ synthesizedHelpCsv: null,
210
+ });
211
+ }
212
+
213
+ // Only use strategy 4 if ALL skills have module files
214
+ if (resolved.length === skillPaths.length) {
215
+ return resolved;
216
+ }
217
+
218
+ // Partial match: fall through to strategy 5
219
+ return null;
220
+ }
221
+
222
+ // ─── Strategy 5: Fallback (Synthesized) ─────────────────────────────────────
223
+
224
+ /**
225
+ * No module files found anywhere. Synthesize from marketplace.json metadata
226
+ * and SKILL.md frontmatter.
227
+ */
228
+ async _synthesizeFallback(repoPath, plugin, skillPaths) {
229
+ const skillInfos = [];
230
+
231
+ for (const skillPath of skillPaths) {
232
+ const frontmatter = await this._parseSkillFrontmatter(skillPath);
233
+ skillInfos.push({
234
+ dirName: path.basename(skillPath),
235
+ name: frontmatter.name || path.basename(skillPath),
236
+ description: frontmatter.description || '',
237
+ });
238
+ }
239
+
240
+ const moduleName = this._formatDisplayName(plugin.name);
241
+ const code = plugin.name;
242
+
243
+ const synthesizedYaml = {
244
+ code,
245
+ name: moduleName,
246
+ description: plugin.description || '',
247
+ module_version: plugin.version || '1.0.0',
248
+ default_selected: false,
249
+ };
250
+
251
+ const synthesizedCsv = this._buildSynthesizedHelpCsv(moduleName, skillInfos);
252
+
253
+ return [
254
+ {
255
+ code,
256
+ name: moduleName,
257
+ version: plugin.version || null,
258
+ description: plugin.description || '',
259
+ strategy: 5,
260
+ pluginName: plugin.name,
261
+ moduleYamlPath: null,
262
+ moduleHelpCsvPath: null,
263
+ skillPaths,
264
+ synthesizedModuleYaml: synthesizedYaml,
265
+ synthesizedHelpCsv: synthesizedCsv,
266
+ },
267
+ ];
268
+ }
269
+
270
+ // ─── Helpers ────────────────────────────────────────────────────────────────
271
+
272
+ /**
273
+ * Compute the deepest common ancestor directory of an array of absolute paths.
274
+ * @param {string[]} absPaths - Absolute directory paths
275
+ * @returns {string} Common parent directory
276
+ */
277
+ _computeCommonParent(absPaths) {
278
+ if (absPaths.length === 0) return '/';
279
+ if (absPaths.length === 1) return path.dirname(absPaths[0]);
280
+
281
+ const segments = absPaths.map((p) => p.split(path.sep));
282
+ const minLen = Math.min(...segments.map((s) => s.length));
283
+ const common = [];
284
+
285
+ for (let i = 0; i < minLen; i++) {
286
+ const segment = segments[0][i];
287
+ if (segments.every((s) => s[i] === segment)) {
288
+ common.push(segment);
289
+ } else {
290
+ break;
291
+ }
292
+ }
293
+
294
+ return common.join(path.sep) || '/';
295
+ }
296
+
297
+ /**
298
+ * Read and parse a module.yaml file.
299
+ * @param {string} yamlPath - Absolute path to module.yaml
300
+ * @returns {Object|null} Parsed content or null on failure
301
+ */
302
+ async _readModuleYaml(yamlPath) {
303
+ try {
304
+ const content = await fs.readFile(yamlPath, 'utf8');
305
+ return yaml.parse(content);
306
+ } catch {
307
+ return null;
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Extract name and description from a SKILL.md YAML frontmatter block.
313
+ * @param {string} skillDirPath - Absolute path to the skill directory
314
+ * @returns {Object} { name, description } or empty strings
315
+ */
316
+ async _parseSkillFrontmatter(skillDirPath) {
317
+ const skillMdPath = path.join(skillDirPath, 'SKILL.md');
318
+ try {
319
+ const content = await fs.readFile(skillMdPath, 'utf8');
320
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
321
+ if (!match) return { name: '', description: '' };
322
+
323
+ const parsed = yaml.parse(match[1]);
324
+ return {
325
+ name: parsed.name || '',
326
+ description: parsed.description || '',
327
+ };
328
+ } catch {
329
+ return { name: '', description: '' };
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Build a synthesized module-help.csv from plugin metadata and skill frontmatter.
335
+ * Uses the standard 13-column format.
336
+ * @param {string} moduleName - Display name for the module column
337
+ * @param {Array<{dirName: string, name: string, description: string}>} skillInfos
338
+ * @returns {string} CSV content
339
+ */
340
+ _buildSynthesizedHelpCsv(moduleName, skillInfos) {
341
+ const header = 'module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs';
342
+ const rows = [header];
343
+
344
+ for (const info of skillInfos) {
345
+ const displayName = this._formatDisplayName(info.name || info.dirName);
346
+ const menuCode = this._generateMenuCode(info.name || info.dirName);
347
+ const description = this._escapeCSVField(info.description);
348
+
349
+ rows.push(`${moduleName},${info.dirName},${displayName},${menuCode},${description},activate,,anytime,,,false,,`);
350
+ }
351
+
352
+ return rows.join('\n') + '\n';
353
+ }
354
+
355
+ /**
356
+ * Format a kebab-case or snake_case name into a display name.
357
+ * Strips common prefixes like "bmad-" or "bmad-agent-".
358
+ * @param {string} name - Raw name
359
+ * @returns {string} Formatted display name
360
+ */
361
+ _formatDisplayName(name) {
362
+ let cleaned = name.replace(/^bmad-agent-/, '').replace(/^bmad-/, '');
363
+ return cleaned
364
+ .split(/[-_]/)
365
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
366
+ .join(' ');
367
+ }
368
+
369
+ /**
370
+ * Generate a short menu code from a skill name.
371
+ * Takes first letter of each significant word, uppercased, max 3 chars.
372
+ * @param {string} name - Skill name (kebab-case)
373
+ * @returns {string} Menu code (e.g., "CC" for "code-coach")
374
+ */
375
+ _generateMenuCode(name) {
376
+ const cleaned = name.replace(/^bmad-agent-/, '').replace(/^bmad-/, '');
377
+ const words = cleaned.split(/[-_]/).filter((w) => w.length > 0);
378
+ return words
379
+ .map((w) => w.charAt(0).toUpperCase())
380
+ .join('')
381
+ .slice(0, 3);
382
+ }
383
+
384
+ /**
385
+ * Escape a value for CSV output (wrap in quotes if it contains commas, quotes, or newlines).
386
+ * @param {string} value
387
+ * @returns {string}
388
+ */
389
+ _escapeCSVField(value) {
390
+ if (!value) return '';
391
+ if (value.includes(',') || value.includes('"') || value.includes('\n')) {
392
+ return `"${value.replaceAll('"', '""')}"`;
393
+ }
394
+ return value;
395
+ }
396
+ }
397
+
398
+ module.exports = { PluginResolver };