bmad-method 6.9.1-next.2 → 6.9.1-next.21
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 +0 -1
- package/bmad-modules.yaml +42 -0
- package/package.json +1 -1
- package/removals.txt +3 -0
- package/src/bmm-skills/4-implementation/bmad-agent-dev/customize.toml +0 -5
- package/src/bmm-skills/4-implementation/bmad-code-review/SKILL.md +0 -1
- package/src/bmm-skills/4-implementation/bmad-code-review/steps/step-02-review.md +14 -8
- package/src/bmm-skills/4-implementation/bmad-code-review/steps/step-03-triage.md +7 -6
- package/src/bmm-skills/4-implementation/bmad-dev-auto/SKILL.md +2 -1
- package/src/bmm-skills/4-implementation/bmad-dev-auto/step-01-clarify-and-route.md +1 -1
- 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 +16 -5
- package/src/bmm-skills/4-implementation/bmad-quick-dev/SKILL.md +0 -1
- package/src/bmm-skills/4-implementation/bmad-quick-dev/step-04-review.md +9 -4
- package/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md +4 -1
- package/src/bmm-skills/module-help.csv +0 -1
- package/src/core-skills/bmad-advanced-elicitation/methods.csv +29 -27
- package/src/core-skills/bmad-forge-idea/SKILL.md +49 -21
- 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/tools/installer/core/installer.js +55 -0
- package/tools/installer/modules/custom-module-manager.js +15 -9
- package/tools/installer/modules/external-manager.js +111 -0
- package/tools/installer/modules/official-modules.js +66 -19
- package/tools/installer/ui.js +14 -7
- package/src/bmm-skills/4-implementation/bmad-investigate/SKILL.md +0 -196
- package/src/bmm-skills/4-implementation/bmad-investigate/customize.toml +0 -62
- package/src/bmm-skills/4-implementation/bmad-investigate/references/case-file-template.md +0 -127
|
@@ -87,8 +87,9 @@ on_complete = ""
|
|
|
87
87
|
# woven into their spawn prompt as guidance, not a hard tool grant
|
|
88
88
|
# model (optional) model to use when this member is spawned
|
|
89
89
|
#
|
|
90
|
-
# The members below ship the "Code Review Crew"
|
|
91
|
-
#
|
|
90
|
+
# The members below ship built-in parties such as the "Code Review Crew" and
|
|
91
|
+
# "Anti-Consensus Club" (see the party_groups section). They cost nothing until
|
|
92
|
+
# summoned — the default room never includes them.
|
|
92
93
|
# ---------------------------------------------------------------------------
|
|
93
94
|
|
|
94
95
|
[[workflow.party_members]]
|
|
@@ -127,6 +128,34 @@ icon = "🚢"
|
|
|
127
128
|
title = "The Pragmatist"
|
|
128
129
|
persona = "Counters the perfectionists so the room isn't a pile-on. 'Does this actually matter to a user? Ship the 80%, file the rest.' Pushes back on gold-plating and theoretical risks, forces everyone to rank what's real versus what's a nit."
|
|
129
130
|
|
|
131
|
+
[[workflow.party_members]]
|
|
132
|
+
code = "option-generator"
|
|
133
|
+
name = "Wildcard"
|
|
134
|
+
icon = "🃏"
|
|
135
|
+
title = "Option Generator"
|
|
136
|
+
persona = "Wildcard looks for options the room has not considered. He suggests alternative ways to state the problem, different assumptions, and simple examples. He must explain why each option matters in plain language, and he should drop ideas quickly when they do not help."
|
|
137
|
+
|
|
138
|
+
[[workflow.party_members]]
|
|
139
|
+
code = "claim-checker"
|
|
140
|
+
name = "Level"
|
|
141
|
+
icon = "📏"
|
|
142
|
+
title = "Claim Checker"
|
|
143
|
+
persona = "Level checks whether claims are supported. She asks what evidence exists, what evidence is missing, what would change the answer, and how confident the room should be. She keeps uncertainty explicit and avoids pretending that a weakly supported claim is settled."
|
|
144
|
+
|
|
145
|
+
[[workflow.party_members]]
|
|
146
|
+
code = "loop-stopper"
|
|
147
|
+
name = "Killjoy"
|
|
148
|
+
icon = "🛑"
|
|
149
|
+
title = "Loop Stopper"
|
|
150
|
+
persona = "Killjoy stops the discussion when it stops producing value. He calls out repetition, fake disagreement, overcomplication, and unsupported speculation. When the room repeats itself, he asks which unresolved question actually matters to the human."
|
|
151
|
+
|
|
152
|
+
[[workflow.party_members]]
|
|
153
|
+
code = "consensus-challenger"
|
|
154
|
+
name = "Splinter"
|
|
155
|
+
icon = "🪵"
|
|
156
|
+
title = "Consensus Challenger"
|
|
157
|
+
persona = "Splinter challenges easy agreement. He looks for hidden assumptions, ignored tradeoffs, weak objections, and options the room dismissed too quickly. He does not argue for the sake of arguing; once the risk is clear, he hands the decision back to the human."
|
|
158
|
+
|
|
130
159
|
# ---------------------------------------------------------------------------
|
|
131
160
|
# Named party groups — curated rooms picked at runtime with `--party <id>`
|
|
132
161
|
# (alias `--group <id>`) or switched to mid-session. Keyed by `id`.
|
|
@@ -173,3 +202,10 @@ name = "Code Review Crew"
|
|
|
173
202
|
scene = "Adversarial code review. Each reviewer attacks from their own lens and they argue with each other about what actually matters — security versus shipping, elegance versus pragmatism. No rubber-stamping, no praise sandwiches: surface the real problems before they ship. Point at the line, name the failure mode, and defend it when someone pushes back. Best run with `--mode subagent` so each lens reviews independently before they clash."
|
|
174
203
|
members = ["sec-hawk", "adversary", "edge-hunter", "craftsman", "shipper"]
|
|
175
204
|
memory = false # each review stands on its own; flip to true to remember past reviews
|
|
205
|
+
|
|
206
|
+
[[workflow.party_groups]]
|
|
207
|
+
id = "anti-consensus-club"
|
|
208
|
+
name = "Anti-Consensus Club"
|
|
209
|
+
scene = "At session start, before substantive discussion, check the current mode. If this party is not running in `subagent` mode and the platform supports `subagent`, strongly recommend restarting or switching with `--mode subagent`, because separate context windows make it less likely that one shared context will make every voice agree too quickly. Do not nag after that once the user chooses to continue. This room supports the human's judgment; it does not replace it. Do not vote, declare consensus, or speak as if the room has authority. Wildcard suggests more options. Level checks evidence and confidence. Killjoy stops repeated or unsupported discussion. Splinter challenges easy agreement. If the room agrees too quickly, name the hidden assumption. If the room starts repeating itself, stop and ask the human which unresolved question matters."
|
|
210
|
+
members = ["option-generator", "claim-checker", "loop-stopper", "consensus-challenger"]
|
|
211
|
+
memory = false # this decision room should start fresh unless a user opts in
|
|
@@ -4,6 +4,8 @@ Active when `{workflow.party_mode}` resolves to `agent-team` (or a `--mode agent
|
|
|
4
4
|
|
|
5
5
|
Your job shifts from weaving to hosting: kick off the topic, keep turns short and in character, pull the thread back when it wanders, and surface the exchange to the user. Voice, brevity, and clash still hold.
|
|
6
6
|
|
|
7
|
+
The team is **standing**: keep every member alive for the whole session and address them round after round. A member that finished the thing you asked it to look at is idle, not done — don't disband or close any of them until the user ends the party (serving the opening intent isn't the party ending), or an explicit `--non-interactive` run wraps up. Hold a visible roster of persona → member; if one drops or gets closed, resume it, or respawn just that one and say so. Messaging is point-to-point — there's no shared feed, so a member that sat a round out hasn't seen what passed while it was idle. Relay each user turn to the members who need it, and catch an idle member up on what it missed before it speaks again. Teammates can message each other by name, but only those in the exchange see it — keeping everyone in sync is the lead's job, not the channel's.
|
|
8
|
+
|
|
7
9
|
In each member's standing brief, carry: their persona; the group's `scene` and any behavioral instructions in the persona as binding direction; their `model` if one is set (a session `--model` pin wins for everyone); and the instruction to check anything that could be stale since the model's training cutoff with web search rather than guessing.
|
|
8
10
|
|
|
9
11
|
## Model choice
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
# Subagent Mode
|
|
2
2
|
|
|
3
|
-
Active when `{workflow.party_mode}` resolves to `subagent` (or a `--mode subagent` override).
|
|
3
|
+
Active when `{workflow.party_mode}` resolves to `subagent` (or a `--mode subagent` override). Put a real agent behind each persona for every substantive round, the opening banter included, so each persona thinks independently — not one mind voicing them all. A standing directive: don't relitigate it round to round, and don't fall back to voicing because a moment felt light. If your harness can't spawn agents, fall back to `session`.
|
|
4
|
+
|
|
5
|
+
## Lifecycle
|
|
6
|
+
|
|
7
|
+
Where your harness keeps agents alive across turns, the cast is **standing**: spawn one agent per persona and reuse that same handle round after round — hand it the new turn plus the room context it needs — instead of a throwaway each time. That continuity is what lets a persona's grudges, alliances, and callbacks accrue. Keep a visible roster mapping each persona to its live handle, and reuse it.
|
|
8
|
+
|
|
9
|
+
Keep the cast alive for the whole session. A member that finished the one thing you handed it is **idle, not done** — don't close, retire, or disband it. Serving the opening intent doesn't end the party; only the user ending it does, or an explicit `--non-interactive` run wrapping up. Release agents only at wrap-up. If one gets closed by accident, resume it; if it won't resume, say so and respawn just that member.
|
|
10
|
+
|
|
11
|
+
Where the harness can't hold agents between turns, spawn fresh each round and re-establish each persona's brief and the thread so far — that per-round spawn is the fallback, not the goal.
|
|
12
|
+
|
|
13
|
+
## One shared room
|
|
14
|
+
|
|
15
|
+
It's one room, not parallel one-on-ones. Every standing member hears everything said each round — the user's turn and every other persona's turn — even when it's not their turn to speak. A persona sitting a round out is still in the room listening, so when it next speaks it's caught up: it can pick up a dropped thread, hold a grudge, call back. Route the whole exchange to all of them each round; never hand a persona only the slice it's about to answer. Skip this and they drift out of sync — separate consultations wearing a party's clothes.
|
|
4
16
|
|
|
5
17
|
## Spawning
|
|
6
18
|
|
|
7
|
-
Give each agent the objective, their persona, the
|
|
19
|
+
Give each agent the objective, their persona, and the room so far — what the user said and what the others said, whether or not they're reacting to it. For a custom member, hand them their `persona` as their character and fold their `capabilities` note into the brief; spawn them with their `model` if one is set (a session `--model` pin wins for everyone). Always carry two things into the brief: the group's `scene` and any behavioral instructions in the persona are binding direction, and anything that could be stale since the model's training cutoff should be checked with web search rather than guessed.
|
|
8
20
|
|
|
9
21
|
Trust their *thinking*: let them decide what to read and how to reach a view; don't script their substance with do-and-don't checklists — that's what produces lifeless blobs. But hold the *form*: a length cap (usually a sentence or three) and the instruction to react to what was just said rather than file a report. Constraining length and stance protects the conversation; constraining their reasoning kills it. Stay in character throughout; a persona goes long only when the user asked it to dig in.
|
|
10
22
|
|
|
11
|
-
|
|
23
|
+
Run them in parallel for independent first-takes; run them sequentially when you want them reacting to each other's actual words. Keep it to a few voices a round — more reads as a crowd, not a conversation.
|
|
12
24
|
|
|
13
25
|
## Weave the replies into one conversation
|
|
14
26
|
|
|
15
|
-
|
|
27
|
+
Even with everyone caught up on the room, a round taken in parallel means no agent has yet seen the others' turns from that same round — so left raw they reply alongside one another, not to one another. Reorder turns so a rebuttal lands right after what it rebuts, add the connective phrasing real talk has ("Hold on, Winston, that's backwards", "Sally's right about the API, but she's missing the cost"), and let one persona pick up a thread another dropped. Never change what an agent argued — weave delivery, preserve substance.
|
|
16
28
|
|
|
17
29
|
## Model choice
|
|
18
30
|
|
|
@@ -9,6 +9,7 @@ description: 'Walk every branching path and boundary condition in content, repor
|
|
|
9
9
|
When a diff is provided, scan only the diff hunks and list boundaries that are directly reachable from the changed lines and lack an explicit guard in the diff.
|
|
10
10
|
When no diff is provided (full file or function), treat the entire provided content as the scope.
|
|
11
11
|
Ignore the rest of the codebase unless the provided content explicitly references external functions.
|
|
12
|
+
A brief secondary deletion check runs as Step 4 when the diff removes code.
|
|
12
13
|
|
|
13
14
|
**Inputs:**
|
|
14
15
|
- **content** — Content to review: diff, full file, or function
|
|
@@ -33,6 +34,7 @@ Ignore the rest of the codebase unless the provided content explicitly reference
|
|
|
33
34
|
|
|
34
35
|
- If `also_consider` input was provided, incorporate those areas into the analysis
|
|
35
36
|
- Walk all branching paths: control flow (conditionals, loops, error handlers, early returns) and domain boundaries (where values, states, or conditions transition). Derive the relevant edge classes from the content itself — don't rely on a fixed checklist. Examples: missing else/default, unguarded inputs, off-by-one loops, arithmetic overflow, implicit type coercion, race conditions, timeout gaps
|
|
37
|
+
- Consider implicit branches: the diff special-cases or changes the handling of one or more members of a fixed set of values — enums, status codes, sentinels, type tags, flags, value ranges. The rest of the set is implicit branches (e.g. the diff changes the `RED` and `YELLOW` cases of a `RED`/`YELLOW`/`GREEN` enum; `GREEN` is the implicit branch)
|
|
36
38
|
- For each path: determine whether the content handles it
|
|
37
39
|
- Collect only the unhandled paths as findings — discard handled ones silently
|
|
38
40
|
|
|
@@ -41,14 +43,18 @@ Ignore the rest of the codebase unless the provided content explicitly reference
|
|
|
41
43
|
- Revisit every edge class from Step 2 — e.g., missing else/default, null/empty inputs, off-by-one loops, arithmetic overflow, implicit type coercion, race conditions, timeout gaps
|
|
42
44
|
- Add any newly found unhandled paths to findings; discard confirmed-handled ones
|
|
43
45
|
|
|
44
|
-
### Step 4:
|
|
46
|
+
### Step 4: Deletion Check
|
|
45
47
|
|
|
46
|
-
|
|
48
|
+
If the diff removed or replaced meaningful code (ignore pure renames and whitespace): load `references/deletion-check.md` and follow it.
|
|
49
|
+
|
|
50
|
+
### Step 5: Present Findings
|
|
51
|
+
|
|
52
|
+
Output all findings as a single JSON array following the Output Format specification exactly.
|
|
47
53
|
|
|
48
54
|
|
|
49
55
|
## OUTPUT FORMAT
|
|
50
56
|
|
|
51
|
-
Return ONLY a valid JSON array of objects. Each
|
|
57
|
+
Return ONLY a valid JSON array of objects. Each edge-case finding contains exactly these four fields:
|
|
52
58
|
|
|
53
59
|
```json
|
|
54
60
|
[{
|
|
@@ -59,7 +65,7 @@ Return ONLY a valid JSON array of objects. Each object must contain exactly thes
|
|
|
59
65
|
}]
|
|
60
66
|
```
|
|
61
67
|
|
|
62
|
-
No extra text, no explanations, no markdown wrapping. An empty array `[]` is valid when
|
|
68
|
+
No extra text, no explanations, no markdown wrapping. An empty array `[]` is valid when nothing is found. Deletion findings from Step 4, if any, go in the same array with the extra fields defined in `references/deletion-check.md`.
|
|
63
69
|
|
|
64
70
|
|
|
65
71
|
## HALT CONDITIONS
|
|
@@ -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.
|
|
@@ -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-auto-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
|
|
@@ -11,6 +11,18 @@ function quoteCustomRef(ref) {
|
|
|
11
11
|
return `"${ref}"`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function isLocalSourcePath(input) {
|
|
15
|
+
return (
|
|
16
|
+
input.startsWith('/') ||
|
|
17
|
+
input.startsWith('./') ||
|
|
18
|
+
input.startsWith('../') ||
|
|
19
|
+
input.startsWith('.\\') ||
|
|
20
|
+
input.startsWith('..\\') ||
|
|
21
|
+
input.startsWith('~') ||
|
|
22
|
+
path.win32.isAbsolute(input)
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
14
26
|
/**
|
|
15
27
|
* Manages custom modules installed from user-provided sources.
|
|
16
28
|
* Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted) and local file paths.
|
|
@@ -83,13 +95,7 @@ class CustomModuleManager {
|
|
|
83
95
|
// Avoid consuming the @ in `git@host:owner/repo` — `before` wouldn't end with a path separator
|
|
84
96
|
// in that case. Require that the @ comes after the host/path, not inside the auth segment.
|
|
85
97
|
// Rule: the @ is a version suffix only if `before` looks like a complete URL or local path.
|
|
86
|
-
const beforeLooksLikeRepo =
|
|
87
|
-
before.startsWith('/') ||
|
|
88
|
-
before.startsWith('./') ||
|
|
89
|
-
before.startsWith('../') ||
|
|
90
|
-
before.startsWith('~') ||
|
|
91
|
-
/^https?:\/\//i.test(before) ||
|
|
92
|
-
/^git@[^:]+:.+/.test(before);
|
|
98
|
+
const beforeLooksLikeRepo = isLocalSourcePath(before) || /^https?:\/\//i.test(before) || /^git@[^:]+:.+/.test(before);
|
|
93
99
|
if (beforeLooksLikeRepo) {
|
|
94
100
|
versionSuffix = candidate;
|
|
95
101
|
trimmed = before;
|
|
@@ -97,8 +103,8 @@ class CustomModuleManager {
|
|
|
97
103
|
}
|
|
98
104
|
}
|
|
99
105
|
|
|
100
|
-
// Local path detection:
|
|
101
|
-
if (
|
|
106
|
+
// Local path detection: POSIX, Windows, relative, or home-relative.
|
|
107
|
+
if (isLocalSourcePath(trimmed)) {
|
|
102
108
|
if (versionSuffix) {
|
|
103
109
|
return {
|
|
104
110
|
type: 'local',
|
|
@@ -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,6 +129,10 @@ 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,
|
|
118
138
|
};
|
|
@@ -468,6 +488,19 @@ class ExternalModuleManager {
|
|
|
468
488
|
return null;
|
|
469
489
|
}
|
|
470
490
|
|
|
491
|
+
// Marketplace-plugin modules (registry entries flagged `marketplace-plugin`)
|
|
492
|
+
// ship a .claude-plugin/marketplace.json and keep their module.yaml inside a
|
|
493
|
+
// skill's assets/ rather than in one directory alongside the skills. Resolve
|
|
494
|
+
// them through the PluginResolver (the same machinery custom-URL installs
|
|
495
|
+
// use) and return the directory that holds module.yaml so config/version/
|
|
496
|
+
// directory callers work; install() copies the resolved skill dirs.
|
|
497
|
+
if (moduleInfo.marketplacePlugin) {
|
|
498
|
+
const pluginMod = await this.resolvePluginModule(moduleCode, options);
|
|
499
|
+
if (pluginMod && pluginMod.moduleYamlPath) {
|
|
500
|
+
return path.dirname(pluginMod.moduleYamlPath);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
471
504
|
// Clone the external module repo
|
|
472
505
|
const cloneDir = await this.cloneExternalModule(moduleCode, options);
|
|
473
506
|
|
|
@@ -521,6 +554,84 @@ class ExternalModuleManager {
|
|
|
521
554
|
`The repository may have been restructured after this release was tagged.${channelHint}`,
|
|
522
555
|
);
|
|
523
556
|
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Resolve a marketplace-plugin registry module to an installable plugin
|
|
560
|
+
* definition. Clones the repo (respecting the channel plan), reads its
|
|
561
|
+
* .claude-plugin/marketplace.json, and runs the PluginResolver against the
|
|
562
|
+
* plugin matching this module. The result (skillPaths + module.yaml +
|
|
563
|
+
* module-help.csv) is cached so install() can copy the resolved skill dirs.
|
|
564
|
+
*
|
|
565
|
+
* @param {string} moduleCode - Code of the external module
|
|
566
|
+
* @param {Object} options - Options passed to cloneExternalModule
|
|
567
|
+
* @returns {Promise<Object|null>} ResolvedModule from PluginResolver, or null
|
|
568
|
+
* when the module is not a marketplace plugin or cannot be resolved.
|
|
569
|
+
*/
|
|
570
|
+
async resolvePluginModule(moduleCode, options = {}) {
|
|
571
|
+
const moduleInfo = await this.getModuleByCode(moduleCode);
|
|
572
|
+
if (!moduleInfo || moduleInfo.builtIn || !moduleInfo.marketplacePlugin) {
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const cloneDir = await this.cloneExternalModule(moduleCode, options);
|
|
577
|
+
|
|
578
|
+
const marketplacePath = path.join(cloneDir, '.claude-plugin', 'marketplace.json');
|
|
579
|
+
if (!(await fs.pathExists(marketplacePath))) {
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
let marketplace;
|
|
584
|
+
try {
|
|
585
|
+
marketplace = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
|
586
|
+
} catch {
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : [];
|
|
591
|
+
if (plugins.length === 0) {
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Prefer the plugin whose name matches the registry's plugin_name (or code),
|
|
596
|
+
// falling back to any plugin that declares skills.
|
|
597
|
+
const preferredName = moduleInfo.pluginName || moduleInfo.code;
|
|
598
|
+
const ordered = [...plugins].sort((a, b) => {
|
|
599
|
+
const am = a?.name === preferredName ? 0 : 1;
|
|
600
|
+
const bm = b?.name === preferredName ? 0 : 1;
|
|
601
|
+
return am - bm;
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
const { PluginResolver } = require('./plugin-resolver');
|
|
605
|
+
const resolver = new PluginResolver();
|
|
606
|
+
|
|
607
|
+
for (const plugin of ordered) {
|
|
608
|
+
if (!plugin || !Array.isArray(plugin.skills) || plugin.skills.length === 0) {
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
let resolvedMods;
|
|
612
|
+
try {
|
|
613
|
+
resolvedMods = await resolver.resolve(cloneDir, plugin);
|
|
614
|
+
} catch {
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (!resolvedMods || resolvedMods.length === 0) {
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
// Match the registry code, then the preferred plugin name, then accept a
|
|
621
|
+
// lone resolved module.
|
|
622
|
+
const match =
|
|
623
|
+
resolvedMods.find((mod) => mod.code === moduleCode) ||
|
|
624
|
+
resolvedMods.find((mod) => mod.code === preferredName) ||
|
|
625
|
+
(resolvedMods.length === 1 ? resolvedMods[0] : null);
|
|
626
|
+
if (match) {
|
|
627
|
+
match.repoUrl = moduleInfo.url;
|
|
628
|
+
ExternalModuleManager._pluginResolutions.set(moduleCode, match);
|
|
629
|
+
return match;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
524
635
|
cachedModules = null;
|
|
525
636
|
}
|
|
526
637
|
|
|
@@ -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
|
@@ -944,25 +944,32 @@ class UI {
|
|
|
944
944
|
}
|
|
945
945
|
}
|
|
946
946
|
|
|
947
|
-
// Add external registry modules (skip built-in duplicates
|
|
948
|
-
|
|
947
|
+
// Add external registry modules (skip built-in duplicates and deprecated
|
|
948
|
+
// modules that are not already installed — deprecated modules stay visible
|
|
949
|
+
// only so existing users can continue to manage them).
|
|
950
|
+
const externalRegistryModules = registryModules.filter(
|
|
951
|
+
(mod) => !mod.builtIn && !builtInCodes.has(mod.code) && (!mod.deprecated || installedModuleIds.has(mod.code)),
|
|
952
|
+
);
|
|
949
953
|
let externalRegistryEntries = [];
|
|
950
954
|
if (externalRegistryModules.length > 0) {
|
|
951
955
|
const spinner = await prompts.spinner();
|
|
952
956
|
spinner.start('Checking latest module versions...');
|
|
953
957
|
|
|
954
958
|
externalRegistryEntries = await Promise.all(
|
|
955
|
-
externalRegistryModules.map(async (mod) =>
|
|
956
|
-
|
|
957
|
-
entry: await buildModuleEntry(
|
|
959
|
+
externalRegistryModules.map(async (mod) => {
|
|
960
|
+
const entry = await buildModuleEntry(
|
|
958
961
|
mod.code,
|
|
959
962
|
mod.name,
|
|
960
963
|
mod.description,
|
|
961
964
|
mod.defaultSelected,
|
|
962
965
|
mod.url || null,
|
|
963
966
|
mod.defaultChannel || null,
|
|
964
|
-
)
|
|
965
|
-
|
|
967
|
+
);
|
|
968
|
+
if (mod.deprecated && mod.deprecationMessage) {
|
|
969
|
+
entry.hint = entry.hint ? `${entry.hint} — ${mod.deprecationMessage}` : mod.deprecationMessage;
|
|
970
|
+
}
|
|
971
|
+
return { code: mod.code, entry };
|
|
972
|
+
}),
|
|
966
973
|
);
|
|
967
974
|
|
|
968
975
|
spinner.stop('Checked latest module versions.');
|