diff --git a/tools/installer/modules/community-manager.js b/tools/installer/modules/community-manager.js index 04904a7e1..192e8f701 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,204 @@ 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 selection = this._selectPluginForModule(plugins, moduleInfo); + if (!selection) { + await this._safeWarn( + `Community module '${moduleInfo.code}' ships marketplace.json but no plugin entry matches the registry code. ` + + `Falling back to legacy install path.`, + ); + return; + } + + if (selection.source === 'single-fallback') { + // Single-entry marketplace.json whose plugin name doesn't match the registry + // code or the module_definition hint. Most likely correct, but worth surfacing + // in case marketplace.json is misconfigured and we'd install the wrong plugin. + await this._safeWarn( + `Community module '${moduleInfo.code}' picked the only plugin in marketplace.json ('${selection.plugin?.name}') ` + + `because no name or module_definition match was found. Verify marketplace.json if the install looks wrong.`, + ); + } + + const { PluginResolver } = require('./plugin-resolver'); + const resolver = new PluginResolver(); + let resolved; + try { + resolved = await resolver.resolve(repoPath, selection.plugin); + } catch (error) { + // PluginResolver threw (malformed plugin entry, missing files, etc.). + // Honor the silent-fallthrough contract — warn and let the legacy + // findModuleSource path handle the install. + await this._safeWarn( + `PluginResolver failed for community module '${moduleInfo.code}': ${error.message}. ` + `Falling back to legacy install path.`, + ); + return; + } + 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; + + // Shallow-clone before stamping provenance — the resolver may cache or reuse + // its return objects, and we don't want install-specific fields leaking back. + const stamped = { + ...matched, + code: moduleInfo.code, + repoUrl: moduleInfo.url, + cloneRef: resolution.channel === 'pinned' ? resolution.version : resolution.approvedTag || null, + cloneSha: resolution.sha, + communitySource: true, + communityChannel: resolution.channel, + communityVersion: resolution.version, + registryApprovedTag: resolution.approvedTag, + registryApprovedSha: resolution.approvedSha, + }; + + CommunityModuleManager._pluginResolutions.set(moduleInfo.code, stamped); + } + + /** + * Lazy fallback: resolve marketplace.json straight from the on-disk cache + * when `_pluginResolutions` is empty (e.g. callers that reach `install()` + * without `cloneModule` having populated the cache earlier in this process). + * + * Reuses an existing channel resolution if present; otherwise synthesizes a + * minimal stable-channel stub from the registry entry + the cached repo's + * current HEAD. Returns the cached plugin resolution if one is produced, + * otherwise null (caller falls back to the legacy path). + * + * @param {string} moduleCode + * @returns {Promise} + */ + async resolveFromCache(moduleCode) { + const existing = this.getPluginResolution(moduleCode); + if (existing) return existing; + + const cacheRepoDir = path.join(this.getCacheDir(), moduleCode); + const marketplacePath = path.join(cacheRepoDir, '.claude-plugin', 'marketplace.json'); + if (!(await fs.pathExists(marketplacePath))) return null; + + let moduleInfo; + try { + moduleInfo = await this.getModuleByCode(moduleCode); + } catch { + return null; + } + if (!moduleInfo) return null; + + let channelResolution = this.getResolution(moduleCode); + if (!channelResolution) { + let sha = ''; + try { + sha = execSync('git rev-parse HEAD', { cwd: cacheRepoDir, stdio: 'pipe' }).toString().trim(); + } catch { + // Not a git repo or unreadable — give up and let the legacy path run. + return null; + } + channelResolution = { + channel: 'stable', + version: moduleInfo.approvedTag || sha.slice(0, 7), + sha, + registryApprovedTag: moduleInfo.approvedTag || null, + registryApprovedSha: moduleInfo.approvedSha || null, + }; + } + + await this._tryResolveMarketplacePlugin(cacheRepoDir, moduleInfo, { + channel: channelResolution.channel, + version: channelResolution.version, + sha: channelResolution.sha, + approvedTag: channelResolution.registryApprovedTag, + approvedSha: channelResolution.registryApprovedSha, + }); + + return this.getPluginResolution(moduleCode); + } + + /** + * Best-effort warning emitter. `prompts.log.warn` may be undefined in some + * harnesses and may return a rejected promise — swallow both cases so a + * fallthrough warning can never crash the install. + */ + async _safeWarn(message) { + try { + const result = prompts.log?.warn?.(message); + if (result && typeof result.then === 'function') await result; + } catch { + /* ignore */ + } + } + + /** + * 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 — accepted with a warning so a + * mismatched-but-uniquely-named plugin doesn't install silently. + * Otherwise null (caller falls back to legacy path). + * + * @returns {{plugin: Object, source: 'name'|'hint'|'single-fallback'}|null} + */ + _selectPluginForModule(plugins, moduleInfo) { + const byCode = plugins.find((p) => p && p.name === moduleInfo.code); + if (byCode) return { plugin: byCode, source: 'name' }; + + 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 { plugin: byHint, source: 'hint' }; + } + } + + if (plugins.length === 1) return { plugin: plugins[0], source: 'single-fallback' }; + return null; + } + // ─── Source Finding ─────────────────────────────────────────────────────── /** diff --git a/tools/installer/modules/official-modules.js b/tools/installer/modules/official-modules.js index baafa7faf..4bd1e56b3 100644 --- a/tools/installer/modules/official-modules.js +++ b/tools/installer/modules/official-modules.js @@ -269,6 +269,21 @@ 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. If the in-process + // cache wasn't populated (e.g. caller skipped the pre-clone phase), fall + // back to resolving directly from `~/.bmad/cache/community-modules//` + // so we don't silently regress to the legacy half-install path. + const { CommunityModuleManager } = require('./community-manager'); + const communityMgr = new CommunityModuleManager(); + let communityResolved = communityMgr.getPluginResolution(moduleName); + if (!communityResolved) { + communityResolved = await communityMgr.resolveFromCache(moduleName); + } + if (communityResolved) { + return this.installFromResolution(communityResolved, bmadDir, fileTrackingCallback, options); + } + const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent, channelOptions: options.channelOptions, @@ -360,21 +375,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; @@ -386,10 +407,13 @@ class OfficialModules { success: true, module: resolved.code, path: targetPath, - // Match the manifestEntry.version expression above so downstream summary - // lines show the cloned ref (tag or 'main') instead of the on-disk - // package.json version for git-backed custom installs. - versionInfo: { version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || '') }, + // Mirror the manifestEntry.version precedence above so downstream summary + // lines show the same string we just wrote to disk (community installs + // use the registry-approved tag via `communityVersion`; custom git-backed + // installs show the cloned ref or 'main'). + versionInfo: { + version: resolved.communityVersion || resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || ''), + }, }; } 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.