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

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 (127) hide show
  1. package/.claude-plugin/marketplace.json +0 -4
  2. package/README.md +8 -9
  3. package/README_CN.md +1 -1
  4. package/README_VN.md +110 -0
  5. package/package.json +2 -1
  6. package/removals.txt +17 -0
  7. package/src/bmm-skills/1-analysis/bmad-agent-analyst/SKILL.md +7 -4
  8. package/src/bmm-skills/1-analysis/bmad-agent-tech-writer/SKILL.md +6 -4
  9. package/src/bmm-skills/1-analysis/bmad-document-project/workflow.md +8 -10
  10. package/src/bmm-skills/1-analysis/bmad-prfaq/SKILL.md +96 -0
  11. package/src/bmm-skills/1-analysis/bmad-prfaq/agents/artifact-analyzer.md +60 -0
  12. package/src/bmm-skills/1-analysis/bmad-prfaq/agents/web-researcher.md +49 -0
  13. package/src/bmm-skills/1-analysis/bmad-prfaq/assets/prfaq-template.md +62 -0
  14. package/src/bmm-skills/1-analysis/bmad-prfaq/bmad-manifest.json +16 -0
  15. package/src/bmm-skills/1-analysis/bmad-prfaq/references/customer-faq.md +55 -0
  16. package/src/bmm-skills/1-analysis/bmad-prfaq/references/internal-faq.md +51 -0
  17. package/src/bmm-skills/1-analysis/bmad-prfaq/references/press-release.md +60 -0
  18. package/src/bmm-skills/1-analysis/bmad-prfaq/references/verdict.md +79 -0
  19. package/src/bmm-skills/1-analysis/bmad-product-brief/SKILL.md +1 -6
  20. package/src/bmm-skills/1-analysis/bmad-product-brief/bmad-manifest.json +1 -1
  21. package/src/bmm-skills/1-analysis/research/bmad-domain-research/workflow.md +8 -6
  22. package/src/bmm-skills/1-analysis/research/bmad-market-research/workflow.md +8 -6
  23. package/src/bmm-skills/1-analysis/research/bmad-technical-research/workflow.md +8 -6
  24. package/src/bmm-skills/2-plan-workflows/bmad-agent-pm/SKILL.md +6 -4
  25. package/src/bmm-skills/2-plan-workflows/bmad-agent-ux-designer/SKILL.md +6 -4
  26. package/src/bmm-skills/2-plan-workflows/bmad-create-prd/workflow.md +8 -9
  27. package/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/steps/step-13-responsive-accessibility.md +1 -1
  28. package/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/workflow.md +8 -9
  29. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-01-discovery.md +1 -1
  30. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-01b-legacy-conversion.md +1 -1
  31. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-02-review.md +1 -1
  32. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-03-edit.md +1 -1
  33. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-04-complete.md +1 -3
  34. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/workflow.md +8 -9
  35. package/src/bmm-skills/2-plan-workflows/bmad-validate-prd/workflow.md +8 -9
  36. package/src/bmm-skills/3-solutioning/bmad-agent-architect/SKILL.md +6 -4
  37. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/steps/step-01-document-discovery.md +1 -1
  38. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/steps/step-02-prd-analysis.md +1 -1
  39. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/steps/step-03-epic-coverage-validation.md +1 -1
  40. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/workflow.md +9 -11
  41. package/src/bmm-skills/3-solutioning/bmad-create-architecture/workflow.md +8 -14
  42. package/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/workflow.md +10 -12
  43. package/src/bmm-skills/3-solutioning/bmad-generate-project-context/workflow.md +8 -12
  44. package/src/bmm-skills/4-implementation/bmad-agent-dev/SKILL.md +11 -4
  45. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/SKILL.md +29 -0
  46. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/generate-trail.md +38 -0
  47. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-01-orientation.md +105 -0
  48. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-02-walkthrough.md +89 -0
  49. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-03-detail-pass.md +106 -0
  50. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-04-testing.md +74 -0
  51. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-05-wrapup.md +24 -0
  52. package/src/bmm-skills/4-implementation/bmad-code-review/steps/step-01-gather-context.md +38 -15
  53. package/src/bmm-skills/4-implementation/bmad-correct-course/checklist.md +2 -2
  54. package/src/bmm-skills/4-implementation/bmad-correct-course/workflow.md +8 -8
  55. package/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/checklist.md +1 -1
  56. package/src/bmm-skills/4-implementation/bmad-quick-dev/compile-epic-context.md +62 -0
  57. package/src/bmm-skills/4-implementation/bmad-quick-dev/spec-template.md +1 -1
  58. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-01-clarify-and-route.md +33 -6
  59. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-02-plan.md +20 -8
  60. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-03-implement.md +2 -0
  61. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md +16 -4
  62. package/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md +1 -5
  63. package/src/bmm-skills/4-implementation/bmad-retrospective/workflow.md +134 -134
  64. package/src/bmm-skills/4-implementation/bmad-sprint-planning/sprint-status-template.yaml +1 -1
  65. package/src/bmm-skills/4-implementation/bmad-sprint-planning/workflow.md +3 -3
  66. package/src/bmm-skills/4-implementation/bmad-sprint-status/workflow.md +2 -2
  67. package/src/bmm-skills/module-help.csv +4 -1
  68. package/src/core-skills/bmad-advanced-elicitation/SKILL.md +1 -2
  69. package/src/core-skills/bmad-distillator/SKILL.md +0 -1
  70. package/src/core-skills/bmad-distillator/resources/distillate-format-reference.md +9 -9
  71. package/src/core-skills/bmad-help/SKILL.md +4 -2
  72. package/src/core-skills/bmad-party-mode/SKILL.md +121 -2
  73. package/src/core-skills/module-help.csv +1 -0
  74. package/tools/installer/cli-utils.js +18 -9
  75. package/tools/installer/commands/install.js +0 -1
  76. package/tools/installer/core/existing-install.js +2 -8
  77. package/tools/installer/core/install-paths.js +0 -3
  78. package/tools/installer/core/installer.js +176 -464
  79. package/tools/installer/core/manifest-generator.js +4 -12
  80. package/tools/installer/core/manifest.js +82 -97
  81. package/tools/installer/ide/_config-driven.js +149 -38
  82. package/tools/installer/ide/platform-codes.yaml +6 -4
  83. package/tools/installer/ide/shared/skill-manifest.js +1 -16
  84. package/tools/installer/install-messages.yaml +19 -26
  85. package/tools/installer/modules/community-manager.js +377 -0
  86. package/tools/installer/modules/custom-module-manager.js +308 -0
  87. package/tools/installer/modules/external-manager.js +65 -49
  88. package/tools/installer/modules/official-modules.js +37 -65
  89. package/tools/installer/modules/registry-client.js +66 -0
  90. package/tools/installer/{external-official-modules.yaml → modules/registry-fallback.yaml} +3 -12
  91. package/tools/installer/ui.js +340 -672
  92. package/tools/platform-codes.yaml +6 -0
  93. package/src/bmm-skills/2-plan-workflows/create-prd/data/domain-complexity.csv +0 -15
  94. package/src/bmm-skills/2-plan-workflows/create-prd/data/project-types.csv +0 -11
  95. package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-01-discovery.md +0 -224
  96. package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-02-format-detection.md +0 -191
  97. package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-02b-parity-check.md +0 -209
  98. package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-03-density-validation.md +0 -174
  99. package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-04-brief-coverage-validation.md +0 -214
  100. package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-05-measurability-validation.md +0 -228
  101. package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-06-traceability-validation.md +0 -217
  102. package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-07-implementation-leakage-validation.md +0 -205
  103. package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-08-domain-compliance-validation.md +0 -243
  104. package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-09-project-type-validation.md +0 -263
  105. package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-10-smart-validation.md +0 -209
  106. package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-11-holistic-quality-validation.md +0 -264
  107. package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-12-completeness-validation.md +0 -242
  108. package/src/bmm-skills/2-plan-workflows/create-prd/steps-v/step-v-13-report-complete.md +0 -232
  109. package/src/bmm-skills/2-plan-workflows/create-prd/workflow-validate-prd.md +0 -65
  110. package/src/bmm-skills/4-implementation/bmad-agent-qa/SKILL.md +0 -59
  111. package/src/bmm-skills/4-implementation/bmad-agent-qa/bmad-skill-manifest.yaml +0 -11
  112. package/src/bmm-skills/4-implementation/bmad-agent-quick-flow-solo-dev/SKILL.md +0 -51
  113. package/src/bmm-skills/4-implementation/bmad-agent-quick-flow-solo-dev/bmad-skill-manifest.yaml +0 -11
  114. package/src/bmm-skills/4-implementation/bmad-agent-sm/SKILL.md +0 -53
  115. package/src/bmm-skills/4-implementation/bmad-agent-sm/bmad-skill-manifest.yaml +0 -11
  116. package/src/core-skills/bmad-init/SKILL.md +0 -100
  117. package/src/core-skills/bmad-init/resources/core-module.yaml +0 -25
  118. package/src/core-skills/bmad-init/scripts/bmad_init.py +0 -624
  119. package/src/core-skills/bmad-init/scripts/tests/test_bmad_init.py +0 -393
  120. package/src/core-skills/bmad-party-mode/steps/step-01-agent-loading.md +0 -138
  121. package/src/core-skills/bmad-party-mode/steps/step-02-discussion-orchestration.md +0 -187
  122. package/src/core-skills/bmad-party-mode/steps/step-03-graceful-exit.md +0 -167
  123. package/src/core-skills/bmad-party-mode/workflow.md +0 -190
  124. package/tools/installer/core/custom-module-cache.js +0 -260
  125. package/tools/installer/custom-handler.js +0 -112
  126. package/tools/installer/modules/custom-modules.js +0 -197
  127. /package/src/bmm-skills/2-plan-workflows/{create-prd → bmad-edit-prd}/data/prd-purpose.md +0 -0
@@ -0,0 +1,377 @@
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
+ const { RegistryClient } = require('./registry-client');
7
+
8
+ const MARKETPLACE_BASE = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main';
9
+ const COMMUNITY_INDEX_URL = `${MARKETPLACE_BASE}/registry/community-index.yaml`;
10
+ const CATEGORIES_URL = `${MARKETPLACE_BASE}/categories.yaml`;
11
+
12
+ /**
13
+ * Manages community modules from the BMad marketplace registry.
14
+ * Fetches community-index.yaml and categories.yaml from GitHub.
15
+ * Returns empty results when the registry is unreachable.
16
+ * Community modules are pinned to approved SHA when set; uses HEAD otherwise.
17
+ */
18
+ class CommunityModuleManager {
19
+ constructor() {
20
+ this._client = new RegistryClient();
21
+ this._cachedIndex = null;
22
+ this._cachedCategories = null;
23
+ }
24
+
25
+ // ─── Data Loading ──────────────────────────────────────────────────────────
26
+
27
+ /**
28
+ * Load the community module index from the marketplace repo.
29
+ * Returns empty when the registry is unreachable.
30
+ * @returns {Object} Parsed YAML with modules array
31
+ */
32
+ async loadCommunityIndex() {
33
+ if (this._cachedIndex) return this._cachedIndex;
34
+
35
+ try {
36
+ const config = await this._client.fetchYaml(COMMUNITY_INDEX_URL);
37
+ if (config?.modules?.length) {
38
+ this._cachedIndex = config;
39
+ return config;
40
+ }
41
+ } catch {
42
+ // Registry unreachable - no community modules available
43
+ }
44
+
45
+ return { modules: [] };
46
+ }
47
+
48
+ /**
49
+ * Load categories from the marketplace repo.
50
+ * Returns empty when the registry is unreachable.
51
+ * @returns {Object} Parsed categories.yaml content
52
+ */
53
+ async loadCategories() {
54
+ if (this._cachedCategories) return this._cachedCategories;
55
+
56
+ try {
57
+ const config = await this._client.fetchYaml(CATEGORIES_URL);
58
+ if (config?.categories) {
59
+ this._cachedCategories = config;
60
+ return config;
61
+ }
62
+ } catch {
63
+ // Registry unreachable - no categories available
64
+ }
65
+
66
+ return { categories: {} };
67
+ }
68
+
69
+ // ─── Listing & Filtering ──────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Get all community modules, normalized.
73
+ * @returns {Array<Object>} Normalized community modules
74
+ */
75
+ async listAll() {
76
+ const index = await this.loadCommunityIndex();
77
+ return (index.modules || []).map((mod) => this._normalizeCommunityModule(mod));
78
+ }
79
+
80
+ /**
81
+ * Get community modules filtered to a category.
82
+ * @param {string} categorySlug - Category slug (e.g., 'design-and-creative')
83
+ * @returns {Array<Object>} Filtered modules
84
+ */
85
+ async listByCategory(categorySlug) {
86
+ const all = await this.listAll();
87
+ return all.filter((mod) => mod.category === categorySlug);
88
+ }
89
+
90
+ /**
91
+ * Get promoted/featured community modules, sorted by rank.
92
+ * @returns {Array<Object>} Featured modules
93
+ */
94
+ async listFeatured() {
95
+ const all = await this.listAll();
96
+ return all.filter((mod) => mod.promoted === true).sort((a, b) => (a.promotedRank || 999) - (b.promotedRank || 999));
97
+ }
98
+
99
+ /**
100
+ * Search community modules by keyword.
101
+ * Matches against name, display name, description, and keywords array.
102
+ * @param {string} query - Search query
103
+ * @returns {Array<Object>} Matching modules
104
+ */
105
+ async searchByKeyword(query) {
106
+ const all = await this.listAll();
107
+ const q = query.toLowerCase();
108
+ return all.filter((mod) => {
109
+ const searchable = [mod.name, mod.displayName, mod.description, ...(mod.keywords || [])].join(' ').toLowerCase();
110
+ return searchable.includes(q);
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Get categories with module counts for UI display.
116
+ * Only returns categories that have at least one community module.
117
+ * @returns {Array<Object>} Array of { slug, name, moduleCount }
118
+ */
119
+ async getCategoryList() {
120
+ const all = await this.listAll();
121
+ const categoriesData = await this.loadCategories();
122
+ const categories = categoriesData.categories || {};
123
+
124
+ // Count modules per category
125
+ const counts = {};
126
+ for (const mod of all) {
127
+ counts[mod.category] = (counts[mod.category] || 0) + 1;
128
+ }
129
+
130
+ // Build list with display names from categories.yaml
131
+ const result = [];
132
+ for (const [slug, count] of Object.entries(counts)) {
133
+ const catInfo = categories[slug];
134
+ result.push({
135
+ slug,
136
+ name: catInfo?.name || slug,
137
+ moduleCount: count,
138
+ });
139
+ }
140
+
141
+ // Sort alphabetically by name
142
+ result.sort((a, b) => a.name.localeCompare(b.name));
143
+ return result;
144
+ }
145
+
146
+ // ─── Module Lookup ────────────────────────────────────────────────────────
147
+
148
+ /**
149
+ * Get a community module by its code.
150
+ * @param {string} code - Module code (e.g., 'wds')
151
+ * @returns {Object|null} Normalized module or null
152
+ */
153
+ async getModuleByCode(code) {
154
+ const all = await this.listAll();
155
+ return all.find((m) => m.code === code) || null;
156
+ }
157
+
158
+ // ─── Clone with Tag Pinning ───────────────────────────────────────────────
159
+
160
+ /**
161
+ * Get the cache directory for community modules.
162
+ * @returns {string} Path to the community modules cache directory
163
+ */
164
+ getCacheDir() {
165
+ return path.join(os.homedir(), '.bmad', 'cache', 'community-modules');
166
+ }
167
+
168
+ /**
169
+ * Clone a community module repository, pinned to its approved tag.
170
+ * @param {string} moduleCode - Module code
171
+ * @param {Object} [options] - Clone options
172
+ * @param {boolean} [options.silent] - Suppress spinner output
173
+ * @returns {string} Path to the cloned repository
174
+ */
175
+ async cloneModule(moduleCode, options = {}) {
176
+ const moduleInfo = await this.getModuleByCode(moduleCode);
177
+ if (!moduleInfo) {
178
+ throw new Error(`Community module '${moduleCode}' not found in the registry`);
179
+ }
180
+
181
+ const cacheDir = this.getCacheDir();
182
+ const moduleCacheDir = path.join(cacheDir, moduleCode);
183
+ const silent = options.silent || false;
184
+
185
+ await fs.ensureDir(cacheDir);
186
+
187
+ const createSpinner = async () => {
188
+ if (silent) {
189
+ return { start() {}, stop() {}, error() {}, message() {} };
190
+ }
191
+ return await prompts.spinner();
192
+ };
193
+
194
+ const sha = moduleInfo.approvedSha;
195
+ let needsDependencyInstall = false;
196
+ let wasNewClone = false;
197
+
198
+ if (await fs.pathExists(moduleCacheDir)) {
199
+ // Already cloned - update to latest HEAD
200
+ const fetchSpinner = await createSpinner();
201
+ fetchSpinner.start(`Checking ${moduleInfo.displayName}...`);
202
+ try {
203
+ const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
204
+ execSync('git fetch origin --depth 1', {
205
+ cwd: moduleCacheDir,
206
+ stdio: ['ignore', 'pipe', 'pipe'],
207
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
208
+ });
209
+ execSync('git reset --hard origin/HEAD', {
210
+ cwd: moduleCacheDir,
211
+ stdio: ['ignore', 'pipe', 'pipe'],
212
+ });
213
+ const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
214
+ if (currentRef !== newRef) needsDependencyInstall = true;
215
+ fetchSpinner.stop(`Verified ${moduleInfo.displayName}`);
216
+ } catch {
217
+ fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.displayName}`);
218
+ await fs.remove(moduleCacheDir);
219
+ wasNewClone = true;
220
+ }
221
+ } else {
222
+ wasNewClone = true;
223
+ }
224
+
225
+ if (wasNewClone) {
226
+ const fetchSpinner = await createSpinner();
227
+ fetchSpinner.start(`Fetching ${moduleInfo.displayName}...`);
228
+ try {
229
+ execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
230
+ stdio: ['ignore', 'pipe', 'pipe'],
231
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
232
+ });
233
+ fetchSpinner.stop(`Fetched ${moduleInfo.displayName}`);
234
+ needsDependencyInstall = true;
235
+ } catch (error) {
236
+ fetchSpinner.error(`Failed to fetch ${moduleInfo.displayName}`);
237
+ throw new Error(`Failed to clone community module '${moduleCode}': ${error.message}`);
238
+ }
239
+ }
240
+
241
+ // If pinned to a specific SHA, check out that exact commit.
242
+ // Refuse to install if the approved SHA cannot be reached - security requirement.
243
+ if (sha) {
244
+ const headSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
245
+ if (headSha !== sha) {
246
+ try {
247
+ execSync(`git fetch --depth 1 origin ${sha}`, {
248
+ cwd: moduleCacheDir,
249
+ stdio: ['ignore', 'pipe', 'pipe'],
250
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
251
+ });
252
+ execSync(`git checkout ${sha}`, {
253
+ cwd: moduleCacheDir,
254
+ stdio: ['ignore', 'pipe', 'pipe'],
255
+ });
256
+ needsDependencyInstall = true;
257
+ } catch {
258
+ await fs.remove(moduleCacheDir);
259
+ throw new Error(
260
+ `Community module '${moduleCode}' could not be pinned to its approved commit (${sha}). ` +
261
+ `Installation refused for security. The module registry entry may need updating.`,
262
+ );
263
+ }
264
+ }
265
+ }
266
+
267
+ // Install dependencies if needed
268
+ const packageJsonPath = path.join(moduleCacheDir, 'package.json');
269
+ if ((needsDependencyInstall || wasNewClone) && (await fs.pathExists(packageJsonPath))) {
270
+ const installSpinner = await createSpinner();
271
+ installSpinner.start(`Installing dependencies for ${moduleInfo.displayName}...`);
272
+ try {
273
+ execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
274
+ cwd: moduleCacheDir,
275
+ stdio: ['ignore', 'pipe', 'pipe'],
276
+ timeout: 120_000,
277
+ });
278
+ installSpinner.stop(`Installed dependencies for ${moduleInfo.displayName}`);
279
+ } catch (error) {
280
+ installSpinner.error(`Failed to install dependencies for ${moduleInfo.displayName}`);
281
+ if (!silent) await prompts.log.warn(` ${error.message}`);
282
+ }
283
+ }
284
+
285
+ return moduleCacheDir;
286
+ }
287
+
288
+ // ─── Source Finding ───────────────────────────────────────────────────────
289
+
290
+ /**
291
+ * Find the source path for a community module (clone + locate module.yaml).
292
+ * @param {string} moduleCode - Module code
293
+ * @param {Object} [options] - Options passed to cloneModule
294
+ * @returns {string|null} Path to the module source or null
295
+ */
296
+ async findModuleSource(moduleCode, options = {}) {
297
+ const moduleInfo = await this.getModuleByCode(moduleCode);
298
+ if (!moduleInfo) return null;
299
+
300
+ const cloneDir = await this.cloneModule(moduleCode, options);
301
+
302
+ // Check configured module_definition path first
303
+ if (moduleInfo.moduleDefinition) {
304
+ const configuredPath = path.join(cloneDir, moduleInfo.moduleDefinition);
305
+ if (await fs.pathExists(configuredPath)) {
306
+ return path.dirname(configuredPath);
307
+ }
308
+ }
309
+
310
+ // Fallback: search skills/ and src/ directories
311
+ for (const dir of ['skills', 'src']) {
312
+ const rootCandidate = path.join(cloneDir, dir, 'module.yaml');
313
+ if (await fs.pathExists(rootCandidate)) {
314
+ return path.dirname(rootCandidate);
315
+ }
316
+ const dirPath = path.join(cloneDir, dir);
317
+ if (await fs.pathExists(dirPath)) {
318
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
319
+ for (const entry of entries) {
320
+ if (entry.isDirectory()) {
321
+ const subCandidate = path.join(dirPath, entry.name, 'module.yaml');
322
+ if (await fs.pathExists(subCandidate)) {
323
+ return path.dirname(subCandidate);
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
329
+
330
+ // Check repo root
331
+ const rootCandidate = path.join(cloneDir, 'module.yaml');
332
+ if (await fs.pathExists(rootCandidate)) {
333
+ return path.dirname(rootCandidate);
334
+ }
335
+
336
+ return moduleInfo.moduleDefinition ? path.dirname(path.join(cloneDir, moduleInfo.moduleDefinition)) : null;
337
+ }
338
+
339
+ // ─── Normalization ────────────────────────────────────────────────────────
340
+
341
+ /**
342
+ * Normalize a community module entry to a consistent shape.
343
+ * @param {Object} mod - Raw module from community-index.yaml
344
+ * @returns {Object} Normalized module info
345
+ */
346
+ _normalizeCommunityModule(mod) {
347
+ return {
348
+ key: mod.name,
349
+ code: mod.code,
350
+ name: mod.display_name || mod.name,
351
+ displayName: mod.display_name || mod.name,
352
+ description: mod.description || '',
353
+ url: mod.repository || mod.url,
354
+ moduleDefinition: mod.module_definition || mod['module-definition'],
355
+ npmPackage: mod.npm_package || mod.npmPackage || null,
356
+ author: mod.author || '',
357
+ license: mod.license || '',
358
+ type: 'community',
359
+ category: mod.category || '',
360
+ subcategory: mod.subcategory || '',
361
+ keywords: mod.keywords || [],
362
+ version: mod.version || null,
363
+ approvedTag: mod.approved_tag || null,
364
+ approvedSha: mod.approved_sha || null,
365
+ approvedDate: mod.approved_date || null,
366
+ reviewer: mod.reviewer || null,
367
+ trustTier: mod.trust_tier || 'unverified',
368
+ promoted: mod.promoted === true,
369
+ promotedRank: mod.promoted_rank || null,
370
+ defaultSelected: false,
371
+ builtIn: false,
372
+ isExternal: true,
373
+ };
374
+ }
375
+ }
376
+
377
+ module.exports = { CommunityModuleManager };
@@ -0,0 +1,308 @@
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
+ const { RegistryClient } = require('./registry-client');
7
+
8
+ /**
9
+ * Manages custom modules installed from user-provided GitHub URLs.
10
+ * Validates URLs, fetches .claude-plugin/marketplace.json, clones repos.
11
+ */
12
+ class CustomModuleManager {
13
+ constructor() {
14
+ this._client = new RegistryClient();
15
+ }
16
+
17
+ // ─── URL Validation ───────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Parse and validate a GitHub repository URL.
21
+ * Supports HTTPS and SSH formats.
22
+ * @param {string} url - GitHub URL to validate
23
+ * @returns {Object} { owner, repo, isValid, error }
24
+ */
25
+ validateGitHubUrl(url) {
26
+ if (!url || typeof url !== 'string') {
27
+ return { owner: null, repo: null, isValid: false, error: 'URL is required' };
28
+ }
29
+
30
+ const trimmed = url.trim();
31
+
32
+ // HTTPS format: https://github.com/owner/repo[.git]
33
+ const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
34
+ if (httpsMatch) {
35
+ return { owner: httpsMatch[1], repo: httpsMatch[2], isValid: true, error: null };
36
+ }
37
+
38
+ // SSH format: git@github.com:owner/repo.git
39
+ const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
40
+ if (sshMatch) {
41
+ return { owner: sshMatch[1], repo: sshMatch[2], isValid: true, error: null };
42
+ }
43
+
44
+ return { owner: null, repo: null, isValid: false, error: 'Not a valid GitHub URL (expected https://github.com/owner/repo)' };
45
+ }
46
+
47
+ // ─── Discovery ────────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Fetch .claude-plugin/marketplace.json from a GitHub repository.
51
+ * @param {string} repoUrl - GitHub repository URL
52
+ * @returns {Object} Parsed marketplace.json content
53
+ */
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
+
60
+ 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}`);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Discover modules from a GitHub repository's marketplace.json.
75
+ * @param {string} repoUrl - GitHub repository URL
76
+ * @returns {Array<Object>} Normalized plugin list
77
+ */
78
+ async discoverModules(repoUrl) {
79
+ const data = await this.fetchMarketplaceJson(repoUrl);
80
+ const plugins = data?.plugins;
81
+
82
+ if (!Array.isArray(plugins) || plugins.length === 0) {
83
+ throw new Error('marketplace.json contains no plugins');
84
+ }
85
+
86
+ return plugins.map((plugin) => this._normalizeCustomModule(plugin, repoUrl, data));
87
+ }
88
+
89
+ // ─── Clone ────────────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Get the cache directory for custom modules.
93
+ * @returns {string} Path to the custom modules cache directory
94
+ */
95
+ getCacheDir() {
96
+ return path.join(os.homedir(), '.bmad', 'cache', 'custom-modules');
97
+ }
98
+
99
+ /**
100
+ * Clone a custom module repository to cache.
101
+ * @param {string} repoUrl - GitHub repository URL
102
+ * @param {Object} [options] - Clone options
103
+ * @param {boolean} [options.silent] - Suppress spinner output
104
+ * @returns {string} Path to the cloned repository
105
+ */
106
+ async cloneRepo(repoUrl, options = {}) {
107
+ const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl);
108
+ if (!isValid) throw new Error(error);
109
+
110
+ const cacheDir = this.getCacheDir();
111
+ const repoCacheDir = path.join(cacheDir, owner, repo);
112
+ const silent = options.silent || false;
113
+
114
+ await fs.ensureDir(path.join(cacheDir, owner));
115
+
116
+ const createSpinner = async () => {
117
+ if (silent) {
118
+ return { start() {}, stop() {}, error() {} };
119
+ }
120
+ return await prompts.spinner();
121
+ };
122
+
123
+ if (await fs.pathExists(repoCacheDir)) {
124
+ // Update existing clone
125
+ const fetchSpinner = await createSpinner();
126
+ fetchSpinner.start(`Updating ${owner}/${repo}...`);
127
+ try {
128
+ execSync('git fetch origin --depth 1', {
129
+ cwd: repoCacheDir,
130
+ stdio: ['ignore', 'pipe', 'pipe'],
131
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
132
+ });
133
+ execSync('git reset --hard origin/HEAD', {
134
+ cwd: repoCacheDir,
135
+ stdio: ['ignore', 'pipe', 'pipe'],
136
+ });
137
+ fetchSpinner.stop(`Updated ${owner}/${repo}`);
138
+ } catch {
139
+ fetchSpinner.error(`Update failed, re-downloading ${owner}/${repo}`);
140
+ await fs.remove(repoCacheDir);
141
+ }
142
+ }
143
+
144
+ if (!(await fs.pathExists(repoCacheDir))) {
145
+ const fetchSpinner = await createSpinner();
146
+ fetchSpinner.start(`Cloning ${owner}/${repo}...`);
147
+ try {
148
+ execSync(`git clone --depth 1 "${repoUrl}" "${repoCacheDir}"`, {
149
+ stdio: ['ignore', 'pipe', 'pipe'],
150
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
151
+ });
152
+ fetchSpinner.stop(`Cloned ${owner}/${repo}`);
153
+ } catch (error_) {
154
+ fetchSpinner.error(`Failed to clone ${owner}/${repo}`);
155
+ throw new Error(`Failed to clone ${repoUrl}: ${error_.message}`);
156
+ }
157
+ }
158
+
159
+ // Install dependencies if package.json exists
160
+ const packageJsonPath = path.join(repoCacheDir, 'package.json');
161
+ if (await fs.pathExists(packageJsonPath)) {
162
+ const installSpinner = await createSpinner();
163
+ installSpinner.start(`Installing dependencies for ${owner}/${repo}...`);
164
+ try {
165
+ execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
166
+ cwd: repoCacheDir,
167
+ stdio: ['ignore', 'pipe', 'pipe'],
168
+ timeout: 120_000,
169
+ });
170
+ installSpinner.stop(`Installed dependencies for ${owner}/${repo}`);
171
+ } catch (error_) {
172
+ installSpinner.error(`Failed to install dependencies for ${owner}/${repo}`);
173
+ if (!silent) await prompts.log.warn(` ${error_.message}`);
174
+ }
175
+ }
176
+
177
+ return repoCacheDir;
178
+ }
179
+
180
+ // ─── Source Finding ───────────────────────────────────────────────────────
181
+
182
+ /**
183
+ * Find the module source path within a cloned custom repo.
184
+ * @param {string} repoUrl - GitHub repository URL (for cache location)
185
+ * @param {string} [pluginSource] - Plugin source path from marketplace.json
186
+ * @returns {string|null} Path to directory containing module.yaml
187
+ */
188
+ async findModuleSource(repoUrl, pluginSource) {
189
+ const { owner, repo } = this.validateGitHubUrl(repoUrl);
190
+ const repoCacheDir = path.join(this.getCacheDir(), owner, repo);
191
+
192
+ if (!(await fs.pathExists(repoCacheDir))) return null;
193
+
194
+ // Try plugin source path first (e.g., "./src/pro-skills")
195
+ if (pluginSource) {
196
+ const sourcePath = path.join(repoCacheDir, pluginSource);
197
+ const moduleYaml = path.join(sourcePath, 'module.yaml');
198
+ if (await fs.pathExists(moduleYaml)) {
199
+ return sourcePath;
200
+ }
201
+ }
202
+
203
+ // Fallback: search skills/ and src/ directories
204
+ for (const dir of ['skills', 'src']) {
205
+ const rootCandidate = path.join(repoCacheDir, dir, 'module.yaml');
206
+ if (await fs.pathExists(rootCandidate)) {
207
+ return path.dirname(rootCandidate);
208
+ }
209
+ const dirPath = path.join(repoCacheDir, dir);
210
+ if (await fs.pathExists(dirPath)) {
211
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
212
+ for (const entry of entries) {
213
+ if (entry.isDirectory()) {
214
+ const subCandidate = path.join(dirPath, entry.name, 'module.yaml');
215
+ if (await fs.pathExists(subCandidate)) {
216
+ return path.dirname(subCandidate);
217
+ }
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ // Check repo root
224
+ const rootCandidate = path.join(repoCacheDir, 'module.yaml');
225
+ if (await fs.pathExists(rootCandidate)) {
226
+ return repoCacheDir;
227
+ }
228
+
229
+ return null;
230
+ }
231
+
232
+ /**
233
+ * Find module source by module code, searching the custom cache.
234
+ * @param {string} moduleCode - Module code to search for
235
+ * @param {Object} [options] - Options
236
+ * @returns {string|null} Path to the module source or null
237
+ */
238
+ async findModuleSourceByCode(moduleCode, options = {}) {
239
+ const cacheDir = this.getCacheDir();
240
+ if (!(await fs.pathExists(cacheDir))) return null;
241
+
242
+ // Search through all custom repo caches
243
+ 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;
265
+ }
266
+ }
267
+ }
268
+ } catch {
269
+ // Skip malformed marketplace.json
270
+ }
271
+ }
272
+ }
273
+ }
274
+ } catch {
275
+ // Cache doesn't exist or is inaccessible
276
+ }
277
+
278
+ return null;
279
+ }
280
+
281
+ // ─── Normalization ────────────────────────────────────────────────────────
282
+
283
+ /**
284
+ * Normalize a plugin from marketplace.json to a consistent shape.
285
+ * @param {Object} plugin - Plugin object from marketplace.json
286
+ * @param {string} repoUrl - Source repository URL
287
+ * @param {Object} data - Full marketplace.json data
288
+ * @returns {Object} Normalized module info
289
+ */
290
+ _normalizeCustomModule(plugin, repoUrl, data) {
291
+ return {
292
+ code: plugin.name,
293
+ name: plugin.name,
294
+ displayName: plugin.name,
295
+ description: plugin.description || '',
296
+ version: plugin.version || null,
297
+ author: plugin.author || data.owner || '',
298
+ url: repoUrl,
299
+ source: plugin.source || null,
300
+ type: 'custom',
301
+ trustTier: 'unverified',
302
+ builtIn: false,
303
+ isExternal: true,
304
+ };
305
+ }
306
+ }
307
+
308
+ module.exports = { CustomModuleManager };