@deeeed/metamask-harness 0.20.0 → 0.22.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.22.0 - 2026-07-26
6
+
7
+ ### Added
8
+
9
+ - Added `execution-template` as the thin public boundary for shared checklist discovery and materialization.
10
+
11
+ ### Fixed
12
+
13
+ - `ui.navigate page=home` recognizes both the current bottom navigation and the selected legacy Tokens tab.
14
+ - Extension readiness recovers deterministically from transient target-list failures.
15
+ - Team-library calls retain their source provenance.
16
+
17
+ ## 0.21.0 - 2026-07-24
18
+
19
+ ### Changed
20
+
21
+ - Simplified action manifests to one schema-backed keyed action map; action kind,
22
+ library identity, categories, and provenance are inferred from authoritative
23
+ runtime inputs.
24
+ - Updated the Farmslot protocol, agent runtime, and recipe harness dependencies
25
+ for Recipe Protocol v1 trace consistency.
26
+
5
27
  ## 0.20.0 - 2026-07-24
6
28
 
7
29
  ### Added
@@ -111,9 +111,7 @@ install_v1_runner_assets() {
111
111
  rsync -a --delete "$METAMASK_RUNNER_DIR/library/recipes/" "$HARNESS_DIR/runner/recipes/"
112
112
  fi
113
113
  rm -rf "$HARNESS_DIR/runner/flows"
114
- if [ -f "$METAMASK_RUNNER_DIR/library/library.json" ]; then
115
- cp "$METAMASK_RUNNER_DIR/library/library.json" "$HARNESS_DIR/runner/library.json"
116
- fi
114
+ rm -f "$HARNESS_DIR/runner/library.json"
117
115
  if [ ! -x "$HARNESS_DIR/runner/bin/mm-harness" ]; then
118
116
  echo "Refusing core recipe harness install: failed to make runner executable." >&2
119
117
  return 1
@@ -90,8 +90,8 @@ copyFile(path.join(runnerDir, 'library/manifests/mobile.action-manifest.json'),
90
90
  copyFile(path.join(runnerDir, 'library/manifests/extension.action-manifest.json'), path.join(harnessDir, 'runner/manifests/extension.action-manifest.json'));
91
91
  copyFile(path.join(runnerDir, 'library/manifests/extension.action-manifest.json'), path.join(harnessDir, 'action-manifest.json'));
92
92
  copyDir(path.join(runnerDir, 'library/recipes'), path.join(harnessDir, 'runner/recipes'));
93
- copyFile(path.join(runnerDir, 'library/library.json'), path.join(harnessDir, 'runner/library.json'));
94
93
  fs.rmSync(path.join(harnessDir, 'runner/flows'), { recursive: true, force: true });
94
+ fs.rmSync(path.join(harnessDir, 'runner/library.json'), { force: true });
95
95
  // The installed overlay has a flat scripts/ runtime API independent of the
96
96
  // package's source layout.
97
97
  copyFile(path.join(runnerDir, 'adapters/extension/launch-browser.cjs'), path.join(harnessDir, 'scripts/launch-browser.cjs'));
@@ -74,8 +74,8 @@ cp "$RUNNER_DIR/library/manifests/mobile.action-manifest.json" "$HARNESS_DIR/act
74
74
  cp "$RUNNER_DIR/library/manifests/mobile.action-manifest.json" "$HARNESS_DIR/runner/manifests/mobile.action-manifest.json"
75
75
  cp "$RUNNER_DIR/library/manifests/extension.action-manifest.json" "$HARNESS_DIR/runner/manifests/extension.action-manifest.json"
76
76
  rsync -a --delete "$RUNNER_DIR/library/recipes/" "$HARNESS_DIR/runner/recipes/"
77
- cp "$RUNNER_DIR/library/library.json" "$HARNESS_DIR/runner/library.json"
78
77
  rm -rf "$HARNESS_DIR/runner/flows"
78
+ rm -f "$HARNESS_DIR/runner/library.json"
79
79
  cp "$RUNNER_DIR/adapters/mobile/verify.sh" "$HARNESS_DIR/scripts/verify.sh"
80
80
  cp "$RUNNER_DIR/adapters/shared/harness-path.sh" "$HARNESS_DIR/scripts/lib/harness-path.sh"
81
81
  cp "$RUNNER_DIR/adapters/shared/path-defaults.json" "$HARNESS_DIR/scripts/lib/path-defaults.json"
@@ -36,9 +36,11 @@ async function openHome(port, extensionId) {
36
36
  try {
37
37
  let res = await fetch(endpoint, { method: "PUT", signal: AbortSignal.timeout(8e3) });
38
38
  if (res.status === 404 || res.status === 405) res = await fetch(endpoint, { signal: AbortSignal.timeout(8e3) });
39
- return res.ok;
39
+ if (!res.ok) return null;
40
+ const value = await res.json();
41
+ return value && typeof value === "object" ? value : {};
40
42
  } catch {
41
- return false;
43
+ return null;
42
44
  }
43
45
  }
44
46
  function homePages(targets, extensionId) {
@@ -94,7 +96,7 @@ async function ensureExtensionReady(target, options) {
94
96
  continue;
95
97
  }
96
98
  action = "opened";
97
- opened = await openHome(cdpPort, extensionId) || opened;
99
+ opened = Boolean(await openHome(cdpPort, extensionId)) || opened;
98
100
  await sleep(1500);
99
101
  } else {
100
102
  action = "pruned";
@@ -149,20 +151,47 @@ async function ensureExtensionReady(target, options) {
149
151
  if (action === "pruned" && (health.status !== "PASS" || after !== 1)) {
150
152
  const listing = await jsonList(cdpPort);
151
153
  if (listing.ok) {
152
- action = "reopened";
153
- for (const h of homePages(listing.targets, extensionId)) {
154
- await closeTab(cdpPort, String(h.id));
155
- closed += 1;
156
- }
157
- await sleep(500);
158
- opened = await openHome(cdpPort, extensionId) || opened;
154
+ const existingHomeIds = new Set(
155
+ homePages(listing.targets, extensionId).map((home) => String(home.id))
156
+ );
157
+ const replacement = await openHome(cdpPort, extensionId);
158
+ opened = Boolean(replacement) || opened;
159
159
  await sleep(1500);
160
- const relisted = await jsonList(cdpPort);
161
- if (!relisted.ok) {
162
- return base({ extensionId, opened, action, homeTabs: { before, closed, after }, reasonCode: "cdp-unreachable" });
160
+ const replacementListing = await jsonList(cdpPort);
161
+ if (!replacementListing.ok) {
162
+ return base({
163
+ extensionId,
164
+ opened,
165
+ action,
166
+ homeTabs: { before, closed, after },
167
+ reasonCode: "cdp-unreachable"
168
+ });
169
+ }
170
+ const replacementHomes = homePages(replacementListing.targets, extensionId);
171
+ const replacementId = replacement?.id && replacementHomes.some((home) => home.id === replacement.id) ? String(replacement.id) : replacementHomes.find((home) => !existingHomeIds.has(String(home.id)))?.id;
172
+ if (replacementId) {
173
+ action = "reopened";
174
+ for (const home of replacementHomes) {
175
+ if (String(home.id) === replacementId) continue;
176
+ await closeTab(cdpPort, String(home.id));
177
+ closed += 1;
178
+ }
179
+ await sleep(500);
180
+ const relisted = await jsonList(cdpPort);
181
+ if (!relisted.ok) {
182
+ return base({
183
+ extensionId,
184
+ opened,
185
+ action,
186
+ homeTabs: { before, closed, after },
187
+ reasonCode: "cdp-unreachable"
188
+ });
189
+ }
190
+ after = homePages(relisted.targets, extensionId).length;
191
+ health = await checkHealth();
192
+ } else {
193
+ after = replacementHomes.length;
163
194
  }
164
- after = homePages(relisted.targets, extensionId).length;
165
- health = await checkHealth();
166
195
  }
167
196
  }
168
197
  let slotTitle;
@@ -18,6 +18,7 @@ const SPEC = {
18
18
  { name: "actions", desc: "List runnable recipe actions", flags: ["--json", "--categories", "--category", "--action", "--library"] },
19
19
  { name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--print-ready", "--cdp-port", "--device"] },
20
20
  { name: "run", desc: "Execute a proof recipe (path or library name, e.g. run perps.smoke)", args: ["recipe.json|name"], flags: ["--list", "--device"] },
21
+ { name: "execution-template", desc: "Discover and materialize shared agent checklists", args: ["list", "materialize", "lint", "new"], flags: ["--dir", "--domain-dir", "--project-worker", "--project-name", "--package-templates", "--package-id", "--flow", "--run-mode", "--platform", "--domain", "--id", "--provenance", "--include-shadowed", "--no-include-shadowed", "--title", "--force", "--json"] },
21
22
  { name: "recipe-quality", desc: "Build the recipe-quality artifact from compact JSON", args: ["build"], flags: ["--input", "--output", "--json"] },
22
23
  { name: "interactive", aliases: ["menu"], desc: "Interactive command menu" },
23
24
  { name: "prepare", desc: "Install harness (+ optional validate)", flags: ["--target", "--runtime-dir", "--json"] },
package/dist/cli.js CHANGED
@@ -20,6 +20,7 @@ import { handleRecipeQuality } from "./commands/recipe-quality.js";
20
20
  import { handleStatus } from "./commands/status.js";
21
21
  import { handleCheck } from "./commands/check.js";
22
22
  import { handleChecklist } from "./commands/checklist.js";
23
+ import { handleExecutionTemplate } from "./commands/execution-template.js";
23
24
  import { handleLast } from "./commands/last.js";
24
25
  import { parseArgs, targetPath } from "./commands/parse-args.js";
25
26
  const COMMANDS = {
@@ -121,6 +122,7 @@ async function main(argv) {
121
122
  if (command === "recipe-quality") return handleRecipeQuality(argv.slice(1));
122
123
  if (command === "check") return handleCheck(argv.slice(1));
123
124
  if (command === "checklist") return handleChecklist(argv.slice(1));
125
+ if (command === "execution-template") return handleExecutionTemplate(argv.slice(1));
124
126
  if (command === "last") return handleLast(parseArgs(argv.slice(1), command));
125
127
  const handler = COMMANDS[command];
126
128
  if (!handler) throw new Error(`Unknown command: ${command}`);
@@ -88,6 +88,32 @@ const PUBLIC_COMMAND_CONTRACTS = {
88
88
  ],
89
89
  minimumPositionals: 2
90
90
  },
91
+ "execution-template": {
92
+ usage: "mm-harness execution-template <list|materialize|lint|new> [target] [options]",
93
+ options: options(HELP, JSON, {
94
+ "--dir": value(),
95
+ "--domain-dir": value(),
96
+ "--project-worker": value(),
97
+ "--project-name": value(),
98
+ "--package-templates": value(),
99
+ "--package-id": value(),
100
+ "--flow": value(),
101
+ "--run-mode": value(["autonomous", "interactive", "validation"]),
102
+ "--platform": value(),
103
+ "--domain": value(),
104
+ "--id": value(),
105
+ "--provenance": value(),
106
+ "--include-shadowed": bool(),
107
+ "--no-include-shadowed": bool(),
108
+ "--title": value(),
109
+ "--force": bool()
110
+ }),
111
+ positionals: [
112
+ { label: "action", choices: ["list", "materialize", "lint", "new"] }
113
+ ],
114
+ minimumPositionals: 1,
115
+ variadic: { label: "target", validate: (value2) => value2.length > 0 }
116
+ },
91
117
  actions: {
92
118
  options: options(HELP, JSON, TARGET, ADAPTER, ADAPTER_OR_MOBILE_PLATFORM, {
93
119
  "--action": value(),
@@ -360,6 +386,37 @@ function validatePublicInvocation(argv, examples = {}) {
360
386
  );
361
387
  }
362
388
  }
389
+ if (name === "execution-template") {
390
+ const action = positionals[0];
391
+ const validAction = ["list", "materialize", "lint", "new"].includes(action ?? "");
392
+ const targetCount = Math.max(0, positionals.length - 1);
393
+ if (validAction && action === "list" && targetCount > 0) {
394
+ return usageFailure(
395
+ "CLI_EXCESS_POSITIONAL",
396
+ name,
397
+ `execution-template list does not accept positional '${positionals[1]}'.`,
398
+ contract,
399
+ examples
400
+ );
401
+ }
402
+ if (validAction && (action === "materialize" || action === "lint" || action === "new") && targetCount === 0) {
403
+ return missingPositionalFailure(
404
+ name,
405
+ `execution-template ${action} requires <target>.`,
406
+ contract,
407
+ examples
408
+ );
409
+ }
410
+ if (validAction && targetCount > 1) {
411
+ return usageFailure(
412
+ "CLI_EXCESS_POSITIONAL",
413
+ name,
414
+ `unexpected positional '${positionals[2]}'; execution-template ${action} accepts one <target>.`,
415
+ contract,
416
+ examples
417
+ );
418
+ }
419
+ }
363
420
  if (contract.leadingPositionals && !contract.requiredUnless?.some((option) => seenOptions.has(option)) && tokens.slice(0, contract.leadingPositionals).some((argument) => !argument || argument.startsWith("-"))) {
364
421
  const missing = contract.positionals?.[0]?.label ?? "argument";
365
422
  return missingPositionalFailure(name, `${name} requires <${missing}> first.`, contract, examples);
@@ -470,7 +470,7 @@ function renderCallExamples(short, examples) {
470
470
  if (!Array.isArray(examples)) return [];
471
471
  const out = [];
472
472
  for (const example of examples.slice(0, 2)) {
473
- const node = isRecord(example) && isRecord(example.node) ? example.node : void 0;
473
+ const node = isRecord(example) ? example : void 0;
474
474
  if (!node) continue;
475
475
  const tokens = Object.entries(node).filter(([key]) => key !== "action" && key !== "intent").map(([key, value]) => `${key}=${typeof value === "string" ? value : JSON.stringify(value)}`);
476
476
  out.push(`mm-harness call ${short} ${tokens.join(" ")}`.trim());
@@ -0,0 +1,28 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ const require2 = createRequire(import.meta.url);
4
+ async function handleExecutionTemplate(argv) {
5
+ let entrypoint;
6
+ try {
7
+ entrypoint = require2.resolve(
8
+ "@farmslot/agent-runtime/scripts/execution-template-cli.mjs"
9
+ );
10
+ } catch (error) {
11
+ console.error(
12
+ `mm-harness execution-template: installed @farmslot/agent-runtime does not expose the catalog command: ${error instanceof Error ? error.message : String(error)}`
13
+ );
14
+ return 1;
15
+ }
16
+ const result = spawnSync(process.execPath, [entrypoint, ...argv], {
17
+ env: process.env,
18
+ stdio: "inherit"
19
+ });
20
+ if (result.error) {
21
+ console.error(`mm-harness execution-template: ${result.error.message}`);
22
+ return 1;
23
+ }
24
+ return result.status ?? 1;
25
+ }
26
+ export {
27
+ handleExecutionTemplate
28
+ };
@@ -12,10 +12,12 @@ import {
12
12
  import { resolveMetaMaskLibrarySources } from "./run-engine.js";
13
13
  import { isSensitiveKey, redactStructuredValue } from "../command-journal.js";
14
14
  import {
15
+ OFFICIAL_RECIPE_ACTIONS,
15
16
  officialRecipeActionCapabilities
16
17
  } from "@farmslot/protocol";
17
18
  import { metaMaskActionExecutionCapabilities } from "../recipe-security.js";
18
19
  import { closest } from "../command-contract.js";
20
+ const OFFICIAL_ACTIONS = new Set(OFFICIAL_RECIPE_ACTIONS);
19
21
  async function handleActions({ options, positional }) {
20
22
  const { adapter, target } = resolveAdapter(options);
21
23
  const librarySources = await resolveMetaMaskLibrarySources(optionStrings(options, "library"));
@@ -211,7 +213,7 @@ function authoredExampleNode(examples, preferredValues, schema) {
211
213
  if (!Array.isArray(examples)) return void 0;
212
214
  if (preferredValues && Object.keys(preferredValues).length > 0) {
213
215
  const normalizedValues = normalizePreferredValues(preferredValues, schema);
214
- const nodes = examples.map((example) => isRecord(example) && isRecord(example.node) ? example.node : void 0).filter((node) => node !== void 0);
216
+ const nodes = examples.filter(isRecord);
215
217
  if (nodes.length === 0) return void 0;
216
218
  const ranked = nodes.map((node) => ({
217
219
  node,
@@ -226,7 +228,7 @@ function authoredExampleNode(examples, preferredValues, schema) {
226
228
  return { ...ranked[0].node, ...normalizedValues };
227
229
  }
228
230
  for (const example of examples) {
229
- if (isRecord(example) && isRecord(example.node)) return example.node;
231
+ if (isRecord(example)) return example;
230
232
  }
231
233
  return void 0;
232
234
  }
@@ -305,22 +307,15 @@ function summarizeActionCategories(actions) {
305
307
  }
306
308
  function describeManifestActions(manifest, actionSources = /* @__PURE__ */ new Map()) {
307
309
  const manifestRecord = isRecord(manifest) ? manifest : {};
308
- const metadata = isRecord(manifestRecord.action_metadata) ? manifestRecord.action_metadata : {};
309
- const official = Array.isArray(manifestRecord.supported_official_actions) ? manifestRecord.supported_official_actions.filter((value) => typeof value === "string") : [];
310
- const custom = Array.isArray(manifestRecord.custom_actions) ? manifestRecord.custom_actions.flatMap((entry) => {
311
- if (typeof entry === "string") return [{ name: entry, metadata: metadata[entry] }];
312
- if (isRecord(entry) && typeof entry.name === "string") {
313
- const entryMetadata = { ...entry };
314
- const metadataOverride = metadata[entry.name];
315
- if (isRecord(metadataOverride)) Object.assign(entryMetadata, metadataOverride);
316
- return [{ name: entry.name, metadata: entryMetadata }];
317
- }
318
- return [];
319
- }) : [];
320
- return [
321
- ...official.map((name) => describeManifestAction(name, "official", metadata[name], actionSources.get(name))),
322
- ...custom.map((entry) => describeManifestAction(entry.name, "custom", entry.metadata, actionSources.get(entry.name)))
323
- ];
310
+ const actions = isRecord(manifestRecord.actions) ? manifestRecord.actions : {};
311
+ return Object.entries(actions).map(
312
+ ([name, metadata]) => describeManifestAction(
313
+ name,
314
+ OFFICIAL_ACTIONS.has(name) ? "official" : "custom",
315
+ metadata,
316
+ actionSources.get(name)
317
+ )
318
+ );
324
319
  }
325
320
  function describeManifestAction(name, kind, metadata, source) {
326
321
  const record = isRecord(metadata) ? metadata : {};
@@ -330,7 +325,7 @@ function describeManifestAction(name, kind, metadata, source) {
330
325
  return {
331
326
  name,
332
327
  kind,
333
- category: actionCategory(name, record.category),
328
+ category: actionCategory(name),
334
329
  description: typeof record.description === "string" ? record.description : "",
335
330
  fields: properties,
336
331
  schema,
@@ -347,8 +342,7 @@ function describeManifestAction(name, kind, metadata, source) {
347
342
  ])]
348
343
  };
349
344
  }
350
- function actionCategory(name, configured) {
351
- if (typeof configured === "string" && configured.trim()) return configured.trim().toLowerCase();
345
+ function actionCategory(name) {
352
346
  const segments = name.split(".");
353
347
  if (segments[0] === "metamask" && segments.length > 2) return segments[1] ?? "metamask";
354
348
  if (segments[0] === "app" || segments[0] === "cdp") return "runtime";
@@ -21,6 +21,7 @@ import {
21
21
  import { captureHelperSupportsRecordSessionSnapshots } from "../recording-target.js";
22
22
  import { startRecipeRecording, stopRecipeRecording } from "../run-recording.js";
23
23
  import { validateMetaMaskActionInputs } from "../metamask-action-validation.js";
24
+ import { gitLibraryProvenance } from "../library-provenance.js";
24
25
  import {
25
26
  beginRunDiagnostics,
26
27
  finishRunDiagnostics
@@ -655,14 +656,22 @@ async function resolveMetaMaskLibrarySources(libraryEntry, recipePath) {
655
656
  if (!sources.some((source) => path.resolve(source.root) === canonicalRoot)) {
656
657
  sources.push({ name: "metamask", root: canonicalRoot });
657
658
  }
658
- return sources.map((source) => ({
659
- ...source,
660
- provenance: source.provenance ?? {
661
- kind: source.name === "metamask" ? "bundled" : "library",
662
- trust: source.name === "metamask" ? "trusted" : "unknown",
663
- name: source.name ?? path.basename(source.root)
664
- }
665
- }));
659
+ return Promise.all(
660
+ sources.map(async (source) => {
661
+ const isBundled = source.name === "metamask";
662
+ const detected = isBundled ? {} : await gitLibraryProvenance(source.root);
663
+ return {
664
+ ...source,
665
+ provenance: {
666
+ kind: isBundled ? "bundled" : "library",
667
+ trust: isBundled ? "trusted" : "unknown",
668
+ name: source.name ?? path.basename(source.root),
669
+ ...detected,
670
+ ...source.provenance
671
+ }
672
+ };
673
+ })
674
+ );
666
675
  }
667
676
  function synthesizeOneNodeRecipe(action, args) {
668
677
  return {
@@ -0,0 +1,33 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ async function gitLibraryProvenance(root) {
5
+ try {
6
+ const { stdout: tracked } = await execFileAsync("git", ["-C", root, "ls-files", "--", "."], {
7
+ encoding: "utf8",
8
+ timeout: 5e3
9
+ });
10
+ if (!tracked.trim()) return {};
11
+ const [{ stdout: revision }, { stdout: status }] = await Promise.all([
12
+ execFileAsync("git", ["-C", root, "rev-parse", "HEAD"], {
13
+ encoding: "utf8",
14
+ timeout: 5e3
15
+ }),
16
+ execFileAsync(
17
+ "git",
18
+ ["-C", root, "status", "--porcelain=v1", "--untracked-files=normal", "--", "."],
19
+ {
20
+ encoding: "utf8",
21
+ timeout: 5e3
22
+ }
23
+ )
24
+ ]);
25
+ const trimmedRevision = revision.trim();
26
+ return trimmedRevision ? { revision: trimmedRevision, dirty: status.trim().length > 0 } : {};
27
+ } catch {
28
+ return {};
29
+ }
30
+ }
31
+ export {
32
+ gitLibraryProvenance
33
+ };