bmad-method 6.6.1-next.9 → 6.7.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 (25) hide show
  1. package/{tools/installer/modules/registry-fallback.yaml → bmad-modules.yaml} +29 -15
  2. package/package.json +1 -1
  3. package/src/bmm-skills/1-analysis/bmad-product-brief/SKILL.md +18 -11
  4. package/src/bmm-skills/1-analysis/bmad-product-brief/customize.toml +13 -8
  5. package/src/bmm-skills/2-plan-workflows/bmad-prd/SKILL.md +54 -57
  6. package/src/bmm-skills/2-plan-workflows/bmad-prd/assets/headless-schemas.md +2 -2
  7. package/src/bmm-skills/2-plan-workflows/bmad-prd/assets/prd-template.md +40 -30
  8. package/src/bmm-skills/2-plan-workflows/bmad-prd/assets/prd-validation-checklist.md +126 -21
  9. package/src/bmm-skills/2-plan-workflows/bmad-prd/assets/validation-report-template.html +193 -58
  10. package/src/bmm-skills/2-plan-workflows/bmad-prd/customize.toml +47 -13
  11. package/src/bmm-skills/2-plan-workflows/bmad-prd/references/headless.md +27 -12
  12. package/src/bmm-skills/2-plan-workflows/bmad-prd/references/validate.md +97 -0
  13. package/src/bmm-skills/module.yaml +2 -2
  14. package/src/core-skills/module.yaml +1 -1
  15. package/tools/installer/core/installer.js +1 -22
  16. package/tools/installer/core/manifest.js +0 -22
  17. package/tools/installer/modules/channel-plan.js +1 -1
  18. package/tools/installer/modules/external-manager.js +9 -27
  19. package/tools/installer/modules/official-modules.js +9 -48
  20. package/tools/installer/ui.js +12 -196
  21. package/src/bmm-skills/2-plan-workflows/bmad-prd/references/facilitation-guide.md +0 -79
  22. package/src/bmm-skills/2-plan-workflows/bmad-prd/references/validation-render.md +0 -58
  23. package/src/bmm-skills/2-plan-workflows/bmad-prd/scripts/render-validation-html.py +0 -290
  24. package/tools/installer/modules/community-manager.js +0 -704
  25. package/tools/installer/modules/registry-client.js +0 -187
@@ -1,704 +0,0 @@
1
- const fs = require('../fs-native');
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
- const { RegistryClient } = require('./registry-client');
7
- const { decideChannelForModule } = require('./channel-plan');
8
- const { parseGitHubRepo, tagExists } = require('./channel-resolver');
9
-
10
- const MARKETPLACE_OWNER = 'bmad-code-org';
11
- const MARKETPLACE_REPO = 'bmad-plugins-marketplace';
12
- const MARKETPLACE_REF = 'main';
13
-
14
- /**
15
- * Manages community modules from the BMad marketplace registry.
16
- * Fetches community-index.yaml and categories.yaml from GitHub.
17
- * Returns empty results when the registry is unreachable.
18
- * Community modules are pinned to approved SHA when set; uses HEAD otherwise.
19
- */
20
- function quoteShellRef(ref) {
21
- if (typeof ref !== 'string' || !/^[\w.\-+/]+$/.test(ref)) {
22
- throw new Error(`Unsafe ref name: ${JSON.stringify(ref)}`);
23
- }
24
- return `"${ref}"`;
25
- }
26
-
27
- class CommunityModuleManager {
28
- // moduleCode → { channel, version, sha, registryApprovedTag, registryApprovedSha, repoUrl, bypassedCurator }
29
- // Shared across all instances; the manifest writer often uses a fresh instance.
30
- static _resolutions = new Map();
31
-
32
- // moduleCode → ResolvedModule (from PluginResolver) when the cloned repo ships
33
- // a `.claude-plugin/marketplace.json`. Lets community installs reuse the same
34
- // skill-level install pipeline as custom-source installs (installFromResolution).
35
- static _pluginResolutions = new Map();
36
-
37
- constructor() {
38
- this._client = new RegistryClient();
39
- this._cachedIndex = null;
40
- this._cachedCategories = null;
41
- }
42
-
43
- /** Get the most recent channel resolution for a community module. */
44
- getResolution(moduleCode) {
45
- return CommunityModuleManager._resolutions.get(moduleCode) || null;
46
- }
47
-
48
- /** Get the marketplace.json-derived plugin resolution for a community module, if any. */
49
- getPluginResolution(moduleCode) {
50
- return CommunityModuleManager._pluginResolutions.get(moduleCode) || null;
51
- }
52
-
53
- // ─── Data Loading ──────────────────────────────────────────────────────────
54
-
55
- /**
56
- * Load the community module index from the marketplace repo.
57
- * Returns empty when the registry is unreachable.
58
- * @returns {Object} Parsed YAML with modules array
59
- */
60
- async loadCommunityIndex() {
61
- if (this._cachedIndex) return this._cachedIndex;
62
-
63
- try {
64
- const config = await this._client.fetchGitHubYaml(
65
- MARKETPLACE_OWNER,
66
- MARKETPLACE_REPO,
67
- 'registry/community-index.yaml',
68
- MARKETPLACE_REF,
69
- );
70
- if (config?.modules?.length) {
71
- this._cachedIndex = config;
72
- return config;
73
- }
74
- } catch {
75
- // Registry unreachable - no community modules available
76
- }
77
-
78
- return { modules: [] };
79
- }
80
-
81
- /**
82
- * Load categories from the marketplace repo.
83
- * Returns empty when the registry is unreachable.
84
- * @returns {Object} Parsed categories.yaml content
85
- */
86
- async loadCategories() {
87
- if (this._cachedCategories) return this._cachedCategories;
88
-
89
- try {
90
- const config = await this._client.fetchGitHubYaml(MARKETPLACE_OWNER, MARKETPLACE_REPO, 'categories.yaml', MARKETPLACE_REF);
91
- if (config?.categories) {
92
- this._cachedCategories = config;
93
- return config;
94
- }
95
- } catch {
96
- // Registry unreachable - no categories available
97
- }
98
-
99
- return { categories: {} };
100
- }
101
-
102
- // ─── Listing & Filtering ──────────────────────────────────────────────────
103
-
104
- /**
105
- * Get all community modules, normalized.
106
- * @returns {Array<Object>} Normalized community modules
107
- */
108
- async listAll() {
109
- const index = await this.loadCommunityIndex();
110
- return (index.modules || []).map((mod) => this._normalizeCommunityModule(mod));
111
- }
112
-
113
- /**
114
- * Get community modules filtered to a category.
115
- * @param {string} categorySlug - Category slug (e.g., 'design-and-creative')
116
- * @returns {Array<Object>} Filtered modules
117
- */
118
- async listByCategory(categorySlug) {
119
- const all = await this.listAll();
120
- return all.filter((mod) => mod.category === categorySlug);
121
- }
122
-
123
- /**
124
- * Get promoted/featured community modules, sorted by rank.
125
- * @returns {Array<Object>} Featured modules
126
- */
127
- async listFeatured() {
128
- const all = await this.listAll();
129
- return all.filter((mod) => mod.promoted === true).sort((a, b) => (a.promotedRank || 999) - (b.promotedRank || 999));
130
- }
131
-
132
- /**
133
- * Search community modules by keyword.
134
- * Matches against name, display name, description, and keywords array.
135
- * @param {string} query - Search query
136
- * @returns {Array<Object>} Matching modules
137
- */
138
- async searchByKeyword(query) {
139
- const all = await this.listAll();
140
- const q = query.toLowerCase();
141
- return all.filter((mod) => {
142
- const searchable = [mod.name, mod.displayName, mod.description, ...(mod.keywords || [])].join(' ').toLowerCase();
143
- return searchable.includes(q);
144
- });
145
- }
146
-
147
- /**
148
- * Get categories with module counts for UI display.
149
- * Only returns categories that have at least one community module.
150
- * @returns {Array<Object>} Array of { slug, name, moduleCount }
151
- */
152
- async getCategoryList() {
153
- const all = await this.listAll();
154
- const categoriesData = await this.loadCategories();
155
- const categories = categoriesData.categories || {};
156
-
157
- // Count modules per category
158
- const counts = {};
159
- for (const mod of all) {
160
- counts[mod.category] = (counts[mod.category] || 0) + 1;
161
- }
162
-
163
- // Build list with display names from categories.yaml
164
- const result = [];
165
- for (const [slug, count] of Object.entries(counts)) {
166
- const catInfo = categories[slug];
167
- result.push({
168
- slug,
169
- name: catInfo?.name || slug,
170
- moduleCount: count,
171
- });
172
- }
173
-
174
- // Sort alphabetically by name
175
- result.sort((a, b) => a.name.localeCompare(b.name));
176
- return result;
177
- }
178
-
179
- // ─── Module Lookup ────────────────────────────────────────────────────────
180
-
181
- /**
182
- * Get a community module by its code.
183
- * @param {string} code - Module code (e.g., 'wds')
184
- * @returns {Object|null} Normalized module or null
185
- */
186
- async getModuleByCode(code) {
187
- const all = await this.listAll();
188
- return all.find((m) => m.code === code) || null;
189
- }
190
-
191
- // ─── Clone with Tag Pinning ───────────────────────────────────────────────
192
-
193
- /**
194
- * Get the cache directory for community modules.
195
- * @returns {string} Path to the community modules cache directory
196
- */
197
- getCacheDir() {
198
- return path.join(os.homedir(), '.bmad', 'cache', 'community-modules');
199
- }
200
-
201
- /**
202
- * Clone a community module repository, pinned to its approved tag.
203
- * @param {string} moduleCode - Module code
204
- * @param {Object} [options] - Clone options
205
- * @param {boolean} [options.silent] - Suppress spinner output
206
- * @returns {string} Path to the cloned repository
207
- */
208
- async cloneModule(moduleCode, options = {}) {
209
- const moduleInfo = await this.getModuleByCode(moduleCode);
210
- if (!moduleInfo) {
211
- throw new Error(`Community module '${moduleCode}' not found in the registry`);
212
- }
213
-
214
- const cacheDir = this.getCacheDir();
215
- const moduleCacheDir = path.join(cacheDir, moduleCode);
216
- const silent = options.silent || false;
217
-
218
- await fs.ensureDir(cacheDir);
219
-
220
- const createSpinner = async () => {
221
- if (silent) {
222
- return { start() {}, stop() {}, error() {}, message() {} };
223
- }
224
- return await prompts.spinner();
225
- };
226
-
227
- // ─── Resolve channel plan ──────────────────────────────────────────────
228
- // Default community behavior (stable channel) honors the curator's
229
- // approved SHA. --next=CODE and --pin CODE=TAG override the curator; we
230
- // warn the user before bypassing the approved version.
231
- const planEntry = decideChannelForModule({
232
- code: moduleCode,
233
- channelOptions: options.channelOptions,
234
- registryDefault: 'stable',
235
- });
236
-
237
- const approvedSha = moduleInfo.approvedSha;
238
- const approvedTag = moduleInfo.approvedTag;
239
-
240
- let bypassedCurator = false;
241
- if (planEntry.channel !== 'stable') {
242
- bypassedCurator = true;
243
- if (!silent) {
244
- const approvedLabel = approvedTag || approvedSha || 'curator-approved version';
245
- await prompts.log.warn(
246
- `WARNING: Installing '${moduleCode}' from ${
247
- planEntry.channel === 'pinned' ? `tag ${planEntry.pin}` : 'main HEAD'
248
- } bypasses the curator-approved ${approvedLabel}. Proceed only if you trust this source.`,
249
- );
250
- if (!options.channelOptions?.acceptBypass) {
251
- const proceed = await prompts.confirm({
252
- message: `Continue installing '${moduleCode}' with curator bypass?`,
253
- default: false,
254
- });
255
- if (!proceed) {
256
- throw new Error(`Install of community module '${moduleCode}' cancelled by user.`);
257
- }
258
- }
259
- }
260
- }
261
-
262
- let needsDependencyInstall = false;
263
- let wasNewClone = false;
264
-
265
- if (await fs.pathExists(moduleCacheDir)) {
266
- // Already cloned — refresh to the correct ref for the resolved channel.
267
- // A pinned install must not reset to origin/HEAD (it would silently drift
268
- // to main on every re-install). Stable + approvedSha is handled below
269
- // by the curator-SHA checkout logic.
270
- const fetchSpinner = await createSpinner();
271
- fetchSpinner.start(`Checking ${moduleInfo.displayName}...`);
272
- try {
273
- const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
274
- execSync('git fetch origin --depth 1', {
275
- cwd: moduleCacheDir,
276
- stdio: ['ignore', 'pipe', 'pipe'],
277
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
278
- });
279
- if (planEntry.channel === 'pinned') {
280
- // Fetch the pin tag specifically and check it out.
281
- execSync(`git fetch --depth 1 origin ${quoteShellRef(planEntry.pin)} --no-tags`, {
282
- cwd: moduleCacheDir,
283
- stdio: ['ignore', 'pipe', 'pipe'],
284
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
285
- });
286
- execSync('git checkout --quiet FETCH_HEAD', {
287
- cwd: moduleCacheDir,
288
- stdio: ['ignore', 'pipe', 'pipe'],
289
- });
290
- } else {
291
- // stable (approvedSha path re-checks out below) and next: track main.
292
- execSync('git reset --hard origin/HEAD', {
293
- cwd: moduleCacheDir,
294
- stdio: ['ignore', 'pipe', 'pipe'],
295
- });
296
- }
297
- const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
298
- if (currentRef !== newRef) needsDependencyInstall = true;
299
- fetchSpinner.stop(`Verified ${moduleInfo.displayName}`);
300
- } catch {
301
- fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.displayName}`);
302
- await fs.remove(moduleCacheDir);
303
- wasNewClone = true;
304
- }
305
- } else {
306
- wasNewClone = true;
307
- }
308
-
309
- if (wasNewClone) {
310
- const fetchSpinner = await createSpinner();
311
- fetchSpinner.start(`Fetching ${moduleInfo.displayName}...`);
312
- try {
313
- if (planEntry.channel === 'pinned') {
314
- execSync(`git clone --depth 1 --branch ${quoteShellRef(planEntry.pin)} "${moduleInfo.url}" "${moduleCacheDir}"`, {
315
- stdio: ['ignore', 'pipe', 'pipe'],
316
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
317
- });
318
- } else {
319
- execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
320
- stdio: ['ignore', 'pipe', 'pipe'],
321
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
322
- });
323
- }
324
- fetchSpinner.stop(`Fetched ${moduleInfo.displayName}`);
325
- needsDependencyInstall = true;
326
- } catch (error) {
327
- fetchSpinner.error(`Failed to fetch ${moduleInfo.displayName}`);
328
- throw new Error(`Failed to clone community module '${moduleCode}': ${error.message}`);
329
- }
330
- }
331
-
332
- // ─── Check out the resolved ref per channel ──────────────────────────
333
- if (planEntry.channel === 'stable' && approvedSha) {
334
- // Default path: pin to the curator-approved SHA. Refuse install if the SHA
335
- // is unreachable (tag may have been deleted or rewritten) — security requirement.
336
- const headSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
337
- if (headSha !== approvedSha) {
338
- try {
339
- execSync(`git fetch --depth 1 origin ${quoteShellRef(approvedSha)}`, {
340
- cwd: moduleCacheDir,
341
- stdio: ['ignore', 'pipe', 'pipe'],
342
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
343
- });
344
- execSync(`git checkout ${quoteShellRef(approvedSha)}`, {
345
- cwd: moduleCacheDir,
346
- stdio: ['ignore', 'pipe', 'pipe'],
347
- });
348
- needsDependencyInstall = true;
349
- } catch {
350
- await fs.remove(moduleCacheDir);
351
- throw new Error(
352
- `Community module '${moduleCode}' could not be pinned to its approved commit (${approvedSha}). ` +
353
- `Installation refused for security. The module registry entry may need updating, ` +
354
- `or use --next=${moduleCode} / --pin ${moduleCode}=<tag> to explicitly bypass.`,
355
- );
356
- }
357
- }
358
- } else if (planEntry.channel === 'stable' && !approvedSha) {
359
- // Registry data gap: tag or SHA missing. Warn but proceed at HEAD (pre-existing behavior).
360
- if (!silent) {
361
- await prompts.log.warn(`Community module '${moduleCode}' has no curator-approved SHA in the registry; installing from main HEAD.`);
362
- }
363
- } else if (planEntry.channel === 'pinned') {
364
- // We cloned the tag directly above (via --branch), but ensure HEAD matches.
365
- // No additional checkout needed.
366
- }
367
- // else: 'next' channel — already at origin/HEAD from the fetch/reset above.
368
-
369
- // Record the resolution so the manifest writer can pick up channel/version/sha.
370
- const installedSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
371
- const recordedVersion =
372
- planEntry.channel === 'pinned' ? planEntry.pin : planEntry.channel === 'next' ? 'main' : approvedTag || installedSha.slice(0, 7);
373
- CommunityModuleManager._resolutions.set(moduleCode, {
374
- channel: planEntry.channel,
375
- version: recordedVersion,
376
- sha: installedSha,
377
- registryApprovedTag: approvedTag || null,
378
- registryApprovedSha: approvedSha || null,
379
- repoUrl: moduleInfo.url,
380
- bypassedCurator,
381
- planSource: planEntry.source,
382
- });
383
-
384
- // If the repo ships a marketplace.json, route through PluginResolver so the
385
- // skill-level install pipeline (installFromResolution) handles the copy.
386
- // Repos without marketplace.json fall through to the legacy findModuleSource
387
- // path unchanged.
388
- await this._tryResolveMarketplacePlugin(moduleCacheDir, moduleInfo, {
389
- channel: planEntry.channel,
390
- version: recordedVersion,
391
- sha: installedSha,
392
- approvedTag,
393
- approvedSha,
394
- });
395
-
396
- // Install dependencies if needed
397
- const packageJsonPath = path.join(moduleCacheDir, 'package.json');
398
- if ((needsDependencyInstall || wasNewClone) && (await fs.pathExists(packageJsonPath))) {
399
- const installSpinner = await createSpinner();
400
- installSpinner.start(`Installing dependencies for ${moduleInfo.displayName}...`);
401
- try {
402
- execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
403
- cwd: moduleCacheDir,
404
- stdio: ['ignore', 'pipe', 'pipe'],
405
- timeout: 120_000,
406
- });
407
- installSpinner.stop(`Installed dependencies for ${moduleInfo.displayName}`);
408
- } catch (error) {
409
- installSpinner.error(`Failed to install dependencies for ${moduleInfo.displayName}`);
410
- if (!silent) await prompts.log.warn(` ${error.message}`);
411
- }
412
- }
413
-
414
- return moduleCacheDir;
415
- }
416
-
417
- // ─── Marketplace.json Resolution ──────────────────────────────────────────
418
-
419
- /**
420
- * Detect `.claude-plugin/marketplace.json` in a cloned community repo and
421
- * route through PluginResolver. When successful, caches the resolution so
422
- * OfficialModulesManager.install() can route the copy through
423
- * installFromResolution() — the same path used by custom-source installs.
424
- *
425
- * Silent no-op when marketplace.json is absent or the resolver returns no
426
- * matches; the legacy findModuleSource path then handles the install.
427
- *
428
- * @param {string} repoPath - Absolute path to the cloned repo
429
- * @param {Object} moduleInfo - Normalized community module info
430
- * @param {Object} resolution - Resolution metadata from cloneModule
431
- * @param {string} resolution.channel - Channel ('stable' | 'next' | 'pinned')
432
- * @param {string} resolution.version - Recorded version string
433
- * @param {string} resolution.sha - Resolved git SHA
434
- * @param {string|null} resolution.approvedTag - Registry approved tag
435
- * @param {string|null} resolution.approvedSha - Registry approved SHA
436
- */
437
- async _tryResolveMarketplacePlugin(repoPath, moduleInfo, resolution) {
438
- const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
439
- if (!(await fs.pathExists(marketplacePath))) return;
440
-
441
- let marketplaceData;
442
- try {
443
- marketplaceData = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
444
- } catch {
445
- // Malformed marketplace.json — fall through to legacy path.
446
- return;
447
- }
448
-
449
- const plugins = Array.isArray(marketplaceData?.plugins) ? marketplaceData.plugins : [];
450
- if (plugins.length === 0) return;
451
-
452
- const selection = this._selectPluginForModule(plugins, moduleInfo);
453
- if (!selection) {
454
- await this._safeWarn(
455
- `Community module '${moduleInfo.code}' ships marketplace.json but no plugin entry matches the registry code. ` +
456
- `Falling back to legacy install path.`,
457
- );
458
- return;
459
- }
460
-
461
- if (selection.source === 'single-fallback') {
462
- // Single-entry marketplace.json whose plugin name doesn't match the registry
463
- // code or the module_definition hint. Most likely correct, but worth surfacing
464
- // in case marketplace.json is misconfigured and we'd install the wrong plugin.
465
- await this._safeWarn(
466
- `Community module '${moduleInfo.code}' picked the only plugin in marketplace.json ('${selection.plugin?.name}') ` +
467
- `because no name or module_definition match was found. Verify marketplace.json if the install looks wrong.`,
468
- );
469
- }
470
-
471
- const { PluginResolver } = require('./plugin-resolver');
472
- const resolver = new PluginResolver();
473
- let resolved;
474
- try {
475
- resolved = await resolver.resolve(repoPath, selection.plugin);
476
- } catch (error) {
477
- // PluginResolver threw (malformed plugin entry, missing files, etc.).
478
- // Honor the silent-fallthrough contract — warn and let the legacy
479
- // findModuleSource path handle the install.
480
- await this._safeWarn(
481
- `PluginResolver failed for community module '${moduleInfo.code}': ${error.message}. ` + `Falling back to legacy install path.`,
482
- );
483
- return;
484
- }
485
- if (!resolved || resolved.length === 0) return;
486
-
487
- // The registry registers a single code per module. If the resolver returns
488
- // multiple modules (Strategy 4: multiple standalone skills), accept only
489
- // the entry whose code matches the registry. Other entries are ignored —
490
- // they belong to plugins not registered in the community catalog.
491
- const matched = resolved.find((mod) => mod.code === moduleInfo.code) || (resolved.length === 1 ? resolved[0] : null);
492
- if (!matched) return;
493
-
494
- // Shallow-clone before stamping provenance — the resolver may cache or reuse
495
- // its return objects, and we don't want install-specific fields leaking back.
496
- const stamped = {
497
- ...matched,
498
- code: moduleInfo.code,
499
- repoUrl: moduleInfo.url,
500
- cloneRef: resolution.channel === 'pinned' ? resolution.version : resolution.approvedTag || null,
501
- cloneSha: resolution.sha,
502
- communitySource: true,
503
- communityChannel: resolution.channel,
504
- communityVersion: resolution.version,
505
- registryApprovedTag: resolution.approvedTag,
506
- registryApprovedSha: resolution.approvedSha,
507
- };
508
-
509
- CommunityModuleManager._pluginResolutions.set(moduleInfo.code, stamped);
510
- }
511
-
512
- /**
513
- * Lazy fallback: resolve marketplace.json straight from the on-disk cache
514
- * when `_pluginResolutions` is empty (e.g. callers that reach `install()`
515
- * without `cloneModule` having populated the cache earlier in this process).
516
- *
517
- * Reuses an existing channel resolution if present; otherwise synthesizes a
518
- * minimal stable-channel stub from the registry entry + the cached repo's
519
- * current HEAD. Returns the cached plugin resolution if one is produced,
520
- * otherwise null (caller falls back to the legacy path).
521
- *
522
- * @param {string} moduleCode
523
- * @returns {Promise<Object|null>}
524
- */
525
- async resolveFromCache(moduleCode) {
526
- const existing = this.getPluginResolution(moduleCode);
527
- if (existing) return existing;
528
-
529
- const cacheRepoDir = path.join(this.getCacheDir(), moduleCode);
530
- const marketplacePath = path.join(cacheRepoDir, '.claude-plugin', 'marketplace.json');
531
- if (!(await fs.pathExists(marketplacePath))) return null;
532
-
533
- let moduleInfo;
534
- try {
535
- moduleInfo = await this.getModuleByCode(moduleCode);
536
- } catch {
537
- return null;
538
- }
539
- if (!moduleInfo) return null;
540
-
541
- let channelResolution = this.getResolution(moduleCode);
542
- if (!channelResolution) {
543
- let sha = '';
544
- try {
545
- sha = execSync('git rev-parse HEAD', { cwd: cacheRepoDir, stdio: 'pipe' }).toString().trim();
546
- } catch {
547
- // Not a git repo or unreadable — give up and let the legacy path run.
548
- return null;
549
- }
550
- channelResolution = {
551
- channel: 'stable',
552
- version: moduleInfo.approvedTag || sha.slice(0, 7),
553
- sha,
554
- registryApprovedTag: moduleInfo.approvedTag || null,
555
- registryApprovedSha: moduleInfo.approvedSha || null,
556
- };
557
- }
558
-
559
- await this._tryResolveMarketplacePlugin(cacheRepoDir, moduleInfo, {
560
- channel: channelResolution.channel,
561
- version: channelResolution.version,
562
- sha: channelResolution.sha,
563
- approvedTag: channelResolution.registryApprovedTag,
564
- approvedSha: channelResolution.registryApprovedSha,
565
- });
566
-
567
- return this.getPluginResolution(moduleCode);
568
- }
569
-
570
- /**
571
- * Best-effort warning emitter. `prompts.log.warn` may be undefined in some
572
- * harnesses and may return a rejected promise — swallow both cases so a
573
- * fallthrough warning can never crash the install.
574
- */
575
- async _safeWarn(message) {
576
- try {
577
- const result = prompts.log?.warn?.(message);
578
- if (result && typeof result.then === 'function') await result;
579
- } catch {
580
- /* ignore */
581
- }
582
- }
583
-
584
- /**
585
- * Pick which plugin entry from marketplace.json represents this community module.
586
- * Precedence:
587
- * 1. Exact match on `plugin.name === moduleInfo.code`
588
- * 2. Trailing directory of `module_definition` matches `plugin.name`
589
- * 3. Single plugin in marketplace.json — accepted with a warning so a
590
- * mismatched-but-uniquely-named plugin doesn't install silently.
591
- * Otherwise null (caller falls back to legacy path).
592
- *
593
- * @returns {{plugin: Object, source: 'name'|'hint'|'single-fallback'}|null}
594
- */
595
- _selectPluginForModule(plugins, moduleInfo) {
596
- const byCode = plugins.find((p) => p && p.name === moduleInfo.code);
597
- if (byCode) return { plugin: byCode, source: 'name' };
598
-
599
- if (moduleInfo.moduleDefinition) {
600
- // module_definition like "src/skills/suno-setup/assets/module.yaml" →
601
- // hint segment "suno-setup". Match that against plugin names.
602
- const segments = moduleInfo.moduleDefinition.split('/').filter(Boolean);
603
- const setupIdx = segments.findIndex((s) => s.endsWith('-setup'));
604
- if (setupIdx !== -1) {
605
- const hint = segments[setupIdx];
606
- const byHint = plugins.find((p) => p && p.name === hint);
607
- if (byHint) return { plugin: byHint, source: 'hint' };
608
- }
609
- }
610
-
611
- if (plugins.length === 1) return { plugin: plugins[0], source: 'single-fallback' };
612
- return null;
613
- }
614
-
615
- // ─── Source Finding ───────────────────────────────────────────────────────
616
-
617
- /**
618
- * Find the source path for a community module (clone + locate module.yaml).
619
- * @param {string} moduleCode - Module code
620
- * @param {Object} [options] - Options passed to cloneModule
621
- * @returns {string|null} Path to the module source or null
622
- */
623
- async findModuleSource(moduleCode, options = {}) {
624
- const moduleInfo = await this.getModuleByCode(moduleCode);
625
- if (!moduleInfo) return null;
626
-
627
- const cloneDir = await this.cloneModule(moduleCode, options);
628
-
629
- // Check configured module_definition path first
630
- if (moduleInfo.moduleDefinition) {
631
- const configuredPath = path.join(cloneDir, moduleInfo.moduleDefinition);
632
- if (await fs.pathExists(configuredPath)) {
633
- return path.dirname(configuredPath);
634
- }
635
- }
636
-
637
- // Fallback: search skills/ and src/ directories
638
- for (const dir of ['skills', 'src']) {
639
- const rootCandidate = path.join(cloneDir, dir, 'module.yaml');
640
- if (await fs.pathExists(rootCandidate)) {
641
- return path.dirname(rootCandidate);
642
- }
643
- const dirPath = path.join(cloneDir, dir);
644
- if (await fs.pathExists(dirPath)) {
645
- const entries = await fs.readdir(dirPath, { withFileTypes: true });
646
- for (const entry of entries) {
647
- if (entry.isDirectory()) {
648
- const subCandidate = path.join(dirPath, entry.name, 'module.yaml');
649
- if (await fs.pathExists(subCandidate)) {
650
- return path.dirname(subCandidate);
651
- }
652
- }
653
- }
654
- }
655
- }
656
-
657
- // Check repo root
658
- const rootCandidate = path.join(cloneDir, 'module.yaml');
659
- if (await fs.pathExists(rootCandidate)) {
660
- return path.dirname(rootCandidate);
661
- }
662
-
663
- return moduleInfo.moduleDefinition ? path.dirname(path.join(cloneDir, moduleInfo.moduleDefinition)) : null;
664
- }
665
-
666
- // ─── Normalization ────────────────────────────────────────────────────────
667
-
668
- /**
669
- * Normalize a community module entry to a consistent shape.
670
- * @param {Object} mod - Raw module from community-index.yaml
671
- * @returns {Object} Normalized module info
672
- */
673
- _normalizeCommunityModule(mod) {
674
- return {
675
- key: mod.name,
676
- code: mod.code,
677
- name: mod.display_name || mod.name,
678
- displayName: mod.display_name || mod.name,
679
- description: mod.description || '',
680
- url: mod.repository || mod.url,
681
- moduleDefinition: mod.module_definition || mod['module-definition'],
682
- npmPackage: mod.npm_package || mod.npmPackage || null,
683
- author: mod.author || '',
684
- license: mod.license || '',
685
- type: 'community',
686
- category: mod.category || '',
687
- subcategory: mod.subcategory || '',
688
- keywords: mod.keywords || [],
689
- version: mod.version || null,
690
- approvedTag: mod.approved_tag || null,
691
- approvedSha: mod.approved_sha || null,
692
- approvedDate: mod.approved_date || null,
693
- reviewer: mod.reviewer || null,
694
- trustTier: mod.trust_tier || 'unverified',
695
- promoted: mod.promoted === true,
696
- promotedRank: mod.promoted_rank || null,
697
- defaultSelected: false,
698
- builtIn: false,
699
- isExternal: true,
700
- };
701
- }
702
- }
703
-
704
- module.exports = { CommunityModuleManager };