ma-agents 3.17.0-beta.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/bin/cli.js +24 -8
- package/lib/bmad-extension-plugin/.claude-plugin/marketplace.json +1 -1
- package/lib/bmad.js +41 -14
- package/lib/custom-marketplace.js +481 -15
- package/lib/profile.js +24 -5
- package/package.json +2 -2
package/bin/cli.js
CHANGED
|
@@ -39,7 +39,7 @@ const { reconfigure: runReconfigure, ReconfigureYesRejectedError, ManifestNotFou
|
|
|
39
39
|
const { uninstallProfileArtifacts } = require('../lib/uninstall');
|
|
40
40
|
const bmad = require('../lib/bmad');
|
|
41
41
|
const { getBmadPlatformCode } = require('../lib/agents');
|
|
42
|
-
const { enumerateCustomSource, NFR71_WARNING, CustomMarketplaceError, reconcileCustomMarketplaceSelection } = require('../lib/custom-marketplace');
|
|
42
|
+
const { enumerateCustomSource, NFR71_WARNING, CustomMarketplaceError, reconcileCustomMarketplaceSelection, registryTrustLabel } = require('../lib/custom-marketplace');
|
|
43
43
|
const { handleCreateSkill, handleValidateSkill, handleSetMandatory, handleCustomizeAgent, handleCreateAgent } = require('../lib/skill-authoring');
|
|
44
44
|
|
|
45
45
|
const PKG = require('../package.json');
|
|
@@ -990,7 +990,7 @@ function resolveBaseBmadOk({ bmadInstalled, attempted, success }) {
|
|
|
990
990
|
* @param {Object} ctx
|
|
991
991
|
* @param {string} [ctx.bmadCustomSourceFlag] - `--bmad-custom-source <ref>` value
|
|
992
992
|
* @param {boolean} [ctx.yesFlag] - non-interactive run
|
|
993
|
-
* @returns {Promise<null|{ source: string, mode: 'discovery'|'direct', selectedCodes: string[],
|
|
993
|
+
* @returns {Promise<null|{ source: string, mode: 'discovery'|'direct'|'registry', selectedCodes: string[],
|
|
994
994
|
* plugins: Array<Object>, rootDir: string, sourceUrl: string|null }>}
|
|
995
995
|
*/
|
|
996
996
|
async function promptCustomMarketplace({ bmadCustomSourceFlag = '', yesFlag = false } = {}) {
|
|
@@ -1056,15 +1056,31 @@ async function promptCustomMarketplace({ bmadCustomSourceFlag = '', yesFlag = fa
|
|
|
1056
1056
|
return { source, mode, selectedCodes, plugins, rootDir, sourceUrl };
|
|
1057
1057
|
}
|
|
1058
1058
|
|
|
1059
|
+
// Registry mode (this story): show display_name + trust tier + category and
|
|
1060
|
+
// make the community/unverified trust tier explicit PER ENTRY (AC2, NFR71).
|
|
1061
|
+
// Registry entries are opt-in — nothing is pre-selected (AC1/AC2). Discovery/
|
|
1062
|
+
// direct keep their existing "all pre-selected" behavior unchanged (AC6).
|
|
1063
|
+
const isRegistry = mode === 'registry';
|
|
1059
1064
|
const { chosenCodes } = await prompts({
|
|
1060
1065
|
type: 'multiselect',
|
|
1061
1066
|
name: 'chosenCodes',
|
|
1062
1067
|
message: `Select extensions to install from "${source}":`,
|
|
1063
|
-
choices: plugins.map((p) =>
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
+
choices: plugins.map((p) => {
|
|
1069
|
+
if (isRegistry) {
|
|
1070
|
+
const label = registryTrustLabel(p);
|
|
1071
|
+
const certified = p.trustTier === 'bmad-certified';
|
|
1072
|
+
const head = chalk.white(p.displayName || p.name || p.code);
|
|
1073
|
+
const tier = (certified ? chalk.green : chalk.yellow)(`[${label}]`);
|
|
1074
|
+
const cat = p.category ? chalk.gray(` {${p.category}${p.subcategory ? `/${p.subcategory}` : ''}}`) : '';
|
|
1075
|
+
const desc = p.description ? chalk.gray(` — ${p.description}`) : '';
|
|
1076
|
+
return { title: `${head} ${tier}${cat}${desc}`, value: p.code, selected: false };
|
|
1077
|
+
}
|
|
1078
|
+
return {
|
|
1079
|
+
title: chalk.white(p.code) + chalk.gray(p.description ? ` — ${p.description}` : ''),
|
|
1080
|
+
value: p.code,
|
|
1081
|
+
selected: true,
|
|
1082
|
+
};
|
|
1083
|
+
}),
|
|
1068
1084
|
instructions: chalk.gray(' Use space to toggle, enter to confirm.'),
|
|
1069
1085
|
});
|
|
1070
1086
|
|
|
@@ -1093,7 +1109,7 @@ async function promptCustomMarketplace({ bmadCustomSourceFlag = '', yesFlag = fa
|
|
|
1093
1109
|
*
|
|
1094
1110
|
* @param {Object} ctx
|
|
1095
1111
|
* @param {string} ctx.projectRoot
|
|
1096
|
-
* @returns {Promise<undefined|null|{ source: string, mode: 'discovery'|'direct',
|
|
1112
|
+
* @returns {Promise<undefined|null|{ source: string, mode: 'discovery'|'direct'|'registry',
|
|
1097
1113
|
* selectedCodes: string[], plugins: Array<Object>, rootDir: string, sourceUrl: string|null }>}
|
|
1098
1114
|
* `undefined` — nothing persisted; caller should fall back to `promptCustomMarketplace()`.
|
|
1099
1115
|
* `null` — persisted source exists but could not be re-enumerated (fail-loud; skip this run).
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"name": "ma-skills",
|
|
38
38
|
"source": "./",
|
|
39
39
|
"description": "ma-agents extension module providing enterprise SDLC personas and operational workflow skills.",
|
|
40
|
-
"version": "3.17.0-beta.
|
|
40
|
+
"version": "3.17.0-beta.1",
|
|
41
41
|
"author": {
|
|
42
42
|
"name": "Alon Mayaffit"
|
|
43
43
|
},
|
package/lib/bmad.js
CHANGED
|
@@ -1686,7 +1686,7 @@ async function deployClineWorkflows(projectRoot, tools = []) {
|
|
|
1686
1686
|
* @param {string} ctx.projectRoot
|
|
1687
1687
|
* @param {string[]} ctx.modules - The same bmad module set used for the base install.
|
|
1688
1688
|
* @param {string[]} ctx.tools - The same IDE/tool set used for the base install.
|
|
1689
|
-
* @param {{ source: string, mode: 'discovery'|'direct', selectedCodes: string[],
|
|
1689
|
+
* @param {{ source: string, mode: 'discovery'|'direct'|'registry', selectedCodes: string[],
|
|
1690
1690
|
* plugins: Array<Object>, rootDir: string, sourceUrl: string|null }} ctx.selection -
|
|
1691
1691
|
* 30.6a's `promptCustomMarketplace()` return value (extended with `rootDir`/`sourceUrl`).
|
|
1692
1692
|
* @returns {Promise<boolean>} true on success; false on any failure (logged, never thrown).
|
|
@@ -1705,6 +1705,7 @@ async function installCustomMarketplaceSubset({ projectRoot, modules, tools, sel
|
|
|
1705
1705
|
// bmad-method child process or fighting Node's require-cache semantics
|
|
1706
1706
|
// to stub out module-internal function bindings.
|
|
1707
1707
|
const stageFn = deps.stageCustomMarketplaceSubset || customMarketplace.stageCustomMarketplaceSubset;
|
|
1708
|
+
const stageRegistryFn = deps.stageRegistrySubset || customMarketplace.stageRegistrySubset;
|
|
1708
1709
|
const runCommandFn = deps.runCommand || runCommand;
|
|
1709
1710
|
const deployClineWorkflowsFn = deps.deployClineWorkflows || deployClineWorkflows;
|
|
1710
1711
|
const cleanupStageFn = deps.cleanupStage || cleanupStage;
|
|
@@ -1719,15 +1720,27 @@ async function installCustomMarketplaceSubset({ projectRoot, modules, tools, sel
|
|
|
1719
1720
|
let customSourceArg;
|
|
1720
1721
|
let resolvedModules;
|
|
1721
1722
|
try {
|
|
1722
|
-
(
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1723
|
+
if (selection.mode === 'registry') {
|
|
1724
|
+
// Registry-index mode (this story): two-level clone+resolve+stage of
|
|
1725
|
+
// each selected entry's own repository. Shares the same stage dir +
|
|
1726
|
+
// install path as discovery/direct; only the staging step differs.
|
|
1727
|
+
({ stagePath, customSourceArg, resolvedModules } = await stageRegistryFn({
|
|
1728
|
+
source: selection.source,
|
|
1729
|
+
plugins: selection.plugins,
|
|
1730
|
+
selectedCodes: selection.selectedCodes,
|
|
1731
|
+
projectRoot,
|
|
1732
|
+
}));
|
|
1733
|
+
} else {
|
|
1734
|
+
({ stagePath, customSourceArg, resolvedModules } = await stageFn({
|
|
1735
|
+
source: selection.source,
|
|
1736
|
+
mode: selection.mode,
|
|
1737
|
+
rootDir: selection.rootDir,
|
|
1738
|
+
sourceUrl: selection.sourceUrl,
|
|
1739
|
+
plugins: selection.plugins,
|
|
1740
|
+
selectedCodes: selection.selectedCodes,
|
|
1741
|
+
projectRoot,
|
|
1742
|
+
}));
|
|
1743
|
+
}
|
|
1731
1744
|
} catch (err) {
|
|
1732
1745
|
const message = err instanceof CustomMarketplaceError ? err.message : `Custom marketplace staging error: ${err.message}`;
|
|
1733
1746
|
console.error(chalk.red(` Custom marketplace subset staging failed: ${message}`));
|
|
@@ -1763,14 +1776,28 @@ async function installCustomMarketplaceSubset({ projectRoot, modules, tools, sel
|
|
|
1763
1776
|
// failure is logged as a warning only — it must never turn an
|
|
1764
1777
|
// otherwise-successful install into a reported failure.
|
|
1765
1778
|
try {
|
|
1766
|
-
const
|
|
1767
|
-
setCustomMarketplaceSelectionFn(projectRoot, {
|
|
1779
|
+
const persistEntry = {
|
|
1768
1780
|
source: selection.source,
|
|
1769
1781
|
sourceUrl: selection.sourceUrl ?? null,
|
|
1770
1782
|
mode: selection.mode,
|
|
1771
1783
|
selectedCodes: selection.selectedCodes,
|
|
1772
|
-
|
|
1773
|
-
|
|
1784
|
+
};
|
|
1785
|
+
if (selection.mode === 'registry') {
|
|
1786
|
+
// AC5 — record each selected entry's registry-provided
|
|
1787
|
+
// `approved_sha` (the immutable pin the registry declares), plus
|
|
1788
|
+
// its `approved_tag`. `sha` (the top-level field) mirrors the
|
|
1789
|
+
// first available approved_sha for back-compat with readers that
|
|
1790
|
+
// only look at `sha`.
|
|
1791
|
+
const selectedSet = new Set(selection.selectedCodes);
|
|
1792
|
+
const registryEntries = (selection.plugins || [])
|
|
1793
|
+
.filter((p) => selectedSet.has(p.code))
|
|
1794
|
+
.map((p) => ({ code: p.code, approvedSha: p.approvedSha ?? null, approvedTag: p.approvedTag ?? null }));
|
|
1795
|
+
persistEntry.entries = registryEntries;
|
|
1796
|
+
persistEntry.sha = registryEntries.map((e) => e.approvedSha).find((s) => s) || null;
|
|
1797
|
+
} else {
|
|
1798
|
+
persistEntry.sha = (resolvedModules || []).map((m) => m.cloneSha).find((s) => s) || null;
|
|
1799
|
+
}
|
|
1800
|
+
setCustomMarketplaceSelectionFn(projectRoot, persistEntry);
|
|
1774
1801
|
} catch (persistErr) {
|
|
1775
1802
|
console.warn(chalk.yellow(` Warning: could not persist custom marketplace selection: ${persistErr.message}`));
|
|
1776
1803
|
}
|
|
@@ -39,10 +39,33 @@
|
|
|
39
39
|
|
|
40
40
|
const path = require('node:path');
|
|
41
41
|
const fs = require('fs-extra');
|
|
42
|
+
const yaml = require('js-yaml');
|
|
42
43
|
|
|
43
44
|
/** Deep, pinned-internal require path (NFR17). Single call site. */
|
|
44
45
|
const CUSTOM_MODULE_MANAGER_PATH = 'bmad-method/tools/installer/modules/custom-module-manager.js';
|
|
45
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
|
+
|
|
46
69
|
/**
|
|
47
70
|
* The `CustomModuleManager` methods the custom-marketplace feature depends on.
|
|
48
71
|
* Any drift in this set (renamed/removed on a bmad-method pin bump) must fail
|
|
@@ -63,6 +86,23 @@ const NFR71_WARNING =
|
|
|
63
86
|
'Custom marketplace content is outside ma-agents\' sealed-bundle guarantee — ' +
|
|
64
87
|
'it is installed as-is from the source you provided and is not vetted by ma-agents.';
|
|
65
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
|
+
|
|
66
106
|
/**
|
|
67
107
|
* Error type raised by this adapter for any enumeration failure (unreachable
|
|
68
108
|
* source, invalid input, drifted surface). Callers should catch this and
|
|
@@ -150,6 +190,150 @@ function assertCustomModuleManagerSurface() {
|
|
|
150
190
|
return { version, mgr };
|
|
151
191
|
}
|
|
152
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
|
+
|
|
153
337
|
/**
|
|
154
338
|
* Enumerate the plugins declared by a custom marketplace source.
|
|
155
339
|
*
|
|
@@ -204,6 +388,31 @@ async function enumerateCustomSource(source) {
|
|
|
204
388
|
return { mode: 'discovery', plugins, rootDir: resolved.rootDir, sourceUrl: resolved.sourceUrl };
|
|
205
389
|
}
|
|
206
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
|
+
|
|
207
416
|
// FR240 — "direct" mode (no marketplace.json at the source root): treat the
|
|
208
417
|
// whole source as a single module so the wizard's multiselect still has
|
|
209
418
|
// exactly one, well-described choice. 30.6b decides how a direct-mode
|
|
@@ -244,17 +453,17 @@ const CUSTOM_MARKETPLACE_STAGE_DIR_NAME = '.ma-agents-custom-marketplace-stage';
|
|
|
244
453
|
* @param {string} rootDir - Absolute path to the direct-mode source root.
|
|
245
454
|
* @returns {Promise<string[]>} Directory names (not paths) that contain a SKILL.md.
|
|
246
455
|
*/
|
|
247
|
-
async function
|
|
456
|
+
async function scanSkillDirs(baseDir) {
|
|
248
457
|
let entries;
|
|
249
458
|
try {
|
|
250
|
-
entries = await fs.readdir(
|
|
459
|
+
entries = await fs.readdir(baseDir, { withFileTypes: true });
|
|
251
460
|
} catch {
|
|
252
461
|
return [];
|
|
253
462
|
}
|
|
254
463
|
const skillDirs = [];
|
|
255
464
|
for (const entry of entries) {
|
|
256
465
|
if (!entry.isDirectory()) continue;
|
|
257
|
-
const skillMd = path.join(
|
|
466
|
+
const skillMd = path.join(baseDir, entry.name, 'SKILL.md');
|
|
258
467
|
if (await fs.pathExists(skillMd)) {
|
|
259
468
|
skillDirs.push(entry.name);
|
|
260
469
|
}
|
|
@@ -262,6 +471,12 @@ async function scanDirectModeSkillDirs(rootDir) {
|
|
|
262
471
|
return skillDirs;
|
|
263
472
|
}
|
|
264
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
|
+
|
|
265
480
|
/**
|
|
266
481
|
* Resolve every selected plugin's installable skill/module layout via
|
|
267
482
|
* bmad-method's own `CustomModuleManager#resolvePlugin` (the pinned-internal
|
|
@@ -336,14 +551,25 @@ async function resolveSelectedPlugins({ mode, rootDir, sourceUrl, plugins, selec
|
|
|
336
551
|
* @returns {Array<Object>} The flattened list of resolved modules (all plugins).
|
|
337
552
|
* @throws {CustomMarketplaceError} on any violation above.
|
|
338
553
|
*/
|
|
339
|
-
function assertResolutionNonEmptyAndUniqueLeaves(resolvedByPlugin, { source = '' } = {}) {
|
|
554
|
+
function assertResolutionNonEmptyAndUniqueLeaves(resolvedByPlugin, { source = '', mode = null } = {}) {
|
|
340
555
|
const allModules = resolvedByPlugin.flatMap((r) => r.resolved || []);
|
|
341
556
|
|
|
342
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
|
+
: '';
|
|
343
569
|
throw new CustomMarketplaceError(
|
|
344
570
|
`Custom marketplace selection from "${source}" resolved to zero installable modules ` +
|
|
345
571
|
`— bmad-method's PluginResolver could not locate module.yaml/skills for any selected ` +
|
|
346
|
-
`plugin. Nothing would be installed; aborting before staging (FR238)
|
|
572
|
+
`plugin. Nothing would be installed; aborting before staging (FR238).${directHint}`
|
|
347
573
|
);
|
|
348
574
|
}
|
|
349
575
|
|
|
@@ -392,17 +618,19 @@ function assertResolutionNonEmptyAndUniqueLeaves(resolvedByPlugin, { source = ''
|
|
|
392
618
|
* @throws {CustomMarketplaceError} on a drifted surface, an unmatched selection, or a failed
|
|
393
619
|
* FR238 pre-flight check (see assertResolutionNonEmptyAndUniqueLeaves).
|
|
394
620
|
*/
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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) {
|
|
401
633
|
const stagePath = path.join(projectRoot, CUSTOM_MARKETPLACE_STAGE_DIR_NAME);
|
|
402
|
-
|
|
403
|
-
// Safety pattern mirrors lib/bmad.js#stagePlugin (Story 22.6/22.8): never
|
|
404
|
-
// follow a pre-existing stage path that is a symlink, and wipe any stale
|
|
405
|
-
// stage from a prior failed run before copying fresh content.
|
|
406
634
|
if (fs.existsSync(stagePath)) {
|
|
407
635
|
const stageLstat = fs.lstatSync(stagePath);
|
|
408
636
|
if (stageLstat.isSymbolicLink()) {
|
|
@@ -418,6 +646,16 @@ async function stageCustomMarketplaceSubset({ source, mode, rootDir, sourceUrl,
|
|
|
418
646
|
}
|
|
419
647
|
}
|
|
420
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);
|
|
421
659
|
|
|
422
660
|
// Copy every resolved file, preserving its path relative to rootDir, so
|
|
423
661
|
// bmad-method's own re-resolution against the stage (a fresh 'local' type
|
|
@@ -459,6 +697,222 @@ async function stageCustomMarketplaceSubset({ source, mode, rootDir, sourceUrl,
|
|
|
459
697
|
return { stagePath, customSourceArg, resolvedModules: allModules };
|
|
460
698
|
}
|
|
461
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
|
+
|
|
462
916
|
// ─── Story 30.7 — persist + re-enumerate/reconcile on update ───────────────
|
|
463
917
|
|
|
464
918
|
/**
|
|
@@ -499,9 +953,21 @@ module.exports = {
|
|
|
499
953
|
// Story 30.6b — prune + stage the selected subset
|
|
500
954
|
CUSTOM_MARKETPLACE_STAGE_DIR_NAME,
|
|
501
955
|
scanDirectModeSkillDirs,
|
|
956
|
+
scanSkillDirs,
|
|
502
957
|
resolveSelectedPlugins,
|
|
503
958
|
assertResolutionNonEmptyAndUniqueLeaves,
|
|
504
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,
|
|
505
971
|
// Story 30.7 — persist + reconcile on update
|
|
506
972
|
reconcileCustomMarketplaceSelection,
|
|
507
973
|
};
|
package/lib/profile.js
CHANGED
|
@@ -286,9 +286,11 @@ function markNoticeShown(projectRoot, noticeId) {
|
|
|
286
286
|
* {
|
|
287
287
|
* source: string, // original user input (local path or Git URL)
|
|
288
288
|
* sourceUrl: string|null, // resolved clone URL (Git sources) or null (local)
|
|
289
|
-
* mode: 'discovery'|'direct',
|
|
289
|
+
* mode: 'discovery'|'direct'|'registry',
|
|
290
290
|
* selectedCodes: string[],
|
|
291
|
-
* sha: string|null, // resolved commit SHA pin (Git-URL sources
|
|
291
|
+
* sha: string|null, // resolved commit SHA pin (Git-URL / registry sources)
|
|
292
|
+
* entries?: Array<{ code: string, approvedSha: string|null, approvedTag: string|null }>,
|
|
293
|
+
* // registry-mode only: per-entry approved_sha/tag pins (AC5)
|
|
292
294
|
* updatedAt: string, // ISO timestamp of the last persist
|
|
293
295
|
* }
|
|
294
296
|
*/
|
|
@@ -342,8 +344,8 @@ function setCustomMarketplaceSelection(projectRoot, entry) {
|
|
|
342
344
|
if (typeof entry.source !== 'string' || !entry.source) {
|
|
343
345
|
throw new Error('setCustomMarketplaceSelection requires a non-empty "source" string');
|
|
344
346
|
}
|
|
345
|
-
if (entry.mode !== 'discovery' && entry.mode !== 'direct') {
|
|
346
|
-
throw new Error(`setCustomMarketplaceSelection requires mode 'discovery' or '
|
|
347
|
+
if (entry.mode !== 'discovery' && entry.mode !== 'direct' && entry.mode !== 'registry') {
|
|
348
|
+
throw new Error(`setCustomMarketplaceSelection requires mode 'discovery', 'direct' or 'registry' (got ${JSON.stringify(entry.mode)})`);
|
|
347
349
|
}
|
|
348
350
|
if (!Array.isArray(entry.selectedCodes)) {
|
|
349
351
|
throw new Error('setCustomMarketplaceSelection requires "selectedCodes" to be an array');
|
|
@@ -362,7 +364,7 @@ function setCustomMarketplaceSelection(projectRoot, entry) {
|
|
|
362
364
|
manifest = bootstrapRootManifest();
|
|
363
365
|
}
|
|
364
366
|
|
|
365
|
-
|
|
367
|
+
const record = {
|
|
366
368
|
source: entry.source,
|
|
367
369
|
sourceUrl: entry.sourceUrl ?? null,
|
|
368
370
|
mode: entry.mode,
|
|
@@ -370,6 +372,23 @@ function setCustomMarketplaceSelection(projectRoot, entry) {
|
|
|
370
372
|
sha: entry.sha ?? null,
|
|
371
373
|
updatedAt: new Date().toISOString(),
|
|
372
374
|
};
|
|
375
|
+
|
|
376
|
+
// Registry-mode (this story): each selected entry pins its own commit via the
|
|
377
|
+
// registry's `approved_sha`. Record them per-entry so a later reconcile/update
|
|
378
|
+
// knows exactly what the registry approved at install time (AC5). Only
|
|
379
|
+
// persisted when the caller supplies a well-formed `entries` array — the
|
|
380
|
+
// discovery/direct path leaves this key absent (back-compat).
|
|
381
|
+
if (Array.isArray(entry.entries)) {
|
|
382
|
+
record.entries = entry.entries
|
|
383
|
+
.filter(e => e && typeof e === 'object' && typeof e.code === 'string' && e.code.length > 0)
|
|
384
|
+
.map(e => ({
|
|
385
|
+
code: e.code,
|
|
386
|
+
approvedSha: e.approvedSha ?? null,
|
|
387
|
+
approvedTag: e.approvedTag ?? null,
|
|
388
|
+
}));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
manifest.customMarketplace = record;
|
|
373
392
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
|
374
393
|
}
|
|
375
394
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ma-agents",
|
|
3
|
-
"version": "3.17.0-beta.
|
|
3
|
+
"version": "3.17.0-beta.1",
|
|
4
4
|
"description": "NPX tool to install skills for AI coding agents (Claude Code, Gemini, Copilot, Kilocode, Cline, Cursor, Roo Code)",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/cli.js",
|
|
11
|
-
"test": "node test/yes-flag.test.js && node test/skill-authoring.test.js && node test/skill-validation.test.js && node test/skill-mandatory.test.js && node test/skill-customize-agent.test.js && node test/create-agent.test.js && node test/generate-project-context.test.js && node test/build-bmad-args.test.js && node test/build-bmad-cache-reader.test.js && node test/bundle-filter-set-equality.test.js && node test/bmad-version-bump.test.js && node test/staging-6-10-0-regression.test.js && node test/uv-transition-resilience.test.js && node test/passthrough-reconciliation-6-9-0.test.js && node test/extension-module-restructure.test.js && node test/convert-agents-to-skills.test.js && node test/migration.test.js && node test/migration-validation.test.js && node test/story-15-5-workflow-skills.test.js && node test/repo-layout.test.js && node test/config-storage.test.js && node test/cross-repo-validation.test.js && node test/config-lost-on-update.test.js && node test/portable-paths.test.js && node test/cicd-remote-mode.test.js && node test/config-layout.test.js && node test/roo-code-agent.test.js && node test/roo-code-injection.test.js && node test/profile.test.js && node test/instruction-block.test.js && node test/instruction-injection.test.js && node test/agents-md.test.js && node test/instruction-block-git.test.js && node test/onprem-injection.test.js && node test/onprem-layer.test.js && node test/copilot-instructions-path.test.js && node test/roomodes.test.js && node test/clinerules.test.js && node test/cline-workflow-reconciliation.test.js && node test/reconfigure.test.js && node test/experimental-warning.test.js && node test/bmad-persona-phase-prefix.test.js && node test/f1a-on-prem-prefix-post-install.test.js && node test/retired-skills-migration.test.js && node test/rename-migration.test.js && node test/uninstall.test.js && node test/offline-recompile.test.js && node test/plugin-manifests.test.js && node test/demerzel-ml-removal-drift.test.js && node test/demerzel-deprecation-notice.test.js && node test/build-plugin.test.js && node test/config-path-migration.test.js && node test/gitignore-plugin-stage.test.js && node test/customizations-translation.test.js && node test/routing-parity.test.js && node test/bmad-extension.test.js && node test/integration-verification.test.js && node test/agent-scope.test.js && node test/obsolete-tool-dirs.test.js && node test/bmad-empty-tools-guard.test.js && node test/v68-pluginresolver-enumeration-passthrough.test.js && node test/agents.test.js && node test/installer.test.js && node test/bmad.test.js && node test/cli.test.js && node test/knowledge-atlas-smoke.test.js && node test/knowledge-atlas-manifest.test.js && node test/knowledge-atlas-render.test.js && node test/knowledge-atlas-graph.test.js && node test/knowledge-atlas-packaging.test.js && node test/knowledge-atlas-offline-safety.test.js && node test/knowledge-atlas-reconcile.test.js && node test/knowledge-atlas-determinism.test.js && node test/knowledge-atlas-degradation.test.js && node test/knowledge-atlas-realproject.test.js && node test/knowledge-atlas-link-integrity.test.js && node test/knowledge-atlas-config-scan.test.js && node test/knowledge-atlas-references.test.js && node test/agent-injection-strategy.test.js && node test/bmad-module-selection.test.js && node test/custom-marketplace-surface-drift.test.js && node test/custom-marketplace-wizard.test.js && node test/custom-marketplace-live-clone.test.js && node test/custom-marketplace-stage-install.test.js && node test/custom-marketplace-persist-reconcile.test.js && node test/demerzel-nondestructive-invariant.test.js && node test/fr243-custom-source-allowlist-nonleak.test.js",
|
|
11
|
+
"test": "node test/yes-flag.test.js && node test/skill-authoring.test.js && node test/skill-validation.test.js && node test/skill-mandatory.test.js && node test/skill-customize-agent.test.js && node test/create-agent.test.js && node test/generate-project-context.test.js && node test/build-bmad-args.test.js && node test/build-bmad-cache-reader.test.js && node test/bundle-filter-set-equality.test.js && node test/bmad-version-bump.test.js && node test/staging-6-10-0-regression.test.js && node test/uv-transition-resilience.test.js && node test/passthrough-reconciliation-6-9-0.test.js && node test/extension-module-restructure.test.js && node test/convert-agents-to-skills.test.js && node test/migration.test.js && node test/migration-validation.test.js && node test/story-15-5-workflow-skills.test.js && node test/repo-layout.test.js && node test/config-storage.test.js && node test/cross-repo-validation.test.js && node test/config-lost-on-update.test.js && node test/portable-paths.test.js && node test/cicd-remote-mode.test.js && node test/config-layout.test.js && node test/roo-code-agent.test.js && node test/roo-code-injection.test.js && node test/profile.test.js && node test/instruction-block.test.js && node test/instruction-injection.test.js && node test/agents-md.test.js && node test/instruction-block-git.test.js && node test/onprem-injection.test.js && node test/onprem-layer.test.js && node test/copilot-instructions-path.test.js && node test/roomodes.test.js && node test/clinerules.test.js && node test/cline-workflow-reconciliation.test.js && node test/reconfigure.test.js && node test/experimental-warning.test.js && node test/bmad-persona-phase-prefix.test.js && node test/f1a-on-prem-prefix-post-install.test.js && node test/retired-skills-migration.test.js && node test/rename-migration.test.js && node test/uninstall.test.js && node test/offline-recompile.test.js && node test/plugin-manifests.test.js && node test/demerzel-ml-removal-drift.test.js && node test/demerzel-deprecation-notice.test.js && node test/build-plugin.test.js && node test/config-path-migration.test.js && node test/gitignore-plugin-stage.test.js && node test/customizations-translation.test.js && node test/routing-parity.test.js && node test/bmad-extension.test.js && node test/integration-verification.test.js && node test/agent-scope.test.js && node test/obsolete-tool-dirs.test.js && node test/bmad-empty-tools-guard.test.js && node test/v68-pluginresolver-enumeration-passthrough.test.js && node test/agents.test.js && node test/installer.test.js && node test/bmad.test.js && node test/cli.test.js && node test/knowledge-atlas-smoke.test.js && node test/knowledge-atlas-manifest.test.js && node test/knowledge-atlas-render.test.js && node test/knowledge-atlas-graph.test.js && node test/knowledge-atlas-packaging.test.js && node test/knowledge-atlas-offline-safety.test.js && node test/knowledge-atlas-reconcile.test.js && node test/knowledge-atlas-determinism.test.js && node test/knowledge-atlas-degradation.test.js && node test/knowledge-atlas-realproject.test.js && node test/knowledge-atlas-link-integrity.test.js && node test/knowledge-atlas-config-scan.test.js && node test/knowledge-atlas-references.test.js && node test/agent-injection-strategy.test.js && node test/bmad-module-selection.test.js && node test/custom-marketplace-surface-drift.test.js && node test/custom-marketplace-wizard.test.js && node test/custom-marketplace-live-clone.test.js && node test/custom-marketplace-stage-install.test.js && node test/custom-marketplace-persist-reconcile.test.js && node test/demerzel-nondestructive-invariant.test.js && node test/fr243-custom-source-allowlist-nonleak.test.js && node test/custom-marketplace-registry.test.js",
|
|
12
12
|
"build:bmad-cache": "node scripts/build-bmad-cache.js",
|
|
13
13
|
"build:plugin": "node scripts/build-plugin.js",
|
|
14
14
|
"build:atlas": "node scripts/build-atlas.js",
|