bmad-method 6.5.1-next.4 → 6.5.1-next.5
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
|
@@ -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
|
/**
|
|
@@ -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
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
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 (
|
|
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
|
-
//
|
|
390
|
-
// lines show the
|
|
391
|
-
//
|
|
392
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (await fs.pathExists(
|
|
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.
|