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.
This commit is contained in:
parent
1ad1f91e38
commit
867ef95654
|
|
@ -29,6 +29,11 @@ class CommunityModuleManager {
|
||||||
// Shared across all instances; the manifest writer often uses a fresh instance.
|
// Shared across all instances; the manifest writer often uses a fresh instance.
|
||||||
static _resolutions = new Map();
|
static _resolutions = new Map();
|
||||||
|
|
||||||
|
// moduleCode → ResolvedModule (from PluginResolver) when the cloned repo ships
|
||||||
|
// a `.claude-plugin/marketplace.json`. Lets community installs reuse the same
|
||||||
|
// skill-level install pipeline as custom-source installs (installFromResolution).
|
||||||
|
static _pluginResolutions = new Map();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this._client = new RegistryClient();
|
this._client = new RegistryClient();
|
||||||
this._cachedIndex = null;
|
this._cachedIndex = null;
|
||||||
|
|
@ -40,6 +45,11 @@ class CommunityModuleManager {
|
||||||
return CommunityModuleManager._resolutions.get(moduleCode) || null;
|
return CommunityModuleManager._resolutions.get(moduleCode) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the marketplace.json-derived plugin resolution for a community module, if any. */
|
||||||
|
getPluginResolution(moduleCode) {
|
||||||
|
return CommunityModuleManager._pluginResolutions.get(moduleCode) || null;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Data Loading ──────────────────────────────────────────────────────────
|
// ─── Data Loading ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -371,6 +381,18 @@ class CommunityModuleManager {
|
||||||
planSource: planEntry.source,
|
planSource: planEntry.source,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If the repo ships a marketplace.json, route through PluginResolver so the
|
||||||
|
// skill-level install pipeline (installFromResolution) handles the copy.
|
||||||
|
// Repos without marketplace.json fall through to the legacy findModuleSource
|
||||||
|
// path unchanged.
|
||||||
|
await this._tryResolveMarketplacePlugin(moduleCacheDir, moduleInfo, {
|
||||||
|
channel: planEntry.channel,
|
||||||
|
version: recordedVersion,
|
||||||
|
sha: installedSha,
|
||||||
|
approvedTag,
|
||||||
|
approvedSha,
|
||||||
|
});
|
||||||
|
|
||||||
// Install dependencies if needed
|
// Install dependencies if needed
|
||||||
const packageJsonPath = path.join(moduleCacheDir, 'package.json');
|
const packageJsonPath = path.join(moduleCacheDir, 'package.json');
|
||||||
if ((needsDependencyInstall || wasNewClone) && (await fs.pathExists(packageJsonPath))) {
|
if ((needsDependencyInstall || wasNewClone) && (await fs.pathExists(packageJsonPath))) {
|
||||||
|
|
@ -392,6 +414,107 @@ class CommunityModuleManager {
|
||||||
return moduleCacheDir;
|
return moduleCacheDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Marketplace.json Resolution ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect `.claude-plugin/marketplace.json` in a cloned community repo and
|
||||||
|
* route through PluginResolver. When successful, caches the resolution so
|
||||||
|
* OfficialModulesManager.install() can route the copy through
|
||||||
|
* installFromResolution() — the same path used by custom-source installs.
|
||||||
|
*
|
||||||
|
* Silent no-op when marketplace.json is absent or the resolver returns no
|
||||||
|
* matches; the legacy findModuleSource path then handles the install.
|
||||||
|
*
|
||||||
|
* @param {string} repoPath - Absolute path to the cloned repo
|
||||||
|
* @param {Object} moduleInfo - Normalized community module info
|
||||||
|
* @param {Object} resolution - Resolution metadata from cloneModule
|
||||||
|
* @param {string} resolution.channel - Channel ('stable' | 'next' | 'pinned')
|
||||||
|
* @param {string} resolution.version - Recorded version string
|
||||||
|
* @param {string} resolution.sha - Resolved git SHA
|
||||||
|
* @param {string|null} resolution.approvedTag - Registry approved tag
|
||||||
|
* @param {string|null} resolution.approvedSha - Registry approved SHA
|
||||||
|
*/
|
||||||
|
async _tryResolveMarketplacePlugin(repoPath, moduleInfo, resolution) {
|
||||||
|
const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
|
||||||
|
if (!(await fs.pathExists(marketplacePath))) return;
|
||||||
|
|
||||||
|
let marketplaceData;
|
||||||
|
try {
|
||||||
|
marketplaceData = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
// Malformed marketplace.json — fall through to legacy path.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugins = Array.isArray(marketplaceData?.plugins) ? marketplaceData.plugins : [];
|
||||||
|
if (plugins.length === 0) return;
|
||||||
|
|
||||||
|
const plugin = this._selectPluginForModule(plugins, moduleInfo);
|
||||||
|
if (!plugin) {
|
||||||
|
await prompts.log
|
||||||
|
.warn?.(
|
||||||
|
`Community module '${moduleInfo.code}' ships marketplace.json but no plugin entry matches the registry code. ` +
|
||||||
|
`Falling back to legacy install path.`,
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { PluginResolver } = require('./plugin-resolver');
|
||||||
|
const resolver = new PluginResolver();
|
||||||
|
const resolved = await resolver.resolve(repoPath, plugin);
|
||||||
|
if (!resolved || resolved.length === 0) return;
|
||||||
|
|
||||||
|
// The registry registers a single code per module. If the resolver returns
|
||||||
|
// multiple modules (Strategy 4: multiple standalone skills), accept only
|
||||||
|
// the entry whose code matches the registry. Other entries are ignored —
|
||||||
|
// they belong to plugins not registered in the community catalog.
|
||||||
|
const matched = resolved.find((mod) => mod.code === moduleInfo.code) || (resolved.length === 1 ? resolved[0] : null);
|
||||||
|
if (!matched) return;
|
||||||
|
|
||||||
|
// Stamp registry/clone provenance so installFromResolution and downstream
|
||||||
|
// manifest writers see the same channel/sha/tag as the legacy path.
|
||||||
|
matched.code = moduleInfo.code;
|
||||||
|
matched.repoUrl = moduleInfo.url;
|
||||||
|
matched.cloneRef = resolution.channel === 'pinned' ? resolution.version : resolution.approvedTag || null;
|
||||||
|
matched.cloneSha = resolution.sha;
|
||||||
|
matched.communitySource = true;
|
||||||
|
matched.communityChannel = resolution.channel;
|
||||||
|
matched.communityVersion = resolution.version;
|
||||||
|
matched.registryApprovedTag = resolution.approvedTag;
|
||||||
|
matched.registryApprovedSha = resolution.approvedSha;
|
||||||
|
|
||||||
|
CommunityModuleManager._pluginResolutions.set(moduleInfo.code, matched);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick which plugin entry from marketplace.json represents this community module.
|
||||||
|
* Precedence:
|
||||||
|
* 1. Exact match on `plugin.name === moduleInfo.code`
|
||||||
|
* 2. Trailing directory of `module_definition` matches `plugin.name`
|
||||||
|
* 3. Single plugin in marketplace.json — use it
|
||||||
|
* Otherwise null (caller falls back to legacy path).
|
||||||
|
*/
|
||||||
|
_selectPluginForModule(plugins, moduleInfo) {
|
||||||
|
const byCode = plugins.find((p) => p && p.name === moduleInfo.code);
|
||||||
|
if (byCode) return byCode;
|
||||||
|
|
||||||
|
if (moduleInfo.moduleDefinition) {
|
||||||
|
// module_definition like "src/skills/suno-setup/assets/module.yaml" →
|
||||||
|
// hint segment "suno-setup". Match that against plugin names.
|
||||||
|
const segments = moduleInfo.moduleDefinition.split('/').filter(Boolean);
|
||||||
|
const setupIdx = segments.findIndex((s) => s.endsWith('-setup'));
|
||||||
|
if (setupIdx !== -1) {
|
||||||
|
const hint = segments[setupIdx];
|
||||||
|
const byHint = plugins.find((p) => p && p.name === hint);
|
||||||
|
if (byHint) return byHint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plugins.length === 1) return plugins[0];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Source Finding ───────────────────────────────────────────────────────
|
// ─── Source Finding ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,14 @@ class OfficialModules {
|
||||||
return this.installFromResolution(resolved, bmadDir, fileTrackingCallback, options);
|
return this.installFromResolution(resolved, bmadDir, fileTrackingCallback, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Community modules whose cloned repo ships marketplace.json get the same
|
||||||
|
// skill-level install treatment as custom-source installs.
|
||||||
|
const { CommunityModuleManager } = require('./community-manager');
|
||||||
|
const communityResolved = new CommunityModuleManager().getPluginResolution(moduleName);
|
||||||
|
if (communityResolved) {
|
||||||
|
return this.installFromResolution(communityResolved, bmadDir, fileTrackingCallback, options);
|
||||||
|
}
|
||||||
|
|
||||||
const sourcePath = await this.findModuleSource(moduleName, {
|
const sourcePath = await this.findModuleSource(moduleName, {
|
||||||
silent: options.silent,
|
silent: options.silent,
|
||||||
channelOptions: options.channelOptions,
|
channelOptions: options.channelOptions,
|
||||||
|
|
@ -360,21 +368,27 @@ class OfficialModules {
|
||||||
await this.createModuleDirectories(resolved.code, bmadDir, options);
|
await this.createModuleDirectories(resolved.code, bmadDir, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update manifest. For custom modules, derive channel from the git ref:
|
// Update manifest. For community installs we honor the channel resolved by
|
||||||
// cloneRef present → pinned at that ref
|
// CommunityModuleManager (stable/next/pinned) and propagate the registry's
|
||||||
// cloneRef absent → next (main HEAD)
|
// approved tag/sha. For custom-source installs we derive channel from the
|
||||||
// local path → no channel concept
|
// cloneRef (present → pinned, absent → next; local paths have no channel).
|
||||||
const { Manifest } = require('../core/manifest');
|
const { Manifest } = require('../core/manifest');
|
||||||
const manifestObj = new Manifest();
|
const manifestObj = new Manifest();
|
||||||
|
|
||||||
const hasGitClone = !!resolved.repoUrl;
|
const hasGitClone = !!resolved.repoUrl;
|
||||||
|
const isCommunity = resolved.communitySource === true;
|
||||||
const manifestEntry = {
|
const manifestEntry = {
|
||||||
version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || null),
|
version: resolved.communityVersion || resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || null),
|
||||||
source: 'custom',
|
source: isCommunity ? 'community' : 'custom',
|
||||||
npmPackage: null,
|
npmPackage: null,
|
||||||
repoUrl: resolved.repoUrl || null,
|
repoUrl: resolved.repoUrl || null,
|
||||||
};
|
};
|
||||||
if (hasGitClone) {
|
if (isCommunity) {
|
||||||
|
if (resolved.communityChannel) manifestEntry.channel = resolved.communityChannel;
|
||||||
|
if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha;
|
||||||
|
if (resolved.registryApprovedTag) manifestEntry.registryApprovedTag = resolved.registryApprovedTag;
|
||||||
|
if (resolved.registryApprovedSha) manifestEntry.registryApprovedSha = resolved.registryApprovedSha;
|
||||||
|
} else if (hasGitClone) {
|
||||||
manifestEntry.channel = resolved.cloneRef ? 'pinned' : 'next';
|
manifestEntry.channel = resolved.cloneRef ? 'pinned' : 'next';
|
||||||
if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha;
|
if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha;
|
||||||
if (resolved.rawInput) manifestEntry.rawSource = resolved.rawInput;
|
if (resolved.rawInput) manifestEntry.rawSource = resolved.rawInput;
|
||||||
|
|
|
||||||
|
|
@ -123,13 +123,19 @@ async function resolveInstalledModuleYaml(moduleName) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BMB standard: {setup-skill}/assets/module.yaml (setup skill is any *-setup directory)
|
// BMB standard: {setup-skill}/assets/module.yaml (setup skill is any *-setup directory).
|
||||||
const rootEntries = await fs.readdir(root, { withFileTypes: true });
|
// Check at the repo root, and also under src/skills/ and skills/ since
|
||||||
for (const entry of rootEntries) {
|
// marketplace plugins commonly nest skills under src/skills/<name>/.
|
||||||
|
const setupSearchRoots = [root, path.join(root, 'src', 'skills'), path.join(root, 'skills')];
|
||||||
|
for (const setupRoot of setupSearchRoots) {
|
||||||
|
if (!(await fs.pathExists(setupRoot))) continue;
|
||||||
|
const entries = await fs.readdir(setupRoot, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
if (!entry.isDirectory() || !entry.name.endsWith('-setup')) continue;
|
if (!entry.isDirectory() || !entry.name.endsWith('-setup')) continue;
|
||||||
const setupAssets = path.join(root, entry.name, 'assets', 'module.yaml');
|
const setupAssets = path.join(setupRoot, entry.name, 'assets', 'module.yaml');
|
||||||
if (await fs.pathExists(setupAssets)) results.push(setupAssets);
|
if (await fs.pathExists(setupAssets)) results.push(setupAssets);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const atRoot = path.join(root, 'module.yaml');
|
const atRoot = path.join(root, 'module.yaml');
|
||||||
if (await fs.pathExists(atRoot)) results.push(atRoot);
|
if (await fs.pathExists(atRoot)) results.push(atRoot);
|
||||||
|
|
@ -149,6 +155,16 @@ async function resolveInstalledModuleYaml(moduleName) {
|
||||||
if (found) return found;
|
if (found) return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Community modules are cloned to ~/.bmad/cache/community-modules/<name>/
|
||||||
|
// (parallel to the external-modules cache used above). Search there too so
|
||||||
|
// collectAgentsFromModuleYaml and writeCentralConfig can locate community
|
||||||
|
// module.yaml files regardless of how nested the layout is.
|
||||||
|
const communityCacheRoot = path.join(os.homedir(), '.bmad', 'cache', 'community-modules', moduleName);
|
||||||
|
if (await fs.pathExists(communityCacheRoot)) {
|
||||||
|
const found = await searchRoot(communityCacheRoot);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback: local custom-source modules store their source path in the
|
// Fallback: local custom-source modules store their source path in the
|
||||||
// CustomModuleManager resolution cache populated during the same install run.
|
// CustomModuleManager resolution cache populated during the same install run.
|
||||||
// Match by code OR name since callers may use either form.
|
// Match by code OR name since callers may use either form.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue