mirror of
https://github.com/bmad-code-org/BMAD-METHOD.git
synced 2026-09-01 15:09:07 +08:00
97d32405d0
* feat(installer): add plugin resolution strategies for custom URL installs When installing from a custom GitHub URL, the installer now analyzes marketplace.json plugin structures to determine how to locate module registration files (module.yaml, module-help.csv). Five strategies are tried in cascade: 1. Root module files at the common parent of listed skills 2. A -setup skill with registration files in its assets/ 3. Single standalone skill with registration files in assets/ 4. Multiple standalone skills, each with their own registration files 5. Fallback: synthesize registration from marketplace.json metadata and SKILL.md frontmatter Also changes the custom URL flow from confirm-all to multiselect, letting users pick which plugins to install. Already-installed modules are pre-checked for update; new modules are unchecked for opt-in. New file: tools/installer/modules/plugin-resolver.js Modified: custom-module-manager.js, official-modules.js, ui.js * fix(installer): address PR review findings for plugin resolver - Guard against path traversal in plugin-resolver.js: skill paths from unverified marketplace.json are now constrained to the repo root using path.resolve() + startsWith check - Skip npm install during browsing phase: cloneRepo() accepts skipInstall option, used in ui.js before user confirms selection, preventing arbitrary lifecycle script execution from untrusted repos - Add createModuleDirectories() call to installFromResolution() so modules with declarative directory config are fully set up - Fix ESLint: use replaceAll instead of replace with global regex * fix(installer): pass version and repoUrl to manifest for custom plugins installFromResolution was passing empty strings for version and repoUrl, which the manifest stores as null. Now threads the repo URL from ui.js through resolvePlugin into each ResolvedModule, and passes the plugin version and URL to the manifest correctly. * fix(installer): manifest-generator overwrites custom module version/repoUrl ManifestGenerator rebuilds the entire manifest via getModuleVersionInfo for every module. For custom modules, this returned null for version and repoUrl because it only checked _readMarketplaceVersion (which searches for marketplace.json on disk) and hardcoded repoUrl to null. Now checks the resolution cache first to get the correct version and repo URL. * fix(installer): resolve custom modules from disk cache on quick update When the resolution cache is empty (fresh CLI process, e.g. quick update), findModuleSourceByCode only matched plugin.name against the module code. This failed for modules like "sam" and "dw" where the code comes from module.yaml inside a setup/standalone skill, not from the plugin name in marketplace.json. Now runs the PluginResolver on cached repos when the direct name match fails, finding the correct module source and re-populating the cache for the install pipeline. * feat(installer): universal source support for custom modules Replace GitHub-only custom module installation with support for any Git host (GitHub, GitLab, Bitbucket, self-hosted) and local file paths. - Add parseSource() universal input parser (local paths, SSH, HTTPS with deep path/subdir extraction for GitHub, GitLab, Gitea) - Add resolveSource() coordinator: parse -> clone if URL -> detect discovery vs direct mode (marketplace.json present or not) - Clone-first approach eliminates host-specific raw URL fetching - 3-level cache structure (host/owner/repo) with .bmad-source.json metadata for URL reconstruction - Local paths install directly without caching; localPath persisted in manifest for quick-update source lookup - Direct mode scans target directory for SKILL.md when no marketplace.json - Fix version display bug where walk-up found parent repo marketplace.json and reported wrong version for custom modules * fix(installer): harden readMarketplaceJsonFromDisk and hoist require - Add try/catch to readMarketplaceJsonFromDisk so malformed JSON returns null instead of throwing an unhandled parse error - Hoist CustomModuleManager require outside the per-module loop in _installOfficialModules * fix(installer): restore validateGitHubUrl strictness and fix prettier - Restore original GitHub-only regex in deprecated validateGitHubUrl wrapper so existing tests pass (rejects non-GitHub URLs, trailing slashes) - Run prettier to fix formatting in custom-module-manager.js * feat(installer): add --custom-source CLI flag for non-interactive installs Allows installing custom modules from Git URLs or local paths directly from the command line without interactive prompts: npx bmad-method install --custom-source /path/to/module npx bmad-method install --custom-source https://gitlab.com/org/repo npx bmad-method install --custom-source /path/one,https://host/org/repo Works alongside --modules and --yes flags. All discovered modules from each source are auto-selected. * docs: add custom and community module installation guide New how-to page covering community module browsing, custom sources (any Git host, local paths), discovery vs direct mode, local development workflow, and the --custom-source CLI flag. Clarifies that .claude-plugin/ is a cross-tool convention, not Claude-specific. Also updates non-interactive installation docs with the new flag and examples, bumps sidebar ordering, and fixes --custom-source to install only core + custom modules when --modules is not specified.
645 lines
24 KiB
JavaScript
645 lines
24 KiB
JavaScript
const fs = require('fs-extra');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { execSync } = require('node:child_process');
|
|
const prompts = require('../prompts');
|
|
|
|
/**
|
|
* Manages custom modules installed from user-provided sources.
|
|
* Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted) and local file paths.
|
|
* Validates input, clones repos, reads .claude-plugin/marketplace.json, resolves plugins.
|
|
*/
|
|
class CustomModuleManager {
|
|
/** @type {Map<string, Object>} Shared across all instances: module code -> ResolvedModule */
|
|
static _resolutionCache = new Map();
|
|
|
|
// ─── Source Parsing ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Parse a user-provided source input into a structured descriptor.
|
|
* Accepts local file paths, HTTPS Git URLs, and SSH Git URLs.
|
|
* For HTTPS URLs with deep paths (e.g., /tree/main/subdir), extracts the subdir.
|
|
*
|
|
* @param {string} input - URL or local file path
|
|
* @returns {Object} Parsed source descriptor:
|
|
* { type: 'url'|'local', cloneUrl, subdir, localPath, cacheKey, displayName, isValid, error }
|
|
*/
|
|
parseSource(input) {
|
|
if (!input || typeof input !== 'string') {
|
|
return {
|
|
type: null,
|
|
cloneUrl: null,
|
|
subdir: null,
|
|
localPath: null,
|
|
cacheKey: null,
|
|
displayName: null,
|
|
isValid: false,
|
|
error: 'Source is required',
|
|
};
|
|
}
|
|
|
|
const trimmed = input.trim();
|
|
if (!trimmed) {
|
|
return {
|
|
type: null,
|
|
cloneUrl: null,
|
|
subdir: null,
|
|
localPath: null,
|
|
cacheKey: null,
|
|
displayName: null,
|
|
isValid: false,
|
|
error: 'Source is required',
|
|
};
|
|
}
|
|
|
|
// Local path detection: starts with /, ./, ../, or ~
|
|
if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../') || trimmed.startsWith('~')) {
|
|
return this._parseLocalPath(trimmed);
|
|
}
|
|
|
|
// SSH URL: git@host:owner/repo.git
|
|
const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
if (sshMatch) {
|
|
const [, host, owner, repo] = sshMatch;
|
|
return {
|
|
type: 'url',
|
|
cloneUrl: trimmed,
|
|
subdir: null,
|
|
localPath: null,
|
|
cacheKey: `${host}/${owner}/${repo}`,
|
|
displayName: `${owner}/${repo}`,
|
|
isValid: true,
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
// HTTPS URL: https://host/owner/repo[/tree/branch/subdir][.git]
|
|
const httpsMatch = trimmed.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/.]+?)(?:\.git)?(\/.*)?$/);
|
|
if (httpsMatch) {
|
|
const [, host, owner, repo, remainder] = httpsMatch;
|
|
const cloneUrl = `https://${host}/${owner}/${repo}`;
|
|
let subdir = null;
|
|
|
|
if (remainder) {
|
|
// Extract subdir from deep path patterns used by various Git hosts
|
|
const deepPathPatterns = [
|
|
/^\/(?:-\/)?tree\/[^/]+\/(.+)$/, // GitHub /tree/branch/path, GitLab /-/tree/branch/path
|
|
/^\/(?:-\/)?blob\/[^/]+\/(.+)$/, // /blob/branch/path (treat same as tree)
|
|
/^\/src\/[^/]+\/(.+)$/, // Gitea/Forgejo /src/branch/path
|
|
];
|
|
|
|
for (const pattern of deepPathPatterns) {
|
|
const match = remainder.match(pattern);
|
|
if (match) {
|
|
subdir = match[1].replace(/\/$/, ''); // strip trailing slash
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
type: 'url',
|
|
cloneUrl,
|
|
subdir,
|
|
localPath: null,
|
|
cacheKey: `${host}/${owner}/${repo}`,
|
|
displayName: `${owner}/${repo}`,
|
|
isValid: true,
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
return {
|
|
type: null,
|
|
cloneUrl: null,
|
|
subdir: null,
|
|
localPath: null,
|
|
cacheKey: null,
|
|
displayName: null,
|
|
isValid: false,
|
|
error: 'Not a valid Git URL or local path',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parse a local filesystem path.
|
|
* @param {string} rawPath - Path string (may contain ~ for home)
|
|
* @returns {Object} Parsed source descriptor
|
|
*/
|
|
_parseLocalPath(rawPath) {
|
|
const expanded = rawPath.startsWith('~') ? path.join(os.homedir(), rawPath.slice(1)) : rawPath;
|
|
const resolved = path.resolve(expanded);
|
|
|
|
if (!fs.pathExistsSync(resolved)) {
|
|
return {
|
|
type: 'local',
|
|
cloneUrl: null,
|
|
subdir: null,
|
|
localPath: resolved,
|
|
cacheKey: null,
|
|
displayName: path.basename(resolved),
|
|
isValid: false,
|
|
error: `Path does not exist: ${resolved}`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
type: 'local',
|
|
cloneUrl: null,
|
|
subdir: null,
|
|
localPath: resolved,
|
|
cacheKey: null,
|
|
displayName: path.basename(resolved),
|
|
isValid: true,
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use parseSource() instead. Kept for backward compatibility.
|
|
* Parse and validate a GitHub repository URL.
|
|
* @param {string} url - GitHub URL to validate
|
|
* @returns {Object} { owner, repo, isValid, error }
|
|
*/
|
|
validateGitHubUrl(url) {
|
|
if (!url || typeof url !== 'string') {
|
|
return { owner: null, repo: null, isValid: false, error: 'URL is required' };
|
|
}
|
|
const trimmed = url.trim();
|
|
|
|
// HTTPS format: https://github.com/owner/repo[.git] (strict, no trailing path)
|
|
const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
if (httpsMatch) {
|
|
return { owner: httpsMatch[1], repo: httpsMatch[2], isValid: true, error: null };
|
|
}
|
|
|
|
// SSH format: git@github.com:owner/repo[.git]
|
|
const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
if (sshMatch) {
|
|
return { owner: sshMatch[1], repo: sshMatch[2], isValid: true, error: null };
|
|
}
|
|
|
|
return { owner: null, repo: null, isValid: false, error: 'Not a valid GitHub URL (expected https://github.com/owner/repo)' };
|
|
}
|
|
|
|
// ─── Marketplace JSON ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Read .claude-plugin/marketplace.json from a local directory.
|
|
* @param {string} dirPath - Directory to read from
|
|
* @returns {Object|null} Parsed marketplace.json or null if not found
|
|
*/
|
|
async readMarketplaceJsonFromDisk(dirPath) {
|
|
const marketplacePath = path.join(dirPath, '.claude-plugin', 'marketplace.json');
|
|
if (!(await fs.pathExists(marketplacePath))) return null;
|
|
try {
|
|
return JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Discovery ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Discover modules from pre-read marketplace.json data.
|
|
* @param {Object} marketplaceData - Parsed marketplace.json content
|
|
* @param {string|null} sourceUrl - Source URL for tracking (null for local paths)
|
|
* @returns {Array<Object>} Normalized plugin list
|
|
*/
|
|
async discoverModules(marketplaceData, sourceUrl) {
|
|
const plugins = marketplaceData?.plugins;
|
|
|
|
if (!Array.isArray(plugins) || plugins.length === 0) {
|
|
throw new Error('marketplace.json contains no plugins');
|
|
}
|
|
|
|
return plugins.map((plugin) => this._normalizeCustomModule(plugin, sourceUrl, marketplaceData));
|
|
}
|
|
|
|
// ─── Source Resolution ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* High-level coordinator: parse input, clone if URL, determine discovery vs direct mode.
|
|
* @param {string} input - URL or local path
|
|
* @param {Object} [options] - Options passed to cloneRepo
|
|
* @returns {Object} { parsed, rootDir, repoPath, sourceUrl, marketplace, mode: 'discovery'|'direct' }
|
|
*/
|
|
async resolveSource(input, options = {}) {
|
|
const parsed = this.parseSource(input);
|
|
if (!parsed.isValid) throw new Error(parsed.error);
|
|
|
|
let rootDir;
|
|
let repoPath;
|
|
let sourceUrl;
|
|
|
|
if (parsed.type === 'local') {
|
|
rootDir = parsed.localPath;
|
|
repoPath = null;
|
|
sourceUrl = null;
|
|
} else {
|
|
repoPath = await this.cloneRepo(input, options);
|
|
sourceUrl = parsed.cloneUrl;
|
|
rootDir = parsed.subdir ? path.join(repoPath, parsed.subdir) : repoPath;
|
|
|
|
if (parsed.subdir && !(await fs.pathExists(rootDir))) {
|
|
throw new Error(`Subdirectory '${parsed.subdir}' not found in cloned repository`);
|
|
}
|
|
}
|
|
|
|
const marketplace = await this.readMarketplaceJsonFromDisk(rootDir);
|
|
const mode = marketplace ? 'discovery' : 'direct';
|
|
|
|
return { parsed, rootDir, repoPath, sourceUrl, marketplace, mode };
|
|
}
|
|
|
|
// ─── Clone ────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get the cache directory for custom modules.
|
|
* @returns {string} Path to the custom modules cache directory
|
|
*/
|
|
getCacheDir() {
|
|
return path.join(os.homedir(), '.bmad', 'cache', 'custom-modules');
|
|
}
|
|
|
|
/**
|
|
* Clone a custom module repository to cache.
|
|
* Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted, etc.).
|
|
* @param {string} sourceInput - Git URL (HTTPS or SSH)
|
|
* @param {Object} [options] - Clone options
|
|
* @param {boolean} [options.silent] - Suppress spinner output
|
|
* @param {boolean} [options.skipInstall] - Skip npm install (for browsing before user confirms)
|
|
* @returns {string} Path to the cloned repository
|
|
*/
|
|
async cloneRepo(sourceInput, options = {}) {
|
|
const parsed = this.parseSource(sourceInput);
|
|
if (!parsed.isValid) throw new Error(parsed.error);
|
|
if (parsed.type === 'local') throw new Error('cloneRepo does not accept local paths');
|
|
|
|
const cacheDir = this.getCacheDir();
|
|
const repoCacheDir = path.join(cacheDir, ...parsed.cacheKey.split('/'));
|
|
const silent = options.silent || false;
|
|
const displayName = parsed.displayName;
|
|
|
|
await fs.ensureDir(path.dirname(repoCacheDir));
|
|
|
|
const createSpinner = async () => {
|
|
if (silent) {
|
|
return { start() {}, stop() {}, error() {} };
|
|
}
|
|
return await prompts.spinner();
|
|
};
|
|
|
|
if (await fs.pathExists(repoCacheDir)) {
|
|
// Update existing clone
|
|
const fetchSpinner = await createSpinner();
|
|
fetchSpinner.start(`Updating ${displayName}...`);
|
|
try {
|
|
execSync('git fetch origin --depth 1', {
|
|
cwd: repoCacheDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
});
|
|
execSync('git reset --hard origin/HEAD', {
|
|
cwd: repoCacheDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
fetchSpinner.stop(`Updated ${displayName}`);
|
|
} catch {
|
|
fetchSpinner.error(`Update failed, re-downloading ${displayName}`);
|
|
await fs.remove(repoCacheDir);
|
|
}
|
|
}
|
|
|
|
if (!(await fs.pathExists(repoCacheDir))) {
|
|
const fetchSpinner = await createSpinner();
|
|
fetchSpinner.start(`Cloning ${displayName}...`);
|
|
try {
|
|
execSync(`git clone --depth 1 "${parsed.cloneUrl}" "${repoCacheDir}"`, {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
});
|
|
fetchSpinner.stop(`Cloned ${displayName}`);
|
|
} catch (error_) {
|
|
fetchSpinner.error(`Failed to clone ${displayName}`);
|
|
throw new Error(`Failed to clone ${parsed.cloneUrl}: ${error_.message}`);
|
|
}
|
|
}
|
|
|
|
// Write source metadata for later URL reconstruction
|
|
const metadataPath = path.join(repoCacheDir, '.bmad-source.json');
|
|
await fs.writeJson(metadataPath, {
|
|
cloneUrl: parsed.cloneUrl,
|
|
cacheKey: parsed.cacheKey,
|
|
displayName: parsed.displayName,
|
|
clonedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Install dependencies if package.json exists (skip during browsing/analysis)
|
|
const packageJsonPath = path.join(repoCacheDir, 'package.json');
|
|
if (!options.skipInstall && (await fs.pathExists(packageJsonPath))) {
|
|
const installSpinner = await createSpinner();
|
|
installSpinner.start(`Installing dependencies for ${displayName}...`);
|
|
try {
|
|
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
|
cwd: repoCacheDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: 120_000,
|
|
});
|
|
installSpinner.stop(`Installed dependencies for ${displayName}`);
|
|
} catch (error_) {
|
|
installSpinner.error(`Failed to install dependencies for ${displayName}`);
|
|
if (!silent) await prompts.log.warn(` ${error_.message}`);
|
|
}
|
|
}
|
|
|
|
return repoCacheDir;
|
|
}
|
|
|
|
// ─── Plugin Resolution ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve a plugin to determine installation strategy and module registration files.
|
|
* Results are cached in _resolutionCache keyed by module code.
|
|
* @param {string} repoPath - Absolute path to the cloned repository or local directory
|
|
* @param {Object} plugin - Raw plugin object from marketplace.json
|
|
* @param {string} [sourceUrl] - Original URL for manifest tracking (null for local)
|
|
* @param {string} [localPath] - Local source path for manifest tracking (null for URLs)
|
|
* @returns {Promise<Array<Object>>} Array of ResolvedModule objects
|
|
*/
|
|
async resolvePlugin(repoPath, plugin, sourceUrl, localPath) {
|
|
const { PluginResolver } = require('./plugin-resolver');
|
|
const resolver = new PluginResolver();
|
|
const resolved = await resolver.resolve(repoPath, plugin);
|
|
|
|
// Stamp source info onto each resolved module for manifest tracking
|
|
for (const mod of resolved) {
|
|
if (sourceUrl) mod.repoUrl = sourceUrl;
|
|
if (localPath) mod.localPath = localPath;
|
|
CustomModuleManager._resolutionCache.set(mod.code, mod);
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
/**
|
|
* Get a cached resolution result by module code.
|
|
* @param {string} moduleCode - Module code to look up
|
|
* @returns {Object|null} ResolvedModule or null if not cached
|
|
*/
|
|
getResolution(moduleCode) {
|
|
return CustomModuleManager._resolutionCache.get(moduleCode) || null;
|
|
}
|
|
|
|
// ─── Source Finding ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Find the module source path within a cached or local source directory.
|
|
* @param {string} sourceInput - Git URL or local path (used to locate cached clone)
|
|
* @param {string} [pluginSource] - Plugin source path from marketplace.json
|
|
* @returns {string|null} Path to directory containing module.yaml
|
|
*/
|
|
async findModuleSource(sourceInput, pluginSource) {
|
|
const parsed = this.parseSource(sourceInput);
|
|
if (!parsed.isValid) return null;
|
|
|
|
let baseDir;
|
|
if (parsed.type === 'local') {
|
|
baseDir = parsed.localPath;
|
|
} else {
|
|
baseDir = path.join(this.getCacheDir(), ...parsed.cacheKey.split('/'));
|
|
}
|
|
|
|
if (!(await fs.pathExists(baseDir))) return null;
|
|
|
|
// Try plugin source path first (e.g., "./src/pro-skills")
|
|
if (pluginSource) {
|
|
const sourcePath = path.join(baseDir, pluginSource);
|
|
const moduleYaml = path.join(sourcePath, 'module.yaml');
|
|
if (await fs.pathExists(moduleYaml)) {
|
|
return sourcePath;
|
|
}
|
|
}
|
|
|
|
// Fallback: search skills/ and src/ directories
|
|
for (const dir of ['skills', 'src']) {
|
|
const rootCandidate = path.join(baseDir, dir, 'module.yaml');
|
|
if (await fs.pathExists(rootCandidate)) {
|
|
return path.dirname(rootCandidate);
|
|
}
|
|
const dirPath = path.join(baseDir, dir);
|
|
if (await fs.pathExists(dirPath)) {
|
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
const subCandidate = path.join(dirPath, entry.name, 'module.yaml');
|
|
if (await fs.pathExists(subCandidate)) {
|
|
return path.dirname(subCandidate);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check base directory root
|
|
const rootCandidate = path.join(baseDir, 'module.yaml');
|
|
if (await fs.pathExists(rootCandidate)) {
|
|
return baseDir;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Find module source by module code, searching the custom cache.
|
|
* Handles both new 3-level cache structure (host/owner/repo) and
|
|
* legacy 2-level structure (owner/repo).
|
|
* @param {string} moduleCode - Module code to search for
|
|
* @param {Object} [options] - Options
|
|
* @returns {string|null} Path to the module source or null
|
|
*/
|
|
async findModuleSourceByCode(moduleCode, options = {}) {
|
|
// Check resolution cache first (populated by resolvePlugin)
|
|
const resolved = CustomModuleManager._resolutionCache.get(moduleCode);
|
|
if (resolved) {
|
|
// For strategies 1-2: the common parent or setup skill's parent has the module files
|
|
if (resolved.moduleYamlPath) {
|
|
return path.dirname(resolved.moduleYamlPath);
|
|
}
|
|
// For strategy 5 (synthesized): return the first skill's parent as a reference path
|
|
if (resolved.skillPaths && resolved.skillPaths.length > 0) {
|
|
return path.dirname(resolved.skillPaths[0]);
|
|
}
|
|
}
|
|
|
|
const cacheDir = this.getCacheDir();
|
|
if (!(await fs.pathExists(cacheDir))) return null;
|
|
|
|
// Search through all cached repo roots
|
|
try {
|
|
const { PluginResolver } = require('./plugin-resolver');
|
|
const resolver = new PluginResolver();
|
|
const repoRoots = await this._findCacheRepoRoots(cacheDir);
|
|
|
|
for (const { repoPath, metadata } of repoRoots) {
|
|
// Check marketplace.json for matching module code
|
|
const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
|
|
if (!(await fs.pathExists(marketplacePath))) continue;
|
|
|
|
try {
|
|
const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
|
for (const plugin of data.plugins || []) {
|
|
// Direct name match (legacy behavior)
|
|
if (plugin.name === moduleCode) {
|
|
const sourcePath = plugin.source ? path.join(repoPath, plugin.source) : repoPath;
|
|
const moduleYaml = path.join(sourcePath, 'module.yaml');
|
|
if (await fs.pathExists(moduleYaml)) {
|
|
return sourcePath;
|
|
}
|
|
}
|
|
|
|
// Resolve plugin to check if any module.yaml code matches
|
|
if (plugin.skills && plugin.skills.length > 0) {
|
|
try {
|
|
const resolvedMods = await resolver.resolve(repoPath, plugin);
|
|
for (const mod of resolvedMods) {
|
|
if (mod.code === moduleCode) {
|
|
// Use metadata for URL reconstruction instead of deriving from path
|
|
mod.repoUrl = metadata?.cloneUrl || null;
|
|
CustomModuleManager._resolutionCache.set(mod.code, mod);
|
|
if (mod.moduleYamlPath) {
|
|
return path.dirname(mod.moduleYamlPath);
|
|
}
|
|
if (mod.skillPaths && mod.skillPaths.length > 0) {
|
|
return path.dirname(mod.skillPaths[0]);
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Skip unresolvable plugins
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Skip malformed marketplace.json
|
|
}
|
|
}
|
|
} catch {
|
|
// Cache doesn't exist or is inaccessible
|
|
}
|
|
|
|
// Fallback: check manifest for localPath (local-source modules not in cache)
|
|
return this._findLocalSourceFromManifest(moduleCode, options);
|
|
}
|
|
|
|
/**
|
|
* Check the installation manifest for a localPath entry for this module.
|
|
* Used as fallback when the module was installed from a local source (no cache entry).
|
|
* Returns the path only if it still exists on disk; never removes installed files.
|
|
* @param {string} moduleCode - Module code to search for
|
|
* @param {Object} [options] - Options (must include bmadDir or will search common locations)
|
|
* @returns {string|null} Path to the local module source or null
|
|
*/
|
|
async _findLocalSourceFromManifest(moduleCode, options = {}) {
|
|
try {
|
|
const { Manifest } = require('../core/manifest');
|
|
const manifestObj = new Manifest();
|
|
|
|
// Try to find bmadDir from options or common locations
|
|
const bmadDir = options.bmadDir;
|
|
if (!bmadDir) return null;
|
|
|
|
const manifestData = await manifestObj.read(bmadDir);
|
|
if (!manifestData?.modulesDetailed) return null;
|
|
|
|
const moduleEntry = manifestData.modulesDetailed.find((m) => m.name === moduleCode);
|
|
if (!moduleEntry?.localPath) return null;
|
|
|
|
// Only return the path if it still exists (source not removed)
|
|
if (await fs.pathExists(moduleEntry.localPath)) {
|
|
return moduleEntry.localPath;
|
|
}
|
|
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recursively find repo root directories within the cache.
|
|
* A repo root is identified by containing .bmad-source.json (new) or .claude-plugin/ (legacy).
|
|
* Handles both 3-level (host/owner/repo) and legacy 2-level (owner/repo) cache layouts.
|
|
* @param {string} dir - Directory to search
|
|
* @param {number} [depth=0] - Current recursion depth
|
|
* @param {number} [maxDepth=4] - Maximum recursion depth
|
|
* @returns {Promise<Array<{repoPath: string, metadata: Object|null}>>}
|
|
*/
|
|
async _findCacheRepoRoots(dir, depth = 0, maxDepth = 4) {
|
|
const results = [];
|
|
|
|
// Check if this directory is a repo root
|
|
const metadataPath = path.join(dir, '.bmad-source.json');
|
|
const claudePluginDir = path.join(dir, '.claude-plugin');
|
|
|
|
if (await fs.pathExists(metadataPath)) {
|
|
try {
|
|
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
|
|
results.push({ repoPath: dir, metadata });
|
|
} catch {
|
|
results.push({ repoPath: dir, metadata: null });
|
|
}
|
|
return results; // Don't recurse into repo contents
|
|
}
|
|
if (await fs.pathExists(claudePluginDir)) {
|
|
results.push({ repoPath: dir, metadata: null });
|
|
return results;
|
|
}
|
|
|
|
// Recurse into subdirectories
|
|
if (depth >= maxDepth) return results;
|
|
try {
|
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
const subResults = await this._findCacheRepoRoots(path.join(dir, entry.name), depth + 1, maxDepth);
|
|
results.push(...subResults);
|
|
}
|
|
} catch {
|
|
// Directory not readable
|
|
}
|
|
return results;
|
|
}
|
|
|
|
// ─── Normalization ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Normalize a plugin from marketplace.json to a consistent shape.
|
|
* @param {Object} plugin - Plugin object from marketplace.json
|
|
* @param {string|null} sourceUrl - Source URL (null for local paths)
|
|
* @param {Object} data - Full marketplace.json data
|
|
* @returns {Object} Normalized module info
|
|
*/
|
|
_normalizeCustomModule(plugin, sourceUrl, data) {
|
|
return {
|
|
code: plugin.name,
|
|
name: plugin.name,
|
|
displayName: plugin.name,
|
|
description: plugin.description || '',
|
|
version: plugin.version || null,
|
|
author: plugin.author || data.owner || '',
|
|
url: sourceUrl || null,
|
|
source: plugin.source || null,
|
|
skills: plugin.skills || [],
|
|
rawPlugin: plugin,
|
|
type: 'custom',
|
|
trustTier: 'unverified',
|
|
builtIn: false,
|
|
isExternal: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = { CustomModuleManager };
|