ma-agents 3.16.0 → 3.17.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +85 -4
  2. package/bin/cli.js +339 -6
  3. package/lib/agents.js +22 -15
  4. package/lib/bmad-cache/cache-manifest.json +2 -2
  5. package/lib/bmad-extension/.claude-plugin/marketplace.json.template +2 -15
  6. package/lib/bmad-extension/skills/module-help.csv +0 -13
  7. package/lib/bmad-extension-plugin/.claude-plugin/marketplace.json +3 -16
  8. package/lib/bmad-extension-plugin/skills/module-help.csv +0 -13
  9. package/lib/bmad.js +143 -1
  10. package/lib/custom-marketplace.js +973 -0
  11. package/lib/installer.js +19 -7
  12. package/lib/knowledge-atlas/dist/atlas.cjs +14651 -14651
  13. package/lib/knowledge-atlas/src/render/vendor.ts +43 -43
  14. package/lib/knowledge-atlas/vendor/drawio-viewer.min.js +7563 -7563
  15. package/lib/knowledge-atlas/vendor/mermaid.min.js +2314 -2314
  16. package/lib/profile.js +220 -0
  17. package/lib/skill-authoring.js +3 -1
  18. package/package.json +3 -3
  19. package/scripts/build-bmad-cache.js +127 -43
  20. package/lib/bmad-extension/skills/ma-agent-ml/.gitkeep +0 -0
  21. package/lib/bmad-extension/skills/ma-agent-ml/SKILL.md +0 -59
  22. package/lib/bmad-extension/skills/ma-agent-ml/bmad-skill-manifest.yaml +0 -11
  23. package/lib/bmad-extension/skills/ml-advise/.gitkeep +0 -0
  24. package/lib/bmad-extension/skills/ml-advise/SKILL.md +0 -76
  25. package/lib/bmad-extension/skills/ml-advise/bmad-skill-manifest.yaml +0 -3
  26. package/lib/bmad-extension/skills/ml-advise/skill.json +0 -7
  27. package/lib/bmad-extension/skills/ml-analysis/.gitkeep +0 -0
  28. package/lib/bmad-extension/skills/ml-analysis/SKILL.md +0 -60
  29. package/lib/bmad-extension/skills/ml-analysis/bmad-skill-manifest.yaml +0 -3
  30. package/lib/bmad-extension/skills/ml-analysis/skill.json +0 -7
  31. package/lib/bmad-extension/skills/ml-architecture/.gitkeep +0 -0
  32. package/lib/bmad-extension/skills/ml-architecture/SKILL.md +0 -55
  33. package/lib/bmad-extension/skills/ml-architecture/bmad-skill-manifest.yaml +0 -3
  34. package/lib/bmad-extension/skills/ml-architecture/skill.json +0 -7
  35. package/lib/bmad-extension/skills/ml-detailed-design/.gitkeep +0 -0
  36. package/lib/bmad-extension/skills/ml-detailed-design/SKILL.md +0 -67
  37. package/lib/bmad-extension/skills/ml-detailed-design/bmad-skill-manifest.yaml +0 -3
  38. package/lib/bmad-extension/skills/ml-detailed-design/skill.json +0 -7
  39. package/lib/bmad-extension/skills/ml-eda/.gitkeep +0 -0
  40. package/lib/bmad-extension/skills/ml-eda/SKILL.md +0 -56
  41. package/lib/bmad-extension/skills/ml-eda/bmad-skill-manifest.yaml +0 -3
  42. package/lib/bmad-extension/skills/ml-eda/scripts/baseline_classifier.py +0 -522
  43. package/lib/bmad-extension/skills/ml-eda/scripts/class_weights_calculator.py +0 -295
  44. package/lib/bmad-extension/skills/ml-eda/scripts/clustering_explorer.py +0 -383
  45. package/lib/bmad-extension/skills/ml-eda/scripts/eda_analyzer.py +0 -654
  46. package/lib/bmad-extension/skills/ml-eda/skill.json +0 -7
  47. package/lib/bmad-extension/skills/ml-experiment/.gitkeep +0 -0
  48. package/lib/bmad-extension/skills/ml-experiment/SKILL.md +0 -74
  49. package/lib/bmad-extension/skills/ml-experiment/assets/advanced_trainer_configs.py +0 -430
  50. package/lib/bmad-extension/skills/ml-experiment/assets/quick_trainer_setup.py +0 -233
  51. package/lib/bmad-extension/skills/ml-experiment/assets/template_datamodule.py +0 -219
  52. package/lib/bmad-extension/skills/ml-experiment/assets/template_gnn_module.py +0 -341
  53. package/lib/bmad-extension/skills/ml-experiment/assets/template_lightning_module.py +0 -158
  54. package/lib/bmad-extension/skills/ml-experiment/bmad-skill-manifest.yaml +0 -3
  55. package/lib/bmad-extension/skills/ml-experiment/skill.json +0 -7
  56. package/lib/bmad-extension/skills/ml-hparam/.gitkeep +0 -0
  57. package/lib/bmad-extension/skills/ml-hparam/SKILL.md +0 -81
  58. package/lib/bmad-extension/skills/ml-hparam/bmad-skill-manifest.yaml +0 -3
  59. package/lib/bmad-extension/skills/ml-hparam/skill.json +0 -7
  60. package/lib/bmad-extension/skills/ml-ideation/.gitkeep +0 -0
  61. package/lib/bmad-extension/skills/ml-ideation/SKILL.md +0 -50
  62. package/lib/bmad-extension/skills/ml-ideation/bmad-skill-manifest.yaml +0 -3
  63. package/lib/bmad-extension/skills/ml-ideation/scripts/validate_ml_prd.py +0 -287
  64. package/lib/bmad-extension/skills/ml-ideation/skill.json +0 -7
  65. package/lib/bmad-extension/skills/ml-infra/.gitkeep +0 -0
  66. package/lib/bmad-extension/skills/ml-infra/SKILL.md +0 -58
  67. package/lib/bmad-extension/skills/ml-infra/bmad-skill-manifest.yaml +0 -3
  68. package/lib/bmad-extension/skills/ml-infra/skill.json +0 -7
  69. package/lib/bmad-extension/skills/ml-retrospective/.gitkeep +0 -0
  70. package/lib/bmad-extension/skills/ml-retrospective/SKILL.md +0 -63
  71. package/lib/bmad-extension/skills/ml-retrospective/bmad-skill-manifest.yaml +0 -3
  72. package/lib/bmad-extension/skills/ml-retrospective/skill.json +0 -7
  73. package/lib/bmad-extension/skills/ml-revision/.gitkeep +0 -0
  74. package/lib/bmad-extension/skills/ml-revision/SKILL.md +0 -82
  75. package/lib/bmad-extension/skills/ml-revision/bmad-skill-manifest.yaml +0 -3
  76. package/lib/bmad-extension/skills/ml-revision/skill.json +0 -7
  77. package/lib/bmad-extension/skills/ml-techspec/.gitkeep +0 -0
  78. package/lib/bmad-extension/skills/ml-techspec/SKILL.md +0 -80
  79. package/lib/bmad-extension/skills/ml-techspec/bmad-skill-manifest.yaml +0 -3
  80. package/lib/bmad-extension/skills/ml-techspec/skill.json +0 -7
  81. package/lib/bmad-extension-plugin/skills/ma-agent-ml/.gitkeep +0 -0
  82. package/lib/bmad-extension-plugin/skills/ma-agent-ml/SKILL.md +0 -59
  83. package/lib/bmad-extension-plugin/skills/ma-agent-ml/bmad-skill-manifest.yaml +0 -11
  84. package/lib/bmad-extension-plugin/skills/ml-advise/.gitkeep +0 -0
  85. package/lib/bmad-extension-plugin/skills/ml-advise/SKILL.md +0 -76
  86. package/lib/bmad-extension-plugin/skills/ml-advise/bmad-skill-manifest.yaml +0 -3
  87. package/lib/bmad-extension-plugin/skills/ml-advise/skill.json +0 -7
  88. package/lib/bmad-extension-plugin/skills/ml-analysis/.gitkeep +0 -0
  89. package/lib/bmad-extension-plugin/skills/ml-analysis/SKILL.md +0 -60
  90. package/lib/bmad-extension-plugin/skills/ml-analysis/bmad-skill-manifest.yaml +0 -3
  91. package/lib/bmad-extension-plugin/skills/ml-analysis/skill.json +0 -7
  92. package/lib/bmad-extension-plugin/skills/ml-architecture/.gitkeep +0 -0
  93. package/lib/bmad-extension-plugin/skills/ml-architecture/SKILL.md +0 -55
  94. package/lib/bmad-extension-plugin/skills/ml-architecture/bmad-skill-manifest.yaml +0 -3
  95. package/lib/bmad-extension-plugin/skills/ml-architecture/skill.json +0 -7
  96. package/lib/bmad-extension-plugin/skills/ml-detailed-design/.gitkeep +0 -0
  97. package/lib/bmad-extension-plugin/skills/ml-detailed-design/SKILL.md +0 -67
  98. package/lib/bmad-extension-plugin/skills/ml-detailed-design/bmad-skill-manifest.yaml +0 -3
  99. package/lib/bmad-extension-plugin/skills/ml-detailed-design/skill.json +0 -7
  100. package/lib/bmad-extension-plugin/skills/ml-eda/.gitkeep +0 -0
  101. package/lib/bmad-extension-plugin/skills/ml-eda/SKILL.md +0 -56
  102. package/lib/bmad-extension-plugin/skills/ml-eda/bmad-skill-manifest.yaml +0 -3
  103. package/lib/bmad-extension-plugin/skills/ml-eda/scripts/baseline_classifier.py +0 -522
  104. package/lib/bmad-extension-plugin/skills/ml-eda/scripts/class_weights_calculator.py +0 -295
  105. package/lib/bmad-extension-plugin/skills/ml-eda/scripts/clustering_explorer.py +0 -383
  106. package/lib/bmad-extension-plugin/skills/ml-eda/scripts/eda_analyzer.py +0 -654
  107. package/lib/bmad-extension-plugin/skills/ml-eda/skill.json +0 -7
  108. package/lib/bmad-extension-plugin/skills/ml-experiment/.gitkeep +0 -0
  109. package/lib/bmad-extension-plugin/skills/ml-experiment/SKILL.md +0 -74
  110. package/lib/bmad-extension-plugin/skills/ml-experiment/assets/advanced_trainer_configs.py +0 -430
  111. package/lib/bmad-extension-plugin/skills/ml-experiment/assets/quick_trainer_setup.py +0 -233
  112. package/lib/bmad-extension-plugin/skills/ml-experiment/assets/template_datamodule.py +0 -219
  113. package/lib/bmad-extension-plugin/skills/ml-experiment/assets/template_gnn_module.py +0 -341
  114. package/lib/bmad-extension-plugin/skills/ml-experiment/assets/template_lightning_module.py +0 -158
  115. package/lib/bmad-extension-plugin/skills/ml-experiment/bmad-skill-manifest.yaml +0 -3
  116. package/lib/bmad-extension-plugin/skills/ml-experiment/skill.json +0 -7
  117. package/lib/bmad-extension-plugin/skills/ml-hparam/.gitkeep +0 -0
  118. package/lib/bmad-extension-plugin/skills/ml-hparam/SKILL.md +0 -81
  119. package/lib/bmad-extension-plugin/skills/ml-hparam/bmad-skill-manifest.yaml +0 -3
  120. package/lib/bmad-extension-plugin/skills/ml-hparam/skill.json +0 -7
  121. package/lib/bmad-extension-plugin/skills/ml-ideation/.gitkeep +0 -0
  122. package/lib/bmad-extension-plugin/skills/ml-ideation/SKILL.md +0 -50
  123. package/lib/bmad-extension-plugin/skills/ml-ideation/bmad-skill-manifest.yaml +0 -3
  124. package/lib/bmad-extension-plugin/skills/ml-ideation/scripts/validate_ml_prd.py +0 -287
  125. package/lib/bmad-extension-plugin/skills/ml-ideation/skill.json +0 -7
  126. package/lib/bmad-extension-plugin/skills/ml-infra/.gitkeep +0 -0
  127. package/lib/bmad-extension-plugin/skills/ml-infra/SKILL.md +0 -58
  128. package/lib/bmad-extension-plugin/skills/ml-infra/bmad-skill-manifest.yaml +0 -3
  129. package/lib/bmad-extension-plugin/skills/ml-infra/skill.json +0 -7
  130. package/lib/bmad-extension-plugin/skills/ml-retrospective/.gitkeep +0 -0
  131. package/lib/bmad-extension-plugin/skills/ml-retrospective/SKILL.md +0 -63
  132. package/lib/bmad-extension-plugin/skills/ml-retrospective/bmad-skill-manifest.yaml +0 -3
  133. package/lib/bmad-extension-plugin/skills/ml-retrospective/skill.json +0 -7
  134. package/lib/bmad-extension-plugin/skills/ml-revision/.gitkeep +0 -0
  135. package/lib/bmad-extension-plugin/skills/ml-revision/SKILL.md +0 -82
  136. package/lib/bmad-extension-plugin/skills/ml-revision/bmad-skill-manifest.yaml +0 -3
  137. package/lib/bmad-extension-plugin/skills/ml-revision/skill.json +0 -7
  138. package/lib/bmad-extension-plugin/skills/ml-techspec/.gitkeep +0 -0
  139. package/lib/bmad-extension-plugin/skills/ml-techspec/SKILL.md +0 -80
  140. package/lib/bmad-extension-plugin/skills/ml-techspec/bmad-skill-manifest.yaml +0 -3
  141. package/lib/bmad-extension-plugin/skills/ml-techspec/skill.json +0 -7
@@ -0,0 +1,973 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Story 30.6a — Custom marketplace source: thin adapter over bmad-method's
5
+ * `CustomModuleManager`.
6
+ *
7
+ * Scope (per the 30.5 spike hand-off design, `_bmad-output/implementation-
8
+ * artifacts/epic-runs/epic-30/30-5-spike-findings.md` §5): enumerate ONLY.
9
+ * This module does NOT prune/stage/install anything — that lands in Story
10
+ * 30.6b, which consumes `enumerateCustomSource()`'s return value (the
11
+ * discovered plugin list) and the wizard's `selectedCodes` (see
12
+ * `bin/cli.js#promptCustomMarketplace`).
13
+ *
14
+ * NFR17 — pinned-internal coupling.
15
+ * bmad-method's `package.json` has no `exports` map, so a deep require of
16
+ * `tools/installer/modules/custom-module-manager.js` is reachable but is an
17
+ * installer-internal surface, not a documented/supported API. This is a
18
+ * single-call-site (blast-radius-of-one) wrapper around that surface, per the
19
+ * spike's mitigation list. `test/custom-marketplace-surface-drift.test.js`
20
+ * fails loudly if the deep path, exported class, consumed methods, or the
21
+ * installed bmad-method version drift from what this file was written
22
+ * against.
23
+ *
24
+ * FR241 — zero token handling. Enumeration relies entirely on bmad-method's
25
+ * own `cloneRepo()`, which sets `GIT_TERMINAL_PROMPT=0` (never prompts) and
26
+ * spreads `process.env` (ambient SSH agent / credential helper / git config
27
+ * creds). ma-agents never reads, stores, or logs a credential here.
28
+ *
29
+ * Story 30.6b — prune + stage the user's selected subset (consumes
30
+ * `enumerateCustomSource()`'s output + the wizard's `selectedCodes`). This
31
+ * still lives in the SAME file as 30.6a's enumerate adapter (not a new
32
+ * module) because staging a selection also needs the pinned-internal
33
+ * `resolvePlugin` method — keeping every deep-require consumer in one file
34
+ * preserves the single-call-site NFR17 mitigation. `stageCustomMarketplaceSubset()`
35
+ * only stages to a local directory; it never invokes bmad-method itself — the
36
+ * actual `--custom-source` install is `lib/bmad.js#installCustomMarketplaceSubset()`,
37
+ * which reuses `buildBmadArgs`/`runCommand` (no hand-rolled child-process/git logic).
38
+ */
39
+
40
+ const path = require('node:path');
41
+ const fs = require('fs-extra');
42
+ const yaml = require('js-yaml');
43
+
44
+ /** Deep, pinned-internal require path (NFR17). Single call site. */
45
+ const CUSTOM_MODULE_MANAGER_PATH = 'bmad-method/tools/installer/modules/custom-module-manager.js';
46
+
47
+ /**
48
+ * Registry-index mode (this story) — the BMAD registry layout used by the
49
+ * official plugin registry (`bmad-code-org/bmad-plugins-marketplace`) and any
50
+ * source that follows the same convention. A `registry/` directory holding
51
+ * `official.yaml` and/or `community-index.yaml` (each a top-level `modules:`
52
+ * list) is a THIRD source shape, distinct from `discovery`
53
+ * (`.claude-plugin/marketplace.json`) and `direct` (whole repo = one module).
54
+ * Detection precedence is: discovery > registry > direct (see
55
+ * `enumerateCustomSource`).
56
+ */
57
+ const REGISTRY_DIR_NAME = 'registry';
58
+ const REGISTRY_OFFICIAL_INDEX = 'official.yaml';
59
+ const REGISTRY_COMMUNITY_INDEX = 'community-index.yaml';
60
+ const REGISTRY_SCHEMA_FILE = 'registry-schema.yaml';
61
+
62
+ /** Convert an OS-native relative path to a POSIX ('/'-separated) one for the
63
+ * staged marketplace.json (bmad-method's parseSource/skills detection is
64
+ * POSIX-oriented, and JSON marketplaces are always written POSIX-style). */
65
+ function toPosix(relPath) {
66
+ return relPath.split(path.sep).join('/');
67
+ }
68
+
69
+ /**
70
+ * The `CustomModuleManager` methods the custom-marketplace feature depends on.
71
+ * Any drift in this set (renamed/removed on a bmad-method pin bump) must fail
72
+ * the surface-drift test. 6a's `enumerateCustomSource()` calls `resolveSource`
73
+ * + `discoverModules` (and `parseSource` transitively); `resolvePlugin` is the
74
+ * staging primitive Story 30.6b will consume — it is guarded here now so the
75
+ * drift test catches a regression BEFORE 6b lands, per the 30.5 spike
76
+ * mitigation list (the four methods it names as the consumed surface).
77
+ */
78
+ const REQUIRED_METHODS = ['parseSource', 'resolveSource', 'discoverModules', 'resolvePlugin'];
79
+
80
+ /**
81
+ * NFR71 — custom-source content is pass-through and outside ma-agents' sealed
82
+ * -bundle guarantee. Shown once, before the user is offered the multiselect
83
+ * (i.e. before anything from the source could be installed).
84
+ */
85
+ const NFR71_WARNING =
86
+ 'Custom marketplace content is outside ma-agents\' sealed-bundle guarantee — ' +
87
+ 'it is installed as-is from the source you provided and is not vetted by ma-agents.';
88
+
89
+ /**
90
+ * NFR71 (registry mode) — a per-entry trust annotation the wizard appends to
91
+ * each registry choice so the community/unverified trust tier is explicit at
92
+ * the point of selection (not just in the one-time source-level warning). A
93
+ * `bmad-certified` (official) entry is labelled as such; anything else is made
94
+ * explicitly "unverified/community — installed as-is, not vetted".
95
+ *
96
+ * @param {{ trustTier?: string, type?: string }} plugin - a normalized registry entry
97
+ * @returns {string} human-readable trust label
98
+ */
99
+ function registryTrustLabel(plugin) {
100
+ if (plugin && plugin.trustTier === 'bmad-certified') {
101
+ return 'bmad-certified';
102
+ }
103
+ return 'unverified/community — installed as-is, not vetted by ma-agents';
104
+ }
105
+
106
+ /**
107
+ * Error type raised by this adapter for any enumeration failure (unreachable
108
+ * source, invalid input, drifted surface). Callers should catch this and
109
+ * print `err.message` only — never `err.stack` — to keep the wizard's
110
+ * "fail clean" contract (no raw stack-dump on an unreachable source).
111
+ */
112
+ class CustomMarketplaceError extends Error {
113
+ constructor(message, { cause } = {}) {
114
+ super(message);
115
+ this.name = 'CustomMarketplaceError';
116
+ if (cause !== undefined) this.cause = cause;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Deep-require bmad-method's CustomModuleManager (NFR17 single call site).
122
+ *
123
+ * @returns {{ CustomModuleManager: Function }}
124
+ * @throws {CustomMarketplaceError} if the pinned-internal surface is not
125
+ * reachable at all (module moved/renamed on a bmad-method version bump).
126
+ */
127
+ function loadCustomModuleManagerModule() {
128
+ try {
129
+ // eslint-disable-next-line global-require, import/no-dynamic-require
130
+ return require(CUSTOM_MODULE_MANAGER_PATH);
131
+ } catch (err) {
132
+ throw new CustomMarketplaceError(
133
+ `bmad-method's CustomModuleManager is not reachable at the pinned-internal ` +
134
+ `path "${CUSTOM_MODULE_MANAGER_PATH}" (NFR17). This usually means the ` +
135
+ `installed bmad-method version moved or renamed the module — re-run the ` +
136
+ `surface-drift test (test/custom-marketplace-surface-drift.test.js) after ` +
137
+ `any bmad-method version bump.`,
138
+ { cause: err }
139
+ );
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Assert the pinned-internal surface this adapter depends on is present and
145
+ * shaped as expected. Used by both this module (defensively, before use) and
146
+ * the drift/smoke test (explicitly, as its own assertion).
147
+ *
148
+ * @returns {{ version: string, mgr: import('bmad-method/tools/installer/modules/custom-module-manager').CustomModuleManager }}
149
+ * @throws {CustomMarketplaceError} on any drift — missing class, missing
150
+ * method, or a method that is no longer a function.
151
+ */
152
+ function assertCustomModuleManagerSurface() {
153
+ const mod = loadCustomModuleManagerModule();
154
+ if (typeof mod.CustomModuleManager !== 'function') {
155
+ throw new CustomMarketplaceError(
156
+ `bmad-method's "${CUSTOM_MODULE_MANAGER_PATH}" no longer exports a ` +
157
+ `CustomModuleManager class (NFR17 surface drift). Cannot enumerate custom ` +
158
+ `marketplace sources until ma-agents' adapter (lib/custom-marketplace.js) ` +
159
+ `is updated for the new shape.`
160
+ );
161
+ }
162
+
163
+ let mgr;
164
+ try {
165
+ mgr = new mod.CustomModuleManager();
166
+ } catch (err) {
167
+ throw new CustomMarketplaceError(
168
+ `bmad-method's CustomModuleManager constructor threw (NFR17 surface drift): ${err.message}`,
169
+ { cause: err }
170
+ );
171
+ }
172
+
173
+ const missing = REQUIRED_METHODS.filter((m) => typeof mgr[m] !== 'function');
174
+ if (missing.length > 0) {
175
+ throw new CustomMarketplaceError(
176
+ `bmad-method's CustomModuleManager is missing expected method(s): ` +
177
+ `${missing.join(', ')} (NFR17 surface drift). The custom-marketplace ` +
178
+ `feature depends on: ${REQUIRED_METHODS.join(', ')}.`
179
+ );
180
+ }
181
+
182
+ let version = null;
183
+ try {
184
+ // eslint-disable-next-line global-require
185
+ version = require('bmad-method/package.json').version;
186
+ } catch {
187
+ // Best-effort only — absence doesn't invalidate the surface check above.
188
+ }
189
+
190
+ return { version, mgr };
191
+ }
192
+
193
+ // ─── Registry-index mode (this story) ──────────────────────────────────────
194
+
195
+ /**
196
+ * Detect the BMAD registry-index layout inside an already-resolved source root.
197
+ * A source is treated as a registry when its `registry/` directory holds an
198
+ * `official.yaml` and/or a `community-index.yaml` module index (or, failing
199
+ * those, a `registry-schema.yaml`, which still marks the source as a registry
200
+ * even if it currently declares no modules). Called ONLY after discovery
201
+ * (`.claude-plugin/marketplace.json`) has been ruled out, so discovery always
202
+ * wins (AC1 precedence).
203
+ *
204
+ * @param {string} rootDir - Absolute resolved source root.
205
+ * @returns {Promise<{ isRegistry: boolean, officialPath: string|null, communityPath: string|null }>}
206
+ */
207
+ async function detectRegistryLayout(rootDir) {
208
+ const registryDir = path.join(rootDir, REGISTRY_DIR_NAME);
209
+ if (!(await fs.pathExists(registryDir))) {
210
+ return { isRegistry: false, officialPath: null, communityPath: null };
211
+ }
212
+ const officialAbs = path.join(registryDir, REGISTRY_OFFICIAL_INDEX);
213
+ const communityAbs = path.join(registryDir, REGISTRY_COMMUNITY_INDEX);
214
+ const schemaAbs = path.join(registryDir, REGISTRY_SCHEMA_FILE);
215
+ const [hasOfficial, hasCommunity, hasSchema] = await Promise.all([
216
+ fs.pathExists(officialAbs),
217
+ fs.pathExists(communityAbs),
218
+ fs.pathExists(schemaAbs),
219
+ ]);
220
+ if (!hasOfficial && !hasCommunity && !hasSchema) {
221
+ return { isRegistry: false, officialPath: null, communityPath: null };
222
+ }
223
+ return {
224
+ isRegistry: true,
225
+ officialPath: hasOfficial ? officialAbs : null,
226
+ communityPath: hasCommunity ? communityAbs : null,
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Normalize one raw registry-index entry (conforming to
232
+ * `registry/registry-schema.yaml`) into the SAME plugin-choice object shape the
233
+ * wizard's multiselect (30.6a/6b) already consumes, plus the registry-only
234
+ * pointer fields the two-level install needs (`repository`, `moduleDefinition`,
235
+ * `approvedSha`, `approvedTag`, `version`, `trustTier`).
236
+ *
237
+ * @param {Object} entry - raw YAML module entry
238
+ * @param {{ official: boolean }} ctx - which index the entry came from
239
+ * @returns {Object} normalized plugin-choice object
240
+ */
241
+ function normalizeRegistryEntry(entry, { official }) {
242
+ const trustTier = entry.trust_tier || (official ? 'bmad-certified' : 'unverified');
243
+ const type = entry.type || (official ? 'bmad-org' : 'community');
244
+ return {
245
+ code: entry.code || entry.name,
246
+ name: entry.name,
247
+ displayName: entry.display_name || entry.name,
248
+ description: entry.description || '',
249
+ type,
250
+ trustTier,
251
+ category: entry.category || null,
252
+ subcategory: entry.subcategory || null,
253
+ // Registry-only pointer fields (two-level install).
254
+ repository: entry.repository || null,
255
+ moduleDefinition: entry.module_definition || null,
256
+ approvedSha: entry.approved_sha || null,
257
+ approvedTag: entry.approved_tag || null,
258
+ version: entry.version || null,
259
+ builtIn: entry.built_in === true,
260
+ isExternal: true,
261
+ };
262
+ }
263
+
264
+ /**
265
+ * Parse one registry index file (`official.yaml` / `community-index.yaml`) into
266
+ * a normalized entry list. A missing/empty `modules:` key yields `[]`; a YAML
267
+ * syntax error or a non-list `modules:` throws a clean `CustomMarketplaceError`
268
+ * (AC4 malformed-index fail-clean). Individual entries that lack a usable
269
+ * `name` or `repository` pointer are SKIPPED (they are not installable and must
270
+ * not sink an otherwise-valid registry).
271
+ *
272
+ * @param {string} indexPath - absolute path to the index file
273
+ * @param {{ official: boolean }} ctx
274
+ * @returns {Promise<Array<Object>>} normalized entries
275
+ * @throws {CustomMarketplaceError}
276
+ */
277
+ async function parseRegistryIndex(indexPath, { official }) {
278
+ const label = path.basename(indexPath);
279
+ let raw;
280
+ try {
281
+ raw = await fs.readFile(indexPath, 'utf8');
282
+ } catch (err) {
283
+ throw new CustomMarketplaceError(`Could not read registry index "${label}": ${err.message}`, { cause: err });
284
+ }
285
+ let doc;
286
+ try {
287
+ doc = yaml.load(raw);
288
+ } catch (err) {
289
+ throw new CustomMarketplaceError(
290
+ `Registry index "${label}" is not valid YAML: ${err.message}`,
291
+ { cause: err }
292
+ );
293
+ }
294
+ if (doc === null || doc === undefined) return [];
295
+ if (typeof doc !== 'object' || Array.isArray(doc)) {
296
+ throw new CustomMarketplaceError(`Registry index "${label}" is not a mapping with a "modules" list.`);
297
+ }
298
+ const modules = doc.modules;
299
+ if (modules === undefined || modules === null) return [];
300
+ if (!Array.isArray(modules)) {
301
+ throw new CustomMarketplaceError(`Registry index "${label}" has a "modules" key that is not a list.`);
302
+ }
303
+ const out = [];
304
+ for (const entry of modules) {
305
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
306
+ if (typeof entry.name !== 'string' || !entry.name.trim()) continue; // not addressable — skip
307
+ if (typeof entry.repository !== 'string' || !entry.repository.trim()) continue; // no pointer — not installable
308
+ out.push(normalizeRegistryEntry(entry, { official }));
309
+ }
310
+ return out;
311
+ }
312
+
313
+ /**
314
+ * Build the `registry`-mode enumeration result: parse the official + community
315
+ * indexes, merge, and de-dupe by `name` (official — bmad-certified — wins over
316
+ * a same-named community entry).
317
+ *
318
+ * @param {string} rootDir
319
+ * @param {string|null} sourceUrl
320
+ * @param {{ officialPath: string|null, communityPath: string|null }} layout
321
+ * @returns {Promise<{ mode: 'registry', plugins: Array<Object>, rootDir: string, sourceUrl: string|null }>}
322
+ */
323
+ async function enumerateRegistrySource(rootDir, sourceUrl, layout) {
324
+ const official = layout.officialPath ? await parseRegistryIndex(layout.officialPath, { official: true }) : [];
325
+ const community = layout.communityPath ? await parseRegistryIndex(layout.communityPath, { official: false }) : [];
326
+
327
+ // Official entries are appended first so they win the de-dupe on a name
328
+ // collision (a bmad-certified module must never be shadowed by an unverified
329
+ // community entry of the same name).
330
+ const byName = new Map();
331
+ for (const p of [...official, ...community]) {
332
+ if (!byName.has(p.name)) byName.set(p.name, p);
333
+ }
334
+ return { mode: 'registry', plugins: [...byName.values()], rootDir, sourceUrl };
335
+ }
336
+
337
+ /**
338
+ * Enumerate the plugins declared by a custom marketplace source.
339
+ *
340
+ * Thin wrapper over `CustomModuleManager#resolveSource` +
341
+ * `CustomModuleManager#discoverModules` (FR240). Performs NO staging, no
342
+ * pruning, no install — see the module doc-comment above. Authentication is
343
+ * whatever bmad-method's own `cloneRepo()` uses (ambient git creds; zero
344
+ * token handling — FR241).
345
+ *
346
+ * @param {string} source - A local filesystem path OR a Git URL (HTTPS/SSH)
347
+ * pointing at a repository. If it contains `.claude-plugin/marketplace.json`
348
+ * ("discovery" mode) every declared plugin is enumerated; otherwise
349
+ * ("direct" mode, FR240) the source itself is treated as a single module.
350
+ * @returns {Promise<{ mode: 'discovery'|'direct', plugins: Array<Object>, rootDir: string, sourceUrl: string|null }>}
351
+ * @throws {CustomMarketplaceError} on invalid input, an unreachable/unauthorized
352
+ * remote, or a drifted pinned-internal surface. Always a clean, human-readable
353
+ * message — never a raw upstream stack.
354
+ */
355
+ async function enumerateCustomSource(source) {
356
+ if (!source || typeof source !== 'string' || !source.trim()) {
357
+ throw new CustomMarketplaceError('A custom marketplace source (path or Git URL) is required.');
358
+ }
359
+
360
+ const { mgr } = assertCustomModuleManagerSurface();
361
+
362
+ let resolved;
363
+ try {
364
+ // skipInstall: true — this is enumeration only, never run a source's own
365
+ // `npm install` as a side effect of browsing it.
366
+ // silent: true — ma-agents owns all user-facing output; suppress
367
+ // bmad-method's own spinner/log lines here.
368
+ resolved = await mgr.resolveSource(source, { skipInstall: true, silent: true });
369
+ } catch (err) {
370
+ // Fail clean (NFR71 posture + spike §3 "fails clean" contract): a
371
+ // human-readable one-liner, no upstream stack surfaced to the wizard.
372
+ throw new CustomMarketplaceError(
373
+ `Could not reach custom marketplace source "${source}": ${err.message}`,
374
+ { cause: err }
375
+ );
376
+ }
377
+
378
+ if (resolved.mode === 'discovery') {
379
+ let plugins;
380
+ try {
381
+ plugins = await mgr.discoverModules(resolved.marketplace, resolved.sourceUrl);
382
+ } catch (err) {
383
+ throw new CustomMarketplaceError(
384
+ `Could not read the plugin list from "${source}": ${err.message}`,
385
+ { cause: err }
386
+ );
387
+ }
388
+ return { mode: 'discovery', plugins, rootDir: resolved.rootDir, sourceUrl: resolved.sourceUrl };
389
+ }
390
+
391
+ // AC1 precedence: discovery (above) wins; else a `registry/` layout →
392
+ // `registry` mode; else `direct` (below). The registry index is a
393
+ // higher-level pointer format (each entry names a separate module repo),
394
+ // so it is enumerated distinctly from a single-repo direct source.
395
+ let registryLayout;
396
+ try {
397
+ registryLayout = await detectRegistryLayout(resolved.rootDir);
398
+ } catch (err) {
399
+ throw new CustomMarketplaceError(
400
+ `Could not inspect "${source}" for a registry layout: ${err.message}`,
401
+ { cause: err }
402
+ );
403
+ }
404
+ if (registryLayout.isRegistry) {
405
+ try {
406
+ return await enumerateRegistrySource(resolved.rootDir, resolved.sourceUrl, registryLayout);
407
+ } catch (err) {
408
+ if (err instanceof CustomMarketplaceError) throw err;
409
+ throw new CustomMarketplaceError(
410
+ `Could not read the registry index from "${source}": ${err.message}`,
411
+ { cause: err }
412
+ );
413
+ }
414
+ }
415
+
416
+ // FR240 — "direct" mode (no marketplace.json at the source root): treat the
417
+ // whole source as a single module so the wizard's multiselect still has
418
+ // exactly one, well-described choice. 30.6b decides how a direct-mode
419
+ // selection stages; 6a only surfaces it.
420
+ const singlePlugin = {
421
+ code: path.basename(resolved.rootDir),
422
+ name: path.basename(resolved.rootDir),
423
+ description: '(direct-mode source — no marketplace.json; the whole source is one module)',
424
+ source: '.',
425
+ skills: [],
426
+ type: 'custom-direct',
427
+ trustTier: 'unverified',
428
+ builtIn: false,
429
+ isExternal: true,
430
+ };
431
+ return { mode: 'direct', plugins: [singlePlugin], rootDir: resolved.rootDir, sourceUrl: resolved.sourceUrl };
432
+ }
433
+
434
+ // ─── Story 30.6b — prune + stage the selected subset ───────────────────────
435
+
436
+ /** Project-relative stage dir for the pruned custom-marketplace subset. Sibling of
437
+ * `lib/bmad.js#PLUGIN_STAGE_DIR_NAME` (the bundled-plugin stage) but a distinct
438
+ * directory: the two stages are unrelated content and must never merge. */
439
+ const CUSTOM_MARKETPLACE_STAGE_DIR_NAME = '.ma-agents-custom-marketplace-stage';
440
+
441
+ /**
442
+ * FR240 direct-mode gap-fill: `enumerateCustomSource()`'s synthetic direct-mode
443
+ * plugin object always carries `skills: []` (6a only needs it to render ONE
444
+ * multiselect choice). bmad-method's own `PluginResolver.resolve()` returns `[]`
445
+ * immediately when `plugin.skills` is empty, so staging a direct-mode selection
446
+ * as-is would resolve to zero installable skills. bmad-method's own CLI
447
+ * (`ui.js#_resolveCustomSourcesCli`, direct-mode branch) works around this by
448
+ * scanning the source root for immediate subdirectories containing `SKILL.md`
449
+ * and using THOSE directory names as `plugin.skills` before calling
450
+ * `resolvePlugin`. This mirrors that scan exactly so 30.6b's direct-mode
451
+ * staging matches what bmad-method's own non-interactive install would do.
452
+ *
453
+ * @param {string} rootDir - Absolute path to the direct-mode source root.
454
+ * @returns {Promise<string[]>} Directory names (not paths) that contain a SKILL.md.
455
+ */
456
+ async function scanSkillDirs(baseDir) {
457
+ let entries;
458
+ try {
459
+ entries = await fs.readdir(baseDir, { withFileTypes: true });
460
+ } catch {
461
+ return [];
462
+ }
463
+ const skillDirs = [];
464
+ for (const entry of entries) {
465
+ if (!entry.isDirectory()) continue;
466
+ const skillMd = path.join(baseDir, entry.name, 'SKILL.md');
467
+ if (await fs.pathExists(skillMd)) {
468
+ skillDirs.push(entry.name);
469
+ }
470
+ }
471
+ return skillDirs;
472
+ }
473
+
474
+ /** Story 30.6b direct-mode scan (thin alias of {@link scanSkillDirs} at the
475
+ * source root) — kept as a named export for the 30.6b surface. */
476
+ async function scanDirectModeSkillDirs(rootDir) {
477
+ return scanSkillDirs(rootDir);
478
+ }
479
+
480
+ /**
481
+ * Resolve every selected plugin's installable skill/module layout via
482
+ * bmad-method's own `CustomModuleManager#resolvePlugin` (the pinned-internal
483
+ * method 30.6a already guards in `REQUIRED_METHODS`, reserved for this story).
484
+ *
485
+ * @param {Object} ctx
486
+ * @param {'discovery'|'direct'} ctx.mode
487
+ * @param {string} ctx.rootDir - Absolute path returned by `enumerateCustomSource`.
488
+ * @param {string|null} ctx.sourceUrl - Absolute/null, as returned by `enumerateCustomSource`.
489
+ * @param {Array<Object>} ctx.plugins - The FULL enumerated plugin list (30.6a's return value).
490
+ * @param {string[]} ctx.selectedCodes - The user's chosen subset of plugin codes.
491
+ * @returns {Promise<Array<{ plugin: Object, rawPlugin: Object, resolved: Array<Object> }>>}
492
+ * @throws {CustomMarketplaceError} if no enumerated plugin matches `selectedCodes`.
493
+ */
494
+ async function resolveSelectedPlugins({ mode, rootDir, sourceUrl, plugins, selectedCodes }) {
495
+ const { mgr } = assertCustomModuleManagerSurface();
496
+ const selectedSet = new Set(selectedCodes);
497
+ const selectedPlugins = (plugins || []).filter((p) => selectedSet.has(p.code));
498
+
499
+ if (selectedPlugins.length === 0) {
500
+ throw new CustomMarketplaceError(
501
+ `None of the selected codes (${[...selectedSet].join(', ') || '(none)'}) matched an ` +
502
+ `enumerated plugin — nothing to stage.`
503
+ );
504
+ }
505
+
506
+ // Local sources pass their rootDir as `localPath` (manifest tracking only);
507
+ // URL sources pass `null` here since the clone metadata already carries the
508
+ // origin. Mirrors resolvePlugin's own call sites (custom-module-manager.js,
509
+ // ui.js#_resolveCustomSourcesCli).
510
+ const localPath = sourceUrl ? null : rootDir;
511
+
512
+ const resolvedByPlugin = [];
513
+ for (const plugin of selectedPlugins) {
514
+ let rawPlugin;
515
+ if (mode === 'discovery') {
516
+ rawPlugin = plugin.rawPlugin;
517
+ } else {
518
+ // FR240 direct mode — see scanDirectModeSkillDirs() doc comment.
519
+ const skillDirNames = await scanDirectModeSkillDirs(rootDir);
520
+ rawPlugin = {
521
+ name: plugin.code,
522
+ description: plugin.description || '',
523
+ version: plugin.version || null,
524
+ source: '.',
525
+ skills: skillDirNames,
526
+ };
527
+ }
528
+ const resolved = await mgr.resolvePlugin(rootDir, rawPlugin, sourceUrl, localPath);
529
+ resolvedByPlugin.push({ plugin, rawPlugin, resolved });
530
+ }
531
+ return resolvedByPlugin;
532
+ }
533
+
534
+ /**
535
+ * FR238 — pre-flight guard mirroring (and pre-empting, with a friendlier
536
+ * message) bmad-method 6.10.0's `_copyResolvedSkills` fail-loud invariants
537
+ * (`tools/installer/modules/official-modules.js`):
538
+ * (a) the overall resolution must be non-empty (an empty selection would
539
+ * otherwise silently install nothing — worse than a loud abort, since
540
+ * the custom-module install path has no built-in guard for this case);
541
+ * (b) each individual resolved module's skill directories must have
542
+ * UNIQUE leaf (basename) names — `_copyResolvedSkills` flattens skills
543
+ * by leaf name into that module's target dir and throws on a collision.
544
+ * Scope note: uniqueness is checked PER MODULE, matching the upstream guard's
545
+ * exact scope — the same leaf name reused across two DIFFERENT modules is
546
+ * fine (each module installs into its own `bmadDir/<code>/`).
547
+ *
548
+ * @param {Array<{ plugin: Object, rawPlugin: Object, resolved: Array<Object> }>} resolvedByPlugin
549
+ * @param {Object} [ctx]
550
+ * @param {string} [ctx.source] - Original source string, for error messages only.
551
+ * @returns {Array<Object>} The flattened list of resolved modules (all plugins).
552
+ * @throws {CustomMarketplaceError} on any violation above.
553
+ */
554
+ function assertResolutionNonEmptyAndUniqueLeaves(resolvedByPlugin, { source = '', mode = null } = {}) {
555
+ const allModules = resolvedByPlugin.flatMap((r) => r.resolved || []);
556
+
557
+ if (allModules.length === 0) {
558
+ // AC7 — a direct-mode source that resolves to nothing is the exact
559
+ // failure a mis-pointed registry URL used to produce. Name the likely
560
+ // cause and note that registry indexes are now supported (so re-running
561
+ // will auto-detect them), instead of a bare "zero modules" abort.
562
+ const directHint =
563
+ mode === 'direct'
564
+ ? ' The source has no `.claude-plugin/marketplace.json` and no `module.yaml`/skills ' +
565
+ 'directories at its root. If it is a BMAD registry index (a `registry/` directory ' +
566
+ 'with `official.yaml`/`community-index.yaml`), that format is now supported — re-run ' +
567
+ 'and it will be detected as a `registry` source.'
568
+ : '';
569
+ throw new CustomMarketplaceError(
570
+ `Custom marketplace selection from "${source}" resolved to zero installable modules ` +
571
+ `— bmad-method's PluginResolver could not locate module.yaml/skills for any selected ` +
572
+ `plugin. Nothing would be installed; aborting before staging (FR238).${directHint}`
573
+ );
574
+ }
575
+
576
+ for (const mod of allModules) {
577
+ if (!Array.isArray(mod.skillPaths) || mod.skillPaths.length === 0) {
578
+ throw new CustomMarketplaceError(
579
+ `Module '${mod.code}' (from plugin '${mod.pluginName}') resolved to zero skill ` +
580
+ `directories — installing it would create an empty module. Aborting before staging (FR238).`
581
+ );
582
+ }
583
+ const seenLeaves = new Map();
584
+ for (const skillPath of mod.skillPaths) {
585
+ const leaf = path.basename(skillPath);
586
+ if (seenLeaves.has(leaf)) {
587
+ throw new CustomMarketplaceError(
588
+ `Module '${mod.code}' has two skill directories that share the leaf name ` +
589
+ `'${leaf}' ('${seenLeaves.get(leaf)}' and '${skillPath}') — they would overwrite ` +
590
+ `each other on install. bmad-method 6.10.0's _copyResolvedSkills fails loud on ` +
591
+ `this exact case; ma-agents checks it first so the failure is clean (FR238).`
592
+ );
593
+ }
594
+ seenLeaves.set(leaf, skillPath);
595
+ }
596
+ }
597
+
598
+ return allModules;
599
+ }
600
+
601
+ /**
602
+ * Prune the enumerated marketplace down to `selectedCodes` and stage the
603
+ * resulting subset's actual content into a project-local directory, ready to
604
+ * be handed to bmad-method as an additional `--custom-source`.
605
+ *
606
+ * Stages ONLY (no bmad-method invocation) — `lib/bmad.js#installCustomMarketplaceSubset`
607
+ * performs the actual install using this function's return value.
608
+ *
609
+ * @param {Object} ctx
610
+ * @param {string} ctx.source - Original source string (path or Git URL), for messages only.
611
+ * @param {'discovery'|'direct'} ctx.mode
612
+ * @param {string} ctx.rootDir
613
+ * @param {string|null} ctx.sourceUrl
614
+ * @param {Array<Object>} ctx.plugins - Full enumerated list (30.6a's return value).
615
+ * @param {string[]} ctx.selectedCodes
616
+ * @param {string} ctx.projectRoot
617
+ * @returns {Promise<{ stagePath: string, customSourceArg: string, resolvedModules: Array<Object> }>}
618
+ * @throws {CustomMarketplaceError} on a drifted surface, an unmatched selection, or a failed
619
+ * FR238 pre-flight check (see assertResolutionNonEmptyAndUniqueLeaves).
620
+ */
621
+ /**
622
+ * Prepare a clean, empty stage directory under `projectRoot`. Safety pattern
623
+ * mirrors lib/bmad.js#stagePlugin (Story 22.6/22.8): never follow a pre-existing
624
+ * stage path that is a symlink, and wipe any stale stage from a prior failed run
625
+ * before copying fresh content. Shared by both the discovery/direct
626
+ * (`stageCustomMarketplaceSubset`) and registry (`stageRegistrySubset`) paths.
627
+ *
628
+ * @param {string} projectRoot - already-resolved absolute project root
629
+ * @returns {string} absolute stage path (created empty)
630
+ * @throws {CustomMarketplaceError}
631
+ */
632
+ function prepareCleanStageDir(projectRoot) {
633
+ const stagePath = path.join(projectRoot, CUSTOM_MARKETPLACE_STAGE_DIR_NAME);
634
+ if (fs.existsSync(stagePath)) {
635
+ const stageLstat = fs.lstatSync(stagePath);
636
+ if (stageLstat.isSymbolicLink()) {
637
+ throw new CustomMarketplaceError(
638
+ `Refusing to stage custom marketplace subset — ${stagePath} is a symbolic link. ` +
639
+ `Remove it manually and re-run.`
640
+ );
641
+ }
642
+ try {
643
+ fs.removeSync(stagePath);
644
+ } catch (err) {
645
+ throw new CustomMarketplaceError(`Cannot wipe stale stage ${stagePath}: ${err.message}`);
646
+ }
647
+ }
648
+ fs.mkdirpSync(stagePath);
649
+ return stagePath;
650
+ }
651
+
652
+ async function stageCustomMarketplaceSubset({ source, mode, rootDir, sourceUrl, plugins, selectedCodes, projectRoot }) {
653
+ projectRoot = path.resolve(projectRoot);
654
+
655
+ const resolvedByPlugin = await resolveSelectedPlugins({ mode, rootDir, sourceUrl, plugins, selectedCodes });
656
+ const allModules = assertResolutionNonEmptyAndUniqueLeaves(resolvedByPlugin, { source, mode });
657
+
658
+ const stagePath = prepareCleanStageDir(projectRoot);
659
+
660
+ // Copy every resolved file, preserving its path relative to rootDir, so
661
+ // bmad-method's own re-resolution against the stage (a fresh 'local' type
662
+ // source once staged) reproduces the identical result: same skill dirs at
663
+ // the same relative locations, same module.yaml if one was found, and the
664
+ // same synthesized fallback if strategy 5 applies (a pure function of the
665
+ // marketplace.json entry + the copied skill dirs' SKILL.md frontmatter).
666
+ const copyRelativeToRoot = (absSrc) => {
667
+ const rel = path.relative(rootDir, absSrc);
668
+ const dest = path.join(stagePath, rel);
669
+ fs.copySync(absSrc, dest, { overwrite: true, dereference: true });
670
+ };
671
+ try {
672
+ for (const mod of allModules) {
673
+ for (const skillPath of mod.skillPaths) copyRelativeToRoot(skillPath);
674
+ if (mod.moduleYamlPath) copyRelativeToRoot(mod.moduleYamlPath);
675
+ if (mod.moduleHelpCsvPath) copyRelativeToRoot(mod.moduleHelpCsvPath);
676
+ }
677
+ } catch (err) {
678
+ try { fs.removeSync(stagePath); } catch { /* swallow — primary error wins */ }
679
+ throw new CustomMarketplaceError(`Staging the custom marketplace subset failed: ${err.message}`, { cause: err });
680
+ }
681
+
682
+ // Prune: write a marketplace.json containing ONLY the selected plugins'
683
+ // raw entries (direct mode has no original marketplace.json to prune, so
684
+ // this synthesizes the one entry from the direct-mode plugin descriptor).
685
+ const prunedPlugins = resolvedByPlugin.map(({ rawPlugin }) => rawPlugin);
686
+ const marketplaceDir = path.join(stagePath, '.claude-plugin');
687
+ fs.mkdirpSync(marketplaceDir);
688
+ fs.writeFileSync(
689
+ path.join(marketplaceDir, 'marketplace.json'),
690
+ JSON.stringify({ name: path.basename(rootDir), plugins: prunedPlugins }, null, 2)
691
+ );
692
+
693
+ // POSIX-style `./<dir>` satisfies bmad-method's parseSource() local-path
694
+ // detector on both Windows and POSIX (see lib/bmad.js#stagePlugin for the
695
+ // same rationale) — bmad-method's cwd is always projectRoot.
696
+ const customSourceArg = `./${CUSTOM_MARKETPLACE_STAGE_DIR_NAME}`;
697
+ return { stagePath, customSourceArg, resolvedModules: allModules };
698
+ }
699
+
700
+ // ─── Registry-index mode — two-level stage + install ───────────────────────
701
+
702
+ /**
703
+ * Resolve one selected registry entry into a staged rawPlugin + resolved
704
+ * modules. Two-level: clone the entry's `repository` (via bmad-method's own
705
+ * `resolveSource`/`cloneRepo` — ambient git creds, GIT_TERMINAL_PROMPT=0, no
706
+ * token handling — FR241), locate the module at `module_definition` (fallback:
707
+ * the repo root, i.e. PluginResolver over the whole clone), scan for skill
708
+ * directories, and resolve via `resolvePlugin`.
709
+ *
710
+ * A community entry is cloned at its `approved_tag` (the tag the registry
711
+ * publishes for the approved release — it points at `approved_sha`). We pin to
712
+ * the TAG rather than the raw SHA because bmad-method's `cloneRepo()` drives
713
+ * `git clone --branch <ref>` / `git fetch origin <ref>`, which accept a tag or
714
+ * branch but NOT a bare commit SHA. The `approved_sha` is still recorded on the
715
+ * persisted selection (see lib/bmad.js#installCustomMarketplaceSubset) as the
716
+ * immutable record of what the registry approved.
717
+ *
718
+ * @param {Object} plugin - a normalized registry entry (from enumerateCustomSource)
719
+ * @param {(input: string, opts: Object) => Promise<Object>} resolveSourceFn
720
+ * @param {(repoPath: string, rawPlugin: Object, url: string|null, local: string|null) => Promise<Array>} resolvePluginFn
721
+ * @returns {Promise<{ plugin: Object, rawPlugin: Object, resolved: Array<Object>, entryRootDir: string }>}
722
+ * @throws {CustomMarketplaceError}
723
+ */
724
+ async function resolveRegistryEntry(plugin, resolveSourceFn, resolvePluginFn) {
725
+ if (!plugin.repository) {
726
+ throw new CustomMarketplaceError(
727
+ `Registry entry '${plugin.code}' has no repository pointer — cannot install it.`
728
+ );
729
+ }
730
+
731
+ // Pin ref (tag/branch) via the `@<ref>` SOURCE-STRING SUFFIX that
732
+ // bmad-method's resolveSource() parses (custom-module-manager.js:
733
+ // "Extract optional @<tag-or-branch> suffix"). resolveSource has NO
734
+ // pinOverride/option for this — the ref MUST ride on the input string. A raw
735
+ // commit SHA is unsupported (`git clone --branch` can't take one), so we pin
736
+ // to `approved_tag` (the registry publishes it pointing at `approved_sha`);
737
+ // `approved_sha` is still recorded verbatim on the persisted selection.
738
+ const pinRef = plugin.approvedTag || null;
739
+ // The ref must match resolveSource's own ref rule (/^[\w.\-+/]+$/, no ':')
740
+ // or we omit it and clone the default branch rather than build a malformed
741
+ // source string.
742
+ const safePinRef = pinRef && /^[\w.\-+/]+$/.test(pinRef) && !pinRef.includes(':') ? pinRef : null;
743
+ const cloneInput = safePinRef ? `${plugin.repository}@${safePinRef}` : plugin.repository;
744
+
745
+ let resolvedSource;
746
+ try {
747
+ resolvedSource = await resolveSourceFn(cloneInput, {
748
+ skipInstall: true,
749
+ silent: true,
750
+ });
751
+ } catch (err) {
752
+ throw new CustomMarketplaceError(
753
+ `Could not clone registry entry '${plugin.code}' from "${plugin.repository}"` +
754
+ `${safePinRef ? ` @ ${safePinRef}` : ''}: ${err.message}`,
755
+ { cause: err }
756
+ );
757
+ }
758
+
759
+ const entryRootDir = resolvedSource.rootDir;
760
+ const entrySourceUrl = resolvedSource.sourceUrl || null;
761
+
762
+ // Locate the module within the clone. `module_definition` is a POSIX,
763
+ // repo-relative path to the module's module.yaml; its directory is the
764
+ // module root. Absent → fall back to the repo root (PluginResolver over the
765
+ // whole clone), matching the AC3 fallback.
766
+ let relModuleDirPosix = '.';
767
+ if (plugin.moduleDefinition) {
768
+ const defPosix = toPosix(plugin.moduleDefinition).replace(/^\.\//, '');
769
+ const moduleYamlAbs = path.join(entryRootDir, ...defPosix.split('/'));
770
+ if (!(await fs.pathExists(moduleYamlAbs))) {
771
+ throw new CustomMarketplaceError(
772
+ `Registry entry '${plugin.code}': module_definition "${plugin.moduleDefinition}" ` +
773
+ `was not found inside ${plugin.repository}. The registry pointer is stale or the ` +
774
+ `repository layout changed; aborting before staging (FR238).`
775
+ );
776
+ }
777
+ relModuleDirPosix = path.posix.dirname(defPosix);
778
+ }
779
+
780
+ const moduleBaseAbs =
781
+ relModuleDirPosix === '.' ? entryRootDir : path.join(entryRootDir, ...relModuleDirPosix.split('/'));
782
+ const skillNames = await scanSkillDirs(moduleBaseAbs);
783
+ const skillRelPaths = skillNames.map((n) => (relModuleDirPosix === '.' ? n : `${relModuleDirPosix}/${n}`));
784
+
785
+ const rawPlugin = {
786
+ name: plugin.code,
787
+ description: plugin.description || '',
788
+ version: plugin.version || null,
789
+ source: relModuleDirPosix,
790
+ // Empty when the module dir has no SKILL.md subdirs — resolvePlugin then
791
+ // returns [] and the FR238 guard aborts cleanly (zero installable modules).
792
+ skills: skillRelPaths,
793
+ };
794
+
795
+ const localPath = entrySourceUrl ? null : entryRootDir;
796
+ const resolved = await resolvePluginFn(entryRootDir, rawPlugin, entrySourceUrl, localPath);
797
+
798
+ return { plugin, rawPlugin, resolved, entryRootDir };
799
+ }
800
+
801
+ /**
802
+ * Registry-mode counterpart of {@link stageCustomMarketplaceSubset}. For each
803
+ * selected registry entry it clones the entry's `repository`, resolves the
804
+ * pointed module, and stages the resolved content into the SAME project-local
805
+ * stage directory the discovery/direct path uses — but namespaced under the
806
+ * entry's `code/` so modules pulled from DIFFERENT repositories can never
807
+ * collide on a shared relative path. Enforces the same FR238 non-empty +
808
+ * unique-leaf guard, then returns the same `{ stagePath, customSourceArg,
809
+ * resolvedModules }` shape so lib/bmad.js#installCustomMarketplaceSubset installs
810
+ * it identically via `buildBmadArgs` + `--custom-source`.
811
+ *
812
+ * @param {Object} ctx
813
+ * @param {string} ctx.source - Original registry source string (messages only).
814
+ * @param {Array<Object>} ctx.plugins - Full enumerated registry entry list.
815
+ * @param {string[]} ctx.selectedCodes
816
+ * @param {string} ctx.projectRoot
817
+ * @param {Object} [ctx.deps] - Injectable seams (tests): { resolveSource, resolvePlugin }.
818
+ * @returns {Promise<{ stagePath: string, customSourceArg: string, resolvedModules: Array<Object> }>}
819
+ * @throws {CustomMarketplaceError}
820
+ */
821
+ async function stageRegistrySubset({ source, plugins, selectedCodes, projectRoot, deps = {} }) {
822
+ projectRoot = path.resolve(projectRoot);
823
+ const { mgr } = assertCustomModuleManagerSurface();
824
+ const resolveSourceFn = deps.resolveSource || ((input, opts) => mgr.resolveSource(input, opts));
825
+ const resolvePluginFn =
826
+ deps.resolvePlugin || ((repoPath, rawPlugin, url, local) => mgr.resolvePlugin(repoPath, rawPlugin, url, local));
827
+
828
+ const selectedSet = new Set(selectedCodes);
829
+ const selected = (plugins || []).filter((p) => selectedSet.has(p.code));
830
+ if (selected.length === 0) {
831
+ throw new CustomMarketplaceError(
832
+ `None of the selected codes (${[...selectedSet].join(', ') || '(none)'}) matched a ` +
833
+ `registry entry — nothing to stage.`
834
+ );
835
+ }
836
+
837
+ // Each entry is staged under its own `code/` namespace, so two selected
838
+ // entries sharing a code would silently overwrite each other. enumerate
839
+ // de-dupes by `name`, but a malformed registry could give two differently-
840
+ // named entries the same `code` — reject that here rather than stage a
841
+ // corrupt subset (FR238 fail-clean).
842
+ const seenCodes = new Set();
843
+ for (const p of selected) {
844
+ if (seenCodes.has(p.code)) {
845
+ throw new CustomMarketplaceError(
846
+ `Two selected registry entries share the code '${p.code}' — codes must be unique ` +
847
+ `to install. Aborting before staging (FR238).`
848
+ );
849
+ }
850
+ seenCodes.add(p.code);
851
+ }
852
+
853
+ // Resolve every selected entry (each from its own clone) BEFORE touching the
854
+ // stage dir, so an unreachable/stale entry aborts clean with nothing staged.
855
+ const entries = [];
856
+ for (const plugin of selected) {
857
+ entries.push(await resolveRegistryEntry(plugin, resolveSourceFn, resolvePluginFn));
858
+ }
859
+
860
+ const allModules = assertResolutionNonEmptyAndUniqueLeaves(
861
+ entries.map(({ plugin, rawPlugin, resolved }) => ({ plugin, rawPlugin, resolved })),
862
+ { source, mode: 'registry' }
863
+ );
864
+
865
+ const stagePath = prepareCleanStageDir(projectRoot);
866
+
867
+ // Copy each entry's resolved content under a `code/` namespace so entries
868
+ // cloned from different repos never overwrite each other, and rewrite the
869
+ // staged rawPlugin's skill/source paths to match that namespace so
870
+ // bmad-method's re-resolution against the stage reproduces the identical
871
+ // module (skills at code/<rel>, module.yaml at their common parent).
872
+ const stagedPlugins = [];
873
+ try {
874
+ for (const { plugin, resolved, entryRootDir } of entries) {
875
+ const prefix = plugin.code;
876
+ const relFromEntry = (absSrc) => toPosix(path.relative(entryRootDir, absSrc));
877
+ const copyUnderPrefix = (absSrc) => {
878
+ const dest = path.join(stagePath, prefix, ...relFromEntry(absSrc).split('/'));
879
+ fs.copySync(absSrc, dest, { overwrite: true, dereference: true });
880
+ };
881
+
882
+ const stagedSkills = [];
883
+ for (const mod of resolved) {
884
+ for (const skillPath of mod.skillPaths) {
885
+ copyUnderPrefix(skillPath);
886
+ stagedSkills.push(`${prefix}/${relFromEntry(skillPath)}`);
887
+ }
888
+ if (mod.moduleYamlPath) copyUnderPrefix(mod.moduleYamlPath);
889
+ if (mod.moduleHelpCsvPath) copyUnderPrefix(mod.moduleHelpCsvPath);
890
+ }
891
+
892
+ stagedPlugins.push({
893
+ name: plugin.code,
894
+ description: plugin.description || '',
895
+ version: plugin.version || null,
896
+ source: prefix,
897
+ skills: stagedSkills,
898
+ });
899
+ }
900
+ } catch (err) {
901
+ try { fs.removeSync(stagePath); } catch { /* swallow — primary error wins */ }
902
+ throw new CustomMarketplaceError(`Staging the registry subset failed: ${err.message}`, { cause: err });
903
+ }
904
+
905
+ const marketplaceDir = path.join(stagePath, '.claude-plugin');
906
+ fs.mkdirpSync(marketplaceDir);
907
+ fs.writeFileSync(
908
+ path.join(marketplaceDir, 'marketplace.json'),
909
+ JSON.stringify({ name: 'ma-agents-custom-registry-subset', plugins: stagedPlugins }, null, 2)
910
+ );
911
+
912
+ const customSourceArg = `./${CUSTOM_MARKETPLACE_STAGE_DIR_NAME}`;
913
+ return { stagePath, customSourceArg, resolvedModules: allModules };
914
+ }
915
+
916
+ // ─── Story 30.7 — persist + re-enumerate/reconcile on update ───────────────
917
+
918
+ /**
919
+ * Pure reconcile — no I/O. Compares a freshly re-enumerated plugin list
920
+ * against the PERSISTED selection from a prior install and reports the
921
+ * difference. Newly-appeared plugins are reported only — never added to the
922
+ * returned selection (FR242's "report only" contract); the caller decides
923
+ * what to do with `added`/`removed` (typically: print a report, and re-install
924
+ * only `stillPresent`).
925
+ *
926
+ * @param {Object} ctx
927
+ * @param {string[]} ctx.persistedSelectedCodes - The previously-persisted `selectedCodes`.
928
+ * @param {Array<{ code: string }>} ctx.enumeratedPlugins - Fresh `enumerateCustomSource()` output.
929
+ * @returns {{ added: string[], removed: string[], stillPresent: string[] }}
930
+ * `added` — declared now but not previously selected (report only, not auto-selected).
931
+ * `removed` — previously selected but no longer declared by the source.
932
+ * `stillPresent` — previously selected AND still declared (safe to re-install as-is).
933
+ */
934
+ function reconcileCustomMarketplaceSelection({ persistedSelectedCodes, enumeratedPlugins }) {
935
+ const persistedSet = new Set(persistedSelectedCodes || []);
936
+ const enumeratedCodes = (enumeratedPlugins || []).map((p) => p.code);
937
+ const enumeratedSet = new Set(enumeratedCodes);
938
+
939
+ const added = enumeratedCodes.filter((c) => !persistedSet.has(c));
940
+ const removed = [...persistedSet].filter((c) => !enumeratedSet.has(c));
941
+ const stillPresent = [...persistedSet].filter((c) => enumeratedSet.has(c));
942
+
943
+ return { added, removed, stillPresent };
944
+ }
945
+
946
+ module.exports = {
947
+ CUSTOM_MODULE_MANAGER_PATH,
948
+ REQUIRED_METHODS,
949
+ NFR71_WARNING,
950
+ CustomMarketplaceError,
951
+ assertCustomModuleManagerSurface,
952
+ enumerateCustomSource,
953
+ // Story 30.6b — prune + stage the selected subset
954
+ CUSTOM_MARKETPLACE_STAGE_DIR_NAME,
955
+ scanDirectModeSkillDirs,
956
+ scanSkillDirs,
957
+ resolveSelectedPlugins,
958
+ assertResolutionNonEmptyAndUniqueLeaves,
959
+ stageCustomMarketplaceSubset,
960
+ // Registry-index mode (this story)
961
+ REGISTRY_DIR_NAME,
962
+ REGISTRY_OFFICIAL_INDEX,
963
+ REGISTRY_COMMUNITY_INDEX,
964
+ registryTrustLabel,
965
+ detectRegistryLayout,
966
+ parseRegistryIndex,
967
+ normalizeRegistryEntry,
968
+ enumerateRegistrySource,
969
+ resolveRegistryEntry,
970
+ stageRegistrySubset,
971
+ // Story 30.7 — persist + reconcile on update
972
+ reconcileCustomMarketplaceSelection,
973
+ };