Skip to content

Commit 6ff74ba

Browse files
authored
fix(installer): route community installs through PluginResolver when marketplace.json ships (#2331)
* fix(installer): route community installs through PluginResolver when marketplace.json ships Community-catalog installs ignored .claude-plugin/marketplace.json, so modules that nest module.yaml inside a setup skill's assets/ directory (e.g. Strategy 2 in PluginResolver) ended up half-installed: only module-help.csv and the generated config.yaml landed in _bmad/<code>/, while the actual skill source trees and module.yaml never got copied. The install would silently emit "could not locate module.yaml" warnings and leave .agents/skills/ without the module's skills. The fix wires the existing PluginResolver onto the community path: - CommunityModuleManager.cloneModule now detects marketplace.json after the clone+ref-checkout completes and runs PluginResolver. The resolution is stamped with channel/sha/registryApprovedTag/registryApprovedSha and cached in _pluginResolutions, mirroring the existing _resolutions cache. - OfficialModules.install consults the community plugin resolution and delegates to installFromResolution (the same code path custom-source installs already use). installFromResolution branches on communitySource to write source: 'community' with the registry's approved tag/sha and channel. - resolveInstalledModuleYaml now searches the community-modules cache root in addition to the external-modules cache, and the BMB setup-skill detector walks src/skills/ and skills/ (not just the repo root) so collectAgents FromModuleYaml and writeCentralConfig can find module.yaml in nested marketplace-plugin layouts. Backward compatibility: repos without marketplace.json (e.g. WDS, which declares module_definition: src/module.yaml at the root) continue through the legacy findModuleSource path with no behavior change. Verified against the live zarlor/suno-band-manager community module and a 23-check fixture suite covering Suno-shape, WDS-shape, and bare-repo layouts. * fix(installer): harden community marketplace.json resolution path Address review feedback on the community marketplace.json install path: - Wrap PluginResolver.resolve() in try/catch so a malformed plugin entry falls through to the legacy install path with a warn instead of crashing cloneModule. - Stop mutating the resolver's return object; shallow-clone before stamping community provenance so install state cannot leak back into resolver-owned objects. - Warn when _selectPluginForModule lands on the single-plugin fallback with a name that doesn't match the registry code or module_definition hint, so a misconfigured marketplace.json can't silently install the wrong plugin. - Add CommunityModuleManager.resolveFromCache() and call it from OfficialModules.install() when the in-process plugin cache is empty, so callers that reach install() without pre-cloning still get the marketplace-aware path. Reuses an existing channel resolution when present, otherwise synthesizes a stable-channel stub from the registry entry plus the cached repo's HEAD. - Align installFromResolution()'s returned versionInfo.version with manifestEntry.version precedence (communityVersion || cloneRef || ...) so downstream summaries match what was written to the manifest. Tests: lint, format:check, lint:md, test:install (290), test:channels (83), test:refs (7) all green.
1 parent 1ad1f91 commit 6ff74ba

3 files changed

Lines changed: 277 additions & 17 deletions

File tree

tools/installer/modules/community-manager.js

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ class CommunityModuleManager {
2929
// Shared across all instances; the manifest writer often uses a fresh instance.
3030
static _resolutions = new Map();
3131

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+
3237
constructor() {
3338
this._client = new RegistryClient();
3439
this._cachedIndex = null;
@@ -40,6 +45,11 @@ class CommunityModuleManager {
4045
return CommunityModuleManager._resolutions.get(moduleCode) || null;
4146
}
4247

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+
4353
// ─── Data Loading ──────────────────────────────────────────────────────────
4454

4555
/**
@@ -371,6 +381,18 @@ class CommunityModuleManager {
371381
planSource: planEntry.source,
372382
});
373383

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+
374396
// Install dependencies if needed
375397
const packageJsonPath = path.join(moduleCacheDir, 'package.json');
376398
if ((needsDependencyInstall || wasNewClone) && (await fs.pathExists(packageJsonPath))) {
@@ -392,6 +414,204 @@ class CommunityModuleManager {
392414
return moduleCacheDir;
393415
}
394416

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+
395615
// ─── Source Finding ───────────────────────────────────────────────────────
396616

397617
/**

tools/installer/modules/official-modules.js

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,21 @@ class OfficialModules {
269269
return this.installFromResolution(resolved, bmadDir, fileTrackingCallback, options);
270270
}
271271

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+
272287
const sourcePath = await this.findModuleSource(moduleName, {
273288
silent: options.silent,
274289
channelOptions: options.channelOptions,
@@ -360,21 +375,27 @@ class OfficialModules {
360375
await this.createModuleDirectories(resolved.code, bmadDir, options);
361376
}
362377

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).
367382
const { Manifest } = require('../core/manifest');
368383
const manifestObj = new Manifest();
369384

370385
const hasGitClone = !!resolved.repoUrl;
386+
const isCommunity = resolved.communitySource === true;
371387
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',
374390
npmPackage: null,
375391
repoUrl: resolved.repoUrl || null,
376392
};
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) {
378399
manifestEntry.channel = resolved.cloneRef ? 'pinned' : 'next';
379400
if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha;
380401
if (resolved.rawInput) manifestEntry.rawSource = resolved.rawInput;
@@ -386,10 +407,13 @@ class OfficialModules {
386407
success: true,
387408
module: resolved.code,
388409
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+
},
393417
};
394418
}
395419

tools/installer/project-root.js

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,18 @@ async function resolveInstalledModuleYaml(moduleName) {
123123
}
124124
}
125125

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+
}
132138
}
133139

134140
const atRoot = path.join(root, 'module.yaml');
@@ -149,6 +155,16 @@ async function resolveInstalledModuleYaml(moduleName) {
149155
if (found) return found;
150156
}
151157

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+
152168
// Fallback: local custom-source modules store their source path in the
153169
// CustomModuleManager resolution cache populated during the same install run.
154170
// Match by code OR name since callers may use either form.

0 commit comments

Comments
 (0)