@deeeed/metamask-harness 0.26.5 → 0.28.0

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.
@@ -18,22 +18,33 @@ import {
18
18
  import { metaMaskActionExecutionCapabilities } from "../recipe-security.js";
19
19
  import { closest } from "../command-contract.js";
20
20
  const OFFICIAL_ACTIONS = new Set(OFFICIAL_RECIPE_ACTIONS);
21
+ const ACTION_MATRIX_ADAPTERS = ["mobile", "extension", "core"];
22
+ function actionLibraryContextArgs(librarySources) {
23
+ return (librarySources ?? []).filter((source) => source.name !== "metamask").map((source) => {
24
+ const entry = source.name ? `${source.name}=${source.root}` : source.root;
25
+ return ` --library ${shellQuoteArg(entry)}`;
26
+ }).join("");
27
+ }
21
28
  async function handleActions({ options, positional }) {
22
- const { adapter, target } = resolveAdapter(options);
23
29
  const librarySources = await resolveMetaMaskLibrarySources(optionStrings(options, "library"));
30
+ const json = optionFlag(options, "json");
31
+ const action = optionString(options, "action");
32
+ const query = positional[0]?.trim();
33
+ const category = optionString(options, "category")?.toLowerCase();
34
+ if (optionFlag(options, "matrix")) {
35
+ return handleActionMatrix(options, { json, action, query, category, librarySources });
36
+ }
37
+ const { adapter, target } = resolveAdapter(options);
38
+ const actionManifestOverride = optionString(options, "actionManifest");
24
39
  const { manifest, actionSources } = await resolveActionManifest(
25
40
  adapter,
26
- optionString(options, "actionManifest"),
41
+ actionManifestOverride,
27
42
  librarySources
28
43
  );
29
44
  if (optionFlag(options, "raw")) {
30
45
  console.log(JSON.stringify(manifest, null, 2));
31
46
  return EXIT.ok;
32
47
  }
33
- const json = optionFlag(options, "json");
34
- const action = optionString(options, "action");
35
- const query = positional[0]?.trim();
36
- const category = optionString(options, "category")?.toLowerCase();
37
48
  const categoriesOnly = optionFlag(options, "categories");
38
49
  const all = describeManifestActions(manifest, actionSources);
39
50
  const categories = summarizeActionCategories(all);
@@ -67,6 +78,36 @@ async function handleActions({ options, positional }) {
67
78
  }
68
79
  const actions = action ? fuzzyResolveActions(categoryActions, action) : query ? searchActions(categoryActions, query) : categoryActions;
69
80
  if (action && actions.length === 0) {
81
+ const refusal = actionManifestOverride ? void 0 : resolveActionCapabilityRefusal(
82
+ action,
83
+ adapter,
84
+ await resolveActionCapabilityMatrix(librarySources)
85
+ );
86
+ if (refusal) {
87
+ const satisfying = refusal.satisfyingAdapters.join(", ");
88
+ const message2 = `missing action capability "${refusal.capability}" for the ${adapter} adapter.`;
89
+ const userAction2 = `Satisfying adapters for "${refusal.capability}": ${satisfying}. Inspect: mm-harness actions --matrix --action ${shellQuoteArg(refusal.capability)}${actionLibraryContextArgs(librarySources)} --json`;
90
+ if (json) {
91
+ console.log(JSON.stringify({
92
+ schemaVersion: 1,
93
+ command: "actions",
94
+ adapter,
95
+ action,
96
+ category,
97
+ error: {
98
+ code: "ACTION_CAPABILITY_UNAVAILABLE",
99
+ message: message2,
100
+ capability: refusal.capability,
101
+ satisfyingAdapters: refusal.satisfyingAdapters,
102
+ userAction: userAction2
103
+ }
104
+ }, null, 2));
105
+ } else {
106
+ console.error(`\u2717 mm-harness actions: ${message2}
107
+ Next: ${userAction2}`);
108
+ }
109
+ return EXIT.usage;
110
+ }
70
111
  const message = `no action matches "${action}" for the ${adapter} adapter.`;
71
112
  const userAction = `mm-harness actions --adapter ${adapter}`;
72
113
  if (json) {
@@ -126,6 +167,154 @@ ${color("label", "Related:", { stream: process.stdout })} ${relatedActions.join(
126
167
  }
127
168
  return 0;
128
169
  }
170
+ async function resolveActionCapabilityMatrix(librarySources) {
171
+ const catalogs = await Promise.all(
172
+ ACTION_MATRIX_ADAPTERS.map(async (adapter) => {
173
+ const { manifest, actionSources } = await resolveActionManifest(
174
+ adapter,
175
+ void 0,
176
+ librarySources
177
+ );
178
+ return { adapter, actions: describeManifestActions(manifest, actionSources) };
179
+ })
180
+ );
181
+ const actions = /* @__PURE__ */ new Map();
182
+ for (const catalog of catalogs) {
183
+ for (const action of catalog.actions) {
184
+ const entry = actions.get(action.name) ?? { entries: [], adapters: /* @__PURE__ */ new Set() };
185
+ entry.entries.push(action);
186
+ entry.adapters.add(catalog.adapter);
187
+ actions.set(action.name, entry);
188
+ }
189
+ }
190
+ return [...actions.entries()].map(([name, entry]) => {
191
+ const representative = entry.entries[0];
192
+ const satisfyingAdapters = ACTION_MATRIX_ADAPTERS.filter(
193
+ (adapter) => entry.adapters.has(adapter)
194
+ );
195
+ return {
196
+ name,
197
+ category: representative.category,
198
+ description: entry.entries.map((action) => action.description).find(Boolean) ?? "",
199
+ fields: [...new Set(entry.entries.flatMap((action) => action.fields))].sort(),
200
+ support: Object.fromEntries(
201
+ ACTION_MATRIX_ADAPTERS.map((adapter) => [
202
+ adapter,
203
+ entry.adapters.has(adapter) ? "available" : "unavailable"
204
+ ])
205
+ ),
206
+ satisfyingAdapters
207
+ };
208
+ }).sort((left, right) => left.name.localeCompare(right.name));
209
+ }
210
+ function resolveActionCapabilityRefusal(action, adapter, matrix) {
211
+ const matches = fuzzyResolveActions(matrix, action);
212
+ if (matches.length !== 1 || matches[0].satisfyingAdapters.includes(adapter)) {
213
+ return void 0;
214
+ }
215
+ return {
216
+ capability: matches[0].name,
217
+ satisfyingAdapters: matches[0].satisfyingAdapters
218
+ };
219
+ }
220
+ function missingActionCapabilities(adapter, actionNames, matrix) {
221
+ return [...new Set(actionNames)].flatMap((action) => {
222
+ const row = matrix.find((entry) => entry.name === action);
223
+ if (!row || row.satisfyingAdapters.includes(adapter)) return [];
224
+ return [{ capability: row.name, satisfyingAdapters: row.satisfyingAdapters }];
225
+ });
226
+ }
227
+ function handleActionMatrixError(json, code, message, userAction, details = {}) {
228
+ if (json) {
229
+ console.log(JSON.stringify({
230
+ schemaVersion: 1,
231
+ command: "actions",
232
+ view: "matrix",
233
+ ...details,
234
+ error: { code, message, userAction }
235
+ }, null, 2));
236
+ } else {
237
+ console.error(`\u2717 mm-harness actions: ${message}
238
+ Next: ${userAction}`);
239
+ }
240
+ return EXIT.usage;
241
+ }
242
+ async function handleActionMatrix(options, input) {
243
+ const inspectCommand = `mm-harness actions --matrix${actionLibraryContextArgs(input.librarySources)} --json`;
244
+ const conflicts = [
245
+ optionFlag(options, "raw") ? "--raw" : void 0,
246
+ optionFlag(options, "categories") ? "--categories" : void 0,
247
+ optionString(options, "adapter") ? "--adapter" : void 0,
248
+ optionString(options, "platform") ? "--platform" : void 0,
249
+ optionString(options, "actionManifest") ? "--action-manifest" : void 0
250
+ ].filter((value) => Boolean(value));
251
+ if (conflicts.length > 0) {
252
+ return handleActionMatrixError(
253
+ input.json,
254
+ "ACTION_MATRIX_CONFLICT",
255
+ `--matrix cannot be combined with ${conflicts.join(", ")}.`,
256
+ inspectCommand
257
+ );
258
+ }
259
+ const matrix = await resolveActionCapabilityMatrix(input.librarySources);
260
+ const categories = summarizeActionCategories(matrix);
261
+ const categoryActions = input.category ? matrix.filter((entry) => entry.category === input.category) : matrix;
262
+ if (input.category && categoryActions.length === 0) {
263
+ return handleActionMatrixError(
264
+ input.json,
265
+ "ACTION_CATEGORY_UNKNOWN",
266
+ `no action category matches "${input.category}" across the adapter matrix.`,
267
+ inspectCommand,
268
+ { category: input.category, availableCategories: categories }
269
+ );
270
+ }
271
+ const actions = input.action ? fuzzyResolveActions(categoryActions, input.action) : input.query ? searchActions(categoryActions, input.query) : categoryActions;
272
+ if (input.action && actions.length === 0) {
273
+ return handleActionMatrixError(
274
+ input.json,
275
+ "ACTION_UNKNOWN",
276
+ `no action capability matches "${input.action}" across Mobile, Extension, or Core.`,
277
+ inspectCommand,
278
+ { action: input.action, category: input.category }
279
+ );
280
+ }
281
+ if (input.query && actions.length === 0) {
282
+ return handleActionMatrixError(
283
+ input.json,
284
+ "ACTION_SEARCH_EMPTY",
285
+ `no action capability matches search "${input.query}" across Mobile, Extension, or Core.`,
286
+ inspectCommand,
287
+ { query: input.query, category: input.category }
288
+ );
289
+ }
290
+ if (input.json) {
291
+ console.log(JSON.stringify({
292
+ schemaVersion: 1,
293
+ command: "actions",
294
+ view: "matrix",
295
+ adapters: ACTION_MATRIX_ADAPTERS,
296
+ ...input.query ? { query: input.query } : {},
297
+ ...input.category ? { category: input.category } : {},
298
+ actions
299
+ }, null, 2));
300
+ } else {
301
+ console.log(renderHumanActionMatrix(actions));
302
+ }
303
+ return EXIT.ok;
304
+ }
305
+ function renderHumanActionMatrix(actions) {
306
+ const out = (style, text) => color(style, text, { stream: process.stdout });
307
+ const width = Math.max("action capability".length, ...actions.map((action) => action.name.length));
308
+ const cell = (status, width2) => status === "available" ? out("accent", "yes".padEnd(width2)) : out("dim", "\u2014".padEnd(width2));
309
+ return [
310
+ out("bold", "action capability matrix"),
311
+ out("comment", "Inspect one: mm-harness actions --matrix --action <name> --json"),
312
+ `${"action capability".padEnd(width)} mobile extension core`,
313
+ ...actions.map(
314
+ (action) => `${action.name.padEnd(width)} ${cell(action.support.mobile, 6)} ${cell(action.support.extension, 9)} ${cell(action.support.core, 4)}`
315
+ )
316
+ ].join("\n");
317
+ }
129
318
  function fuzzyResolveActions(entries, query) {
130
319
  const exactFull = entries.filter((e) => e.name === query);
131
320
  if (exactFull.length > 0) return exactFull;
@@ -393,13 +582,19 @@ function levenshtein(left, right) {
393
582
  return prior[right.length] ?? right.length;
394
583
  }
395
584
  export {
585
+ ACTION_MATRIX_ADAPTERS,
396
586
  actionExampleCommand,
587
+ actionLibraryContextArgs,
397
588
  describeManifestActions,
398
589
  findRelatedActions,
399
590
  fuzzyResolveActions,
400
591
  handleActions,
592
+ missingActionCapabilities,
401
593
  renderHumanActionCatalog,
402
594
  renderHumanActionExample,
595
+ renderHumanActionMatrix,
596
+ resolveActionCapabilityMatrix,
597
+ resolveActionCapabilityRefusal,
403
598
  searchActions,
404
599
  summarizeActionCategories,
405
600
  summarizeDescribedAction
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { resolveMobileToolPath } from "../../library/actions/mobile/platform/tool-paths.mjs";
2
3
  import { listConnectedDevices } from "../devices.js";
3
4
  import { renderDeviceList, scopedDevices } from "./device-target.js";
4
5
  import { probeMobileLiveState } from "./status-probe.js";
@@ -116,8 +117,10 @@ function selectedAndroidPortHints(devices, liveMap) {
116
117
  }).filter((hint) => hint.reversePorts.length > 0);
117
118
  }
118
119
  function androidReversePorts(serial) {
120
+ const adbPath = resolveMobileToolPath("adb");
121
+ if (!adbPath) return [];
119
122
  try {
120
- const out = execFileSync("adb", ["-s", serial, "reverse", "--list"], {
123
+ const out = execFileSync(adbPath, ["-s", serial, "reverse", "--list"], {
121
124
  encoding: "utf8",
122
125
  stdio: ["ignore", "pipe", "ignore"],
123
126
  timeout: 5e3
@@ -29,6 +29,7 @@ function parseArgs(argv, command) {
29
29
  "list",
30
30
  "describe",
31
31
  "raw",
32
+ "matrix",
32
33
  "categories",
33
34
  "fix",
34
35
  "force",
@@ -35,6 +35,30 @@ import { JsonStreamWriter } from "../json-stream.js";
35
35
  import { formatRunDiagnosticsForHuman, readRunDiagnosticsDocument } from "../run-diagnostics.js";
36
36
  import { recipeTrustFailure } from "../recipe-security.js";
37
37
  import { recordCommandEvidence, redactStructuredValue } from "../command-journal.js";
38
+ import {
39
+ actionLibraryContextArgs,
40
+ missingActionCapabilities,
41
+ resolveActionCapabilityMatrix
42
+ } from "./manifest.js";
43
+ async function validationCapabilityRefusals(adapter, findings, librarySources) {
44
+ const actionNames = findings.flatMap((finding) => {
45
+ if (finding.code !== "recipe.action_not_declared_by_manifest") return [];
46
+ const match = /^Recipe action ([^ ]+) is not declared by the runner action manifest[.]?$/u.exec(finding.message);
47
+ return match?.[1] ? [match[1]] : [];
48
+ });
49
+ if (actionNames.length === 0) return [];
50
+ return missingActionCapabilities(
51
+ adapter,
52
+ actionNames,
53
+ await resolveActionCapabilityMatrix(librarySources)
54
+ );
55
+ }
56
+ function capabilityValidationUserAction(adapter, refusals, libraryContextArgs) {
57
+ const capabilities = refusals.map(
58
+ (refusal) => `"${refusal.capability}" [${refusal.satisfyingAdapters.join(", ")}]`
59
+ ).join("; ");
60
+ return `Missing action capabilities for ${adapter}, with satisfying adapters: ${capabilities}. Inspect: mm-harness actions --matrix${libraryContextArgs} --json; then rerun from a checkout matching a satisfying adapter, splitting the recipe if no single adapter satisfies every capability`;
61
+ }
38
62
  async function handleRun(parsed) {
39
63
  const stream = new JsonStreamWriter("run", optionFlag(parsed.options, "jsonStream"));
40
64
  const restoreStdout = stream.isolateStdout();
@@ -165,7 +189,21 @@ async function handleRunInner({ positional, options }, stream) {
165
189
  );
166
190
  }
167
191
  if (validated.errorCount > 0) {
168
- return emitRunValidationError(jsonOutput, stream, adapter, validated.recipeFile, validated.findings, validated.errorCount);
192
+ const capabilityRefusals = optionString(options, "actionManifest") ? [] : await validationCapabilityRefusals(
193
+ adapter,
194
+ validated.findings,
195
+ validated.librarySources
196
+ );
197
+ return emitRunValidationError(
198
+ jsonOutput,
199
+ stream,
200
+ adapter,
201
+ validated.recipeFile,
202
+ validated.findings,
203
+ validated.errorCount,
204
+ capabilityRefusals,
205
+ actionLibraryContextArgs(validated.librarySources)
206
+ );
169
207
  }
170
208
  const depsBlock = adapter === "core" && await recipeUsesCoreController(validated.recipe, validated.librarySources) ? coreDependencyBlock(target) : null;
171
209
  if (depsBlock) {
@@ -447,8 +485,23 @@ async function handleRunPlan(recipeArg, params, options, stream) {
447
485
  userAction
448
486
  );
449
487
  }
450
- const { recipe, recipeFile, findings, errorCount, manifestOk, schemaValid, effectiveParams } = validated;
488
+ const {
489
+ recipe,
490
+ recipeFile,
491
+ findings,
492
+ errorCount,
493
+ manifestOk,
494
+ schemaValid,
495
+ effectiveParams,
496
+ librarySources
497
+ } = validated;
451
498
  const status = errorCount === 0 ? "pass" : "fail";
499
+ const capabilityRefusals = status === "fail" && !optionString(options, "actionManifest") ? await validationCapabilityRefusals(adapter, findings, librarySources) : [];
500
+ const failureUserAction = capabilityRefusals.length > 0 ? capabilityValidationUserAction(
501
+ adapter,
502
+ capabilityRefusals,
503
+ actionLibraryContextArgs(librarySources)
504
+ ) : runPlanProbe(adapter, recipeFile);
452
505
  const nodeCount = countRecipeNodes(recipe);
453
506
  const artifactsDir = optionString(options, "artifactsDir");
454
507
  const plan = [
@@ -514,7 +567,8 @@ async function handleRunPlan(recipeArg, params, options, stream) {
514
567
  payload.error = {
515
568
  code: "RECIPE_VALIDATION_FAILED",
516
569
  message: `recipe validation found ${errorCount} error(s)`,
517
- userAction: runPlanProbe(adapter, recipeFile)
570
+ ...capabilityRefusals.length > 0 ? { missingCapabilities: capabilityRefusals } : {},
571
+ userAction: failureUserAction
518
572
  };
519
573
  }
520
574
  if (stream.enabled) {
@@ -534,7 +588,7 @@ async function handleRunPlan(recipeArg, params, options, stream) {
534
588
  console.log(` ${finding.severity === "error" ? "\u2717" : "\u26A0"} ${finding.code} ${finding.path} \u2014 ${finding.message}`);
535
589
  }
536
590
  }
537
- if (status === "fail") console.error(` Next: ${runPlanProbe(adapter, recipeFile)}`);
591
+ if (status === "fail") console.error(` Next: ${failureUserAction}`);
538
592
  }
539
593
  return status === "pass" ? EXIT.ok : EXIT.validation;
540
594
  }
@@ -582,10 +636,17 @@ function emitRunUsageError(json, stream, adapter, recipeFile, code, message, use
582
636
  }
583
637
  return EXIT.usage;
584
638
  }
585
- function emitRunValidationError(json, stream, adapter, recipeFile, findings, errorCount) {
639
+ function emitRunValidationError(json, stream, adapter, recipeFile, findings, errorCount, capabilityRefusals, libraryContextArgs) {
586
640
  const message = `recipe validation found ${errorCount} error(s)`;
587
- const userAction = runPlanProbe(adapter, recipeFile);
588
- stream.error({ code: "RECIPE_VALIDATION_FAILED", message, userAction, findings });
641
+ const userAction = capabilityRefusals.length > 0 ? capabilityValidationUserAction(adapter, capabilityRefusals, libraryContextArgs) : runPlanProbe(adapter, recipeFile);
642
+ const capabilityDetails = capabilityRefusals.length > 0 ? { missingCapabilities: capabilityRefusals } : {};
643
+ stream.error({
644
+ code: "RECIPE_VALIDATION_FAILED",
645
+ message,
646
+ userAction,
647
+ findings,
648
+ ...capabilityDetails
649
+ });
589
650
  if (json) {
590
651
  console.log(
591
652
  JSON.stringify(
@@ -599,7 +660,12 @@ function emitRunValidationError(json, stream, adapter, recipeFile, findings, err
599
660
  mutations: [],
600
661
  recipe: recipeFile,
601
662
  findings,
602
- error: { code: "RECIPE_VALIDATION_FAILED", message, userAction }
663
+ error: {
664
+ code: "RECIPE_VALIDATION_FAILED",
665
+ message,
666
+ userAction,
667
+ ...capabilityDetails
668
+ }
603
669
  },
604
670
  null,
605
671
  2
package/dist/devices.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { resolveMobileToolPath } from "../library/actions/mobile/platform/tool-paths.mjs";
2
3
  function listConnectedDevices(platform) {
3
4
  const devices = [];
4
5
  if (!platform || platform === "android") devices.push(...listAndroidDevices());
@@ -6,9 +7,11 @@ function listConnectedDevices(platform) {
6
7
  return devices;
7
8
  }
8
9
  function listAndroidDevices() {
10
+ const adbPath = resolveMobileToolPath("adb");
11
+ if (!adbPath) return [];
9
12
  let output;
10
13
  try {
11
- output = execFileSync("adb", ["devices", "-l"], {
14
+ output = execFileSync(adbPath, ["devices", "-l"], {
12
15
  encoding: "utf8",
13
16
  timeout: 5e3,
14
17
  stdio: ["ignore", "pipe", "ignore"]
package/dist/doctor.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { color } from "./cli-color.js";
4
+ import {
5
+ mobileToolRecovery,
6
+ resolveMobileToolPath
7
+ } from "../library/actions/mobile/platform/tool-paths.mjs";
4
8
  import { mobilePerpsEnvironment } from "./adapters/mobile/perps-env.js";
5
9
  import { readRuntimeContextField, resolveRuntimeContextPath } from "./harness.js";
6
10
  import { manifestPath, readJson, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from "./paths.js";
@@ -128,7 +132,7 @@ function renderRuntimeContext(runtimeContext) {
128
132
  }
129
133
  return lines.join("\n");
130
134
  }
131
- function createDoctorReport(adapter, target, manifestValidation, actionManifestPath = manifestPath(adapter)) {
135
+ function createDoctorReport(adapter, target, manifestValidation, actionManifestPath = manifestPath(adapter), mobilePlatform) {
132
136
  const mode = compatibilityMode(adapter, target);
133
137
  const manifestErrors = Number(manifestValidation.summary?.errors ?? 0);
134
138
  const checks = [
@@ -143,7 +147,8 @@ function createDoctorReport(adapter, target, manifestValidation, actionManifestP
143
147
  status: mode === "unsupported/no bridge" ? "fail" : "pass",
144
148
  required: false,
145
149
  message: mode === "unsupported/no bridge" ? `No ${adapter} bridge is available for this checkout.` : `${adapter} compatibility mode: ${mode}.`
146
- }
150
+ },
151
+ ...adapter === "mobile" ? mobileToolDoctorChecks(mobilePlatform) : []
147
152
  ];
148
153
  const requiredChecks = requiredDoctorCheckSummary(checks);
149
154
  return {
@@ -164,6 +169,23 @@ function createDoctorReport(adapter, target, manifestValidation, actionManifestP
164
169
  manifestValidation: manifestValidation.summary
165
170
  };
166
171
  }
172
+ function mobileToolDoctorChecks(platform) {
173
+ const specs = platform === "android" ? [{ tool: "adb", platform: "Android" }] : platform === "ios" ? [{ tool: "idb", platform: "iOS" }] : [
174
+ { tool: "adb", platform: "Android" },
175
+ { tool: "idb", platform: "iOS" }
176
+ ];
177
+ return specs.map(({ tool, platform: platformName }) => {
178
+ const resolved = resolveMobileToolPath(tool);
179
+ return {
180
+ id: `device-tool-${tool}`,
181
+ status: resolved ? "pass" : "fail",
182
+ required: platform !== void 0,
183
+ message: resolved ? `${platformName} device tool ${tool} is ready.` : `${platformName} device tool ${tool} is unavailable.`,
184
+ ...resolved ? { detail: resolved } : {},
185
+ ...!resolved ? { userAction: mobileToolRecovery(tool) } : {}
186
+ };
187
+ });
188
+ }
167
189
  function runnerInstallKind(runnerRoot, invokedPath, executablePath) {
168
190
  const normalizedRoot = path.normalize(runnerRoot);
169
191
  const nodeModulesSegment = `${path.sep}node_modules${path.sep}`;
@@ -230,6 +252,7 @@ export {
230
252
  createDoctorReport,
231
253
  fixtureFileSummary,
232
254
  fixtureSummary,
255
+ mobileToolDoctorChecks,
233
256
  renderRuntimeContext,
234
257
  repoShape,
235
258
  requiredDoctorCheckSummary,
@@ -121,6 +121,7 @@ Materialize:
121
121
 
122
122
  query Search names, categories, fields, and descriptions (typo-tolerant)
123
123
  --action <name> Describe one action; fuzzy-resolves like call (short or full name)
124
+ --matrix Compare action availability across Mobile, Extension, and Core
124
125
  --categories List compact action categories and counts
125
126
  --category <name> List only one category (for example ui, wallet, or perps)
126
127
  --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
@@ -130,6 +131,7 @@ Materialize:
130
131
 
131
132
  Example:
132
133
  mm-harness actions --adapter mobile
134
+ mm-harness actions --matrix --json
133
135
  mm-harness actions positions --adapter mobile
134
136
  mm-harness actions --adapter mobile --categories --json
135
137
  mm-harness actions --adapter mobile --category ui --json
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { lstat, readFile, readdir, realpath } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  const READ_ONLY_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
5
+ "ui.locators",
5
6
  "metamask.wallet.fixture_status",
6
7
  "metamask.wallet.list_accounts",
7
8
  "metamask.wallet.read_state",