bmad-method 6.5.1-next.4 → 6.5.1-next.6

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "bmad-method",
4
- "version": "6.5.1-next.4",
4
+ "version": "6.5.1-next.6",
5
5
  "description": "Breakthrough Method of Agile AI-driven Development",
6
6
  "keywords": [
7
7
  "agile",
@@ -29,6 +29,11 @@ class CommunityModuleManager {
29
29
  // Shared across all instances; the manifest writer often uses a fresh instance.
30
30
  static _resolutions = new Map();
31
31
 
32
+ // moduleCode → ResolvedModule (from PluginResolver) when the cloned repo ships
33
+ // a `.claude-plugin/marketplace.json`. Lets community installs reuse the same
34
+ // skill-level install pipeline as custom-source installs (installFromResolution).
35
+ static _pluginResolutions = new Map();
36
+
32
37
  constructor() {
33
38
  this._client = new RegistryClient();
34
39
  this._cachedIndex = null;
@@ -40,6 +45,11 @@ class CommunityModuleManager {
40
45
  return CommunityModuleManager._resolutions.get(moduleCode) || null;
41
46
  }
42
47
 
48
+ /** Get the marketplace.json-derived plugin resolution for a community module, if any. */
49
+ getPluginResolution(moduleCode) {
50
+ return CommunityModuleManager._pluginResolutions.get(moduleCode) || null;
51
+ }
52
+
43
53
  // ─── Data Loading ──────────────────────────────────────────────────────────
44
54
 
45
55
  /**
@@ -371,6 +381,18 @@ class CommunityModuleManager {
371
381
  planSource: planEntry.source,
372
382
  });
373
383
 
384
+ // If the repo ships a marketplace.json, route through PluginResolver so the
385
+ // skill-level install pipeline (installFromResolution) handles the copy.
386
+ // Repos without marketplace.json fall through to the legacy findModuleSource
387
+ // path unchanged.
388
+ await this._tryResolveMarketplacePlugin(moduleCacheDir, moduleInfo, {
389
+ channel: planEntry.channel,
390
+ version: recordedVersion,
391
+ sha: installedSha,
392
+ approvedTag,
393
+ approvedSha,
394
+ });
395
+
374
396
  // Install dependencies if needed
375
397
  const packageJsonPath = path.join(moduleCacheDir, 'package.json');
376
398
  if ((needsDependencyInstall || wasNewClone) && (await fs.pathExists(packageJsonPath))) {
@@ -392,6 +414,204 @@ class CommunityModuleManager {
392
414
  return moduleCacheDir;
393
415
  }
394
416
 
417
+ // ─── Marketplace.json Resolution ──────────────────────────────────────────
418
+
419
+ /**
420
+ * Detect `.claude-plugin/marketplace.json` in a cloned community repo and
421
+ * route through PluginResolver. When successful, caches the resolution so
422
+ * OfficialModulesManager.install() can route the copy through
423
+ * installFromResolution() — the same path used by custom-source installs.
424
+ *
425
+ * Silent no-op when marketplace.json is absent or the resolver returns no
426
+ * matches; the legacy findModuleSource path then handles the install.
427
+ *
428
+ * @param {string} repoPath - Absolute path to the cloned repo
429
+ * @param {Object} moduleInfo - Normalized community module info
430
+ * @param {Object} resolution - Resolution metadata from cloneModule
431
+ * @param {string} resolution.channel - Channel ('stable' | 'next' | 'pinned')
432
+ * @param {string} resolution.version - Recorded version string
433
+ * @param {string} resolution.sha - Resolved git SHA
434
+ * @param {string|null} resolution.approvedTag - Registry approved tag
435
+ * @param {string|null} resolution.approvedSha - Registry approved SHA
436
+ */
437
+ async _tryResolveMarketplacePlugin(repoPath, moduleInfo, resolution) {
438
+ const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
439
+ if (!(await fs.pathExists(marketplacePath))) return;
440
+
441
+ let marketplaceData;
442
+ try {
443
+ marketplaceData = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
444
+ } catch {
445
+ // Malformed marketplace.json — fall through to legacy path.
446
+ return;
447
+ }
448
+
449
+ const plugins = Array.isArray(marketplaceData?.plugins) ? marketplaceData.plugins : [];
450
+ if (plugins.length === 0) return;
451
+
452
+ const selection = this._selectPluginForModule(plugins, moduleInfo);
453
+ if (!selection) {
454
+ await this._safeWarn(
455
+ `Community module '${moduleInfo.code}' ships marketplace.json but no plugin entry matches the registry code. ` +
456
+ `Falling back to legacy install path.`,
457
+ );
458
+ return;
459
+ }
460
+
461
+ if (selection.source === 'single-fallback') {
462
+ // Single-entry marketplace.json whose plugin name doesn't match the registry
463
+ // code or the module_definition hint. Most likely correct, but worth surfacing
464
+ // in case marketplace.json is misconfigured and we'd install the wrong plugin.
465
+ await this._safeWarn(
466
+ `Community module '${moduleInfo.code}' picked the only plugin in marketplace.json ('${selection.plugin?.name}') ` +
467
+ `because no name or module_definition match was found. Verify marketplace.json if the install looks wrong.`,
468
+ );
469
+ }
470
+
471
+ const { PluginResolver } = require('./plugin-resolver');
472
+ const resolver = new PluginResolver();
473
+ let resolved;
474
+ try {
475
+ resolved = await resolver.resolve(repoPath, selection.plugin);
476
+ } catch (error) {
477
+ // PluginResolver threw (malformed plugin entry, missing files, etc.).
478
+ // Honor the silent-fallthrough contract — warn and let the legacy
479
+ // findModuleSource path handle the install.
480
+ await this._safeWarn(
481
+ `PluginResolver failed for community module '${moduleInfo.code}': ${error.message}. ` + `Falling back to legacy install path.`,
482
+ );
483
+ return;
484
+ }
485
+ if (!resolved || resolved.length === 0) return;
486
+
487
+ // The registry registers a single code per module. If the resolver returns
488
+ // multiple modules (Strategy 4: multiple standalone skills), accept only
489
+ // the entry whose code matches the registry. Other entries are ignored —
490
+ // they belong to plugins not registered in the community catalog.
491
+ const matched = resolved.find((mod) => mod.code === moduleInfo.code) || (resolved.length === 1 ? resolved[0] : null);
492
+ if (!matched) return;
493
+
494
+ // Shallow-clone before stamping provenance — the resolver may cache or reuse
495
+ // its return objects, and we don't want install-specific fields leaking back.
496
+ const stamped = {
497
+ ...matched,
498
+ code: moduleInfo.code,
499
+ repoUrl: moduleInfo.url,
500
+ cloneRef: resolution.channel === 'pinned' ? resolution.version : resolution.approvedTag || null,
501
+ cloneSha: resolution.sha,
502
+ communitySource: true,
503
+ communityChannel: resolution.channel,
504
+ communityVersion: resolution.version,
505
+ registryApprovedTag: resolution.approvedTag,
506
+ registryApprovedSha: resolution.approvedSha,
507
+ };
508
+
509
+ CommunityModuleManager._pluginResolutions.set(moduleInfo.code, stamped);
510
+ }
511
+
512
+ /**
513
+ * Lazy fallback: resolve marketplace.json straight from the on-disk cache
514
+ * when `_pluginResolutions` is empty (e.g. callers that reach `install()`
515
+ * without `cloneModule` having populated the cache earlier in this process).
516
+ *
517
+ * Reuses an existing channel resolution if present; otherwise synthesizes a
518
+ * minimal stable-channel stub from the registry entry + the cached repo's
519
+ * current HEAD. Returns the cached plugin resolution if one is produced,
520
+ * otherwise null (caller falls back to the legacy path).
521
+ *
522
+ * @param {string} moduleCode
523
+ * @returns {Promise<Object|null>}
524
+ */
525
+ async resolveFromCache(moduleCode) {
526
+ const existing = this.getPluginResolution(moduleCode);
527
+ if (existing) return existing;
528
+
529
+ const cacheRepoDir = path.join(this.getCacheDir(), moduleCode);
530
+ const marketplacePath = path.join(cacheRepoDir, '.claude-plugin', 'marketplace.json');
531
+ if (!(await fs.pathExists(marketplacePath))) return null;
532
+
533
+ let moduleInfo;
534
+ try {
535
+ moduleInfo = await this.getModuleByCode(moduleCode);
536
+ } catch {
537
+ return null;
538
+ }
539
+ if (!moduleInfo) return null;
540
+
541
+ let channelResolution = this.getResolution(moduleCode);
542
+ if (!channelResolution) {
543
+ let sha = '';
544
+ try {
545
+ sha = execSync('git rev-parse HEAD', { cwd: cacheRepoDir, stdio: 'pipe' }).toString().trim();
546
+ } catch {
547
+ // Not a git repo or unreadable — give up and let the legacy path run.
548
+ return null;
549
+ }
550
+ channelResolution = {
551
+ channel: 'stable',
552
+ version: moduleInfo.approvedTag || sha.slice(0, 7),
553
+ sha,
554
+ registryApprovedTag: moduleInfo.approvedTag || null,
555
+ registryApprovedSha: moduleInfo.approvedSha || null,
556
+ };
557
+ }
558
+
559
+ await this._tryResolveMarketplacePlugin(cacheRepoDir, moduleInfo, {
560
+ channel: channelResolution.channel,
561
+ version: channelResolution.version,
562
+ sha: channelResolution.sha,
563
+ approvedTag: channelResolution.registryApprovedTag,
564
+ approvedSha: channelResolution.registryApprovedSha,
565
+ });
566
+
567
+ return this.getPluginResolution(moduleCode);
568
+ }
569
+
570
+ /**
571
+ * Best-effort warning emitter. `prompts.log.warn` may be undefined in some
572
+ * harnesses and may return a rejected promise — swallow both cases so a
573
+ * fallthrough warning can never crash the install.
574
+ */
575
+ async _safeWarn(message) {
576
+ try {
577
+ const result = prompts.log?.warn?.(message);
578
+ if (result && typeof result.then === 'function') await result;
579
+ } catch {
580
+ /* ignore */
581
+ }
582
+ }
583
+
584
+ /**
585
+ * Pick which plugin entry from marketplace.json represents this community module.
586
+ * Precedence:
587
+ * 1. Exact match on `plugin.name === moduleInfo.code`
588
+ * 2. Trailing directory of `module_definition` matches `plugin.name`
589
+ * 3. Single plugin in marketplace.json — accepted with a warning so a
590
+ * mismatched-but-uniquely-named plugin doesn't install silently.
591
+ * Otherwise null (caller falls back to legacy path).
592
+ *
593
+ * @returns {{plugin: Object, source: 'name'|'hint'|'single-fallback'}|null}
594
+ */
595
+ _selectPluginForModule(plugins, moduleInfo) {
596
+ const byCode = plugins.find((p) => p && p.name === moduleInfo.code);
597
+ if (byCode) return { plugin: byCode, source: 'name' };
598
+
599
+ if (moduleInfo.moduleDefinition) {
600
+ // module_definition like "src/skills/suno-setup/assets/module.yaml" →
601
+ // hint segment "suno-setup". Match that against plugin names.
602
+ const segments = moduleInfo.moduleDefinition.split('/').filter(Boolean);
603
+ const setupIdx = segments.findIndex((s) => s.endsWith('-setup'));
604
+ if (setupIdx !== -1) {
605
+ const hint = segments[setupIdx];
606
+ const byHint = plugins.find((p) => p && p.name === hint);
607
+ if (byHint) return { plugin: byHint, source: 'hint' };
608
+ }
609
+ }
610
+
611
+ if (plugins.length === 1) return { plugin: plugins[0], source: 'single-fallback' };
612
+ return null;
613
+ }
614
+
395
615
  // ─── Source Finding ───────────────────────────────────────────────────────
396
616
 
397
617
  /**
@@ -24,8 +24,9 @@ class CustomModuleManager {
24
24
 
25
25
  /**
26
26
  * Parse a user-provided source input into a structured descriptor.
27
- * Accepts local file paths, HTTPS Git URLs, and SSH Git URLs.
28
- * For HTTPS URLs with deep paths (e.g., /tree/main/subdir), extracts the subdir.
27
+ * Accepts local file paths, HTTPS Git URLs, HTTP Git URLs, and SSH Git URLs.
28
+ * For HTTPS/HTTP URLs with deep paths (e.g., /tree/main/subdir), extracts the subdir.
29
+ * The original protocol (http or https) is preserved in the returned cloneUrl.
29
30
  *
30
31
  * @param {string} input - URL or local file path
31
32
  * @returns {Object} Parsed source descriptor:
@@ -127,11 +128,11 @@ class CustomModuleManager {
127
128
  };
128
129
  }
129
130
 
130
- // HTTPS URL: https://host/owner/repo[/tree/branch/subdir][.git]
131
- const httpsMatch = trimmed.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/.]+?)(?:\.git)?(\/.*)?$/);
131
+ // HTTPS/HTTP URL: https://host/owner/repo[/tree/branch/subdir][.git]
132
+ const httpsMatch = trimmed.match(/^(https?):\/\/([^/]+)\/([^/]+)\/([^/.]+?)(?:\.git)?(\/.*)?$/);
132
133
  if (httpsMatch) {
133
- const [, host, owner, repo, remainder] = httpsMatch;
134
- const cloneUrl = `https://${host}/${owner}/${repo}`;
134
+ const [, protocol, host, owner, repo, remainder] = httpsMatch;
135
+ const cloneUrl = `${protocol}://${host}/${owner}/${repo}`;
135
136
  let subdir = null;
136
137
  let urlRef = null; // branch/tag extracted from /tree/<ref>/subdir
137
138
 
@@ -311,7 +312,7 @@ class CustomModuleManager {
311
312
  /**
312
313
  * Clone a custom module repository to cache.
313
314
  * Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted, etc.).
314
- * @param {string} sourceInput - Git URL (HTTPS or SSH)
315
+ * @param {string} sourceInput - Git URL (HTTPS, HTTP, or SSH)
315
316
  * @param {Object} [options] - Clone options
316
317
  * @param {boolean} [options.silent] - Suppress spinner output
317
318
  * @param {boolean} [options.skipInstall] - Skip npm install (for browsing before user confirms)
@@ -269,6 +269,21 @@ class OfficialModules {
269
269
  return this.installFromResolution(resolved, bmadDir, fileTrackingCallback, options);
270
270
  }
271
271
 
272
+ // Community modules whose cloned repo ships marketplace.json get the same
273
+ // skill-level install treatment as custom-source installs. If the in-process
274
+ // cache wasn't populated (e.g. caller skipped the pre-clone phase), fall
275
+ // back to resolving directly from `~/.bmad/cache/community-modules/<name>/`
276
+ // so we don't silently regress to the legacy half-install path.
277
+ const { CommunityModuleManager } = require('./community-manager');
278
+ const communityMgr = new CommunityModuleManager();
279
+ let communityResolved = communityMgr.getPluginResolution(moduleName);
280
+ if (!communityResolved) {
281
+ communityResolved = await communityMgr.resolveFromCache(moduleName);
282
+ }
283
+ if (communityResolved) {
284
+ return this.installFromResolution(communityResolved, bmadDir, fileTrackingCallback, options);
285
+ }
286
+
272
287
  const sourcePath = await this.findModuleSource(moduleName, {
273
288
  silent: options.silent,
274
289
  channelOptions: options.channelOptions,
@@ -360,21 +375,27 @@ class OfficialModules {
360
375
  await this.createModuleDirectories(resolved.code, bmadDir, options);
361
376
  }
362
377
 
363
- // Update manifest. For custom modules, derive channel from the git ref:
364
- // cloneRef present pinned at that ref
365
- // cloneRef absent → next (main HEAD)
366
- // local path → no channel concept
378
+ // Update manifest. For community installs we honor the channel resolved by
379
+ // CommunityModuleManager (stable/next/pinned) and propagate the registry's
380
+ // approved tag/sha. For custom-source installs we derive channel from the
381
+ // cloneRef (present pinned, absent → next; local paths have no channel).
367
382
  const { Manifest } = require('../core/manifest');
368
383
  const manifestObj = new Manifest();
369
384
 
370
385
  const hasGitClone = !!resolved.repoUrl;
386
+ const isCommunity = resolved.communitySource === true;
371
387
  const manifestEntry = {
372
- version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || null),
373
- source: 'custom',
388
+ version: resolved.communityVersion || resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || null),
389
+ source: isCommunity ? 'community' : 'custom',
374
390
  npmPackage: null,
375
391
  repoUrl: resolved.repoUrl || null,
376
392
  };
377
- if (hasGitClone) {
393
+ if (isCommunity) {
394
+ if (resolved.communityChannel) manifestEntry.channel = resolved.communityChannel;
395
+ if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha;
396
+ if (resolved.registryApprovedTag) manifestEntry.registryApprovedTag = resolved.registryApprovedTag;
397
+ if (resolved.registryApprovedSha) manifestEntry.registryApprovedSha = resolved.registryApprovedSha;
398
+ } else if (hasGitClone) {
378
399
  manifestEntry.channel = resolved.cloneRef ? 'pinned' : 'next';
379
400
  if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha;
380
401
  if (resolved.rawInput) manifestEntry.rawSource = resolved.rawInput;
@@ -386,10 +407,13 @@ class OfficialModules {
386
407
  success: true,
387
408
  module: resolved.code,
388
409
  path: targetPath,
389
- // Match the manifestEntry.version expression above so downstream summary
390
- // lines show the cloned ref (tag or 'main') instead of the on-disk
391
- // package.json version for git-backed custom installs.
392
- versionInfo: { version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || '') },
410
+ // Mirror the manifestEntry.version precedence above so downstream summary
411
+ // lines show the same string we just wrote to disk (community installs
412
+ // use the registry-approved tag via `communityVersion`; custom git-backed
413
+ // installs show the cloned ref or 'main').
414
+ versionInfo: {
415
+ version: resolved.communityVersion || resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || ''),
416
+ },
393
417
  };
394
418
  }
395
419
 
@@ -123,12 +123,18 @@ async function resolveInstalledModuleYaml(moduleName) {
123
123
  }
124
124
  }
125
125
 
126
- // BMB standard: {setup-skill}/assets/module.yaml (setup skill is any *-setup directory)
127
- const rootEntries = await fs.readdir(root, { withFileTypes: true });
128
- for (const entry of rootEntries) {
129
- if (!entry.isDirectory() || !entry.name.endsWith('-setup')) continue;
130
- const setupAssets = path.join(root, entry.name, 'assets', 'module.yaml');
131
- if (await fs.pathExists(setupAssets)) results.push(setupAssets);
126
+ // BMB standard: {setup-skill}/assets/module.yaml (setup skill is any *-setup directory).
127
+ // Check at the repo root, and also under src/skills/ and skills/ since
128
+ // marketplace plugins commonly nest skills under src/skills/<name>/.
129
+ const setupSearchRoots = [root, path.join(root, 'src', 'skills'), path.join(root, 'skills')];
130
+ for (const setupRoot of setupSearchRoots) {
131
+ if (!(await fs.pathExists(setupRoot))) continue;
132
+ const entries = await fs.readdir(setupRoot, { withFileTypes: true });
133
+ for (const entry of entries) {
134
+ if (!entry.isDirectory() || !entry.name.endsWith('-setup')) continue;
135
+ const setupAssets = path.join(setupRoot, entry.name, 'assets', 'module.yaml');
136
+ if (await fs.pathExists(setupAssets)) results.push(setupAssets);
137
+ }
132
138
  }
133
139
 
134
140
  const atRoot = path.join(root, 'module.yaml');
@@ -149,6 +155,16 @@ async function resolveInstalledModuleYaml(moduleName) {
149
155
  if (found) return found;
150
156
  }
151
157
 
158
+ // Community modules are cloned to ~/.bmad/cache/community-modules/<name>/
159
+ // (parallel to the external-modules cache used above). Search there too so
160
+ // collectAgentsFromModuleYaml and writeCentralConfig can locate community
161
+ // module.yaml files regardless of how nested the layout is.
162
+ const communityCacheRoot = path.join(os.homedir(), '.bmad', 'cache', 'community-modules', moduleName);
163
+ if (await fs.pathExists(communityCacheRoot)) {
164
+ const found = await searchRoot(communityCacheRoot);
165
+ if (found) return found;
166
+ }
167
+
152
168
  // Fallback: local custom-source modules store their source path in the
153
169
  // CustomModuleManager resolution cache populated during the same install run.
154
170
  // Match by code OR name since callers may use either form.
@@ -200,12 +200,15 @@ class UI {
200
200
  actionType = options.action;
201
201
  await prompts.log.info(`Using action from command-line: ${actionType}`);
202
202
  } else if (options.yes) {
203
- // Default to quick-update if available, otherwise first available choice
203
+ // Default to quick-update if available, unless flags that require the
204
+ // full update path are present (e.g. --custom-source which re-clones
205
+ // modules at a new version — quick-update skips that entirely).
204
206
  if (choices.length === 0) {
205
207
  throw new Error('No valid actions available for this installation');
206
208
  }
207
209
  const hasQuickUpdate = choices.some((c) => c.value === 'quick-update');
208
- actionType = hasQuickUpdate ? 'quick-update' : choices[0].value;
210
+ const needsFullUpdate = !!options.customSource;
211
+ actionType = hasQuickUpdate && !needsFullUpdate ? 'quick-update' : (choices.find((c) => c.value === 'update') || choices[0]).value;
209
212
  await prompts.log.info(`Non-interactive mode (--yes): defaulting to ${actionType}`);
210
213
  } else {
211
214
  actionType = await prompts.select({
@@ -241,8 +244,11 @@ class UI {
241
244
  .map((m) => m.trim())
242
245
  .filter(Boolean);
243
246
  await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`);
244
- } else if (options.customSource) {
245
- // Custom source without --modules: start with empty list (core added below)
247
+ } else if (options.customSource && !options.yes) {
248
+ // Custom source without --modules or --yes: start with empty list
249
+ // (only custom source modules + core will be installed).
250
+ // When --yes is also set, fall through to the --yes branch so all
251
+ // installed modules are included alongside the custom source modules.
246
252
  selectedModules = [];
247
253
  } else if (options.yes) {
248
254
  selectedModules = await this.getDefaultModules(installedModuleIds);