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