bmad-method 6.2.3-next.9 → 6.3.1-next.0

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.
Files changed (73) hide show
  1. package/.claude-plugin/marketplace.json +0 -3
  2. package/README.md +8 -9
  3. package/README_CN.md +1 -1
  4. package/README_VN.md +109 -0
  5. package/package.json +1 -1
  6. package/removals.txt +17 -0
  7. package/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/steps/step-13-responsive-accessibility.md +1 -1
  8. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/data/prd-purpose.md +197 -0
  9. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-01-discovery.md +1 -1
  10. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-01b-legacy-conversion.md +1 -1
  11. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-02-review.md +1 -1
  12. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-03-edit.md +1 -1
  13. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-04-complete.md +1 -3
  14. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/steps/step-01-document-discovery.md +1 -1
  15. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/steps/step-02-prd-analysis.md +1 -1
  16. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/steps/step-03-epic-coverage-validation.md +1 -1
  17. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/workflow.md +1 -1
  18. package/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/workflow.md +1 -1
  19. package/src/bmm-skills/4-implementation/bmad-agent-dev/SKILL.md +5 -0
  20. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/SKILL.md +29 -0
  21. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/generate-trail.md +38 -0
  22. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-01-orientation.md +105 -0
  23. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-02-walkthrough.md +89 -0
  24. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-03-detail-pass.md +106 -0
  25. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-04-testing.md +74 -0
  26. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-05-wrapup.md +24 -0
  27. package/src/bmm-skills/4-implementation/bmad-code-review/steps/step-01-gather-context.md +38 -15
  28. package/src/bmm-skills/4-implementation/bmad-code-review/steps/step-04-present.md +14 -17
  29. package/src/bmm-skills/4-implementation/bmad-correct-course/checklist.md +2 -2
  30. package/src/bmm-skills/4-implementation/bmad-correct-course/workflow.md +8 -8
  31. package/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/checklist.md +1 -1
  32. package/src/bmm-skills/4-implementation/bmad-quick-dev/compile-epic-context.md +62 -0
  33. package/src/bmm-skills/4-implementation/bmad-quick-dev/spec-template.md +1 -1
  34. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-01-clarify-and-route.md +33 -6
  35. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-02-plan.md +20 -8
  36. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-03-implement.md +2 -0
  37. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md +16 -4
  38. package/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md +1 -5
  39. package/src/bmm-skills/4-implementation/bmad-retrospective/workflow.md +134 -134
  40. package/src/bmm-skills/4-implementation/bmad-sprint-planning/sprint-status-template.yaml +1 -1
  41. package/src/bmm-skills/4-implementation/bmad-sprint-planning/workflow.md +3 -3
  42. package/src/bmm-skills/4-implementation/bmad-sprint-status/workflow.md +2 -2
  43. package/src/bmm-skills/module-help.csv +2 -0
  44. package/src/core-skills/bmad-help/SKILL.md +4 -2
  45. package/src/core-skills/bmad-party-mode/SKILL.md +8 -6
  46. package/src/core-skills/module-help.csv +1 -0
  47. package/tools/installer/cli-utils.js +18 -9
  48. package/tools/installer/commands/install.js +1 -1
  49. package/tools/installer/core/existing-install.js +2 -8
  50. package/tools/installer/core/install-paths.js +0 -3
  51. package/tools/installer/core/installer.js +180 -463
  52. package/tools/installer/core/manifest-generator.js +8 -14
  53. package/tools/installer/core/manifest.js +94 -102
  54. package/tools/installer/ide/_config-driven.js +149 -38
  55. package/tools/installer/ide/shared/skill-manifest.js +1 -16
  56. package/tools/installer/install-messages.yaml +19 -26
  57. package/tools/installer/modules/community-manager.js +377 -0
  58. package/tools/installer/modules/custom-module-manager.js +644 -0
  59. package/tools/installer/modules/external-manager.js +65 -49
  60. package/tools/installer/modules/official-modules.js +117 -65
  61. package/tools/installer/modules/plugin-resolver.js +398 -0
  62. package/tools/installer/modules/registry-client.js +66 -0
  63. package/tools/installer/{external-official-modules.yaml → modules/registry-fallback.yaml} +3 -12
  64. package/tools/installer/ui.js +549 -666
  65. package/src/bmm-skills/4-implementation/bmad-agent-qa/SKILL.md +0 -61
  66. package/src/bmm-skills/4-implementation/bmad-agent-qa/bmad-skill-manifest.yaml +0 -11
  67. package/src/bmm-skills/4-implementation/bmad-agent-quick-flow-solo-dev/SKILL.md +0 -53
  68. package/src/bmm-skills/4-implementation/bmad-agent-quick-flow-solo-dev/bmad-skill-manifest.yaml +0 -11
  69. package/src/bmm-skills/4-implementation/bmad-agent-sm/SKILL.md +0 -55
  70. package/src/bmm-skills/4-implementation/bmad-agent-sm/bmad-skill-manifest.yaml +0 -11
  71. package/tools/installer/core/custom-module-cache.js +0 -260
  72. package/tools/installer/custom-handler.js +0 -112
  73. package/tools/installer/modules/custom-modules.js +0 -197
@@ -0,0 +1,644 @@
1
+ const fs = require('fs-extra');
2
+ const os = require('node:os');
3
+ const path = require('node:path');
4
+ const { execSync } = require('node:child_process');
5
+ const prompts = require('../prompts');
6
+
7
+ /**
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
+ */
12
+ class CustomModuleManager {
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
+ };
122
+ }
123
+
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
+ }
157
+
158
+ /**
159
+ * @deprecated Use parseSource() instead. Kept for backward compatibility.
160
+ * Parse and validate a GitHub repository URL.
161
+ * @param {string} url - GitHub URL to validate
162
+ * @returns {Object} { owner, repo, isValid, error }
163
+ */
164
+ validateGitHubUrl(url) {
165
+ if (!url || typeof url !== 'string') {
166
+ return { owner: null, repo: null, isValid: false, error: 'URL is required' };
167
+ }
168
+ const trimmed = url.trim();
169
+
170
+ // HTTPS format: https://github.com/owner/repo[.git] (strict, no trailing path)
171
+ const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
172
+ if (httpsMatch) {
173
+ return { owner: httpsMatch[1], repo: httpsMatch[2], isValid: true, error: null };
174
+ }
175
+
176
+ // SSH format: git@github.com:owner/repo[.git]
177
+ const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
178
+ if (sshMatch) {
179
+ return { owner: sshMatch[1], repo: sshMatch[2], isValid: true, error: null };
180
+ }
181
+
182
+ return { owner: null, repo: null, isValid: false, error: 'Not a valid GitHub URL (expected https://github.com/owner/repo)' };
183
+ }
184
+
185
+ // ─── Marketplace JSON ─────────────────────────────────────────────────────
186
+
187
+ /**
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
191
+ */
192
+ async readMarketplaceJsonFromDisk(dirPath) {
193
+ const marketplacePath = path.join(dirPath, '.claude-plugin', 'marketplace.json');
194
+ if (!(await fs.pathExists(marketplacePath))) return null;
195
+ try {
196
+ return JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ // ─── Discovery ────────────────────────────────────────────────────────────
203
+
204
+ /**
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)
208
+ * @returns {Array<Object>} Normalized plugin list
209
+ */
210
+ async discoverModules(marketplaceData, sourceUrl) {
211
+ const plugins = marketplaceData?.plugins;
212
+
213
+ if (!Array.isArray(plugins) || plugins.length === 0) {
214
+ throw new Error('marketplace.json contains no plugins');
215
+ }
216
+
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 };
254
+ }
255
+
256
+ // ─── Clone ────────────────────────────────────────────────────────────────
257
+
258
+ /**
259
+ * Get the cache directory for custom modules.
260
+ * @returns {string} Path to the custom modules cache directory
261
+ */
262
+ getCacheDir() {
263
+ return path.join(os.homedir(), '.bmad', 'cache', 'custom-modules');
264
+ }
265
+
266
+ /**
267
+ * Clone a custom module repository to cache.
268
+ * Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted, etc.).
269
+ * @param {string} sourceInput - Git URL (HTTPS or SSH)
270
+ * @param {Object} [options] - Clone options
271
+ * @param {boolean} [options.silent] - Suppress spinner output
272
+ * @param {boolean} [options.skipInstall] - Skip npm install (for browsing before user confirms)
273
+ * @returns {string} Path to the cloned repository
274
+ */
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');
279
+
280
+ const cacheDir = this.getCacheDir();
281
+ const repoCacheDir = path.join(cacheDir, ...parsed.cacheKey.split('/'));
282
+ const silent = options.silent || false;
283
+ const displayName = parsed.displayName;
284
+
285
+ await fs.ensureDir(path.dirname(repoCacheDir));
286
+
287
+ const createSpinner = async () => {
288
+ if (silent) {
289
+ return { start() {}, stop() {}, error() {} };
290
+ }
291
+ return await prompts.spinner();
292
+ };
293
+
294
+ if (await fs.pathExists(repoCacheDir)) {
295
+ // Update existing clone
296
+ const fetchSpinner = await createSpinner();
297
+ fetchSpinner.start(`Updating ${displayName}...`);
298
+ try {
299
+ execSync('git fetch origin --depth 1', {
300
+ cwd: repoCacheDir,
301
+ stdio: ['ignore', 'pipe', 'pipe'],
302
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
303
+ });
304
+ execSync('git reset --hard origin/HEAD', {
305
+ cwd: repoCacheDir,
306
+ stdio: ['ignore', 'pipe', 'pipe'],
307
+ });
308
+ fetchSpinner.stop(`Updated ${displayName}`);
309
+ } catch {
310
+ fetchSpinner.error(`Update failed, re-downloading ${displayName}`);
311
+ await fs.remove(repoCacheDir);
312
+ }
313
+ }
314
+
315
+ if (!(await fs.pathExists(repoCacheDir))) {
316
+ const fetchSpinner = await createSpinner();
317
+ fetchSpinner.start(`Cloning ${displayName}...`);
318
+ try {
319
+ execSync(`git clone --depth 1 "${parsed.cloneUrl}" "${repoCacheDir}"`, {
320
+ stdio: ['ignore', 'pipe', 'pipe'],
321
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
322
+ });
323
+ fetchSpinner.stop(`Cloned ${displayName}`);
324
+ } catch (error_) {
325
+ fetchSpinner.error(`Failed to clone ${displayName}`);
326
+ throw new Error(`Failed to clone ${parsed.cloneUrl}: ${error_.message}`);
327
+ }
328
+ }
329
+
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)
340
+ const packageJsonPath = path.join(repoCacheDir, 'package.json');
341
+ if (!options.skipInstall && (await fs.pathExists(packageJsonPath))) {
342
+ const installSpinner = await createSpinner();
343
+ installSpinner.start(`Installing dependencies for ${displayName}...`);
344
+ try {
345
+ execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
346
+ cwd: repoCacheDir,
347
+ stdio: ['ignore', 'pipe', 'pipe'],
348
+ timeout: 120_000,
349
+ });
350
+ installSpinner.stop(`Installed dependencies for ${displayName}`);
351
+ } catch (error_) {
352
+ installSpinner.error(`Failed to install dependencies for ${displayName}`);
353
+ if (!silent) await prompts.log.warn(` ${error_.message}`);
354
+ }
355
+ }
356
+
357
+ return repoCacheDir;
358
+ }
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
+
395
+ // ─── Source Finding ───────────────────────────────────────────────────────
396
+
397
+ /**
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)
400
+ * @param {string} [pluginSource] - Plugin source path from marketplace.json
401
+ * @returns {string|null} Path to directory containing module.yaml
402
+ */
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
+ }
413
+
414
+ if (!(await fs.pathExists(baseDir))) return null;
415
+
416
+ // Try plugin source path first (e.g., "./src/pro-skills")
417
+ if (pluginSource) {
418
+ const sourcePath = path.join(baseDir, pluginSource);
419
+ const moduleYaml = path.join(sourcePath, 'module.yaml');
420
+ if (await fs.pathExists(moduleYaml)) {
421
+ return sourcePath;
422
+ }
423
+ }
424
+
425
+ // Fallback: search skills/ and src/ directories
426
+ for (const dir of ['skills', 'src']) {
427
+ const rootCandidate = path.join(baseDir, dir, 'module.yaml');
428
+ if (await fs.pathExists(rootCandidate)) {
429
+ return path.dirname(rootCandidate);
430
+ }
431
+ const dirPath = path.join(baseDir, dir);
432
+ if (await fs.pathExists(dirPath)) {
433
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
434
+ for (const entry of entries) {
435
+ if (entry.isDirectory()) {
436
+ const subCandidate = path.join(dirPath, entry.name, 'module.yaml');
437
+ if (await fs.pathExists(subCandidate)) {
438
+ return path.dirname(subCandidate);
439
+ }
440
+ }
441
+ }
442
+ }
443
+ }
444
+
445
+ // Check base directory root
446
+ const rootCandidate = path.join(baseDir, 'module.yaml');
447
+ if (await fs.pathExists(rootCandidate)) {
448
+ return baseDir;
449
+ }
450
+
451
+ return null;
452
+ }
453
+
454
+ /**
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).
458
+ * @param {string} moduleCode - Module code to search for
459
+ * @param {Object} [options] - Options
460
+ * @returns {string|null} Path to the module source or null
461
+ */
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
+
476
+ const cacheDir = this.getCacheDir();
477
+ if (!(await fs.pathExists(cacheDir))) return null;
478
+
479
+ // Search through all cached repo roots
480
+ try {
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
+ }
517
+ }
518
+ }
519
+ } catch {
520
+ // Skip unresolvable plugins
521
+ }
522
+ }
523
+ }
524
+ } catch {
525
+ // Skip malformed marketplace.json
526
+ }
527
+ }
528
+ } catch {
529
+ // Cache doesn't exist or is inaccessible
530
+ }
531
+
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;
613
+ }
614
+
615
+ // ─── Normalization ────────────────────────────────────────────────────────
616
+
617
+ /**
618
+ * Normalize a plugin from marketplace.json to a consistent shape.
619
+ * @param {Object} plugin - Plugin object from marketplace.json
620
+ * @param {string|null} sourceUrl - Source URL (null for local paths)
621
+ * @param {Object} data - Full marketplace.json data
622
+ * @returns {Object} Normalized module info
623
+ */
624
+ _normalizeCustomModule(plugin, sourceUrl, data) {
625
+ return {
626
+ code: plugin.name,
627
+ name: plugin.name,
628
+ displayName: plugin.name,
629
+ description: plugin.description || '',
630
+ version: plugin.version || null,
631
+ author: plugin.author || data.owner || '',
632
+ url: sourceUrl || null,
633
+ source: plugin.source || null,
634
+ skills: plugin.skills || [],
635
+ rawPlugin: plugin,
636
+ type: 'custom',
637
+ trustTier: 'unverified',
638
+ builtIn: false,
639
+ isExternal: true,
640
+ };
641
+ }
642
+ }
643
+
644
+ module.exports = { CustomModuleManager };