bmad-method 6.2.3-next.31 → 6.2.3-next.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,22 +3,161 @@ const os = require('node:os');
3
3
  const path = require('node:path');
4
4
  const { execSync } = require('node:child_process');
5
5
  const prompts = require('../prompts');
6
- const { RegistryClient } = require('./registry-client');
7
6
 
8
7
  /**
9
- * Manages custom modules installed from user-provided GitHub URLs.
10
- * Validates URLs, fetches .claude-plugin/marketplace.json, clones repos.
8
+ * Manages custom modules installed from user-provided sources.
9
+ * Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted) and local file paths.
10
+ * Validates input, clones repos, reads .claude-plugin/marketplace.json, resolves plugins.
11
11
  */
12
12
  class CustomModuleManager {
13
- constructor() {
14
- this._client = new RegistryClient();
13
+ /** @type {Map<string, Object>} Shared across all instances: module code -> ResolvedModule */
14
+ static _resolutionCache = new Map();
15
+
16
+ // ─── Source Parsing ───────────────────────────────────────────────────────
17
+
18
+ /**
19
+ * Parse a user-provided source input into a structured descriptor.
20
+ * Accepts local file paths, HTTPS Git URLs, and SSH Git URLs.
21
+ * For HTTPS URLs with deep paths (e.g., /tree/main/subdir), extracts the subdir.
22
+ *
23
+ * @param {string} input - URL or local file path
24
+ * @returns {Object} Parsed source descriptor:
25
+ * { type: 'url'|'local', cloneUrl, subdir, localPath, cacheKey, displayName, isValid, error }
26
+ */
27
+ parseSource(input) {
28
+ if (!input || typeof input !== 'string') {
29
+ return {
30
+ type: null,
31
+ cloneUrl: null,
32
+ subdir: null,
33
+ localPath: null,
34
+ cacheKey: null,
35
+ displayName: null,
36
+ isValid: false,
37
+ error: 'Source is required',
38
+ };
39
+ }
40
+
41
+ const trimmed = input.trim();
42
+ if (!trimmed) {
43
+ return {
44
+ type: null,
45
+ cloneUrl: null,
46
+ subdir: null,
47
+ localPath: null,
48
+ cacheKey: null,
49
+ displayName: null,
50
+ isValid: false,
51
+ error: 'Source is required',
52
+ };
53
+ }
54
+
55
+ // Local path detection: starts with /, ./, ../, or ~
56
+ if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../') || trimmed.startsWith('~')) {
57
+ return this._parseLocalPath(trimmed);
58
+ }
59
+
60
+ // SSH URL: git@host:owner/repo.git
61
+ const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/.]+?)(?:\.git)?$/);
62
+ if (sshMatch) {
63
+ const [, host, owner, repo] = sshMatch;
64
+ return {
65
+ type: 'url',
66
+ cloneUrl: trimmed,
67
+ subdir: null,
68
+ localPath: null,
69
+ cacheKey: `${host}/${owner}/${repo}`,
70
+ displayName: `${owner}/${repo}`,
71
+ isValid: true,
72
+ error: null,
73
+ };
74
+ }
75
+
76
+ // HTTPS URL: https://host/owner/repo[/tree/branch/subdir][.git]
77
+ const httpsMatch = trimmed.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/.]+?)(?:\.git)?(\/.*)?$/);
78
+ if (httpsMatch) {
79
+ const [, host, owner, repo, remainder] = httpsMatch;
80
+ const cloneUrl = `https://${host}/${owner}/${repo}`;
81
+ let subdir = null;
82
+
83
+ if (remainder) {
84
+ // Extract subdir from deep path patterns used by various Git hosts
85
+ const deepPathPatterns = [
86
+ /^\/(?:-\/)?tree\/[^/]+\/(.+)$/, // GitHub /tree/branch/path, GitLab /-/tree/branch/path
87
+ /^\/(?:-\/)?blob\/[^/]+\/(.+)$/, // /blob/branch/path (treat same as tree)
88
+ /^\/src\/[^/]+\/(.+)$/, // Gitea/Forgejo /src/branch/path
89
+ ];
90
+
91
+ for (const pattern of deepPathPatterns) {
92
+ const match = remainder.match(pattern);
93
+ if (match) {
94
+ subdir = match[1].replace(/\/$/, ''); // strip trailing slash
95
+ break;
96
+ }
97
+ }
98
+ }
99
+
100
+ return {
101
+ type: 'url',
102
+ cloneUrl,
103
+ subdir,
104
+ localPath: null,
105
+ cacheKey: `${host}/${owner}/${repo}`,
106
+ displayName: `${owner}/${repo}`,
107
+ isValid: true,
108
+ error: null,
109
+ };
110
+ }
111
+
112
+ return {
113
+ type: null,
114
+ cloneUrl: null,
115
+ subdir: null,
116
+ localPath: null,
117
+ cacheKey: null,
118
+ displayName: null,
119
+ isValid: false,
120
+ error: 'Not a valid Git URL or local path',
121
+ };
15
122
  }
16
123
 
17
- // ─── URL Validation ───────────────────────────────────────────────────────
124
+ /**
125
+ * Parse a local filesystem path.
126
+ * @param {string} rawPath - Path string (may contain ~ for home)
127
+ * @returns {Object} Parsed source descriptor
128
+ */
129
+ _parseLocalPath(rawPath) {
130
+ const expanded = rawPath.startsWith('~') ? path.join(os.homedir(), rawPath.slice(1)) : rawPath;
131
+ const resolved = path.resolve(expanded);
132
+
133
+ if (!fs.pathExistsSync(resolved)) {
134
+ return {
135
+ type: 'local',
136
+ cloneUrl: null,
137
+ subdir: null,
138
+ localPath: resolved,
139
+ cacheKey: null,
140
+ displayName: path.basename(resolved),
141
+ isValid: false,
142
+ error: `Path does not exist: ${resolved}`,
143
+ };
144
+ }
145
+
146
+ return {
147
+ type: 'local',
148
+ cloneUrl: null,
149
+ subdir: null,
150
+ localPath: resolved,
151
+ cacheKey: null,
152
+ displayName: path.basename(resolved),
153
+ isValid: true,
154
+ error: null,
155
+ };
156
+ }
18
157
 
19
158
  /**
159
+ * @deprecated Use parseSource() instead. Kept for backward compatibility.
20
160
  * Parse and validate a GitHub repository URL.
21
- * Supports HTTPS and SSH formats.
22
161
  * @param {string} url - GitHub URL to validate
23
162
  * @returns {Object} { owner, repo, isValid, error }
24
163
  */
@@ -26,16 +165,15 @@ class CustomModuleManager {
26
165
  if (!url || typeof url !== 'string') {
27
166
  return { owner: null, repo: null, isValid: false, error: 'URL is required' };
28
167
  }
29
-
30
168
  const trimmed = url.trim();
31
169
 
32
- // HTTPS format: https://github.com/owner/repo[.git]
170
+ // HTTPS format: https://github.com/owner/repo[.git] (strict, no trailing path)
33
171
  const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
34
172
  if (httpsMatch) {
35
173
  return { owner: httpsMatch[1], repo: httpsMatch[2], isValid: true, error: null };
36
174
  }
37
175
 
38
- // SSH format: git@github.com:owner/repo.git
176
+ // SSH format: git@github.com:owner/repo[.git]
39
177
  const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
40
178
  if (sshMatch) {
41
179
  return { owner: sshMatch[1], repo: sshMatch[2], isValid: true, error: null };
@@ -44,46 +182,75 @@ class CustomModuleManager {
44
182
  return { owner: null, repo: null, isValid: false, error: 'Not a valid GitHub URL (expected https://github.com/owner/repo)' };
45
183
  }
46
184
 
47
- // ─── Discovery ────────────────────────────────────────────────────────────
185
+ // ─── Marketplace JSON ─────────────────────────────────────────────────────
48
186
 
49
187
  /**
50
- * Fetch .claude-plugin/marketplace.json from a GitHub repository.
51
- * @param {string} repoUrl - GitHub repository URL
52
- * @returns {Object} Parsed marketplace.json content
188
+ * Read .claude-plugin/marketplace.json from a local directory.
189
+ * @param {string} dirPath - Directory to read from
190
+ * @returns {Object|null} Parsed marketplace.json or null if not found
53
191
  */
54
- async fetchMarketplaceJson(repoUrl) {
55
- const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl);
56
- if (!isValid) throw new Error(error);
57
-
58
- const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/.claude-plugin/marketplace.json`;
59
-
192
+ async readMarketplaceJsonFromDisk(dirPath) {
193
+ const marketplacePath = path.join(dirPath, '.claude-plugin', 'marketplace.json');
194
+ if (!(await fs.pathExists(marketplacePath))) return null;
60
195
  try {
61
- return await this._client.fetchJson(rawUrl);
62
- } catch (error_) {
63
- if (error_.message.includes('404')) {
64
- throw new Error(`No .claude-plugin/marketplace.json found in ${owner}/${repo}. This repository may not be a BMad module.`);
65
- }
66
- if (error_.message.includes('403')) {
67
- throw new Error(`Repository ${owner}/${repo} is not accessible. Make sure it is public.`);
68
- }
69
- throw new Error(`Failed to fetch marketplace.json from ${owner}/${repo}: ${error_.message}`);
196
+ return JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
197
+ } catch {
198
+ return null;
70
199
  }
71
200
  }
72
201
 
202
+ // ─── Discovery ────────────────────────────────────────────────────────────
203
+
73
204
  /**
74
- * Discover modules from a GitHub repository's marketplace.json.
75
- * @param {string} repoUrl - GitHub repository URL
205
+ * Discover modules from pre-read marketplace.json data.
206
+ * @param {Object} marketplaceData - Parsed marketplace.json content
207
+ * @param {string|null} sourceUrl - Source URL for tracking (null for local paths)
76
208
  * @returns {Array<Object>} Normalized plugin list
77
209
  */
78
- async discoverModules(repoUrl) {
79
- const data = await this.fetchMarketplaceJson(repoUrl);
80
- const plugins = data?.plugins;
210
+ async discoverModules(marketplaceData, sourceUrl) {
211
+ const plugins = marketplaceData?.plugins;
81
212
 
82
213
  if (!Array.isArray(plugins) || plugins.length === 0) {
83
214
  throw new Error('marketplace.json contains no plugins');
84
215
  }
85
216
 
86
- return plugins.map((plugin) => this._normalizeCustomModule(plugin, repoUrl, data));
217
+ return plugins.map((plugin) => this._normalizeCustomModule(plugin, sourceUrl, marketplaceData));
218
+ }
219
+
220
+ // ─── Source Resolution ────────────────────────────────────────────────────
221
+
222
+ /**
223
+ * High-level coordinator: parse input, clone if URL, determine discovery vs direct mode.
224
+ * @param {string} input - URL or local path
225
+ * @param {Object} [options] - Options passed to cloneRepo
226
+ * @returns {Object} { parsed, rootDir, repoPath, sourceUrl, marketplace, mode: 'discovery'|'direct' }
227
+ */
228
+ async resolveSource(input, options = {}) {
229
+ const parsed = this.parseSource(input);
230
+ if (!parsed.isValid) throw new Error(parsed.error);
231
+
232
+ let rootDir;
233
+ let repoPath;
234
+ let sourceUrl;
235
+
236
+ if (parsed.type === 'local') {
237
+ rootDir = parsed.localPath;
238
+ repoPath = null;
239
+ sourceUrl = null;
240
+ } else {
241
+ repoPath = await this.cloneRepo(input, options);
242
+ sourceUrl = parsed.cloneUrl;
243
+ rootDir = parsed.subdir ? path.join(repoPath, parsed.subdir) : repoPath;
244
+
245
+ if (parsed.subdir && !(await fs.pathExists(rootDir))) {
246
+ throw new Error(`Subdirectory '${parsed.subdir}' not found in cloned repository`);
247
+ }
248
+ }
249
+
250
+ const marketplace = await this.readMarketplaceJsonFromDisk(rootDir);
251
+ const mode = marketplace ? 'discovery' : 'direct';
252
+
253
+ return { parsed, rootDir, repoPath, sourceUrl, marketplace, mode };
87
254
  }
88
255
 
89
256
  // ─── Clone ────────────────────────────────────────────────────────────────
@@ -98,20 +265,24 @@ class CustomModuleManager {
98
265
 
99
266
  /**
100
267
  * Clone a custom module repository to cache.
101
- * @param {string} repoUrl - GitHub repository URL
268
+ * Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted, etc.).
269
+ * @param {string} sourceInput - Git URL (HTTPS or SSH)
102
270
  * @param {Object} [options] - Clone options
103
271
  * @param {boolean} [options.silent] - Suppress spinner output
272
+ * @param {boolean} [options.skipInstall] - Skip npm install (for browsing before user confirms)
104
273
  * @returns {string} Path to the cloned repository
105
274
  */
106
- async cloneRepo(repoUrl, options = {}) {
107
- const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl);
108
- if (!isValid) throw new Error(error);
275
+ async cloneRepo(sourceInput, options = {}) {
276
+ const parsed = this.parseSource(sourceInput);
277
+ if (!parsed.isValid) throw new Error(parsed.error);
278
+ if (parsed.type === 'local') throw new Error('cloneRepo does not accept local paths');
109
279
 
110
280
  const cacheDir = this.getCacheDir();
111
- const repoCacheDir = path.join(cacheDir, owner, repo);
281
+ const repoCacheDir = path.join(cacheDir, ...parsed.cacheKey.split('/'));
112
282
  const silent = options.silent || false;
283
+ const displayName = parsed.displayName;
113
284
 
114
- await fs.ensureDir(path.join(cacheDir, owner));
285
+ await fs.ensureDir(path.dirname(repoCacheDir));
115
286
 
116
287
  const createSpinner = async () => {
117
288
  if (silent) {
@@ -123,7 +294,7 @@ class CustomModuleManager {
123
294
  if (await fs.pathExists(repoCacheDir)) {
124
295
  // Update existing clone
125
296
  const fetchSpinner = await createSpinner();
126
- fetchSpinner.start(`Updating ${owner}/${repo}...`);
297
+ fetchSpinner.start(`Updating ${displayName}...`);
127
298
  try {
128
299
  execSync('git fetch origin --depth 1', {
129
300
  cwd: repoCacheDir,
@@ -134,42 +305,51 @@ class CustomModuleManager {
134
305
  cwd: repoCacheDir,
135
306
  stdio: ['ignore', 'pipe', 'pipe'],
136
307
  });
137
- fetchSpinner.stop(`Updated ${owner}/${repo}`);
308
+ fetchSpinner.stop(`Updated ${displayName}`);
138
309
  } catch {
139
- fetchSpinner.error(`Update failed, re-downloading ${owner}/${repo}`);
310
+ fetchSpinner.error(`Update failed, re-downloading ${displayName}`);
140
311
  await fs.remove(repoCacheDir);
141
312
  }
142
313
  }
143
314
 
144
315
  if (!(await fs.pathExists(repoCacheDir))) {
145
316
  const fetchSpinner = await createSpinner();
146
- fetchSpinner.start(`Cloning ${owner}/${repo}...`);
317
+ fetchSpinner.start(`Cloning ${displayName}...`);
147
318
  try {
148
- execSync(`git clone --depth 1 "${repoUrl}" "${repoCacheDir}"`, {
319
+ execSync(`git clone --depth 1 "${parsed.cloneUrl}" "${repoCacheDir}"`, {
149
320
  stdio: ['ignore', 'pipe', 'pipe'],
150
321
  env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
151
322
  });
152
- fetchSpinner.stop(`Cloned ${owner}/${repo}`);
323
+ fetchSpinner.stop(`Cloned ${displayName}`);
153
324
  } catch (error_) {
154
- fetchSpinner.error(`Failed to clone ${owner}/${repo}`);
155
- throw new Error(`Failed to clone ${repoUrl}: ${error_.message}`);
325
+ fetchSpinner.error(`Failed to clone ${displayName}`);
326
+ throw new Error(`Failed to clone ${parsed.cloneUrl}: ${error_.message}`);
156
327
  }
157
328
  }
158
329
 
159
- // Install dependencies if package.json exists
330
+ // Write source metadata for later URL reconstruction
331
+ const metadataPath = path.join(repoCacheDir, '.bmad-source.json');
332
+ await fs.writeJson(metadataPath, {
333
+ cloneUrl: parsed.cloneUrl,
334
+ cacheKey: parsed.cacheKey,
335
+ displayName: parsed.displayName,
336
+ clonedAt: new Date().toISOString(),
337
+ });
338
+
339
+ // Install dependencies if package.json exists (skip during browsing/analysis)
160
340
  const packageJsonPath = path.join(repoCacheDir, 'package.json');
161
- if (await fs.pathExists(packageJsonPath)) {
341
+ if (!options.skipInstall && (await fs.pathExists(packageJsonPath))) {
162
342
  const installSpinner = await createSpinner();
163
- installSpinner.start(`Installing dependencies for ${owner}/${repo}...`);
343
+ installSpinner.start(`Installing dependencies for ${displayName}...`);
164
344
  try {
165
345
  execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
166
346
  cwd: repoCacheDir,
167
347
  stdio: ['ignore', 'pipe', 'pipe'],
168
348
  timeout: 120_000,
169
349
  });
170
- installSpinner.stop(`Installed dependencies for ${owner}/${repo}`);
350
+ installSpinner.stop(`Installed dependencies for ${displayName}`);
171
351
  } catch (error_) {
172
- installSpinner.error(`Failed to install dependencies for ${owner}/${repo}`);
352
+ installSpinner.error(`Failed to install dependencies for ${displayName}`);
173
353
  if (!silent) await prompts.log.warn(` ${error_.message}`);
174
354
  }
175
355
  }
@@ -177,23 +357,65 @@ class CustomModuleManager {
177
357
  return repoCacheDir;
178
358
  }
179
359
 
360
+ // ─── Plugin Resolution ────────────────────────────────────────────────────
361
+
362
+ /**
363
+ * Resolve a plugin to determine installation strategy and module registration files.
364
+ * Results are cached in _resolutionCache keyed by module code.
365
+ * @param {string} repoPath - Absolute path to the cloned repository or local directory
366
+ * @param {Object} plugin - Raw plugin object from marketplace.json
367
+ * @param {string} [sourceUrl] - Original URL for manifest tracking (null for local)
368
+ * @param {string} [localPath] - Local source path for manifest tracking (null for URLs)
369
+ * @returns {Promise<Array<Object>>} Array of ResolvedModule objects
370
+ */
371
+ async resolvePlugin(repoPath, plugin, sourceUrl, localPath) {
372
+ const { PluginResolver } = require('./plugin-resolver');
373
+ const resolver = new PluginResolver();
374
+ const resolved = await resolver.resolve(repoPath, plugin);
375
+
376
+ // Stamp source info onto each resolved module for manifest tracking
377
+ for (const mod of resolved) {
378
+ if (sourceUrl) mod.repoUrl = sourceUrl;
379
+ if (localPath) mod.localPath = localPath;
380
+ CustomModuleManager._resolutionCache.set(mod.code, mod);
381
+ }
382
+
383
+ return resolved;
384
+ }
385
+
386
+ /**
387
+ * Get a cached resolution result by module code.
388
+ * @param {string} moduleCode - Module code to look up
389
+ * @returns {Object|null} ResolvedModule or null if not cached
390
+ */
391
+ getResolution(moduleCode) {
392
+ return CustomModuleManager._resolutionCache.get(moduleCode) || null;
393
+ }
394
+
180
395
  // ─── Source Finding ───────────────────────────────────────────────────────
181
396
 
182
397
  /**
183
- * Find the module source path within a cloned custom repo.
184
- * @param {string} repoUrl - GitHub repository URL (for cache location)
398
+ * Find the module source path within a cached or local source directory.
399
+ * @param {string} sourceInput - Git URL or local path (used to locate cached clone)
185
400
  * @param {string} [pluginSource] - Plugin source path from marketplace.json
186
401
  * @returns {string|null} Path to directory containing module.yaml
187
402
  */
188
- async findModuleSource(repoUrl, pluginSource) {
189
- const { owner, repo } = this.validateGitHubUrl(repoUrl);
190
- const repoCacheDir = path.join(this.getCacheDir(), owner, repo);
403
+ async findModuleSource(sourceInput, pluginSource) {
404
+ const parsed = this.parseSource(sourceInput);
405
+ if (!parsed.isValid) return null;
406
+
407
+ let baseDir;
408
+ if (parsed.type === 'local') {
409
+ baseDir = parsed.localPath;
410
+ } else {
411
+ baseDir = path.join(this.getCacheDir(), ...parsed.cacheKey.split('/'));
412
+ }
191
413
 
192
- if (!(await fs.pathExists(repoCacheDir))) return null;
414
+ if (!(await fs.pathExists(baseDir))) return null;
193
415
 
194
416
  // Try plugin source path first (e.g., "./src/pro-skills")
195
417
  if (pluginSource) {
196
- const sourcePath = path.join(repoCacheDir, pluginSource);
418
+ const sourcePath = path.join(baseDir, pluginSource);
197
419
  const moduleYaml = path.join(sourcePath, 'module.yaml');
198
420
  if (await fs.pathExists(moduleYaml)) {
199
421
  return sourcePath;
@@ -202,11 +424,11 @@ class CustomModuleManager {
202
424
 
203
425
  // Fallback: search skills/ and src/ directories
204
426
  for (const dir of ['skills', 'src']) {
205
- const rootCandidate = path.join(repoCacheDir, dir, 'module.yaml');
427
+ const rootCandidate = path.join(baseDir, dir, 'module.yaml');
206
428
  if (await fs.pathExists(rootCandidate)) {
207
429
  return path.dirname(rootCandidate);
208
430
  }
209
- const dirPath = path.join(repoCacheDir, dir);
431
+ const dirPath = path.join(baseDir, dir);
210
432
  if (await fs.pathExists(dirPath)) {
211
433
  const entries = await fs.readdir(dirPath, { withFileTypes: true });
212
434
  for (const entry of entries) {
@@ -220,10 +442,10 @@ class CustomModuleManager {
220
442
  }
221
443
  }
222
444
 
223
- // Check repo root
224
- const rootCandidate = path.join(repoCacheDir, 'module.yaml');
445
+ // Check base directory root
446
+ const rootCandidate = path.join(baseDir, 'module.yaml');
225
447
  if (await fs.pathExists(rootCandidate)) {
226
- return repoCacheDir;
448
+ return baseDir;
227
449
  }
228
450
 
229
451
  return null;
@@ -231,51 +453,163 @@ class CustomModuleManager {
231
453
 
232
454
  /**
233
455
  * Find module source by module code, searching the custom cache.
456
+ * Handles both new 3-level cache structure (host/owner/repo) and
457
+ * legacy 2-level structure (owner/repo).
234
458
  * @param {string} moduleCode - Module code to search for
235
459
  * @param {Object} [options] - Options
236
460
  * @returns {string|null} Path to the module source or null
237
461
  */
238
462
  async findModuleSourceByCode(moduleCode, options = {}) {
463
+ // Check resolution cache first (populated by resolvePlugin)
464
+ const resolved = CustomModuleManager._resolutionCache.get(moduleCode);
465
+ if (resolved) {
466
+ // For strategies 1-2: the common parent or setup skill's parent has the module files
467
+ if (resolved.moduleYamlPath) {
468
+ return path.dirname(resolved.moduleYamlPath);
469
+ }
470
+ // For strategy 5 (synthesized): return the first skill's parent as a reference path
471
+ if (resolved.skillPaths && resolved.skillPaths.length > 0) {
472
+ return path.dirname(resolved.skillPaths[0]);
473
+ }
474
+ }
475
+
239
476
  const cacheDir = this.getCacheDir();
240
477
  if (!(await fs.pathExists(cacheDir))) return null;
241
478
 
242
- // Search through all custom repo caches
479
+ // Search through all cached repo roots
243
480
  try {
244
- const owners = await fs.readdir(cacheDir, { withFileTypes: true });
245
- for (const ownerEntry of owners) {
246
- if (!ownerEntry.isDirectory()) continue;
247
- const ownerPath = path.join(cacheDir, ownerEntry.name);
248
- const repos = await fs.readdir(ownerPath, { withFileTypes: true });
249
- for (const repoEntry of repos) {
250
- if (!repoEntry.isDirectory()) continue;
251
- const repoPath = path.join(ownerPath, repoEntry.name);
252
-
253
- // Check marketplace.json for matching module code
254
- const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
255
- if (await fs.pathExists(marketplacePath)) {
256
- try {
257
- const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
258
- for (const plugin of data.plugins || []) {
259
- if (plugin.name === moduleCode) {
260
- // Found the module - find its source
261
- const sourcePath = plugin.source ? path.join(repoPath, plugin.source) : repoPath;
262
- const moduleYaml = path.join(sourcePath, 'module.yaml');
263
- if (await fs.pathExists(moduleYaml)) {
264
- return sourcePath;
481
+ const { PluginResolver } = require('./plugin-resolver');
482
+ const resolver = new PluginResolver();
483
+ const repoRoots = await this._findCacheRepoRoots(cacheDir);
484
+
485
+ for (const { repoPath, metadata } of repoRoots) {
486
+ // Check marketplace.json for matching module code
487
+ const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
488
+ if (!(await fs.pathExists(marketplacePath))) continue;
489
+
490
+ try {
491
+ const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
492
+ for (const plugin of data.plugins || []) {
493
+ // Direct name match (legacy behavior)
494
+ if (plugin.name === moduleCode) {
495
+ const sourcePath = plugin.source ? path.join(repoPath, plugin.source) : repoPath;
496
+ const moduleYaml = path.join(sourcePath, 'module.yaml');
497
+ if (await fs.pathExists(moduleYaml)) {
498
+ return sourcePath;
499
+ }
500
+ }
501
+
502
+ // Resolve plugin to check if any module.yaml code matches
503
+ if (plugin.skills && plugin.skills.length > 0) {
504
+ try {
505
+ const resolvedMods = await resolver.resolve(repoPath, plugin);
506
+ for (const mod of resolvedMods) {
507
+ if (mod.code === moduleCode) {
508
+ // Use metadata for URL reconstruction instead of deriving from path
509
+ mod.repoUrl = metadata?.cloneUrl || null;
510
+ CustomModuleManager._resolutionCache.set(mod.code, mod);
511
+ if (mod.moduleYamlPath) {
512
+ return path.dirname(mod.moduleYamlPath);
513
+ }
514
+ if (mod.skillPaths && mod.skillPaths.length > 0) {
515
+ return path.dirname(mod.skillPaths[0]);
516
+ }
265
517
  }
266
518
  }
519
+ } catch {
520
+ // Skip unresolvable plugins
267
521
  }
268
- } catch {
269
- // Skip malformed marketplace.json
270
522
  }
271
523
  }
524
+ } catch {
525
+ // Skip malformed marketplace.json
272
526
  }
273
527
  }
274
528
  } catch {
275
529
  // Cache doesn't exist or is inaccessible
276
530
  }
277
531
 
278
- return null;
532
+ // Fallback: check manifest for localPath (local-source modules not in cache)
533
+ return this._findLocalSourceFromManifest(moduleCode, options);
534
+ }
535
+
536
+ /**
537
+ * Check the installation manifest for a localPath entry for this module.
538
+ * Used as fallback when the module was installed from a local source (no cache entry).
539
+ * Returns the path only if it still exists on disk; never removes installed files.
540
+ * @param {string} moduleCode - Module code to search for
541
+ * @param {Object} [options] - Options (must include bmadDir or will search common locations)
542
+ * @returns {string|null} Path to the local module source or null
543
+ */
544
+ async _findLocalSourceFromManifest(moduleCode, options = {}) {
545
+ try {
546
+ const { Manifest } = require('../core/manifest');
547
+ const manifestObj = new Manifest();
548
+
549
+ // Try to find bmadDir from options or common locations
550
+ const bmadDir = options.bmadDir;
551
+ if (!bmadDir) return null;
552
+
553
+ const manifestData = await manifestObj.read(bmadDir);
554
+ if (!manifestData?.modulesDetailed) return null;
555
+
556
+ const moduleEntry = manifestData.modulesDetailed.find((m) => m.name === moduleCode);
557
+ if (!moduleEntry?.localPath) return null;
558
+
559
+ // Only return the path if it still exists (source not removed)
560
+ if (await fs.pathExists(moduleEntry.localPath)) {
561
+ return moduleEntry.localPath;
562
+ }
563
+
564
+ return null;
565
+ } catch {
566
+ return null;
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Recursively find repo root directories within the cache.
572
+ * A repo root is identified by containing .bmad-source.json (new) or .claude-plugin/ (legacy).
573
+ * Handles both 3-level (host/owner/repo) and legacy 2-level (owner/repo) cache layouts.
574
+ * @param {string} dir - Directory to search
575
+ * @param {number} [depth=0] - Current recursion depth
576
+ * @param {number} [maxDepth=4] - Maximum recursion depth
577
+ * @returns {Promise<Array<{repoPath: string, metadata: Object|null}>>}
578
+ */
579
+ async _findCacheRepoRoots(dir, depth = 0, maxDepth = 4) {
580
+ const results = [];
581
+
582
+ // Check if this directory is a repo root
583
+ const metadataPath = path.join(dir, '.bmad-source.json');
584
+ const claudePluginDir = path.join(dir, '.claude-plugin');
585
+
586
+ if (await fs.pathExists(metadataPath)) {
587
+ try {
588
+ const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
589
+ results.push({ repoPath: dir, metadata });
590
+ } catch {
591
+ results.push({ repoPath: dir, metadata: null });
592
+ }
593
+ return results; // Don't recurse into repo contents
594
+ }
595
+ if (await fs.pathExists(claudePluginDir)) {
596
+ results.push({ repoPath: dir, metadata: null });
597
+ return results;
598
+ }
599
+
600
+ // Recurse into subdirectories
601
+ if (depth >= maxDepth) return results;
602
+ try {
603
+ const entries = await fs.readdir(dir, { withFileTypes: true });
604
+ for (const entry of entries) {
605
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
606
+ const subResults = await this._findCacheRepoRoots(path.join(dir, entry.name), depth + 1, maxDepth);
607
+ results.push(...subResults);
608
+ }
609
+ } catch {
610
+ // Directory not readable
611
+ }
612
+ return results;
279
613
  }
280
614
 
281
615
  // ─── Normalization ────────────────────────────────────────────────────────
@@ -283,11 +617,11 @@ class CustomModuleManager {
283
617
  /**
284
618
  * Normalize a plugin from marketplace.json to a consistent shape.
285
619
  * @param {Object} plugin - Plugin object from marketplace.json
286
- * @param {string} repoUrl - Source repository URL
620
+ * @param {string|null} sourceUrl - Source URL (null for local paths)
287
621
  * @param {Object} data - Full marketplace.json data
288
622
  * @returns {Object} Normalized module info
289
623
  */
290
- _normalizeCustomModule(plugin, repoUrl, data) {
624
+ _normalizeCustomModule(plugin, sourceUrl, data) {
291
625
  return {
292
626
  code: plugin.name,
293
627
  name: plugin.name,
@@ -295,8 +629,10 @@ class CustomModuleManager {
295
629
  description: plugin.description || '',
296
630
  version: plugin.version || null,
297
631
  author: plugin.author || data.owner || '',
298
- url: repoUrl,
632
+ url: sourceUrl || null,
299
633
  source: plugin.source || null,
634
+ skills: plugin.skills || [],
635
+ rawPlugin: plugin,
300
636
  type: 'custom',
301
637
  trustTier: 'unverified',
302
638
  builtIn: false,