bmad-method 6.9.1-next.9 → 6.10.1-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -1
- package/bmad-modules.yaml +52 -0
- package/package.json +1 -1
- package/removals.txt +5 -0
- package/src/bmm-skills/4-implementation/bmad-code-review/steps/step-02-review.md +19 -8
- package/src/bmm-skills/4-implementation/bmad-code-review/steps/step-03-triage.md +9 -15
- package/src/bmm-skills/4-implementation/bmad-dev-auto/SKILL.md +2 -0
- package/src/bmm-skills/4-implementation/bmad-dev-auto/step-03-implement.md +1 -1
- package/src/bmm-skills/4-implementation/bmad-dev-auto/step-04-review.md +18 -5
- package/src/bmm-skills/4-implementation/bmad-quick-dev/step-04-review.md +13 -4
- package/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md +4 -1
- package/src/core-skills/bmad-advanced-elicitation/methods.csv +29 -27
- package/src/core-skills/bmad-help/SKILL.md +1 -1
- package/src/core-skills/bmad-party-mode/SKILL.md +4 -2
- package/src/core-skills/bmad-party-mode/customize.toml +38 -2
- package/src/core-skills/bmad-party-mode/references/mode-agent-team.md +2 -0
- package/src/core-skills/bmad-party-mode/references/mode-subagent.md +16 -4
- package/src/core-skills/bmad-review-edge-case-hunter/SKILL.md +10 -4
- package/src/core-skills/bmad-review-edge-case-hunter/references/deletion-check.md +14 -0
- package/src/core-skills/bmad-review-verification-gap/SKILL.md +106 -0
- package/tools/installer/core/installer.js +90 -1
- package/tools/installer/core/uv-check.js +1 -1
- package/tools/installer/modules/external-manager.js +143 -3
- package/tools/installer/modules/official-modules.js +66 -19
- package/tools/installer/ui.js +20 -8
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Deletion Check
|
|
2
|
+
|
|
3
|
+
Secondary pass for the Edge Case Hunter — runs only when the diff removed meaningful code. Subordinate to the edge-case pass; findings are usually few or none.
|
|
4
|
+
|
|
5
|
+
For each chunk of removed or replaced code (ignore pure renames and whitespace), ask: did it carry behavior or a contract that the change neither re-established nor intentionally retired? Add a finding for any resulting regression, orphaned reference, or newly-dead code. Skip anything already covered by your edge-case findings.
|
|
6
|
+
|
|
7
|
+
Append each finding to the same JSON array as the edge-case findings, with the four standard fields plus:
|
|
8
|
+
|
|
9
|
+
- `kind`: `"deletion"`
|
|
10
|
+
- `confidence`: `"high"`, `"medium"`, or `"low"` — these are inferences; rate them
|
|
11
|
+
|
|
12
|
+
For a deletion finding the standard fields read as: `location` = the removed item; `trigger_condition` = the behavior or contract it enforced; `guard_snippet` = where or how to re-establish it; `potential_consequence` = the regression or orphan.
|
|
13
|
+
|
|
14
|
+
Add nothing if nothing qualifies.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: bmad-review-verification-gap
|
|
3
|
+
description: 'Review a code change for changed behavior that could regress without reliable verification catching it. Use when checking whether a change is adequately verified.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Verification Gap Review
|
|
7
|
+
|
|
8
|
+
**Goal:** Find changed behavior that could break without reliable verification catching it. Ask one question — "if the behavior this change is supposed to produce broke where it's actually used, would verification fail?" Do not hunt for correctness bugs, but report genuine problems you notice while tracing verification.
|
|
9
|
+
|
|
10
|
+
The main verification gap shapes are:
|
|
11
|
+
|
|
12
|
+
1. **Regression gap:** the changed code regresses where it's used, and no test covering that use would fail.
|
|
13
|
+
2. **Missing-adoption gap:** a place that should now use the new behavior doesn't; it handles the same case its own way, or not at all, and no test would flag the omission.
|
|
14
|
+
3. **Broken-verification gap:** a test appears to cover the changed behavior, but would not actually protect it because it is skipped, flaky, not run in the normal verification path, or too weak to observe the regression.
|
|
15
|
+
|
|
16
|
+
## Evidence Rules
|
|
17
|
+
|
|
18
|
+
- Read a test before claiming what it covers, runs, asserts, or misses.
|
|
19
|
+
- Before claiming no test exists, search the whole repo by the symbol under test and by import references; expected file locations are not enough.
|
|
20
|
+
- Never assert what you did not verify. If a finding cannot be grounded, drop it.
|
|
21
|
+
- In a finding, say what you actually checked — "none of the tests I read cover this" — and show how far you looked. Say a test doesn't exist anywhere only when the symbol/import-reference search actually shows that.
|
|
22
|
+
- Do not assign severity, confidence, priority, or ranking.
|
|
23
|
+
|
|
24
|
+
## Review Sequence
|
|
25
|
+
|
|
26
|
+
### Step 1: Screen for behavioral change
|
|
27
|
+
|
|
28
|
+
If the change is non-behavioral, stop here and output the clean result (see Output Format). Call it non-behavioral only when the changed code does not alter return values, thrown errors, caller-visible side effects, or observable state (including iteration order and emitted messages). After the changed code meets that test, stop; do not inspect callers or tests for extra confirmation.
|
|
29
|
+
|
|
30
|
+
Common non-behavioral examples: formatting, comments, whitespace; pure renames; trivial getters/setters and pass-throughs; type-only or compiler-enforced changes with no runtime effect; etc.
|
|
31
|
+
|
|
32
|
+
### Step 2: Find the behavior that changed
|
|
33
|
+
|
|
34
|
+
Identify what behavior changed compared to the previous version: output, side effect, branch, error path, schema/event shape, config default, validation/authorization rule, external contract, etc. If the change affects more than one behavior, handle each separately.
|
|
35
|
+
|
|
36
|
+
Treat broad-impact changes as behavioral even when no single changed line looks important: dependency, toolchain, build/config, data-file, etc.
|
|
37
|
+
|
|
38
|
+
### Step 3: Trace where that behavior is used
|
|
39
|
+
|
|
40
|
+
Trace the changed behavior to the places that observe it. Start with direct callers and registered entry points (routes, commands, DI), contract consumers (schemas, events, APIs, database readers), and reverse-dependency info if already available.
|
|
41
|
+
|
|
42
|
+
Follow a path only while the changed behavior is reachable and unverified. Stop when a test at that boundary would fail, the consumer does not observe the changed behavior, or the next hop is guesswork (dynamic dispatch, reflection, outside-repo consumers, etc.). Prefer the nearest observable boundary, often one to three hops away, especially across contract, integration, or service edges. If there are more than five similar consumers, group obvious repeats and check representative paths; expand only when a consumer observes the behavior differently.
|
|
43
|
+
|
|
44
|
+
### Step 4: Qualify the consumer, then check its test
|
|
45
|
+
|
|
46
|
+
For each consumer, name the smallest realistic regression this consumer would observe: invert the branch, drop the default, omit the field, return the old error code, skip the integration call, etc. This is the Demonstration. If no such regression exists, drop the path; untested downstream code is not a finding.
|
|
47
|
+
|
|
48
|
+
A `Missing-adoption gap` qualifies not by the adoption failure alone but by a supersession signal: the change gives clear evidence the new behavior is meant to replace the local one — PR intent, naming or docs, a replaced sibling site, deleted duplicate logic, or a test defining the new rule — and the local site shares the same observable contract. Without a supersession signal and a shared observable contract, it is a refactor suggestion, not a verification-gap finding. Once both hold, check whether any test for that site would flag the non-adoption; missing coverage of the non-adoption is the gap itself, not a disqualifier.
|
|
49
|
+
|
|
50
|
+
Find and read the relevant test. Ask whether the Demonstration would make an assertion fail.
|
|
51
|
+
|
|
52
|
+
- If yes, the behavior is verified. No finding.
|
|
53
|
+
- For a regression-style Demonstration: if no test runs the path, the test is skipped/flaky/not run normally, or the test runs the code without checking the changed result, report a `Regression gap` or `Broken-verification gap`.
|
|
54
|
+
- For a qualifying Missing-adoption case: if none of the site tests you found assert it adopts the new behavior, report a `Missing-adoption gap`.
|
|
55
|
+
|
|
56
|
+
A test counts only if it runs normally and an assertion observes the changed output, branch, or contract. These do not count: no execution; success/no-throw/snapshot-only checks; mock/log-call checks; human-only checks; tests that mock away the integration; e2e tests that pass through without checking the changed output; stale assertions or fixtures.
|
|
57
|
+
|
|
58
|
+
Common patterns:
|
|
59
|
+
|
|
60
|
+
- **Caller-path gap** — helper test covers the branch, but caller values skip it.
|
|
61
|
+
- **Contract drift** — payload/schema/event changes must be verified at the consumer.
|
|
62
|
+
- **Migration compatibility** — tests only create new-format rows or fresh schemas.
|
|
63
|
+
- **Phantom exception** — handled partial-failure path has no test.
|
|
64
|
+
- **Missing-adoption gap** — sibling site should use the new rule/helper and does not.
|
|
65
|
+
- **Removed verification** — deleted test or weakened assertion leaves behavior unpinned.
|
|
66
|
+
|
|
67
|
+
### Step 5: Confirm each finding is real
|
|
68
|
+
|
|
69
|
+
Before writing a finding, re-open the specific tests or search results the finding relies on. Verify the Demonstration would not make any test you checked fail, or that the absence claim is backed by the symbol/import-reference search. Do not claim more than you verified; drop any finding you cannot ground.
|
|
70
|
+
|
|
71
|
+
Do not report: compiler/type-checker-enforced cases; behavior already verified by an integration, contract, or e2e test; implementation-detail or mock-only tests; low coverage or a missing test file by itself; legacy untested code the change did not affect.
|
|
72
|
+
|
|
73
|
+
Report genuine problems you noticed while tracing verification, even if they are not verification gaps. Put them under `Other findings` in the output. This permits reporting what you already reached, not extra hunting.
|
|
74
|
+
|
|
75
|
+
## OUTPUT FORMAT
|
|
76
|
+
|
|
77
|
+
Emit each verification-gap finding as one block. No general advice, no severity or confidence.
|
|
78
|
+
|
|
79
|
+
```markdown
|
|
80
|
+
### <one-line title naming the gap>
|
|
81
|
+
|
|
82
|
+
- **Changed surface:** the exact behavior or contract that changed — `file:line`.
|
|
83
|
+
- **Impacted consumer or site:** named concretely with `file:line` (e.g. "the `createInvoice` mutation used by the billing dashboard at `billing/dashboard.ts:88`," not "callers of this function").
|
|
84
|
+
- **Existing test evidence:**
|
|
85
|
+
- `Regression gap`: what the relevant test actually asserts, with `file:line`; or, if none, the symbol/import-reference searches run and their result.
|
|
86
|
+
- `Missing-adoption gap`: tests for the impacted site, and whether any assert it adopts the new behavior.
|
|
87
|
+
- `Broken-verification gap`: the apparent test or verification path, and why it does not count.
|
|
88
|
+
- **Missing verification:** the precise assertion or check that's absent.
|
|
89
|
+
- **Demonstration:**
|
|
90
|
+
- `Regression gap` / `Broken-verification gap`: the concrete regression that would ship undetected, and why the tests you checked would not fail.
|
|
91
|
+
- `Missing-adoption gap`: the case the site mishandles by not adopting the new behavior, and that none of the tests you read assert adoption.
|
|
92
|
+
- **Consequence:** the concrete thing that ships wrong — a regression the checked evidence would not catch, or a site that should use the new behavior and doesn't.
|
|
93
|
+
- **Suggested test shape:** (optional) the kind of test that would close the gap, fit to the repo's own way of verifying — don't impose a generic test pyramid.
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
If you noticed genuine non-gap problems while tracing verification, append:
|
|
97
|
+
|
|
98
|
+
```markdown
|
|
99
|
+
## Other findings
|
|
100
|
+
|
|
101
|
+
- <description only; no severity, confidence, priority, or ranking>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
When you find no verification gaps and no other findings, output exactly this single line, not an empty response:
|
|
105
|
+
|
|
106
|
+
`No verification gaps found.`
|
|
@@ -102,6 +102,11 @@ class Installer {
|
|
|
102
102
|
|
|
103
103
|
const restoreResult = await this._restoreUserFiles(paths, updateState);
|
|
104
104
|
|
|
105
|
+
// Surface any "action needed" post-install messages for installed modules
|
|
106
|
+
// (e.g. run a setup skill) and let the user acknowledge them before the
|
|
107
|
+
// final summary, so "BMAD is ready to use!" stays the last thing shown.
|
|
108
|
+
await this._displayPostInstallMessages(config, officialModules);
|
|
109
|
+
|
|
105
110
|
// Render consolidated summary
|
|
106
111
|
await this.renderInstallSummary(results, {
|
|
107
112
|
bmadDir: paths.bmadDir,
|
|
@@ -1246,6 +1251,56 @@ class Installer {
|
|
|
1246
1251
|
});
|
|
1247
1252
|
}
|
|
1248
1253
|
|
|
1254
|
+
/**
|
|
1255
|
+
* Display registry-defined post-install messages for the modules installed in
|
|
1256
|
+
* this run. These are "action needed" notices (e.g. "run the bmad-loop-setup
|
|
1257
|
+
* skill") that the user must see to finish setup. They are defined via the
|
|
1258
|
+
* `post-install-message` property on a module's bmad-modules.yaml entry.
|
|
1259
|
+
*
|
|
1260
|
+
* Interactive installs require the user to acknowledge each message (press
|
|
1261
|
+
* Enter); non-interactive (--yes / skipPrompts) installs print the message
|
|
1262
|
+
* and continue without blocking, so CI/scripted installs don't hang.
|
|
1263
|
+
*
|
|
1264
|
+
* @param {Object} config - Install config (config.modules, config.skipPrompts)
|
|
1265
|
+
* @param {Object} officialModules - OfficialModules instance (carries the registry)
|
|
1266
|
+
*/
|
|
1267
|
+
async _displayPostInstallMessages(config, officialModules) {
|
|
1268
|
+
const moduleCodes = config.modules || [];
|
|
1269
|
+
if (moduleCodes.length === 0) return;
|
|
1270
|
+
|
|
1271
|
+
const externalManager = officialModules.externalModuleManager;
|
|
1272
|
+
if (!externalManager) return;
|
|
1273
|
+
|
|
1274
|
+
const color = await prompts.getColor();
|
|
1275
|
+
|
|
1276
|
+
for (const code of moduleCodes) {
|
|
1277
|
+
let moduleInfo;
|
|
1278
|
+
try {
|
|
1279
|
+
moduleInfo = await externalManager.getModuleByCode(code);
|
|
1280
|
+
} catch {
|
|
1281
|
+
continue; // Built-in modules (core/bmm) aren't in the registry — skip.
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const message = moduleInfo && moduleInfo.postInstallMessage;
|
|
1285
|
+
if (!message) continue;
|
|
1286
|
+
|
|
1287
|
+
await prompts.box(String(message).trim(), `⚑ Action needed — ${moduleInfo.name || code}`, {
|
|
1288
|
+
rounded: true,
|
|
1289
|
+
formatBorder: color.yellow,
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
// Interactive: require the user to acknowledge before continuing. Skip the
|
|
1293
|
+
// blocking prompt in non-interactive installs (the message is still shown).
|
|
1294
|
+
if (!config.skipPrompts) {
|
|
1295
|
+
await prompts.text({
|
|
1296
|
+
message: 'Press Enter to acknowledge',
|
|
1297
|
+
placeholder: '',
|
|
1298
|
+
default: '',
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1249
1304
|
/**
|
|
1250
1305
|
* Quick update method - preserves all settings and only prompts for new config fields
|
|
1251
1306
|
* @param {Object} config - Configuration with directory
|
|
@@ -1262,10 +1317,32 @@ class Installer {
|
|
|
1262
1317
|
|
|
1263
1318
|
// Detect existing installation
|
|
1264
1319
|
const existingInstall = await ExistingInstall.detect(bmadDir);
|
|
1265
|
-
const installedModules = existingInstall.moduleIds;
|
|
1266
1320
|
const configuredIdes = existingInstall.ides;
|
|
1267
1321
|
const projectRoot = path.dirname(bmadDir);
|
|
1268
1322
|
|
|
1323
|
+
// Resolve any legacy/aliased module codes (e.g. an install recorded as
|
|
1324
|
+
// `bauto` before the registry renamed it to `bmad-loop`) to their current
|
|
1325
|
+
// canonical code up front. Without this, a renamed module's old installs
|
|
1326
|
+
// would fall out of `availableModuleIds` below and get silently frozen
|
|
1327
|
+
// (see the `baut` → `automator` incident in CHANGELOG v6.7.1) instead of
|
|
1328
|
+
// migrating forward.
|
|
1329
|
+
const aliasMigrations = [];
|
|
1330
|
+
const seenModuleIds = new Set();
|
|
1331
|
+
const installedModules = [];
|
|
1332
|
+
for (const rawId of existingInstall.moduleIds) {
|
|
1333
|
+
const canonicalId = await this.externalModuleManager.resolveCanonicalCode(rawId);
|
|
1334
|
+
if (canonicalId !== rawId) {
|
|
1335
|
+
aliasMigrations.push({ from: rawId, to: canonicalId });
|
|
1336
|
+
}
|
|
1337
|
+
if (!seenModuleIds.has(canonicalId)) {
|
|
1338
|
+
seenModuleIds.add(canonicalId);
|
|
1339
|
+
installedModules.push(canonicalId);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
for (const { from, to } of aliasMigrations) {
|
|
1343
|
+
await prompts.log.info(`Migrating installed module '${from}' to its renamed successor '${to}'.`);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1269
1346
|
// Get available modules (what we have source for)
|
|
1270
1347
|
const availableModulesData = await new OfficialModules().listAvailable();
|
|
1271
1348
|
const availableModules = [...availableModulesData.modules];
|
|
@@ -1410,6 +1487,18 @@ class Installer {
|
|
|
1410
1487
|
|
|
1411
1488
|
await this.install(installConfig);
|
|
1412
1489
|
|
|
1490
|
+
// Now that the canonical module has been installed successfully, remove
|
|
1491
|
+
// the stale directory left behind under its old code so the two don't
|
|
1492
|
+
// coexist (e.g. `_bmad/bauto/` once `_bmad/bmad-loop/` is in place).
|
|
1493
|
+
for (const { from, to } of aliasMigrations) {
|
|
1494
|
+
if (!modulesToUpdate.includes(to)) continue; // new code wasn't actually installed this run
|
|
1495
|
+
const oldModuleDir = path.join(bmadDir, from);
|
|
1496
|
+
if (await fs.pathExists(oldModuleDir)) {
|
|
1497
|
+
await fs.remove(oldModuleDir);
|
|
1498
|
+
await prompts.log.success(`Removed legacy '${from}' directory after migrating to '${to}'.`);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1413
1502
|
return {
|
|
1414
1503
|
success: true,
|
|
1415
1504
|
moduleCount: modulesToUpdate.length,
|
|
@@ -77,7 +77,7 @@ async function checkUvEnvironment() {
|
|
|
77
77
|
const detected = module.exports.detectUv();
|
|
78
78
|
|
|
79
79
|
if (detected) {
|
|
80
|
-
await prompts.log.success(
|
|
80
|
+
await prompts.log.success(`✅ Python UV check pass (uv ${detected.version.raw} detected).`);
|
|
81
81
|
return { status: 'found', detected };
|
|
82
82
|
}
|
|
83
83
|
|
|
@@ -62,6 +62,13 @@ class ExternalModuleManager {
|
|
|
62
62
|
// ExternalModuleManager) sees resolutions made during install.
|
|
63
63
|
static _resolutions = new Map();
|
|
64
64
|
|
|
65
|
+
// moduleCode → ResolvedModule (from PluginResolver). Populated for registry
|
|
66
|
+
// modules flagged `marketplace-plugin: true`, whose installable skills live
|
|
67
|
+
// outside a single module.yaml directory and must be resolved from
|
|
68
|
+
// .claude-plugin/marketplace.json. Shared across instances so install() can
|
|
69
|
+
// pick up the resolution computed during findExternalModuleSource.
|
|
70
|
+
static _pluginResolutions = new Map();
|
|
71
|
+
|
|
65
72
|
constructor() {}
|
|
66
73
|
|
|
67
74
|
/**
|
|
@@ -73,6 +80,15 @@ class ExternalModuleManager {
|
|
|
73
80
|
return ExternalModuleManager._resolutions.get(moduleCode) || null;
|
|
74
81
|
}
|
|
75
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Get the cached marketplace-plugin resolution for a module (if any).
|
|
85
|
+
* @param {string} moduleCode
|
|
86
|
+
* @returns {Object|null} ResolvedModule from PluginResolver, or null
|
|
87
|
+
*/
|
|
88
|
+
getPluginResolution(moduleCode) {
|
|
89
|
+
return ExternalModuleManager._pluginResolutions.get(moduleCode) || null;
|
|
90
|
+
}
|
|
91
|
+
|
|
76
92
|
/**
|
|
77
93
|
* Load the official modules registry from the bundled YAML file.
|
|
78
94
|
* @returns {Object} Parsed YAML content with modules array
|
|
@@ -113,8 +129,16 @@ class ExternalModuleManager {
|
|
|
113
129
|
npmPackage: mod.npm_package || mod.npmPackage || null,
|
|
114
130
|
pluginName: mod.plugin_name || mod.pluginName || null,
|
|
115
131
|
defaultChannel: normalizeChannelName(mod.default_channel || mod.defaultChannel) || 'stable',
|
|
132
|
+
deprecated: mod.deprecated === true,
|
|
133
|
+
deprecationMessage: mod.deprecation_message || mod['deprecation-message'] || mod.deprecationMessage || null,
|
|
134
|
+
marketplacePlugin: mod.marketplace_plugin === true || mod['marketplace-plugin'] === true || mod.marketplacePlugin === true,
|
|
135
|
+
postInstallMessage: mod.post_install_message || mod['post-install-message'] || mod.postInstallMessage || null,
|
|
116
136
|
builtIn: mod.built_in === true,
|
|
117
137
|
isExternal: mod.built_in !== true,
|
|
138
|
+
// Prior codes this module was registered under (e.g. `bmad-loop` was
|
|
139
|
+
// `bauto`). Lets a renamed module keep resolving existing installs
|
|
140
|
+
// instead of orphaning them — see getModuleByCode().
|
|
141
|
+
aliases: Array.isArray(mod.aliases) ? mod.aliases : [],
|
|
118
142
|
};
|
|
119
143
|
}
|
|
120
144
|
|
|
@@ -139,13 +163,27 @@ class ExternalModuleManager {
|
|
|
139
163
|
}
|
|
140
164
|
|
|
141
165
|
/**
|
|
142
|
-
* Get module info by code
|
|
143
|
-
*
|
|
166
|
+
* Get module info by code. Falls back to matching a registry entry's
|
|
167
|
+
* `aliases` list, so a module that was renamed (its `code` changed) still
|
|
168
|
+
* resolves for installs recorded under the prior code.
|
|
169
|
+
* @param {string} code - The module code (e.g., 'cis'), current or aliased
|
|
144
170
|
* @returns {Object|null} Module info or null if not found
|
|
145
171
|
*/
|
|
146
172
|
async getModuleByCode(code) {
|
|
147
173
|
const modules = await this.listAvailable();
|
|
148
|
-
return modules.find((m) => m.code === code) || null;
|
|
174
|
+
return modules.find((m) => m.code === code) || modules.find((m) => m.aliases.includes(code)) || null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Resolve a possibly-legacy module code to its current canonical code.
|
|
179
|
+
* Returns the input unchanged if it doesn't match any registry entry or
|
|
180
|
+
* alias (e.g. a custom/unknown module).
|
|
181
|
+
* @param {string} code
|
|
182
|
+
* @returns {Promise<string>}
|
|
183
|
+
*/
|
|
184
|
+
async resolveCanonicalCode(code) {
|
|
185
|
+
const info = await this.getModuleByCode(code);
|
|
186
|
+
return info ? info.code : code;
|
|
149
187
|
}
|
|
150
188
|
|
|
151
189
|
/**
|
|
@@ -176,6 +214,11 @@ class ExternalModuleManager {
|
|
|
176
214
|
throw new Error(`External module '${moduleCode}' not found in the BMad registry`);
|
|
177
215
|
}
|
|
178
216
|
|
|
217
|
+
// Normalize to the canonical code so cache dir, in-memory resolutions,
|
|
218
|
+
// and log/error text stay consistent even when called with a renamed
|
|
219
|
+
// module's prior alias (getModuleByCode resolves aliases above).
|
|
220
|
+
moduleCode = moduleInfo.code;
|
|
221
|
+
|
|
179
222
|
const cacheDir = this.getExternalCacheDir();
|
|
180
223
|
const moduleCacheDir = path.join(cacheDir, moduleCode);
|
|
181
224
|
const silent = options.silent || false;
|
|
@@ -468,6 +511,22 @@ class ExternalModuleManager {
|
|
|
468
511
|
return null;
|
|
469
512
|
}
|
|
470
513
|
|
|
514
|
+
// Normalize to the canonical code — see cloneExternalModule for why.
|
|
515
|
+
moduleCode = moduleInfo.code;
|
|
516
|
+
|
|
517
|
+
// Marketplace-plugin modules (registry entries flagged `marketplace-plugin`)
|
|
518
|
+
// ship a .claude-plugin/marketplace.json and keep their module.yaml inside a
|
|
519
|
+
// skill's assets/ rather than in one directory alongside the skills. Resolve
|
|
520
|
+
// them through the PluginResolver (the same machinery custom-URL installs
|
|
521
|
+
// use) and return the directory that holds module.yaml so config/version/
|
|
522
|
+
// directory callers work; install() copies the resolved skill dirs.
|
|
523
|
+
if (moduleInfo.marketplacePlugin) {
|
|
524
|
+
const pluginMod = await this.resolvePluginModule(moduleCode, options);
|
|
525
|
+
if (pluginMod && pluginMod.moduleYamlPath) {
|
|
526
|
+
return path.dirname(pluginMod.moduleYamlPath);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
471
530
|
// Clone the external module repo
|
|
472
531
|
const cloneDir = await this.cloneExternalModule(moduleCode, options);
|
|
473
532
|
|
|
@@ -521,6 +580,87 @@ class ExternalModuleManager {
|
|
|
521
580
|
`The repository may have been restructured after this release was tagged.${channelHint}`,
|
|
522
581
|
);
|
|
523
582
|
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Resolve a marketplace-plugin registry module to an installable plugin
|
|
586
|
+
* definition. Clones the repo (respecting the channel plan), reads its
|
|
587
|
+
* .claude-plugin/marketplace.json, and runs the PluginResolver against the
|
|
588
|
+
* plugin matching this module. The result (skillPaths + module.yaml +
|
|
589
|
+
* module-help.csv) is cached so install() can copy the resolved skill dirs.
|
|
590
|
+
*
|
|
591
|
+
* @param {string} moduleCode - Code of the external module
|
|
592
|
+
* @param {Object} options - Options passed to cloneExternalModule
|
|
593
|
+
* @returns {Promise<Object|null>} ResolvedModule from PluginResolver, or null
|
|
594
|
+
* when the module is not a marketplace plugin or cannot be resolved.
|
|
595
|
+
*/
|
|
596
|
+
async resolvePluginModule(moduleCode, options = {}) {
|
|
597
|
+
const moduleInfo = await this.getModuleByCode(moduleCode);
|
|
598
|
+
if (!moduleInfo || moduleInfo.builtIn || !moduleInfo.marketplacePlugin) {
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Normalize to the canonical code — see cloneExternalModule for why.
|
|
603
|
+
moduleCode = moduleInfo.code;
|
|
604
|
+
|
|
605
|
+
const cloneDir = await this.cloneExternalModule(moduleCode, options);
|
|
606
|
+
|
|
607
|
+
const marketplacePath = path.join(cloneDir, '.claude-plugin', 'marketplace.json');
|
|
608
|
+
if (!(await fs.pathExists(marketplacePath))) {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
let marketplace;
|
|
613
|
+
try {
|
|
614
|
+
marketplace = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
|
615
|
+
} catch {
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : [];
|
|
620
|
+
if (plugins.length === 0) {
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Prefer the plugin whose name matches the registry's plugin_name (or code),
|
|
625
|
+
// falling back to any plugin that declares skills.
|
|
626
|
+
const preferredName = moduleInfo.pluginName || moduleInfo.code;
|
|
627
|
+
const ordered = [...plugins].sort((a, b) => {
|
|
628
|
+
const am = a?.name === preferredName ? 0 : 1;
|
|
629
|
+
const bm = b?.name === preferredName ? 0 : 1;
|
|
630
|
+
return am - bm;
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
const { PluginResolver } = require('./plugin-resolver');
|
|
634
|
+
const resolver = new PluginResolver();
|
|
635
|
+
|
|
636
|
+
for (const plugin of ordered) {
|
|
637
|
+
if (!plugin || !Array.isArray(plugin.skills) || plugin.skills.length === 0) {
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
let resolvedMods;
|
|
641
|
+
try {
|
|
642
|
+
resolvedMods = await resolver.resolve(cloneDir, plugin);
|
|
643
|
+
} catch {
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
if (!resolvedMods || resolvedMods.length === 0) {
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
// Match the registry code, then the preferred plugin name, then accept a
|
|
650
|
+
// lone resolved module.
|
|
651
|
+
const match =
|
|
652
|
+
resolvedMods.find((mod) => mod.code === moduleCode) ||
|
|
653
|
+
resolvedMods.find((mod) => mod.code === preferredName) ||
|
|
654
|
+
(resolvedMods.length === 1 ? resolvedMods[0] : null);
|
|
655
|
+
if (match) {
|
|
656
|
+
match.repoUrl = moduleInfo.url;
|
|
657
|
+
ExternalModuleManager._pluginResolutions.set(moduleCode, match);
|
|
658
|
+
return match;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
524
664
|
cachedModules = null;
|
|
525
665
|
}
|
|
526
666
|
|
|
@@ -277,7 +277,29 @@ class OfficialModules {
|
|
|
277
277
|
await fs.remove(targetPath);
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
|
|
280
|
+
// Marketplace-plugin registry modules keep their installable skills outside
|
|
281
|
+
// the directory that holds module.yaml (sourcePath points at the -setup
|
|
282
|
+
// skill's assets/), so they cannot be installed by copying sourcePath. Copy
|
|
283
|
+
// the resolved skill directories instead, matching how custom marketplace
|
|
284
|
+
// installs lay out a module. Everything else (manifest, version info) flows
|
|
285
|
+
// through the standard external-module path below.
|
|
286
|
+
const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleName);
|
|
287
|
+
if (moduleInfo && moduleInfo.marketplacePlugin) {
|
|
288
|
+
const pluginResolution = this.externalModuleManager.getPluginResolution(moduleName);
|
|
289
|
+
// Fail loud: copying sourcePath here would install only the -setup skill's
|
|
290
|
+
// assets/ (module.yaml + module-help.csv) and none of the skills — a
|
|
291
|
+
// silent, broken partial install. Abort instead.
|
|
292
|
+
if (!pluginResolution || !Array.isArray(pluginResolution.skillPaths) || pluginResolution.skillPaths.length === 0) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`Module '${moduleName}' is registered as a marketplace plugin but its skills could not be resolved ` +
|
|
295
|
+
`from .claude-plugin/marketplace.json (missing or malformed on the selected channel). ` +
|
|
296
|
+
`Aborting to avoid a partial install with no skills.`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
await this._copyResolvedSkills(pluginResolution, targetPath, fileTrackingCallback, options.moduleConfig);
|
|
300
|
+
} else {
|
|
301
|
+
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig);
|
|
302
|
+
}
|
|
281
303
|
|
|
282
304
|
if (!options.skipModuleInstaller) {
|
|
283
305
|
await this.createModuleDirectories(moduleName, bmadDir, options);
|
|
@@ -304,41 +326,66 @@ class OfficialModules {
|
|
|
304
326
|
}
|
|
305
327
|
|
|
306
328
|
/**
|
|
307
|
-
*
|
|
308
|
-
*
|
|
329
|
+
* Lay out a PluginResolver resolution on disk: copy each resolved skill
|
|
330
|
+
* directory (flattened by leaf name) into targetPath and place module-help.csv
|
|
331
|
+
* at the module root. Shared by both custom marketplace installs
|
|
332
|
+
* (installFromResolution) and official marketplace-plugin registry installs
|
|
333
|
+
* (install), so the two paths cannot drift.
|
|
309
334
|
* @param {Object} resolved - ResolvedModule from PluginResolver
|
|
310
|
-
* @param {string}
|
|
335
|
+
* @param {string} targetPath - Destination module directory (e.g. bmadDir/<code>)
|
|
311
336
|
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
|
312
|
-
* @param {Object}
|
|
337
|
+
* @param {Object} moduleConfig - Module configuration passed to copy filtering
|
|
313
338
|
*/
|
|
314
|
-
async
|
|
315
|
-
const targetPath = path.join(bmadDir, resolved.code);
|
|
316
|
-
|
|
317
|
-
if (await fs.pathExists(targetPath)) {
|
|
318
|
-
await fs.remove(targetPath);
|
|
319
|
-
}
|
|
320
|
-
|
|
339
|
+
async _copyResolvedSkills(resolved, targetPath, fileTrackingCallback = null, moduleConfig = {}) {
|
|
321
340
|
await fs.ensureDir(targetPath);
|
|
322
341
|
|
|
323
|
-
// Copy each skill directory, flattened by leaf name
|
|
342
|
+
// Copy each skill directory, flattened by leaf name. Leaf names must be
|
|
343
|
+
// unique — two skills that flatten to the same directory would silently
|
|
344
|
+
// overwrite each other, so fail loud instead.
|
|
345
|
+
const seenLeaves = new Map();
|
|
324
346
|
for (const skillPath of resolved.skillPaths) {
|
|
325
347
|
const skillDirName = path.basename(skillPath);
|
|
348
|
+
if (seenLeaves.has(skillDirName)) {
|
|
349
|
+
throw new Error(
|
|
350
|
+
`Cannot install module '${resolved.code}': skill directories '${seenLeaves.get(skillDirName)}' and ` +
|
|
351
|
+
`'${skillPath}' share the leaf name '${skillDirName}' and would overwrite each other. ` +
|
|
352
|
+
`Skill directory names must be unique.`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
seenLeaves.set(skillDirName, skillPath);
|
|
326
356
|
const skillTarget = path.join(targetPath, skillDirName);
|
|
327
|
-
await this.copyModuleWithFiltering(skillPath, skillTarget, fileTrackingCallback,
|
|
357
|
+
await this.copyModuleWithFiltering(skillPath, skillTarget, fileTrackingCallback, moduleConfig);
|
|
328
358
|
}
|
|
329
359
|
|
|
330
|
-
// Place module-help.csv at the module root
|
|
360
|
+
// Place module-help.csv at the module root.
|
|
361
|
+
const helpTarget = path.join(targetPath, 'module-help.csv');
|
|
331
362
|
if (resolved.moduleHelpCsvPath) {
|
|
332
|
-
// Strategies 1-4: copy the existing file
|
|
333
|
-
const helpTarget = path.join(targetPath, 'module-help.csv');
|
|
363
|
+
// Strategies 1-4: copy the existing file.
|
|
334
364
|
await fs.copy(resolved.moduleHelpCsvPath, helpTarget, { overwrite: true });
|
|
335
365
|
if (fileTrackingCallback) fileTrackingCallback(helpTarget);
|
|
336
366
|
} else if (resolved.synthesizedHelpCsv) {
|
|
337
|
-
// Strategy 5: write synthesized content
|
|
338
|
-
const helpTarget = path.join(targetPath, 'module-help.csv');
|
|
367
|
+
// Strategy 5: write synthesized content.
|
|
339
368
|
await fs.writeFile(helpTarget, resolved.synthesizedHelpCsv, 'utf8');
|
|
340
369
|
if (fileTrackingCallback) fileTrackingCallback(helpTarget);
|
|
341
370
|
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Install a module from a PluginResolver resolution result.
|
|
375
|
+
* Copies specific skill directories and places module-help.csv at the target root.
|
|
376
|
+
* @param {Object} resolved - ResolvedModule from PluginResolver
|
|
377
|
+
* @param {string} bmadDir - Target bmad directory
|
|
378
|
+
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
|
379
|
+
* @param {Object} options - Installation options
|
|
380
|
+
*/
|
|
381
|
+
async installFromResolution(resolved, bmadDir, fileTrackingCallback = null, options = {}) {
|
|
382
|
+
const targetPath = path.join(bmadDir, resolved.code);
|
|
383
|
+
|
|
384
|
+
if (await fs.pathExists(targetPath)) {
|
|
385
|
+
await fs.remove(targetPath);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
await this._copyResolvedSkills(resolved, targetPath, fileTrackingCallback, options.moduleConfig);
|
|
342
389
|
|
|
343
390
|
// Create directories declared in module.yaml (strategies 1-4 may have these)
|
|
344
391
|
if (!options.skipModuleInstaller) {
|
package/tools/installer/ui.js
CHANGED
|
@@ -133,7 +133,12 @@ class UI {
|
|
|
133
133
|
for (const moduleId of installedModuleIds) {
|
|
134
134
|
if (moduleId === 'core') continue;
|
|
135
135
|
if (!selectedSet.has(moduleId) && !options.preserveUnselected) continue;
|
|
136
|
-
|
|
136
|
+
// Resolve a possibly-renamed module code (e.g. `bauto` -> `bmad-loop`)
|
|
137
|
+
// before checking availability, so a registry rename doesn't freeze
|
|
138
|
+
// the install here the way it would have prior to the alias support
|
|
139
|
+
// in ExternalModuleManager.getModuleByCode().
|
|
140
|
+
const canonicalId = await externalManager.resolveCanonicalCode(moduleId);
|
|
141
|
+
if (officialCodes.has(canonicalId)) continue;
|
|
137
142
|
|
|
138
143
|
const customSource = await customMgr.findModuleSourceByCode(moduleId, { bmadDir });
|
|
139
144
|
if (!customSource) {
|
|
@@ -944,25 +949,32 @@ class UI {
|
|
|
944
949
|
}
|
|
945
950
|
}
|
|
946
951
|
|
|
947
|
-
// Add external registry modules (skip built-in duplicates
|
|
948
|
-
|
|
952
|
+
// Add external registry modules (skip built-in duplicates and deprecated
|
|
953
|
+
// modules that are not already installed — deprecated modules stay visible
|
|
954
|
+
// only so existing users can continue to manage them).
|
|
955
|
+
const externalRegistryModules = registryModules.filter(
|
|
956
|
+
(mod) => !mod.builtIn && !builtInCodes.has(mod.code) && (!mod.deprecated || installedModuleIds.has(mod.code)),
|
|
957
|
+
);
|
|
949
958
|
let externalRegistryEntries = [];
|
|
950
959
|
if (externalRegistryModules.length > 0) {
|
|
951
960
|
const spinner = await prompts.spinner();
|
|
952
961
|
spinner.start('Checking latest module versions...');
|
|
953
962
|
|
|
954
963
|
externalRegistryEntries = await Promise.all(
|
|
955
|
-
externalRegistryModules.map(async (mod) =>
|
|
956
|
-
|
|
957
|
-
entry: await buildModuleEntry(
|
|
964
|
+
externalRegistryModules.map(async (mod) => {
|
|
965
|
+
const entry = await buildModuleEntry(
|
|
958
966
|
mod.code,
|
|
959
967
|
mod.name,
|
|
960
968
|
mod.description,
|
|
961
969
|
mod.defaultSelected,
|
|
962
970
|
mod.url || null,
|
|
963
971
|
mod.defaultChannel || null,
|
|
964
|
-
)
|
|
965
|
-
|
|
972
|
+
);
|
|
973
|
+
if (mod.deprecated && mod.deprecationMessage) {
|
|
974
|
+
entry.hint = entry.hint ? `${entry.hint} — ${mod.deprecationMessage}` : mod.deprecationMessage;
|
|
975
|
+
}
|
|
976
|
+
return { code: mod.code, entry };
|
|
977
|
+
}),
|
|
966
978
|
);
|
|
967
979
|
|
|
968
980
|
spinner.stop('Checked latest module versions.');
|