diff --git a/tools/installer/modules/community-manager.js b/tools/installer/modules/community-manager.js index 04904a7e1..62f0e364b 100644 --- a/tools/installer/modules/community-manager.js +++ b/tools/installer/modules/community-manager.js @@ -29,6 +29,11 @@ class CommunityModuleManager { // Shared across all instances; the manifest writer often uses a fresh instance. 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() { this._client = new RegistryClient(); this._cachedIndex = null; @@ -40,6 +45,11 @@ class CommunityModuleManager { 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 ────────────────────────────────────────────────────────── /** @@ -371,6 +381,18 @@ class CommunityModuleManager { 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 const packageJsonPath = path.join(moduleCacheDir, 'package.json'); if ((needsDependencyInstall || wasNewClone) && (await fs.pathExists(packageJsonPath))) { @@ -392,6 +414,107 @@ class CommunityModuleManager { 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 ─────────────────────────────────────────────────────── /** diff --git a/tools/installer/modules/official-modules.js b/tools/installer/modules/official-modules.js index baafa7faf..89c21c1cd 100644 --- a/tools/installer/modules/official-modules.js +++ b/tools/installer/modules/official-modules.js @@ -269,6 +269,14 @@ class OfficialModules { 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, { silent: options.silent, channelOptions: options.channelOptions, @@ -360,21 +368,27 @@ class OfficialModules { await this.createModuleDirectories(resolved.code, bmadDir, options); } - // Update manifest. For custom modules, derive channel from the git ref: - // cloneRef present → pinned at that ref - // cloneRef absent → next (main HEAD) - // local path → no channel concept + // Update manifest. For community installs we honor the channel resolved by + // CommunityModuleManager (stable/next/pinned) and propagate the registry's + // approved tag/sha. For custom-source installs we derive channel from the + // cloneRef (present → pinned, absent → next; local paths have no channel). const { Manifest } = require('../core/manifest'); const manifestObj = new Manifest(); const hasGitClone = !!resolved.repoUrl; + const isCommunity = resolved.communitySource === true; const manifestEntry = { - version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || null), - source: 'custom', + version: resolved.communityVersion || resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || null), + source: isCommunity ? 'community' : 'custom', npmPackage: 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'; if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha; if (resolved.rawInput) manifestEntry.rawSource = resolved.rawInput; diff --git a/tools/installer/project-root.js b/tools/installer/project-root.js index f883c8a2e..84ecde5b0 100644 --- a/tools/installer/project-root.js +++ b/tools/installer/project-root.js @@ -123,12 +123,18 @@ async function resolveInstalledModuleYaml(moduleName) { } } - // BMB standard: {setup-skill}/assets/module.yaml (setup skill is any *-setup directory) - const rootEntries = await fs.readdir(root, { withFileTypes: true }); - for (const entry of rootEntries) { - if (!entry.isDirectory() || !entry.name.endsWith('-setup')) continue; - const setupAssets = path.join(root, entry.name, 'assets', 'module.yaml'); - if (await fs.pathExists(setupAssets)) results.push(setupAssets); + // BMB standard: {setup-skill}/assets/module.yaml (setup skill is any *-setup directory). + // Check at the repo root, and also under src/skills/ and skills/ since + // marketplace plugins commonly nest skills under src/skills//. + 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; + const setupAssets = path.join(setupRoot, entry.name, 'assets', 'module.yaml'); + if (await fs.pathExists(setupAssets)) results.push(setupAssets); + } } const atRoot = path.join(root, 'module.yaml'); @@ -149,6 +155,16 @@ async function resolveInstalledModuleYaml(moduleName) { if (found) return found; } + // Community modules are cloned to ~/.bmad/cache/community-modules// + // (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 // CustomModuleManager resolution cache populated during the same install run. // Match by code OR name since callers may use either form.