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.
- package/README.md +85 -4
- package/bin/cli.js +339 -6
- package/lib/agents.js +22 -15
- package/lib/bmad-cache/cache-manifest.json +2 -2
- package/lib/bmad-extension/.claude-plugin/marketplace.json.template +2 -15
- package/lib/bmad-extension/skills/module-help.csv +0 -13
- package/lib/bmad-extension-plugin/.claude-plugin/marketplace.json +3 -16
- package/lib/bmad-extension-plugin/skills/module-help.csv +0 -13
- package/lib/bmad.js +143 -1
- package/lib/custom-marketplace.js +973 -0
- package/lib/installer.js +19 -7
- package/lib/knowledge-atlas/dist/atlas.cjs +14651 -14651
- package/lib/knowledge-atlas/src/render/vendor.ts +43 -43
- package/lib/knowledge-atlas/vendor/drawio-viewer.min.js +7563 -7563
- package/lib/knowledge-atlas/vendor/mermaid.min.js +2314 -2314
- package/lib/profile.js +220 -0
- package/lib/skill-authoring.js +3 -1
- package/package.json +3 -3
- package/scripts/build-bmad-cache.js +127 -43
- package/lib/bmad-extension/skills/ma-agent-ml/.gitkeep +0 -0
- package/lib/bmad-extension/skills/ma-agent-ml/SKILL.md +0 -59
- package/lib/bmad-extension/skills/ma-agent-ml/bmad-skill-manifest.yaml +0 -11
- package/lib/bmad-extension/skills/ml-advise/.gitkeep +0 -0
- package/lib/bmad-extension/skills/ml-advise/SKILL.md +0 -76
- package/lib/bmad-extension/skills/ml-advise/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension/skills/ml-advise/skill.json +0 -7
- package/lib/bmad-extension/skills/ml-analysis/.gitkeep +0 -0
- package/lib/bmad-extension/skills/ml-analysis/SKILL.md +0 -60
- package/lib/bmad-extension/skills/ml-analysis/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension/skills/ml-analysis/skill.json +0 -7
- package/lib/bmad-extension/skills/ml-architecture/.gitkeep +0 -0
- package/lib/bmad-extension/skills/ml-architecture/SKILL.md +0 -55
- package/lib/bmad-extension/skills/ml-architecture/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension/skills/ml-architecture/skill.json +0 -7
- package/lib/bmad-extension/skills/ml-detailed-design/.gitkeep +0 -0
- package/lib/bmad-extension/skills/ml-detailed-design/SKILL.md +0 -67
- package/lib/bmad-extension/skills/ml-detailed-design/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension/skills/ml-detailed-design/skill.json +0 -7
- package/lib/bmad-extension/skills/ml-eda/.gitkeep +0 -0
- package/lib/bmad-extension/skills/ml-eda/SKILL.md +0 -56
- package/lib/bmad-extension/skills/ml-eda/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension/skills/ml-eda/scripts/baseline_classifier.py +0 -522
- package/lib/bmad-extension/skills/ml-eda/scripts/class_weights_calculator.py +0 -295
- package/lib/bmad-extension/skills/ml-eda/scripts/clustering_explorer.py +0 -383
- package/lib/bmad-extension/skills/ml-eda/scripts/eda_analyzer.py +0 -654
- package/lib/bmad-extension/skills/ml-eda/skill.json +0 -7
- package/lib/bmad-extension/skills/ml-experiment/.gitkeep +0 -0
- package/lib/bmad-extension/skills/ml-experiment/SKILL.md +0 -74
- package/lib/bmad-extension/skills/ml-experiment/assets/advanced_trainer_configs.py +0 -430
- package/lib/bmad-extension/skills/ml-experiment/assets/quick_trainer_setup.py +0 -233
- package/lib/bmad-extension/skills/ml-experiment/assets/template_datamodule.py +0 -219
- package/lib/bmad-extension/skills/ml-experiment/assets/template_gnn_module.py +0 -341
- package/lib/bmad-extension/skills/ml-experiment/assets/template_lightning_module.py +0 -158
- package/lib/bmad-extension/skills/ml-experiment/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension/skills/ml-experiment/skill.json +0 -7
- package/lib/bmad-extension/skills/ml-hparam/.gitkeep +0 -0
- package/lib/bmad-extension/skills/ml-hparam/SKILL.md +0 -81
- package/lib/bmad-extension/skills/ml-hparam/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension/skills/ml-hparam/skill.json +0 -7
- package/lib/bmad-extension/skills/ml-ideation/.gitkeep +0 -0
- package/lib/bmad-extension/skills/ml-ideation/SKILL.md +0 -50
- package/lib/bmad-extension/skills/ml-ideation/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension/skills/ml-ideation/scripts/validate_ml_prd.py +0 -287
- package/lib/bmad-extension/skills/ml-ideation/skill.json +0 -7
- package/lib/bmad-extension/skills/ml-infra/.gitkeep +0 -0
- package/lib/bmad-extension/skills/ml-infra/SKILL.md +0 -58
- package/lib/bmad-extension/skills/ml-infra/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension/skills/ml-infra/skill.json +0 -7
- package/lib/bmad-extension/skills/ml-retrospective/.gitkeep +0 -0
- package/lib/bmad-extension/skills/ml-retrospective/SKILL.md +0 -63
- package/lib/bmad-extension/skills/ml-retrospective/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension/skills/ml-retrospective/skill.json +0 -7
- package/lib/bmad-extension/skills/ml-revision/.gitkeep +0 -0
- package/lib/bmad-extension/skills/ml-revision/SKILL.md +0 -82
- package/lib/bmad-extension/skills/ml-revision/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension/skills/ml-revision/skill.json +0 -7
- package/lib/bmad-extension/skills/ml-techspec/.gitkeep +0 -0
- package/lib/bmad-extension/skills/ml-techspec/SKILL.md +0 -80
- package/lib/bmad-extension/skills/ml-techspec/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension/skills/ml-techspec/skill.json +0 -7
- package/lib/bmad-extension-plugin/skills/ma-agent-ml/.gitkeep +0 -0
- package/lib/bmad-extension-plugin/skills/ma-agent-ml/SKILL.md +0 -59
- package/lib/bmad-extension-plugin/skills/ma-agent-ml/bmad-skill-manifest.yaml +0 -11
- package/lib/bmad-extension-plugin/skills/ml-advise/.gitkeep +0 -0
- package/lib/bmad-extension-plugin/skills/ml-advise/SKILL.md +0 -76
- package/lib/bmad-extension-plugin/skills/ml-advise/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension-plugin/skills/ml-advise/skill.json +0 -7
- package/lib/bmad-extension-plugin/skills/ml-analysis/.gitkeep +0 -0
- package/lib/bmad-extension-plugin/skills/ml-analysis/SKILL.md +0 -60
- package/lib/bmad-extension-plugin/skills/ml-analysis/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension-plugin/skills/ml-analysis/skill.json +0 -7
- package/lib/bmad-extension-plugin/skills/ml-architecture/.gitkeep +0 -0
- package/lib/bmad-extension-plugin/skills/ml-architecture/SKILL.md +0 -55
- package/lib/bmad-extension-plugin/skills/ml-architecture/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension-plugin/skills/ml-architecture/skill.json +0 -7
- package/lib/bmad-extension-plugin/skills/ml-detailed-design/.gitkeep +0 -0
- package/lib/bmad-extension-plugin/skills/ml-detailed-design/SKILL.md +0 -67
- package/lib/bmad-extension-plugin/skills/ml-detailed-design/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension-plugin/skills/ml-detailed-design/skill.json +0 -7
- package/lib/bmad-extension-plugin/skills/ml-eda/.gitkeep +0 -0
- package/lib/bmad-extension-plugin/skills/ml-eda/SKILL.md +0 -56
- package/lib/bmad-extension-plugin/skills/ml-eda/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension-plugin/skills/ml-eda/scripts/baseline_classifier.py +0 -522
- package/lib/bmad-extension-plugin/skills/ml-eda/scripts/class_weights_calculator.py +0 -295
- package/lib/bmad-extension-plugin/skills/ml-eda/scripts/clustering_explorer.py +0 -383
- package/lib/bmad-extension-plugin/skills/ml-eda/scripts/eda_analyzer.py +0 -654
- package/lib/bmad-extension-plugin/skills/ml-eda/skill.json +0 -7
- package/lib/bmad-extension-plugin/skills/ml-experiment/.gitkeep +0 -0
- package/lib/bmad-extension-plugin/skills/ml-experiment/SKILL.md +0 -74
- package/lib/bmad-extension-plugin/skills/ml-experiment/assets/advanced_trainer_configs.py +0 -430
- package/lib/bmad-extension-plugin/skills/ml-experiment/assets/quick_trainer_setup.py +0 -233
- package/lib/bmad-extension-plugin/skills/ml-experiment/assets/template_datamodule.py +0 -219
- package/lib/bmad-extension-plugin/skills/ml-experiment/assets/template_gnn_module.py +0 -341
- package/lib/bmad-extension-plugin/skills/ml-experiment/assets/template_lightning_module.py +0 -158
- package/lib/bmad-extension-plugin/skills/ml-experiment/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension-plugin/skills/ml-experiment/skill.json +0 -7
- package/lib/bmad-extension-plugin/skills/ml-hparam/.gitkeep +0 -0
- package/lib/bmad-extension-plugin/skills/ml-hparam/SKILL.md +0 -81
- package/lib/bmad-extension-plugin/skills/ml-hparam/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension-plugin/skills/ml-hparam/skill.json +0 -7
- package/lib/bmad-extension-plugin/skills/ml-ideation/.gitkeep +0 -0
- package/lib/bmad-extension-plugin/skills/ml-ideation/SKILL.md +0 -50
- package/lib/bmad-extension-plugin/skills/ml-ideation/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension-plugin/skills/ml-ideation/scripts/validate_ml_prd.py +0 -287
- package/lib/bmad-extension-plugin/skills/ml-ideation/skill.json +0 -7
- package/lib/bmad-extension-plugin/skills/ml-infra/.gitkeep +0 -0
- package/lib/bmad-extension-plugin/skills/ml-infra/SKILL.md +0 -58
- package/lib/bmad-extension-plugin/skills/ml-infra/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension-plugin/skills/ml-infra/skill.json +0 -7
- package/lib/bmad-extension-plugin/skills/ml-retrospective/.gitkeep +0 -0
- package/lib/bmad-extension-plugin/skills/ml-retrospective/SKILL.md +0 -63
- package/lib/bmad-extension-plugin/skills/ml-retrospective/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension-plugin/skills/ml-retrospective/skill.json +0 -7
- package/lib/bmad-extension-plugin/skills/ml-revision/.gitkeep +0 -0
- package/lib/bmad-extension-plugin/skills/ml-revision/SKILL.md +0 -82
- package/lib/bmad-extension-plugin/skills/ml-revision/bmad-skill-manifest.yaml +0 -3
- package/lib/bmad-extension-plugin/skills/ml-revision/skill.json +0 -7
- package/lib/bmad-extension-plugin/skills/ml-techspec/.gitkeep +0 -0
- package/lib/bmad-extension-plugin/skills/ml-techspec/SKILL.md +0 -80
- package/lib/bmad-extension-plugin/skills/ml-techspec/bmad-skill-manifest.yaml +0 -3
- 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
|
+
};
|