diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index b71e8a05b..46814d831 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -591,11 +591,16 @@ class Installer { }, ); - // Get display name from source module.yaml; version from marketplace.json + // Get display name from source module.yaml; version from resolution cache or marketplace.json const sourcePath = await officialModules.findModuleSource(moduleName, { silent: true }); const moduleInfo = sourcePath ? await officialModules.getModuleInfo(sourcePath, moduleName, '') : null; const displayName = moduleInfo?.name || moduleName; - const version = sourcePath ? await this._getMarketplaceVersion(sourcePath) : ''; + + // Prefer version from resolution cache (accurate for custom/local modules), + // fall back to marketplace.json walk-up for official modules + const { CustomModuleManager } = require('../modules/custom-module-manager'); + const cachedResolution = CustomModuleManager._resolutionCache.get(moduleName); + const version = cachedResolution?.version || (sourcePath ? await this._getMarketplaceVersion(sourcePath) : ''); addResult(displayName, 'ok', '', { moduleCode: moduleName, newVersion: version }); } } @@ -1189,7 +1194,7 @@ class Installer { const customMgr = new CustomModuleManager(); for (const moduleId of installedModules) { if (!availableModules.some((m) => m.id === moduleId)) { - const customSource = await customMgr.findModuleSourceByCode(moduleId); + const customSource = await customMgr.findModuleSourceByCode(moduleId, { bmadDir }); if (customSource) { availableModules.push({ id: moduleId, diff --git a/tools/installer/core/manifest-generator.js b/tools/installer/core/manifest-generator.js index 28ede065e..13e33af56 100644 --- a/tools/installer/core/manifest-generator.js +++ b/tools/installer/core/manifest-generator.js @@ -412,7 +412,7 @@ class ManifestGenerator { // Get existing install date if available const existing = existingModulesMap.get(moduleName); - updatedModules.push({ + const moduleEntry = { name: moduleName, version: versionInfo.version, installDate: existing?.installDate || new Date().toISOString(), @@ -420,7 +420,9 @@ class ManifestGenerator { source: versionInfo.source, npmPackage: versionInfo.npmPackage, repoUrl: versionInfo.repoUrl, - }); + }; + if (versionInfo.localPath) moduleEntry.localPath = versionInfo.localPath; + updatedModules.push(moduleEntry); } const manifest = { diff --git a/tools/installer/core/manifest.js b/tools/installer/core/manifest.js index 88190ccbf..1ba776ffd 100644 --- a/tools/installer/core/manifest.js +++ b/tools/installer/core/manifest.js @@ -181,10 +181,10 @@ class Manifest { // Handle adding a new module with version info if (updates.addModule) { - const { name, version, source, npmPackage, repoUrl } = updates.addModule; + const { name, version, source, npmPackage, repoUrl, localPath } = updates.addModule; const existing = manifest.modules.find((m) => m.name === name); if (!existing) { - manifest.modules.push({ + const entry = { name, version: version || null, installDate: new Date().toISOString(), @@ -192,7 +192,9 @@ class Manifest { source: source || 'external', npmPackage: npmPackage || null, repoUrl: repoUrl || null, - }); + }; + if (localPath) entry.localPath = localPath; + manifest.modules.push(entry); } } @@ -280,7 +282,7 @@ class Manifest { if (existingIndex === -1) { // Module doesn't exist, add it - manifest.modules.push({ + const entry = { name: moduleName, version: options.version || null, installDate: new Date().toISOString(), @@ -288,7 +290,9 @@ class Manifest { source: options.source || 'unknown', npmPackage: options.npmPackage || null, repoUrl: options.repoUrl || null, - }); + }; + if (options.localPath) entry.localPath = options.localPath; + manifest.modules.push(entry); } else { // Module exists, update its version info const existing = manifest.modules[existingIndex]; @@ -298,6 +302,7 @@ class Manifest { source: options.source || existing.source, npmPackage: options.npmPackage === undefined ? existing.npmPackage : options.npmPackage, repoUrl: options.repoUrl === undefined ? existing.repoUrl : options.repoUrl, + localPath: options.localPath === undefined ? existing.localPath : options.localPath, lastUpdated: new Date().toISOString(), }; } @@ -832,11 +837,11 @@ class Manifest { }; } - // Check if this is a custom module (from user-provided URL) + // Check if this is a custom module (from user-provided URL or local path) const { CustomModuleManager } = require('../modules/custom-module-manager'); const customMgr = new CustomModuleManager(); const resolved = customMgr.getResolution(moduleName); - const customSource = await customMgr.findModuleSourceByCode(moduleName); + const customSource = await customMgr.findModuleSourceByCode(moduleName, { bmadDir }); if (customSource || resolved) { const customVersion = resolved?.version || (await this._readMarketplaceVersion(moduleName, moduleSourcePath)); return { @@ -844,6 +849,7 @@ class Manifest { source: 'custom', npmPackage: null, repoUrl: resolved?.repoUrl || null, + localPath: resolved?.localPath || null, }; } diff --git a/tools/installer/modules/custom-module-manager.js b/tools/installer/modules/custom-module-manager.js index a23505bf1..cf4dd083c 100644 --- a/tools/installer/modules/custom-module-manager.js +++ b/tools/installer/modules/custom-module-manager.js @@ -3,90 +3,206 @@ const os = require('node:os'); const path = require('node:path'); const { execSync } = require('node:child_process'); const prompts = require('../prompts'); -const { RegistryClient } = require('./registry-client'); /** - * Manages custom modules installed from user-provided GitHub URLs. - * Validates URLs, fetches .claude-plugin/marketplace.json, clones repos. + * 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} Shared across all instances: module code -> ResolvedModule */ static _resolutionCache = new Map(); - constructor() { - this._client = new RegistryClient(); - } - - // ─── URL Validation ─────────────────────────────────────────────────────── + // ─── 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. - * Supports HTTPS and SSH formats. * @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 parsed = this.parseSource(url); + if (!parsed.isValid) { + return { owner: null, repo: null, isValid: false, error: parsed.error }; } - - const trimmed = url.trim(); - - // HTTPS format: https://github.com/owner/repo[.git] - const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/); - if (httpsMatch) { - return { owner: httpsMatch[1], repo: httpsMatch[2], isValid: true, error: null }; + if (parsed.type === 'local') { + return { owner: null, repo: null, isValid: false, error: 'Not a valid GitHub URL (expected https://github.com/owner/repo)' }; } + // Extract owner/repo from cacheKey (host/owner/repo) + const parts = parsed.cacheKey.split('/'); + return { owner: parts[1] || null, repo: parts[2] || null, 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 }; - } + // ─── Marketplace JSON ───────────────────────────────────────────────────── - return { owner: null, repo: null, isValid: false, error: 'Not a valid GitHub URL (expected https://github.com/owner/repo)' }; + /** + * 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; + return JSON.parse(await fs.readFile(marketplacePath, 'utf8')); } // ─── Discovery ──────────────────────────────────────────────────────────── /** - * Fetch .claude-plugin/marketplace.json from a GitHub repository. - * @param {string} repoUrl - GitHub repository URL - * @returns {Object} Parsed marketplace.json content - */ - async fetchMarketplaceJson(repoUrl) { - const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl); - if (!isValid) throw new Error(error); - - const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/.claude-plugin/marketplace.json`; - - try { - return await this._client.fetchJson(rawUrl); - } catch (error_) { - if (error_.message.includes('404')) { - throw new Error(`No .claude-plugin/marketplace.json found in ${owner}/${repo}. This repository may not be a BMad module.`); - } - if (error_.message.includes('403')) { - throw new Error(`Repository ${owner}/${repo} is not accessible. Make sure it is public.`); - } - throw new Error(`Failed to fetch marketplace.json from ${owner}/${repo}: ${error_.message}`); - } - } - - /** - * Discover modules from a GitHub repository's marketplace.json. - * @param {string} repoUrl - GitHub repository URL + * 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} Normalized plugin list */ - async discoverModules(repoUrl) { - const data = await this.fetchMarketplaceJson(repoUrl); - const plugins = data?.plugins; + 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, repoUrl, data)); + 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 ──────────────────────────────────────────────────────────────── @@ -101,21 +217,24 @@ class CustomModuleManager { /** * Clone a custom module repository to cache. - * @param {string} repoUrl - GitHub repository URL + * 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(repoUrl, options = {}) { - const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl); - if (!isValid) throw new Error(error); + 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, owner, repo); + const repoCacheDir = path.join(cacheDir, ...parsed.cacheKey.split('/')); const silent = options.silent || false; + const displayName = parsed.displayName; - await fs.ensureDir(path.join(cacheDir, owner)); + await fs.ensureDir(path.dirname(repoCacheDir)); const createSpinner = async () => { if (silent) { @@ -127,7 +246,7 @@ class CustomModuleManager { if (await fs.pathExists(repoCacheDir)) { // Update existing clone const fetchSpinner = await createSpinner(); - fetchSpinner.start(`Updating ${owner}/${repo}...`); + fetchSpinner.start(`Updating ${displayName}...`); try { execSync('git fetch origin --depth 1', { cwd: repoCacheDir, @@ -138,42 +257,51 @@ class CustomModuleManager { cwd: repoCacheDir, stdio: ['ignore', 'pipe', 'pipe'], }); - fetchSpinner.stop(`Updated ${owner}/${repo}`); + fetchSpinner.stop(`Updated ${displayName}`); } catch { - fetchSpinner.error(`Update failed, re-downloading ${owner}/${repo}`); + fetchSpinner.error(`Update failed, re-downloading ${displayName}`); await fs.remove(repoCacheDir); } } if (!(await fs.pathExists(repoCacheDir))) { const fetchSpinner = await createSpinner(); - fetchSpinner.start(`Cloning ${owner}/${repo}...`); + fetchSpinner.start(`Cloning ${displayName}...`); try { - execSync(`git clone --depth 1 "${repoUrl}" "${repoCacheDir}"`, { + execSync(`git clone --depth 1 "${parsed.cloneUrl}" "${repoCacheDir}"`, { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, }); - fetchSpinner.stop(`Cloned ${owner}/${repo}`); + fetchSpinner.stop(`Cloned ${displayName}`); } catch (error_) { - fetchSpinner.error(`Failed to clone ${owner}/${repo}`); - throw new Error(`Failed to clone ${repoUrl}: ${error_.message}`); + 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 ${owner}/${repo}...`); + 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 ${owner}/${repo}`); + installSpinner.stop(`Installed dependencies for ${displayName}`); } catch (error_) { - installSpinner.error(`Failed to install dependencies for ${owner}/${repo}`); + installSpinner.error(`Failed to install dependencies for ${displayName}`); if (!silent) await prompts.log.warn(` ${error_.message}`); } } @@ -186,19 +314,21 @@ class CustomModuleManager { /** * 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 + * @param {string} repoPath - Absolute path to the cloned repository or local directory * @param {Object} plugin - Raw plugin object from marketplace.json - * @param {string} [repoUrl] - Original GitHub URL for manifest tracking + * @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 of ResolvedModule objects */ - async resolvePlugin(repoPath, plugin, repoUrl) { + async resolvePlugin(repoPath, plugin, sourceUrl, localPath) { const { PluginResolver } = require('./plugin-resolver'); const resolver = new PluginResolver(); const resolved = await resolver.resolve(repoPath, plugin); - // Stamp repo URL onto each resolved module for manifest tracking + // Stamp source info onto each resolved module for manifest tracking for (const mod of resolved) { - if (repoUrl) mod.repoUrl = repoUrl; + if (sourceUrl) mod.repoUrl = sourceUrl; + if (localPath) mod.localPath = localPath; CustomModuleManager._resolutionCache.set(mod.code, mod); } @@ -217,20 +347,27 @@ class CustomModuleManager { // ─── Source Finding ─────────────────────────────────────────────────────── /** - * Find the module source path within a cloned custom repo. - * @param {string} repoUrl - GitHub repository URL (for cache location) + * 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(repoUrl, pluginSource) { - const { owner, repo } = this.validateGitHubUrl(repoUrl); - const repoCacheDir = path.join(this.getCacheDir(), owner, repo); + async findModuleSource(sourceInput, pluginSource) { + const parsed = this.parseSource(sourceInput); + if (!parsed.isValid) return null; - if (!(await fs.pathExists(repoCacheDir))) 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(repoCacheDir, pluginSource); + const sourcePath = path.join(baseDir, pluginSource); const moduleYaml = path.join(sourcePath, 'module.yaml'); if (await fs.pathExists(moduleYaml)) { return sourcePath; @@ -239,11 +376,11 @@ class CustomModuleManager { // Fallback: search skills/ and src/ directories for (const dir of ['skills', 'src']) { - const rootCandidate = path.join(repoCacheDir, dir, 'module.yaml'); + const rootCandidate = path.join(baseDir, dir, 'module.yaml'); if (await fs.pathExists(rootCandidate)) { return path.dirname(rootCandidate); } - const dirPath = path.join(repoCacheDir, dir); + const dirPath = path.join(baseDir, dir); if (await fs.pathExists(dirPath)) { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { @@ -257,10 +394,10 @@ class CustomModuleManager { } } - // Check repo root - const rootCandidate = path.join(repoCacheDir, 'module.yaml'); + // Check base directory root + const rootCandidate = path.join(baseDir, 'module.yaml'); if (await fs.pathExists(rootCandidate)) { - return repoCacheDir; + return baseDir; } return null; @@ -268,6 +405,8 @@ class CustomModuleManager { /** * 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 @@ -289,68 +428,140 @@ class CustomModuleManager { const cacheDir = this.getCacheDir(); if (!(await fs.pathExists(cacheDir))) return null; - // Search through all custom repo caches + // Search through all cached repo roots try { const { PluginResolver } = require('./plugin-resolver'); const resolver = new PluginResolver(); - const owners = await fs.readdir(cacheDir, { withFileTypes: true }); - for (const ownerEntry of owners) { - if (!ownerEntry.isDirectory()) continue; - const ownerPath = path.join(cacheDir, ownerEntry.name); - const repos = await fs.readdir(ownerPath, { withFileTypes: true }); - for (const repoEntry of repos) { - if (!repoEntry.isDirectory()) continue; - const repoPath = path.join(ownerPath, repoEntry.name); + const repoRoots = await this._findCacheRepoRoots(cacheDir); - // Check marketplace.json for matching module code - const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json'); - if (await fs.pathExists(marketplacePath)) { - 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; - } - } + 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; - // Resolve plugin to check if any module.yaml code matches - if (plugin.skills && plugin.skills.length > 0) { - try { - const resolved = await resolver.resolve(repoPath, plugin); - for (const mod of resolved) { - if (mod.code === moduleCode) { - // Derive repo URL from cache path for manifest tracking - const repoUrl = `https://github.com/${ownerEntry.name}/${repoEntry.name}`; - mod.repoUrl = repoUrl; - 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 - } - } + 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 { + // Skip malformed marketplace.json } } } catch { // Cache doesn't exist or is inaccessible } - return null; + // 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>} + */ + 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 ──────────────────────────────────────────────────────── @@ -358,11 +569,11 @@ class CustomModuleManager { /** * Normalize a plugin from marketplace.json to a consistent shape. * @param {Object} plugin - Plugin object from marketplace.json - * @param {string} repoUrl - Source repository URL + * @param {string|null} sourceUrl - Source URL (null for local paths) * @param {Object} data - Full marketplace.json data * @returns {Object} Normalized module info */ - _normalizeCustomModule(plugin, repoUrl, data) { + _normalizeCustomModule(plugin, sourceUrl, data) { return { code: plugin.name, name: plugin.name, @@ -370,7 +581,7 @@ class CustomModuleManager { description: plugin.description || '', version: plugin.version || null, author: plugin.author || data.owner || '', - url: repoUrl, + url: sourceUrl || null, source: plugin.source || null, skills: plugin.skills || [], rawPlugin: plugin, diff --git a/tools/installer/ui.js b/tools/installer/ui.js index 9908a462f..058492af3 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -818,13 +818,13 @@ class UI { } /** - * Prompt user to install modules from custom GitHub URLs. + * Prompt user to install modules from custom sources (Git URLs or local paths). * @param {Set} installedModuleIds - Currently installed module IDs * @returns {Array} Selected custom module code strings */ async _addCustomUrlModules(installedModuleIds = new Set()) { const addCustom = await prompts.confirm({ - message: 'Would you like to install from a custom GitHub URL?', + message: 'Would you like to install from a custom source (Git URL or local path)?', default: false, }); if (!addCustom) return []; @@ -835,76 +835,117 @@ class UI { let addMore = true; while (addMore) { - const url = await prompts.text({ - message: 'GitHub repository URL:', - placeholder: 'https://github.com/owner/repo', + const sourceInput = await prompts.text({ + message: 'Git URL or local path:', + placeholder: 'https://github.com/owner/repo or /path/to/module', validate: (input) => { - if (!input || input.trim() === '') return 'URL is required'; - const result = customMgr.validateGitHubUrl(input.trim()); + if (!input || input.trim() === '') return 'Source is required'; + const result = customMgr.parseSource(input.trim()); return result.isValid ? undefined : result.error; }, }); const s = await prompts.spinner(); - s.start('Fetching module info...'); + s.start('Resolving source...'); - let plugins; + let sourceResult; try { - plugins = await customMgr.discoverModules(url.trim()); - s.stop('Module info loaded'); + sourceResult = await customMgr.resolveSource(sourceInput.trim(), { skipInstall: true, silent: true }); + s.stop(sourceResult.parsed.type === 'local' ? 'Local source resolved' : 'Repository cloned'); } catch (error) { - s.error('Failed to load module info'); + s.error('Failed to resolve source'); await prompts.log.error(` ${error.message}`); - addMore = await prompts.confirm({ message: 'Try another URL?', default: false }); + addMore = await prompts.confirm({ message: 'Try another source?', default: false }); continue; } - await prompts.log.warn( - 'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.', - ); - - // Clone the repo so we can resolve plugin structures (skip npm install until user confirms) - s.start('Cloning repository...'); - let repoPath; - try { - repoPath = await customMgr.cloneRepo(url.trim(), { skipInstall: true }); - s.stop('Repository cloned'); - } catch (cloneError) { - s.error('Failed to clone repository'); - await prompts.log.error(` ${cloneError.message}`); - addMore = await prompts.confirm({ message: 'Try another URL?', default: false }); - continue; + if (sourceResult.parsed.type === 'local') { + await prompts.log.info('LOCAL MODULE: Pointing directly at local source (changes take effect on reinstall).'); + } else { + await prompts.log.warn( + 'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.', + ); } - // Resolve each plugin to determine installable modules + // Resolve plugins based on discovery mode vs direct mode s.start('Analyzing plugin structure...'); const allResolved = []; - for (const plugin of plugins) { + const localPath = sourceResult.parsed.type === 'local' ? sourceResult.rootDir : null; + + if (sourceResult.mode === 'discovery') { + // Discovery mode: marketplace.json found, list available plugins + let plugins; try { - const resolved = await customMgr.resolvePlugin(repoPath, plugin.rawPlugin, url.trim()); - if (resolved.length > 0) { - allResolved.push(...resolved); - } else { - // No skills array or empty - use plugin metadata as-is (legacy) - allResolved.push({ - code: plugin.code, - name: plugin.displayName || plugin.name, - version: plugin.version, - description: plugin.description, - strategy: 0, - pluginName: plugin.name, - skillPaths: [], - }); + plugins = await customMgr.discoverModules(sourceResult.marketplace, sourceResult.sourceUrl); + } catch (discoverError) { + s.error('Failed to discover modules'); + await prompts.log.error(` ${discoverError.message}`); + addMore = await prompts.confirm({ message: 'Try another source?', default: false }); + continue; + } + + const effectiveRepoPath = sourceResult.repoPath || sourceResult.rootDir; + for (const plugin of plugins) { + try { + const resolved = await customMgr.resolvePlugin(effectiveRepoPath, plugin.rawPlugin, sourceResult.sourceUrl, localPath); + if (resolved.length > 0) { + allResolved.push(...resolved); + } else { + // No skills array or empty - use plugin metadata as-is (legacy) + allResolved.push({ + code: plugin.code, + name: plugin.displayName || plugin.name, + version: plugin.version, + description: plugin.description, + strategy: 0, + pluginName: plugin.name, + skillPaths: [], + }); + } + } catch (resolveError) { + await prompts.log.warn(` Could not resolve ${plugin.name}: ${resolveError.message}`); + } + } + } else { + // Direct mode: no marketplace.json, scan directory for skills and resolve + const directPlugin = { + name: sourceResult.parsed.displayName || path.basename(sourceResult.rootDir), + source: '.', + skills: [], + }; + + // Scan for SKILL.md directories to populate skills array + try { + const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md'); + if (await fs.pathExists(skillMd)) { + directPlugin.skills.push(entry.name); + } + } + } + } catch (scanError) { + s.error('Failed to scan directory'); + await prompts.log.error(` ${scanError.message}`); + addMore = await prompts.confirm({ message: 'Try another source?', default: false }); + continue; + } + + if (directPlugin.skills.length > 0) { + try { + const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath); + allResolved.push(...resolved); + } catch (resolveError) { + await prompts.log.warn(` Could not resolve: ${resolveError.message}`); } - } catch (resolveError) { - await prompts.log.warn(` Could not resolve ${plugin.name}: ${resolveError.message}`); } } s.stop(`Found ${allResolved.length} installable module${allResolved.length === 1 ? '' : 's'}`); if (allResolved.length === 0) { - await prompts.log.warn('No installable modules found in this repository.'); - addMore = await prompts.confirm({ message: 'Try another URL?', default: false }); + await prompts.log.warn('No installable modules found in this source.'); + addMore = await prompts.confirm({ message: 'Try another source?', default: false }); continue; } @@ -945,7 +986,7 @@ class UI { } addMore = await prompts.confirm({ - message: 'Add another custom module URL?', + message: 'Add another custom source?', default: false, }); }