ma-agents 3.17.0-beta.0 → 3.17.0-beta.2
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 +62 -10
- package/lib/bmad-extension-plugin/.claude-plugin/marketplace.json +1 -1
- package/lib/bmad.js +41 -14
- package/lib/custom-marketplace.js +508 -15
- package/lib/diag.js +86 -0
- package/lib/profile.js +24 -5
- package/package.json +2 -2
package/bin/cli.js
CHANGED
|
@@ -39,7 +39,8 @@ 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
|
+
const { setLogSink, diag, diagError } = require('../lib/diag');
|
|
43
44
|
const { handleCreateSkill, handleValidateSkill, handleSetMandatory, handleCustomizeAgent, handleCreateAgent } = require('../lib/skill-authoring');
|
|
44
45
|
|
|
45
46
|
const PKG = require('../package.json');
|
|
@@ -55,9 +56,21 @@ const VERSION = PKG.version;
|
|
|
55
56
|
*/
|
|
56
57
|
function setupLogging(logFilePath) {
|
|
57
58
|
const logFd = fs.openSync(logFilePath, 'w');
|
|
58
|
-
|
|
59
|
+
// Rich header — the environment facts needed to make sense of a report later:
|
|
60
|
+
// exact version, how it was invoked, runtime, platform, and where it ran.
|
|
61
|
+
const header =
|
|
62
|
+
`[${new Date().toISOString()}] ${NAME} v${VERSION} — log started\n` +
|
|
63
|
+
` argv: ${process.argv.slice(1).join(' ')}\n` +
|
|
64
|
+
` node: ${process.version}\n` +
|
|
65
|
+
` platform: ${process.platform} ${process.arch}\n` +
|
|
66
|
+
` cwd: ${process.cwd()}\n`;
|
|
59
67
|
fs.writeSync(logFd, header);
|
|
60
68
|
|
|
69
|
+
// Route log-file-only diagnostics (lib/diag.js) to this fd. These breadcrumbs
|
|
70
|
+
// (custom-marketplace phases, swallowed error causes, etc.) land ONLY in the
|
|
71
|
+
// log, never on the user's console.
|
|
72
|
+
setLogSink((s) => { try { fs.writeSync(logFd, s); } catch { /* ignore */ } });
|
|
73
|
+
|
|
61
74
|
const origStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
62
75
|
const origStderrWrite = process.stderr.write.bind(process.stderr);
|
|
63
76
|
|
|
@@ -81,6 +94,7 @@ function setupLogging(logFilePath) {
|
|
|
81
94
|
process.stderr.write = origStderrWrite;
|
|
82
95
|
const footer = `\n[${new Date().toISOString()}] log ended\n`;
|
|
83
96
|
try { fs.writeSync(logFd, footer); } catch { /* ignore */ }
|
|
97
|
+
setLogSink(null);
|
|
84
98
|
try { fs.closeSync(logFd); } catch { /* ignore */ }
|
|
85
99
|
delete process.env.MA_AGENTS_LOG_ACTIVE;
|
|
86
100
|
};
|
|
@@ -990,7 +1004,7 @@ function resolveBaseBmadOk({ bmadInstalled, attempted, success }) {
|
|
|
990
1004
|
* @param {Object} ctx
|
|
991
1005
|
* @param {string} [ctx.bmadCustomSourceFlag] - `--bmad-custom-source <ref>` value
|
|
992
1006
|
* @param {boolean} [ctx.yesFlag] - non-interactive run
|
|
993
|
-
* @returns {Promise<null|{ source: string, mode: 'discovery'|'direct', selectedCodes: string[],
|
|
1007
|
+
* @returns {Promise<null|{ source: string, mode: 'discovery'|'direct'|'registry', selectedCodes: string[],
|
|
994
1008
|
* plugins: Array<Object>, rootDir: string, sourceUrl: string|null }>}
|
|
995
1009
|
*/
|
|
996
1010
|
async function promptCustomMarketplace({ bmadCustomSourceFlag = '', yesFlag = false } = {}) {
|
|
@@ -1025,13 +1039,16 @@ async function promptCustomMarketplace({ bmadCustomSourceFlag = '', yesFlag = fa
|
|
|
1025
1039
|
}
|
|
1026
1040
|
|
|
1027
1041
|
let enumerated;
|
|
1042
|
+
diag('custom-source: enumerate begin', { source });
|
|
1028
1043
|
try {
|
|
1029
1044
|
enumerated = await enumerateCustomSource(source);
|
|
1030
1045
|
} catch (err) {
|
|
1031
1046
|
// Fail clean (spike §3 contract) — a short, human-readable message only.
|
|
1032
1047
|
// Never print err.stack here: an unreachable/unauthorized custom source
|
|
1033
1048
|
// must not dump a raw upstream stack, and must never touch the base
|
|
1034
|
-
// install (the rest of installWizard proceeds unaffected).
|
|
1049
|
+
// install (the rest of installWizard proceeds unaffected). The full cause
|
|
1050
|
+
// (and stack) goes to the LOG FILE only, for diagnosis.
|
|
1051
|
+
diagError('custom-source: enumerate failed', err);
|
|
1035
1052
|
const message = err instanceof CustomMarketplaceError ? err.message : `Custom marketplace source error: ${err.message}`;
|
|
1036
1053
|
console.log(chalk.red(`\n ${message}`));
|
|
1037
1054
|
console.log(chalk.gray(' Skipping custom marketplace source; the rest of the install is unaffected.'));
|
|
@@ -1039,6 +1056,11 @@ async function promptCustomMarketplace({ bmadCustomSourceFlag = '', yesFlag = fa
|
|
|
1039
1056
|
}
|
|
1040
1057
|
|
|
1041
1058
|
const { plugins, mode, rootDir, sourceUrl } = enumerated;
|
|
1059
|
+
diag('custom-source: enumerated', {
|
|
1060
|
+
source, mode, rootDir, sourceUrl,
|
|
1061
|
+
pluginCount: (plugins || []).length,
|
|
1062
|
+
codes: (plugins || []).map((p) => p.code),
|
|
1063
|
+
});
|
|
1042
1064
|
if (!plugins || plugins.length === 0) {
|
|
1043
1065
|
console.log(chalk.yellow(` Custom marketplace source "${source}" declared no plugins — nothing to select.`));
|
|
1044
1066
|
return { source, mode, selectedCodes: [], plugins: [], rootDir, sourceUrl };
|
|
@@ -1056,15 +1078,31 @@ async function promptCustomMarketplace({ bmadCustomSourceFlag = '', yesFlag = fa
|
|
|
1056
1078
|
return { source, mode, selectedCodes, plugins, rootDir, sourceUrl };
|
|
1057
1079
|
}
|
|
1058
1080
|
|
|
1081
|
+
// Registry mode (this story): show display_name + trust tier + category and
|
|
1082
|
+
// make the community/unverified trust tier explicit PER ENTRY (AC2, NFR71).
|
|
1083
|
+
// Registry entries are opt-in — nothing is pre-selected (AC1/AC2). Discovery/
|
|
1084
|
+
// direct keep their existing "all pre-selected" behavior unchanged (AC6).
|
|
1085
|
+
const isRegistry = mode === 'registry';
|
|
1059
1086
|
const { chosenCodes } = await prompts({
|
|
1060
1087
|
type: 'multiselect',
|
|
1061
1088
|
name: 'chosenCodes',
|
|
1062
1089
|
message: `Select extensions to install from "${source}":`,
|
|
1063
|
-
choices: plugins.map((p) =>
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1090
|
+
choices: plugins.map((p) => {
|
|
1091
|
+
if (isRegistry) {
|
|
1092
|
+
const label = registryTrustLabel(p);
|
|
1093
|
+
const certified = p.trustTier === 'bmad-certified';
|
|
1094
|
+
const head = chalk.white(p.displayName || p.name || p.code);
|
|
1095
|
+
const tier = (certified ? chalk.green : chalk.yellow)(`[${label}]`);
|
|
1096
|
+
const cat = p.category ? chalk.gray(` {${p.category}${p.subcategory ? `/${p.subcategory}` : ''}}`) : '';
|
|
1097
|
+
const desc = p.description ? chalk.gray(` — ${p.description}`) : '';
|
|
1098
|
+
return { title: `${head} ${tier}${cat}${desc}`, value: p.code, selected: false };
|
|
1099
|
+
}
|
|
1100
|
+
return {
|
|
1101
|
+
title: chalk.white(p.code) + chalk.gray(p.description ? ` — ${p.description}` : ''),
|
|
1102
|
+
value: p.code,
|
|
1103
|
+
selected: true,
|
|
1104
|
+
};
|
|
1105
|
+
}),
|
|
1068
1106
|
instructions: chalk.gray(' Use space to toggle, enter to confirm.'),
|
|
1069
1107
|
});
|
|
1070
1108
|
|
|
@@ -1093,7 +1131,7 @@ async function promptCustomMarketplace({ bmadCustomSourceFlag = '', yesFlag = fa
|
|
|
1093
1131
|
*
|
|
1094
1132
|
* @param {Object} ctx
|
|
1095
1133
|
* @param {string} ctx.projectRoot
|
|
1096
|
-
* @returns {Promise<undefined|null|{ source: string, mode: 'discovery'|'direct',
|
|
1134
|
+
* @returns {Promise<undefined|null|{ source: string, mode: 'discovery'|'direct'|'registry',
|
|
1097
1135
|
* selectedCodes: string[], plugins: Array<Object>, rootDir: string, sourceUrl: string|null }>}
|
|
1098
1136
|
* `undefined` — nothing persisted; caller should fall back to `promptCustomMarketplace()`.
|
|
1099
1137
|
* `null` — persisted source exists but could not be re-enumerated (fail-loud; skip this run).
|
|
@@ -1101,12 +1139,16 @@ async function promptCustomMarketplace({ bmadCustomSourceFlag = '', yesFlag = fa
|
|
|
1101
1139
|
*/
|
|
1102
1140
|
async function reconcileCustomMarketplaceOnUpdate({ projectRoot }) {
|
|
1103
1141
|
const persisted = getCustomMarketplaceSelection(projectRoot);
|
|
1142
|
+
diag('custom-source: reconcile on update', persisted
|
|
1143
|
+
? { source: persisted.source, mode: persisted.mode, selectedCodes: persisted.selectedCodes }
|
|
1144
|
+
: { persisted: false });
|
|
1104
1145
|
if (!persisted) return undefined;
|
|
1105
1146
|
|
|
1106
1147
|
let enumerated;
|
|
1107
1148
|
try {
|
|
1108
1149
|
enumerated = await enumerateCustomSource(persisted.source);
|
|
1109
1150
|
} catch (err) {
|
|
1151
|
+
diagError('custom-source: reconcile re-enumerate failed', err);
|
|
1110
1152
|
const message = err instanceof CustomMarketplaceError ? err.message : `Custom marketplace source error: ${err.message}`;
|
|
1111
1153
|
console.log(chalk.red(`\n Custom marketplace update FAILED: ${message}`));
|
|
1112
1154
|
console.log(
|
|
@@ -1531,6 +1573,12 @@ async function installWizard(preselectedSkill, preselectedAgents, customPath, fo
|
|
|
1531
1573
|
}
|
|
1532
1574
|
}
|
|
1533
1575
|
if (customMarketplaceSelection && customMarketplaceSelection.selectedCodes.length > 0) {
|
|
1576
|
+
diag('custom-source: install begin', {
|
|
1577
|
+
mode: customMarketplaceSelection.mode,
|
|
1578
|
+
source: customMarketplaceSelection.source,
|
|
1579
|
+
selectedCodes: customMarketplaceSelection.selectedCodes,
|
|
1580
|
+
baseBmadOk,
|
|
1581
|
+
});
|
|
1534
1582
|
if (!baseBmadOk) {
|
|
1535
1583
|
// AC 6 (30.6b) — never layer --custom-source onto a project with no
|
|
1536
1584
|
// (or a broken) base BMAD install. The rest of the install is
|
|
@@ -1549,10 +1597,14 @@ async function installWizard(preselectedSkill, preselectedAgents, customPath, fo
|
|
|
1549
1597
|
tools: bmadTools,
|
|
1550
1598
|
selection: customMarketplaceSelection,
|
|
1551
1599
|
});
|
|
1600
|
+
diag('custom-source: install result', { success: !!success });
|
|
1552
1601
|
if (success) {
|
|
1553
1602
|
console.log(chalk.green(' Custom marketplace subset installed successfully!'));
|
|
1554
1603
|
}
|
|
1555
1604
|
} catch (err) {
|
|
1605
|
+
// User sees the one-liner; the full cause/stack (which entry, the
|
|
1606
|
+
// git/resolve error, the FR238 zero-module detail) goes to the log.
|
|
1607
|
+
diagError('custom-source: install failed', err);
|
|
1556
1608
|
console.log(chalk.red(`\n Custom marketplace install failed: ${err.message}`));
|
|
1557
1609
|
}
|
|
1558
1610
|
}
|
|
@@ -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.2",
|
|
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,34 @@
|
|
|
39
39
|
|
|
40
40
|
const path = require('node:path');
|
|
41
41
|
const fs = require('fs-extra');
|
|
42
|
+
const yaml = require('js-yaml');
|
|
43
|
+
const { diag } = require('./diag');
|
|
42
44
|
|
|
43
45
|
/** Deep, pinned-internal require path (NFR17). Single call site. */
|
|
44
46
|
const CUSTOM_MODULE_MANAGER_PATH = 'bmad-method/tools/installer/modules/custom-module-manager.js';
|
|
45
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Registry-index mode (this story) — the BMAD registry layout used by the
|
|
50
|
+
* official plugin registry (`bmad-code-org/bmad-plugins-marketplace`) and any
|
|
51
|
+
* source that follows the same convention. A `registry/` directory holding
|
|
52
|
+
* `official.yaml` and/or `community-index.yaml` (each a top-level `modules:`
|
|
53
|
+
* list) is a THIRD source shape, distinct from `discovery`
|
|
54
|
+
* (`.claude-plugin/marketplace.json`) and `direct` (whole repo = one module).
|
|
55
|
+
* Detection precedence is: discovery > registry > direct (see
|
|
56
|
+
* `enumerateCustomSource`).
|
|
57
|
+
*/
|
|
58
|
+
const REGISTRY_DIR_NAME = 'registry';
|
|
59
|
+
const REGISTRY_OFFICIAL_INDEX = 'official.yaml';
|
|
60
|
+
const REGISTRY_COMMUNITY_INDEX = 'community-index.yaml';
|
|
61
|
+
const REGISTRY_SCHEMA_FILE = 'registry-schema.yaml';
|
|
62
|
+
|
|
63
|
+
/** Convert an OS-native relative path to a POSIX ('/'-separated) one for the
|
|
64
|
+
* staged marketplace.json (bmad-method's parseSource/skills detection is
|
|
65
|
+
* POSIX-oriented, and JSON marketplaces are always written POSIX-style). */
|
|
66
|
+
function toPosix(relPath) {
|
|
67
|
+
return relPath.split(path.sep).join('/');
|
|
68
|
+
}
|
|
69
|
+
|
|
46
70
|
/**
|
|
47
71
|
* The `CustomModuleManager` methods the custom-marketplace feature depends on.
|
|
48
72
|
* Any drift in this set (renamed/removed on a bmad-method pin bump) must fail
|
|
@@ -63,6 +87,23 @@ const NFR71_WARNING =
|
|
|
63
87
|
'Custom marketplace content is outside ma-agents\' sealed-bundle guarantee — ' +
|
|
64
88
|
'it is installed as-is from the source you provided and is not vetted by ma-agents.';
|
|
65
89
|
|
|
90
|
+
/**
|
|
91
|
+
* NFR71 (registry mode) — a per-entry trust annotation the wizard appends to
|
|
92
|
+
* each registry choice so the community/unverified trust tier is explicit at
|
|
93
|
+
* the point of selection (not just in the one-time source-level warning). A
|
|
94
|
+
* `bmad-certified` (official) entry is labelled as such; anything else is made
|
|
95
|
+
* explicitly "unverified/community — installed as-is, not vetted".
|
|
96
|
+
*
|
|
97
|
+
* @param {{ trustTier?: string, type?: string }} plugin - a normalized registry entry
|
|
98
|
+
* @returns {string} human-readable trust label
|
|
99
|
+
*/
|
|
100
|
+
function registryTrustLabel(plugin) {
|
|
101
|
+
if (plugin && plugin.trustTier === 'bmad-certified') {
|
|
102
|
+
return 'bmad-certified';
|
|
103
|
+
}
|
|
104
|
+
return 'unverified/community — installed as-is, not vetted by ma-agents';
|
|
105
|
+
}
|
|
106
|
+
|
|
66
107
|
/**
|
|
67
108
|
* Error type raised by this adapter for any enumeration failure (unreachable
|
|
68
109
|
* source, invalid input, drifted surface). Callers should catch this and
|
|
@@ -150,6 +191,150 @@ function assertCustomModuleManagerSurface() {
|
|
|
150
191
|
return { version, mgr };
|
|
151
192
|
}
|
|
152
193
|
|
|
194
|
+
// ─── Registry-index mode (this story) ──────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Detect the BMAD registry-index layout inside an already-resolved source root.
|
|
198
|
+
* A source is treated as a registry when its `registry/` directory holds an
|
|
199
|
+
* `official.yaml` and/or a `community-index.yaml` module index (or, failing
|
|
200
|
+
* those, a `registry-schema.yaml`, which still marks the source as a registry
|
|
201
|
+
* even if it currently declares no modules). Called ONLY after discovery
|
|
202
|
+
* (`.claude-plugin/marketplace.json`) has been ruled out, so discovery always
|
|
203
|
+
* wins (AC1 precedence).
|
|
204
|
+
*
|
|
205
|
+
* @param {string} rootDir - Absolute resolved source root.
|
|
206
|
+
* @returns {Promise<{ isRegistry: boolean, officialPath: string|null, communityPath: string|null }>}
|
|
207
|
+
*/
|
|
208
|
+
async function detectRegistryLayout(rootDir) {
|
|
209
|
+
const registryDir = path.join(rootDir, REGISTRY_DIR_NAME);
|
|
210
|
+
if (!(await fs.pathExists(registryDir))) {
|
|
211
|
+
return { isRegistry: false, officialPath: null, communityPath: null };
|
|
212
|
+
}
|
|
213
|
+
const officialAbs = path.join(registryDir, REGISTRY_OFFICIAL_INDEX);
|
|
214
|
+
const communityAbs = path.join(registryDir, REGISTRY_COMMUNITY_INDEX);
|
|
215
|
+
const schemaAbs = path.join(registryDir, REGISTRY_SCHEMA_FILE);
|
|
216
|
+
const [hasOfficial, hasCommunity, hasSchema] = await Promise.all([
|
|
217
|
+
fs.pathExists(officialAbs),
|
|
218
|
+
fs.pathExists(communityAbs),
|
|
219
|
+
fs.pathExists(schemaAbs),
|
|
220
|
+
]);
|
|
221
|
+
if (!hasOfficial && !hasCommunity && !hasSchema) {
|
|
222
|
+
return { isRegistry: false, officialPath: null, communityPath: null };
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
isRegistry: true,
|
|
226
|
+
officialPath: hasOfficial ? officialAbs : null,
|
|
227
|
+
communityPath: hasCommunity ? communityAbs : null,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Normalize one raw registry-index entry (conforming to
|
|
233
|
+
* `registry/registry-schema.yaml`) into the SAME plugin-choice object shape the
|
|
234
|
+
* wizard's multiselect (30.6a/6b) already consumes, plus the registry-only
|
|
235
|
+
* pointer fields the two-level install needs (`repository`, `moduleDefinition`,
|
|
236
|
+
* `approvedSha`, `approvedTag`, `version`, `trustTier`).
|
|
237
|
+
*
|
|
238
|
+
* @param {Object} entry - raw YAML module entry
|
|
239
|
+
* @param {{ official: boolean }} ctx - which index the entry came from
|
|
240
|
+
* @returns {Object} normalized plugin-choice object
|
|
241
|
+
*/
|
|
242
|
+
function normalizeRegistryEntry(entry, { official }) {
|
|
243
|
+
const trustTier = entry.trust_tier || (official ? 'bmad-certified' : 'unverified');
|
|
244
|
+
const type = entry.type || (official ? 'bmad-org' : 'community');
|
|
245
|
+
return {
|
|
246
|
+
code: entry.code || entry.name,
|
|
247
|
+
name: entry.name,
|
|
248
|
+
displayName: entry.display_name || entry.name,
|
|
249
|
+
description: entry.description || '',
|
|
250
|
+
type,
|
|
251
|
+
trustTier,
|
|
252
|
+
category: entry.category || null,
|
|
253
|
+
subcategory: entry.subcategory || null,
|
|
254
|
+
// Registry-only pointer fields (two-level install).
|
|
255
|
+
repository: entry.repository || null,
|
|
256
|
+
moduleDefinition: entry.module_definition || null,
|
|
257
|
+
approvedSha: entry.approved_sha || null,
|
|
258
|
+
approvedTag: entry.approved_tag || null,
|
|
259
|
+
version: entry.version || null,
|
|
260
|
+
builtIn: entry.built_in === true,
|
|
261
|
+
isExternal: true,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Parse one registry index file (`official.yaml` / `community-index.yaml`) into
|
|
267
|
+
* a normalized entry list. A missing/empty `modules:` key yields `[]`; a YAML
|
|
268
|
+
* syntax error or a non-list `modules:` throws a clean `CustomMarketplaceError`
|
|
269
|
+
* (AC4 malformed-index fail-clean). Individual entries that lack a usable
|
|
270
|
+
* `name` or `repository` pointer are SKIPPED (they are not installable and must
|
|
271
|
+
* not sink an otherwise-valid registry).
|
|
272
|
+
*
|
|
273
|
+
* @param {string} indexPath - absolute path to the index file
|
|
274
|
+
* @param {{ official: boolean }} ctx
|
|
275
|
+
* @returns {Promise<Array<Object>>} normalized entries
|
|
276
|
+
* @throws {CustomMarketplaceError}
|
|
277
|
+
*/
|
|
278
|
+
async function parseRegistryIndex(indexPath, { official }) {
|
|
279
|
+
const label = path.basename(indexPath);
|
|
280
|
+
let raw;
|
|
281
|
+
try {
|
|
282
|
+
raw = await fs.readFile(indexPath, 'utf8');
|
|
283
|
+
} catch (err) {
|
|
284
|
+
throw new CustomMarketplaceError(`Could not read registry index "${label}": ${err.message}`, { cause: err });
|
|
285
|
+
}
|
|
286
|
+
let doc;
|
|
287
|
+
try {
|
|
288
|
+
doc = yaml.load(raw);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
throw new CustomMarketplaceError(
|
|
291
|
+
`Registry index "${label}" is not valid YAML: ${err.message}`,
|
|
292
|
+
{ cause: err }
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (doc === null || doc === undefined) return [];
|
|
296
|
+
if (typeof doc !== 'object' || Array.isArray(doc)) {
|
|
297
|
+
throw new CustomMarketplaceError(`Registry index "${label}" is not a mapping with a "modules" list.`);
|
|
298
|
+
}
|
|
299
|
+
const modules = doc.modules;
|
|
300
|
+
if (modules === undefined || modules === null) return [];
|
|
301
|
+
if (!Array.isArray(modules)) {
|
|
302
|
+
throw new CustomMarketplaceError(`Registry index "${label}" has a "modules" key that is not a list.`);
|
|
303
|
+
}
|
|
304
|
+
const out = [];
|
|
305
|
+
for (const entry of modules) {
|
|
306
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
307
|
+
if (typeof entry.name !== 'string' || !entry.name.trim()) continue; // not addressable — skip
|
|
308
|
+
if (typeof entry.repository !== 'string' || !entry.repository.trim()) continue; // no pointer — not installable
|
|
309
|
+
out.push(normalizeRegistryEntry(entry, { official }));
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Build the `registry`-mode enumeration result: parse the official + community
|
|
316
|
+
* indexes, merge, and de-dupe by `name` (official — bmad-certified — wins over
|
|
317
|
+
* a same-named community entry).
|
|
318
|
+
*
|
|
319
|
+
* @param {string} rootDir
|
|
320
|
+
* @param {string|null} sourceUrl
|
|
321
|
+
* @param {{ officialPath: string|null, communityPath: string|null }} layout
|
|
322
|
+
* @returns {Promise<{ mode: 'registry', plugins: Array<Object>, rootDir: string, sourceUrl: string|null }>}
|
|
323
|
+
*/
|
|
324
|
+
async function enumerateRegistrySource(rootDir, sourceUrl, layout) {
|
|
325
|
+
const official = layout.officialPath ? await parseRegistryIndex(layout.officialPath, { official: true }) : [];
|
|
326
|
+
const community = layout.communityPath ? await parseRegistryIndex(layout.communityPath, { official: false }) : [];
|
|
327
|
+
|
|
328
|
+
// Official entries are appended first so they win the de-dupe on a name
|
|
329
|
+
// collision (a bmad-certified module must never be shadowed by an unverified
|
|
330
|
+
// community entry of the same name).
|
|
331
|
+
const byName = new Map();
|
|
332
|
+
for (const p of [...official, ...community]) {
|
|
333
|
+
if (!byName.has(p.name)) byName.set(p.name, p);
|
|
334
|
+
}
|
|
335
|
+
return { mode: 'registry', plugins: [...byName.values()], rootDir, sourceUrl };
|
|
336
|
+
}
|
|
337
|
+
|
|
153
338
|
/**
|
|
154
339
|
* Enumerate the plugins declared by a custom marketplace source.
|
|
155
340
|
*
|
|
@@ -204,6 +389,31 @@ async function enumerateCustomSource(source) {
|
|
|
204
389
|
return { mode: 'discovery', plugins, rootDir: resolved.rootDir, sourceUrl: resolved.sourceUrl };
|
|
205
390
|
}
|
|
206
391
|
|
|
392
|
+
// AC1 precedence: discovery (above) wins; else a `registry/` layout →
|
|
393
|
+
// `registry` mode; else `direct` (below). The registry index is a
|
|
394
|
+
// higher-level pointer format (each entry names a separate module repo),
|
|
395
|
+
// so it is enumerated distinctly from a single-repo direct source.
|
|
396
|
+
let registryLayout;
|
|
397
|
+
try {
|
|
398
|
+
registryLayout = await detectRegistryLayout(resolved.rootDir);
|
|
399
|
+
} catch (err) {
|
|
400
|
+
throw new CustomMarketplaceError(
|
|
401
|
+
`Could not inspect "${source}" for a registry layout: ${err.message}`,
|
|
402
|
+
{ cause: err }
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
if (registryLayout.isRegistry) {
|
|
406
|
+
try {
|
|
407
|
+
return await enumerateRegistrySource(resolved.rootDir, resolved.sourceUrl, registryLayout);
|
|
408
|
+
} catch (err) {
|
|
409
|
+
if (err instanceof CustomMarketplaceError) throw err;
|
|
410
|
+
throw new CustomMarketplaceError(
|
|
411
|
+
`Could not read the registry index from "${source}": ${err.message}`,
|
|
412
|
+
{ cause: err }
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
207
417
|
// FR240 — "direct" mode (no marketplace.json at the source root): treat the
|
|
208
418
|
// whole source as a single module so the wizard's multiselect still has
|
|
209
419
|
// exactly one, well-described choice. 30.6b decides how a direct-mode
|
|
@@ -244,17 +454,17 @@ const CUSTOM_MARKETPLACE_STAGE_DIR_NAME = '.ma-agents-custom-marketplace-stage';
|
|
|
244
454
|
* @param {string} rootDir - Absolute path to the direct-mode source root.
|
|
245
455
|
* @returns {Promise<string[]>} Directory names (not paths) that contain a SKILL.md.
|
|
246
456
|
*/
|
|
247
|
-
async function
|
|
457
|
+
async function scanSkillDirs(baseDir) {
|
|
248
458
|
let entries;
|
|
249
459
|
try {
|
|
250
|
-
entries = await fs.readdir(
|
|
460
|
+
entries = await fs.readdir(baseDir, { withFileTypes: true });
|
|
251
461
|
} catch {
|
|
252
462
|
return [];
|
|
253
463
|
}
|
|
254
464
|
const skillDirs = [];
|
|
255
465
|
for (const entry of entries) {
|
|
256
466
|
if (!entry.isDirectory()) continue;
|
|
257
|
-
const skillMd = path.join(
|
|
467
|
+
const skillMd = path.join(baseDir, entry.name, 'SKILL.md');
|
|
258
468
|
if (await fs.pathExists(skillMd)) {
|
|
259
469
|
skillDirs.push(entry.name);
|
|
260
470
|
}
|
|
@@ -262,6 +472,12 @@ async function scanDirectModeSkillDirs(rootDir) {
|
|
|
262
472
|
return skillDirs;
|
|
263
473
|
}
|
|
264
474
|
|
|
475
|
+
/** Story 30.6b direct-mode scan (thin alias of {@link scanSkillDirs} at the
|
|
476
|
+
* source root) — kept as a named export for the 30.6b surface. */
|
|
477
|
+
async function scanDirectModeSkillDirs(rootDir) {
|
|
478
|
+
return scanSkillDirs(rootDir);
|
|
479
|
+
}
|
|
480
|
+
|
|
265
481
|
/**
|
|
266
482
|
* Resolve every selected plugin's installable skill/module layout via
|
|
267
483
|
* bmad-method's own `CustomModuleManager#resolvePlugin` (the pinned-internal
|
|
@@ -336,14 +552,38 @@ async function resolveSelectedPlugins({ mode, rootDir, sourceUrl, plugins, selec
|
|
|
336
552
|
* @returns {Array<Object>} The flattened list of resolved modules (all plugins).
|
|
337
553
|
* @throws {CustomMarketplaceError} on any violation above.
|
|
338
554
|
*/
|
|
339
|
-
function assertResolutionNonEmptyAndUniqueLeaves(resolvedByPlugin, { source = '' } = {}) {
|
|
555
|
+
function assertResolutionNonEmptyAndUniqueLeaves(resolvedByPlugin, { source = '', mode = null } = {}) {
|
|
340
556
|
const allModules = resolvedByPlugin.flatMap((r) => r.resolved || []);
|
|
341
557
|
|
|
558
|
+
// Per-plugin resolution detail → log file only. When the guard below trips,
|
|
559
|
+
// this line pinpoints WHICH entry resolved to nothing (and how many
|
|
560
|
+
// skills each resolved module got) — the crux of a "zero modules" report.
|
|
561
|
+
diag('custom-source: resolution', {
|
|
562
|
+
source, mode,
|
|
563
|
+
perPlugin: resolvedByPlugin.map((r) => ({
|
|
564
|
+
plugin: (r.plugin && (r.plugin.code || r.plugin.name)) || (r.rawPlugin && r.rawPlugin.name) || '?',
|
|
565
|
+
modules: (r.resolved || []).length,
|
|
566
|
+
skills: (r.resolved || []).reduce((n, m) => n + ((m.skillPaths && m.skillPaths.length) || 0), 0),
|
|
567
|
+
})),
|
|
568
|
+
totalModules: allModules.length,
|
|
569
|
+
});
|
|
570
|
+
|
|
342
571
|
if (allModules.length === 0) {
|
|
572
|
+
// AC7 — a direct-mode source that resolves to nothing is the exact
|
|
573
|
+
// failure a mis-pointed registry URL used to produce. Name the likely
|
|
574
|
+
// cause and note that registry indexes are now supported (so re-running
|
|
575
|
+
// will auto-detect them), instead of a bare "zero modules" abort.
|
|
576
|
+
const directHint =
|
|
577
|
+
mode === 'direct'
|
|
578
|
+
? ' The source has no `.claude-plugin/marketplace.json` and no `module.yaml`/skills ' +
|
|
579
|
+
'directories at its root. If it is a BMAD registry index (a `registry/` directory ' +
|
|
580
|
+
'with `official.yaml`/`community-index.yaml`), that format is now supported — re-run ' +
|
|
581
|
+
'and it will be detected as a `registry` source.'
|
|
582
|
+
: '';
|
|
343
583
|
throw new CustomMarketplaceError(
|
|
344
584
|
`Custom marketplace selection from "${source}" resolved to zero installable modules ` +
|
|
345
585
|
`— bmad-method's PluginResolver could not locate module.yaml/skills for any selected ` +
|
|
346
|
-
`plugin. Nothing would be installed; aborting before staging (FR238)
|
|
586
|
+
`plugin. Nothing would be installed; aborting before staging (FR238).${directHint}`
|
|
347
587
|
);
|
|
348
588
|
}
|
|
349
589
|
|
|
@@ -392,17 +632,19 @@ function assertResolutionNonEmptyAndUniqueLeaves(resolvedByPlugin, { source = ''
|
|
|
392
632
|
* @throws {CustomMarketplaceError} on a drifted surface, an unmatched selection, or a failed
|
|
393
633
|
* FR238 pre-flight check (see assertResolutionNonEmptyAndUniqueLeaves).
|
|
394
634
|
*/
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
635
|
+
/**
|
|
636
|
+
* Prepare a clean, empty stage directory under `projectRoot`. Safety pattern
|
|
637
|
+
* mirrors lib/bmad.js#stagePlugin (Story 22.6/22.8): never follow a pre-existing
|
|
638
|
+
* stage path that is a symlink, and wipe any stale stage from a prior failed run
|
|
639
|
+
* before copying fresh content. Shared by both the discovery/direct
|
|
640
|
+
* (`stageCustomMarketplaceSubset`) and registry (`stageRegistrySubset`) paths.
|
|
641
|
+
*
|
|
642
|
+
* @param {string} projectRoot - already-resolved absolute project root
|
|
643
|
+
* @returns {string} absolute stage path (created empty)
|
|
644
|
+
* @throws {CustomMarketplaceError}
|
|
645
|
+
*/
|
|
646
|
+
function prepareCleanStageDir(projectRoot) {
|
|
401
647
|
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
648
|
if (fs.existsSync(stagePath)) {
|
|
407
649
|
const stageLstat = fs.lstatSync(stagePath);
|
|
408
650
|
if (stageLstat.isSymbolicLink()) {
|
|
@@ -418,6 +660,16 @@ async function stageCustomMarketplaceSubset({ source, mode, rootDir, sourceUrl,
|
|
|
418
660
|
}
|
|
419
661
|
}
|
|
420
662
|
fs.mkdirpSync(stagePath);
|
|
663
|
+
return stagePath;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function stageCustomMarketplaceSubset({ source, mode, rootDir, sourceUrl, plugins, selectedCodes, projectRoot }) {
|
|
667
|
+
projectRoot = path.resolve(projectRoot);
|
|
668
|
+
|
|
669
|
+
const resolvedByPlugin = await resolveSelectedPlugins({ mode, rootDir, sourceUrl, plugins, selectedCodes });
|
|
670
|
+
const allModules = assertResolutionNonEmptyAndUniqueLeaves(resolvedByPlugin, { source, mode });
|
|
671
|
+
|
|
672
|
+
const stagePath = prepareCleanStageDir(projectRoot);
|
|
421
673
|
|
|
422
674
|
// Copy every resolved file, preserving its path relative to rootDir, so
|
|
423
675
|
// bmad-method's own re-resolution against the stage (a fresh 'local' type
|
|
@@ -459,6 +711,235 @@ async function stageCustomMarketplaceSubset({ source, mode, rootDir, sourceUrl,
|
|
|
459
711
|
return { stagePath, customSourceArg, resolvedModules: allModules };
|
|
460
712
|
}
|
|
461
713
|
|
|
714
|
+
// ─── Registry-index mode — two-level stage + install ───────────────────────
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Resolve one selected registry entry into a staged rawPlugin + resolved
|
|
718
|
+
* modules. Two-level: clone the entry's `repository` (via bmad-method's own
|
|
719
|
+
* `resolveSource`/`cloneRepo` — ambient git creds, GIT_TERMINAL_PROMPT=0, no
|
|
720
|
+
* token handling — FR241), locate the module at `module_definition` (fallback:
|
|
721
|
+
* the repo root, i.e. PluginResolver over the whole clone), scan for skill
|
|
722
|
+
* directories, and resolve via `resolvePlugin`.
|
|
723
|
+
*
|
|
724
|
+
* A community entry is cloned at its `approved_tag` (the tag the registry
|
|
725
|
+
* publishes for the approved release — it points at `approved_sha`). We pin to
|
|
726
|
+
* the TAG rather than the raw SHA because bmad-method's `cloneRepo()` drives
|
|
727
|
+
* `git clone --branch <ref>` / `git fetch origin <ref>`, which accept a tag or
|
|
728
|
+
* branch but NOT a bare commit SHA. The `approved_sha` is still recorded on the
|
|
729
|
+
* persisted selection (see lib/bmad.js#installCustomMarketplaceSubset) as the
|
|
730
|
+
* immutable record of what the registry approved.
|
|
731
|
+
*
|
|
732
|
+
* @param {Object} plugin - a normalized registry entry (from enumerateCustomSource)
|
|
733
|
+
* @param {(input: string, opts: Object) => Promise<Object>} resolveSourceFn
|
|
734
|
+
* @param {(repoPath: string, rawPlugin: Object, url: string|null, local: string|null) => Promise<Array>} resolvePluginFn
|
|
735
|
+
* @returns {Promise<{ plugin: Object, rawPlugin: Object, resolved: Array<Object>, entryRootDir: string }>}
|
|
736
|
+
* @throws {CustomMarketplaceError}
|
|
737
|
+
*/
|
|
738
|
+
async function resolveRegistryEntry(plugin, resolveSourceFn, resolvePluginFn) {
|
|
739
|
+
if (!plugin.repository) {
|
|
740
|
+
throw new CustomMarketplaceError(
|
|
741
|
+
`Registry entry '${plugin.code}' has no repository pointer — cannot install it.`
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Pin ref (tag/branch) via the `@<ref>` SOURCE-STRING SUFFIX that
|
|
746
|
+
// bmad-method's resolveSource() parses (custom-module-manager.js:
|
|
747
|
+
// "Extract optional @<tag-or-branch> suffix"). resolveSource has NO
|
|
748
|
+
// pinOverride/option for this — the ref MUST ride on the input string. A raw
|
|
749
|
+
// commit SHA is unsupported (`git clone --branch` can't take one), so we pin
|
|
750
|
+
// to `approved_tag` (the registry publishes it pointing at `approved_sha`);
|
|
751
|
+
// `approved_sha` is still recorded verbatim on the persisted selection.
|
|
752
|
+
const pinRef = plugin.approvedTag || null;
|
|
753
|
+
// The ref must match resolveSource's own ref rule (/^[\w.\-+/]+$/, no ':')
|
|
754
|
+
// or we omit it and clone the default branch rather than build a malformed
|
|
755
|
+
// source string.
|
|
756
|
+
const safePinRef = pinRef && /^[\w.\-+/]+$/.test(pinRef) && !pinRef.includes(':') ? pinRef : null;
|
|
757
|
+
const cloneInput = safePinRef ? `${plugin.repository}@${safePinRef}` : plugin.repository;
|
|
758
|
+
diag('custom-source: registry entry clone', {
|
|
759
|
+
code: plugin.code,
|
|
760
|
+
repository: plugin.repository,
|
|
761
|
+
pinRef: safePinRef,
|
|
762
|
+
approvedSha: plugin.approvedSha || null,
|
|
763
|
+
moduleDefinition: plugin.moduleDefinition || '(none — repo root)',
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
let resolvedSource;
|
|
767
|
+
try {
|
|
768
|
+
resolvedSource = await resolveSourceFn(cloneInput, {
|
|
769
|
+
skipInstall: true,
|
|
770
|
+
silent: true,
|
|
771
|
+
});
|
|
772
|
+
} catch (err) {
|
|
773
|
+
throw new CustomMarketplaceError(
|
|
774
|
+
`Could not clone registry entry '${plugin.code}' from "${plugin.repository}"` +
|
|
775
|
+
`${safePinRef ? ` @ ${safePinRef}` : ''}: ${err.message}`,
|
|
776
|
+
{ cause: err }
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const entryRootDir = resolvedSource.rootDir;
|
|
781
|
+
const entrySourceUrl = resolvedSource.sourceUrl || null;
|
|
782
|
+
|
|
783
|
+
// Locate the module within the clone. `module_definition` is a POSIX,
|
|
784
|
+
// repo-relative path to the module's module.yaml; its directory is the
|
|
785
|
+
// module root. Absent → fall back to the repo root (PluginResolver over the
|
|
786
|
+
// whole clone), matching the AC3 fallback.
|
|
787
|
+
let relModuleDirPosix = '.';
|
|
788
|
+
if (plugin.moduleDefinition) {
|
|
789
|
+
const defPosix = toPosix(plugin.moduleDefinition).replace(/^\.\//, '');
|
|
790
|
+
const moduleYamlAbs = path.join(entryRootDir, ...defPosix.split('/'));
|
|
791
|
+
if (!(await fs.pathExists(moduleYamlAbs))) {
|
|
792
|
+
throw new CustomMarketplaceError(
|
|
793
|
+
`Registry entry '${plugin.code}': module_definition "${plugin.moduleDefinition}" ` +
|
|
794
|
+
`was not found inside ${plugin.repository}. The registry pointer is stale or the ` +
|
|
795
|
+
`repository layout changed; aborting before staging (FR238).`
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
relModuleDirPosix = path.posix.dirname(defPosix);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const moduleBaseAbs =
|
|
802
|
+
relModuleDirPosix === '.' ? entryRootDir : path.join(entryRootDir, ...relModuleDirPosix.split('/'));
|
|
803
|
+
const skillNames = await scanSkillDirs(moduleBaseAbs);
|
|
804
|
+
const skillRelPaths = skillNames.map((n) => (relModuleDirPosix === '.' ? n : `${relModuleDirPosix}/${n}`));
|
|
805
|
+
diag('custom-source: registry entry resolved', {
|
|
806
|
+
code: plugin.code,
|
|
807
|
+
moduleDir: relModuleDirPosix,
|
|
808
|
+
skillDirsFound: skillNames.length,
|
|
809
|
+
skills: skillNames,
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
const rawPlugin = {
|
|
813
|
+
name: plugin.code,
|
|
814
|
+
description: plugin.description || '',
|
|
815
|
+
version: plugin.version || null,
|
|
816
|
+
source: relModuleDirPosix,
|
|
817
|
+
// Empty when the module dir has no SKILL.md subdirs — resolvePlugin then
|
|
818
|
+
// returns [] and the FR238 guard aborts cleanly (zero installable modules).
|
|
819
|
+
skills: skillRelPaths,
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
const localPath = entrySourceUrl ? null : entryRootDir;
|
|
823
|
+
const resolved = await resolvePluginFn(entryRootDir, rawPlugin, entrySourceUrl, localPath);
|
|
824
|
+
|
|
825
|
+
return { plugin, rawPlugin, resolved, entryRootDir };
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Registry-mode counterpart of {@link stageCustomMarketplaceSubset}. For each
|
|
830
|
+
* selected registry entry it clones the entry's `repository`, resolves the
|
|
831
|
+
* pointed module, and stages the resolved content into the SAME project-local
|
|
832
|
+
* stage directory the discovery/direct path uses — but namespaced under the
|
|
833
|
+
* entry's `code/` so modules pulled from DIFFERENT repositories can never
|
|
834
|
+
* collide on a shared relative path. Enforces the same FR238 non-empty +
|
|
835
|
+
* unique-leaf guard, then returns the same `{ stagePath, customSourceArg,
|
|
836
|
+
* resolvedModules }` shape so lib/bmad.js#installCustomMarketplaceSubset installs
|
|
837
|
+
* it identically via `buildBmadArgs` + `--custom-source`.
|
|
838
|
+
*
|
|
839
|
+
* @param {Object} ctx
|
|
840
|
+
* @param {string} ctx.source - Original registry source string (messages only).
|
|
841
|
+
* @param {Array<Object>} ctx.plugins - Full enumerated registry entry list.
|
|
842
|
+
* @param {string[]} ctx.selectedCodes
|
|
843
|
+
* @param {string} ctx.projectRoot
|
|
844
|
+
* @param {Object} [ctx.deps] - Injectable seams (tests): { resolveSource, resolvePlugin }.
|
|
845
|
+
* @returns {Promise<{ stagePath: string, customSourceArg: string, resolvedModules: Array<Object> }>}
|
|
846
|
+
* @throws {CustomMarketplaceError}
|
|
847
|
+
*/
|
|
848
|
+
async function stageRegistrySubset({ source, plugins, selectedCodes, projectRoot, deps = {} }) {
|
|
849
|
+
projectRoot = path.resolve(projectRoot);
|
|
850
|
+
const { mgr } = assertCustomModuleManagerSurface();
|
|
851
|
+
const resolveSourceFn = deps.resolveSource || ((input, opts) => mgr.resolveSource(input, opts));
|
|
852
|
+
const resolvePluginFn =
|
|
853
|
+
deps.resolvePlugin || ((repoPath, rawPlugin, url, local) => mgr.resolvePlugin(repoPath, rawPlugin, url, local));
|
|
854
|
+
|
|
855
|
+
const selectedSet = new Set(selectedCodes);
|
|
856
|
+
const selected = (plugins || []).filter((p) => selectedSet.has(p.code));
|
|
857
|
+
if (selected.length === 0) {
|
|
858
|
+
throw new CustomMarketplaceError(
|
|
859
|
+
`None of the selected codes (${[...selectedSet].join(', ') || '(none)'}) matched a ` +
|
|
860
|
+
`registry entry — nothing to stage.`
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Each entry is staged under its own `code/` namespace, so two selected
|
|
865
|
+
// entries sharing a code would silently overwrite each other. enumerate
|
|
866
|
+
// de-dupes by `name`, but a malformed registry could give two differently-
|
|
867
|
+
// named entries the same `code` — reject that here rather than stage a
|
|
868
|
+
// corrupt subset (FR238 fail-clean).
|
|
869
|
+
const seenCodes = new Set();
|
|
870
|
+
for (const p of selected) {
|
|
871
|
+
if (seenCodes.has(p.code)) {
|
|
872
|
+
throw new CustomMarketplaceError(
|
|
873
|
+
`Two selected registry entries share the code '${p.code}' — codes must be unique ` +
|
|
874
|
+
`to install. Aborting before staging (FR238).`
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
seenCodes.add(p.code);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Resolve every selected entry (each from its own clone) BEFORE touching the
|
|
881
|
+
// stage dir, so an unreachable/stale entry aborts clean with nothing staged.
|
|
882
|
+
const entries = [];
|
|
883
|
+
for (const plugin of selected) {
|
|
884
|
+
entries.push(await resolveRegistryEntry(plugin, resolveSourceFn, resolvePluginFn));
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const allModules = assertResolutionNonEmptyAndUniqueLeaves(
|
|
888
|
+
entries.map(({ plugin, rawPlugin, resolved }) => ({ plugin, rawPlugin, resolved })),
|
|
889
|
+
{ source, mode: 'registry' }
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
const stagePath = prepareCleanStageDir(projectRoot);
|
|
893
|
+
|
|
894
|
+
// Copy each entry's resolved content under a `code/` namespace so entries
|
|
895
|
+
// cloned from different repos never overwrite each other, and rewrite the
|
|
896
|
+
// staged rawPlugin's skill/source paths to match that namespace so
|
|
897
|
+
// bmad-method's re-resolution against the stage reproduces the identical
|
|
898
|
+
// module (skills at code/<rel>, module.yaml at their common parent).
|
|
899
|
+
const stagedPlugins = [];
|
|
900
|
+
try {
|
|
901
|
+
for (const { plugin, resolved, entryRootDir } of entries) {
|
|
902
|
+
const prefix = plugin.code;
|
|
903
|
+
const relFromEntry = (absSrc) => toPosix(path.relative(entryRootDir, absSrc));
|
|
904
|
+
const copyUnderPrefix = (absSrc) => {
|
|
905
|
+
const dest = path.join(stagePath, prefix, ...relFromEntry(absSrc).split('/'));
|
|
906
|
+
fs.copySync(absSrc, dest, { overwrite: true, dereference: true });
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
const stagedSkills = [];
|
|
910
|
+
for (const mod of resolved) {
|
|
911
|
+
for (const skillPath of mod.skillPaths) {
|
|
912
|
+
copyUnderPrefix(skillPath);
|
|
913
|
+
stagedSkills.push(`${prefix}/${relFromEntry(skillPath)}`);
|
|
914
|
+
}
|
|
915
|
+
if (mod.moduleYamlPath) copyUnderPrefix(mod.moduleYamlPath);
|
|
916
|
+
if (mod.moduleHelpCsvPath) copyUnderPrefix(mod.moduleHelpCsvPath);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
stagedPlugins.push({
|
|
920
|
+
name: plugin.code,
|
|
921
|
+
description: plugin.description || '',
|
|
922
|
+
version: plugin.version || null,
|
|
923
|
+
source: prefix,
|
|
924
|
+
skills: stagedSkills,
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
} catch (err) {
|
|
928
|
+
try { fs.removeSync(stagePath); } catch { /* swallow — primary error wins */ }
|
|
929
|
+
throw new CustomMarketplaceError(`Staging the registry subset failed: ${err.message}`, { cause: err });
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const marketplaceDir = path.join(stagePath, '.claude-plugin');
|
|
933
|
+
fs.mkdirpSync(marketplaceDir);
|
|
934
|
+
fs.writeFileSync(
|
|
935
|
+
path.join(marketplaceDir, 'marketplace.json'),
|
|
936
|
+
JSON.stringify({ name: 'ma-agents-custom-registry-subset', plugins: stagedPlugins }, null, 2)
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
const customSourceArg = `./${CUSTOM_MARKETPLACE_STAGE_DIR_NAME}`;
|
|
940
|
+
return { stagePath, customSourceArg, resolvedModules: allModules };
|
|
941
|
+
}
|
|
942
|
+
|
|
462
943
|
// ─── Story 30.7 — persist + re-enumerate/reconcile on update ───────────────
|
|
463
944
|
|
|
464
945
|
/**
|
|
@@ -499,9 +980,21 @@ module.exports = {
|
|
|
499
980
|
// Story 30.6b — prune + stage the selected subset
|
|
500
981
|
CUSTOM_MARKETPLACE_STAGE_DIR_NAME,
|
|
501
982
|
scanDirectModeSkillDirs,
|
|
983
|
+
scanSkillDirs,
|
|
502
984
|
resolveSelectedPlugins,
|
|
503
985
|
assertResolutionNonEmptyAndUniqueLeaves,
|
|
504
986
|
stageCustomMarketplaceSubset,
|
|
987
|
+
// Registry-index mode (this story)
|
|
988
|
+
REGISTRY_DIR_NAME,
|
|
989
|
+
REGISTRY_OFFICIAL_INDEX,
|
|
990
|
+
REGISTRY_COMMUNITY_INDEX,
|
|
991
|
+
registryTrustLabel,
|
|
992
|
+
detectRegistryLayout,
|
|
993
|
+
parseRegistryIndex,
|
|
994
|
+
normalizeRegistryEntry,
|
|
995
|
+
enumerateRegistrySource,
|
|
996
|
+
resolveRegistryEntry,
|
|
997
|
+
stageRegistrySubset,
|
|
505
998
|
// Story 30.7 — persist + reconcile on update
|
|
506
999
|
reconcileCustomMarketplaceSelection,
|
|
507
1000
|
};
|
package/lib/diag.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Log-file-only diagnostics.
|
|
5
|
+
*
|
|
6
|
+
* When `--log` is active, bin/cli.js#setupLogging installs a sink that writes
|
|
7
|
+
* to the log file descriptor. `diag()`/`diagError()` then emit structured
|
|
8
|
+
* breadcrumbs to the LOG FILE ONLY — never to the user's console (the wizard's
|
|
9
|
+
* user-facing output intentionally stays clean; e.g. a failed custom-marketplace
|
|
10
|
+
* source shows a one-line message, not a raw stack — NFR71). Without a sink
|
|
11
|
+
* (no `--log`), every call is a cheap no-op.
|
|
12
|
+
*
|
|
13
|
+
* This is how the swallowed root cause of a custom-marketplace failure (the
|
|
14
|
+
* git/clone/resolve error behind a clean CustomMarketplaceError, the per-entry
|
|
15
|
+
* module_definition that was missing, the FR238 zero-module trigger, etc.)
|
|
16
|
+
* reaches the log for post-hoc diagnosis without leaking to the terminal.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
let sink = null;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {((text: string) => void)|null} fn - writer for log-file output, or null to disable.
|
|
23
|
+
*/
|
|
24
|
+
function setLogSink(fn) {
|
|
25
|
+
sink = typeof fn === 'function' ? fn : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @returns {boolean} whether diagnostics are being captured. */
|
|
29
|
+
function isActive() {
|
|
30
|
+
return sink !== null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stamp() {
|
|
34
|
+
// new Date() is available in the CLI runtime (this is not a workflow script).
|
|
35
|
+
return new Date().toISOString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stringify(detail) {
|
|
39
|
+
if (typeof detail === 'string') return detail;
|
|
40
|
+
try {
|
|
41
|
+
return JSON.stringify(detail);
|
|
42
|
+
} catch {
|
|
43
|
+
return String(detail);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Emit one diagnostic breadcrumb to the log file (no-op without a sink).
|
|
49
|
+
* @param {string} label
|
|
50
|
+
* @param {*} [detail] - string or JSON-serializable value.
|
|
51
|
+
*/
|
|
52
|
+
function diag(label, detail) {
|
|
53
|
+
if (!sink) return;
|
|
54
|
+
let line = `[diag ${stamp()}] ${label}`;
|
|
55
|
+
if (detail !== undefined) line += `: ${stringify(detail)}`;
|
|
56
|
+
try {
|
|
57
|
+
sink(line + '\n');
|
|
58
|
+
} catch {
|
|
59
|
+
/* never let diagnostics break the install */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Emit an error — including its `stack` AND any wrapped `cause` (message +
|
|
65
|
+
* stack) — to the log file. This is the crucial bit for custom-marketplace
|
|
66
|
+
* failures, whose user-facing message deliberately hides the upstream cause.
|
|
67
|
+
* @param {string} label
|
|
68
|
+
* @param {Error|*} err
|
|
69
|
+
*/
|
|
70
|
+
function diagError(label, err) {
|
|
71
|
+
if (!sink || !err) return;
|
|
72
|
+
const parts = [`[diag ${stamp()}] ${label}: ${err.message || String(err)}`];
|
|
73
|
+
if (err.stack) parts.push(` stack: ${err.stack}`);
|
|
74
|
+
if (err.cause) {
|
|
75
|
+
const c = err.cause;
|
|
76
|
+
parts.push(` cause: ${(c && c.message) || String(c)}`);
|
|
77
|
+
if (c && c.stack) parts.push(` cause.stack: ${c.stack}`);
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
sink(parts.join('\n') + '\n');
|
|
81
|
+
} catch {
|
|
82
|
+
/* never let diagnostics break the install */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { setLogSink, isActive, diag, diagError };
|
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.2",
|
|
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 && node test/diag.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",
|