@deeeed/metamask-harness 0.18.0 → 0.19.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +187 -0
  2. package/README.md +4 -7
  3. package/adapters/core/inject.sh +1 -6
  4. package/adapters/extension/inject.mjs +1 -4
  5. package/adapters/extension/verify.sh +1 -1
  6. package/adapters/mobile/inject.sh +5 -4
  7. package/adapters/mobile/verify.sh +15 -19
  8. package/adapters/shared/harness-source-fingerprint.mjs +14 -12
  9. package/dist/adapters/harness-freshness.js +49 -0
  10. package/dist/cli.js +0 -4
  11. package/dist/command-contract.js +51 -40
  12. package/dist/command-journal.js +50 -12
  13. package/dist/commands/call.js +95 -20
  14. package/dist/commands/check.js +1 -1
  15. package/dist/commands/completion-candidates.js +7 -19
  16. package/dist/commands/fixtures.js +50 -1
  17. package/dist/commands/last.js +9 -1
  18. package/dist/commands/launch/extension.js +2 -2
  19. package/dist/commands/launch/mobile.js +2 -0
  20. package/dist/commands/list-executables.js +56 -20
  21. package/dist/commands/manifest.js +28 -12
  22. package/dist/commands/parse-args.js +55 -3
  23. package/dist/commands/provision.js +0 -1
  24. package/dist/commands/run-engine.js +514 -336
  25. package/dist/commands/run.js +25 -37
  26. package/dist/commands/shared.js +1 -1
  27. package/dist/heal-bounds.js +5 -0
  28. package/dist/mm-harness-cli.js +22 -30
  29. package/dist/run-diagnostics.js +1 -1
  30. package/dist/run-recording.js +1 -1
  31. package/dist/runner.js +24 -2
  32. package/docs/CONTRIBUTING.md +2 -3
  33. package/docs/QA.md +0 -1
  34. package/docs/RECIPES.md +52 -91
  35. package/library/README.md +5 -5
  36. package/library/actions/core/perps/_controller.mjs +33 -1
  37. package/library/actions/core/perps/assert_orders.mjs +6 -7
  38. package/library/actions/core/perps/assert_positions.mjs +6 -7
  39. package/library/actions/core/perps/close_orders.mjs +2 -0
  40. package/library/actions/core/perps/close_positions.mjs +2 -0
  41. package/library/actions/core/perps/ensure_orders.mjs +4 -2
  42. package/library/actions/core/perps/ensure_positions.mjs +4 -2
  43. package/library/actions/core/perps/place_order.mjs +7 -3
  44. package/library/actions/extension/perps/assert_orders.mjs +2 -1
  45. package/library/actions/extension/perps/assert_positions.mjs +2 -1
  46. package/library/actions/extension/perps/perps.mjs +43 -14
  47. package/library/actions/mobile/perps/assert_orders.mjs +2 -1
  48. package/library/actions/mobile/perps/assert_positions.mjs +2 -1
  49. package/library/actions/mobile/perps/perps.mjs +40 -12
  50. package/library/library.json +1 -1
  51. package/library/manifests/core.action-manifest.json +1170 -413
  52. package/library/manifests/extension.action-manifest.json +1495 -641
  53. package/library/manifests/mobile.action-manifest.json +1704 -769
  54. package/library/recipes/app/lifecycle.android-smoke.mobile.recipe.json +63 -81
  55. package/library/recipes/perps/clean-market-testnet.core.recipe.json +44 -0
  56. package/library/recipes/perps/clean-market-testnet.recipe.json +49 -0
  57. package/library/recipes/perps/lifecycle.recipe.json +136 -180
  58. package/library/recipes/perps/order-lifecycle.core.recipe.json +71 -67
  59. package/library/recipes/perps/performance.background-resume.mobile.recipe.json +51 -67
  60. package/library/recipes/perps/performance.cold-start.mobile.recipe.json +51 -67
  61. package/library/recipes/perps/performance.mobile.recipe.json +37 -51
  62. package/library/recipes/perps/performance.warm-start.mobile.recipe.json +44 -59
  63. package/library/recipes/perps/read-markets.core.recipe.json +29 -31
  64. package/library/recipes/perps/smoke.core.recipe.json +29 -32
  65. package/library/recipes/perps/smoke.extension.recipe.json +41 -44
  66. package/library/recipes/perps/smoke.mobile.recipe.json +42 -44
  67. package/library/recipes/perps/trading-lifecycle.core.recipe.json +69 -65
  68. package/library/recipes/runner/action-validation.extension.recipe.json +312 -405
  69. package/library/recipes/runner/action-validation.mobile.recipe.json +316 -409
  70. package/library/recipes/runner/smoke.core.recipe.json +18 -20
  71. package/library/recipes/runner/smoke.extension.recipe.json +23 -24
  72. package/library/recipes/runner/smoke.mobile.recipe.json +23 -24
  73. package/library/recipes/wallet/smoke.extension.recipe.json +33 -35
  74. package/library/recipes/wallet/smoke.mobile.recipe.json +33 -35
  75. package/package.json +3 -3
  76. package/scripts/completions.sh +1 -4
  77. package/dist/adapters/extension/harness-freshness.js +0 -39
  78. package/dist/commands/flows.js +0 -91
  79. package/library/flows/perps.flows.json +0 -64
package/README.md CHANGED
@@ -5,8 +5,7 @@ reviewable recipe evidence. Run it inside a checkout; the product, slot, ports,
5
5
  and runtime paths are detected automatically.
6
6
 
7
7
  - **Action:** one typed operation.
8
- - **Flow:** a reusable, parameterized sequence of actions.
9
- - **Recipe:** the complete proof for a task or regression.
8
+ - **Recipe:** a reusable, parameterized graph of actions and called recipes.
10
9
 
11
10
  The generic graph engine and evidence schemas live in Farmslot packages.
12
11
  `mm-harness` owns MetaMask runtime control and domain capabilities.
@@ -71,12 +70,11 @@ mm-harness logs --source metro # Mobile bundler
71
70
  mm-harness actions positions
72
71
  mm-harness actions --action metamask.wallet.ensure_unlocked
73
72
  mm-harness call metamask.wallet.ensure_unlocked
74
- mm-harness flows
75
- mm-harness flows describe perps.clean_market_testnet
76
73
 
77
74
  mm-harness run --list
75
+ mm-harness run perps.clean-market-testnet --describe
78
76
  mm-harness run wallet.smoke --describe
79
- mm-harness run path/to/recipe.json --plan
77
+ mm-harness run path/to/recipe.json market=ETH --plan
80
78
  mm-harness run path/to/recipe.json
81
79
  ```
82
80
 
@@ -92,11 +90,10 @@ line-buffered JSONL progress and a terminal event.
92
90
 
93
91
  ```bash
94
92
  export RECIPE_LIBRARY_PATH="team=$HOME/shared-library/team-recipes"
95
- mm-harness flows
96
93
  mm-harness run --list
97
94
  ```
98
95
 
99
- Shared libraries hold durable actions, flows, and recipes. Task acceptance
96
+ Shared libraries hold durable actions and composable recipes. Task acceptance
100
97
  criteria remain task-local. See [Recipes](docs/RECIPES.md).
101
98
 
102
99
  ## Recover
@@ -110,12 +110,7 @@ install_v1_runner_assets() {
110
110
  if [ -d "$METAMASK_RUNNER_DIR/library/recipes" ]; then
111
111
  rsync -a --delete "$METAMASK_RUNNER_DIR/library/recipes/" "$HARNESS_DIR/runner/recipes/"
112
112
  fi
113
- # Canonical recipe-library snapshot: flows/ + library.json so the installed
114
- # harness mirrors the runner's library layout.
115
- if [ -d "$METAMASK_RUNNER_DIR/library/flows" ]; then
116
- mkdir -p "$HARNESS_DIR/runner/flows"
117
- rsync -a --delete "$METAMASK_RUNNER_DIR/library/flows/" "$HARNESS_DIR/runner/flows/"
118
- fi
113
+ rm -rf "$HARNESS_DIR/runner/flows"
119
114
  if [ -f "$METAMASK_RUNNER_DIR/library/library.json" ]; then
120
115
  cp "$METAMASK_RUNNER_DIR/library/library.json" "$HARNESS_DIR/runner/library.json"
121
116
  fi
@@ -90,11 +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
- // Canonical recipe-library snapshot: flows/ + library.json so the installed
94
- // harness mirrors the runner's library layout.
95
93
  copyFile(path.join(runnerDir, 'library/library.json'), path.join(harnessDir, 'runner/library.json'));
96
- fs.mkdirSync(path.join(harnessDir, 'runner/flows'), { recursive: true });
97
- copyDir(path.join(runnerDir, 'library/flows'), path.join(harnessDir, 'runner/flows'));
94
+ fs.rmSync(path.join(harnessDir, 'runner/flows'), { recursive: true, force: true });
98
95
  // The installed overlay has a flat scripts/ runtime API independent of the
99
96
  // package's source layout.
100
97
  copyFile(path.join(runnerDir, 'adapters/extension/launch-browser.cjs'), path.join(harnessDir, 'scripts/launch-browser.cjs'));
@@ -434,7 +434,7 @@ if [ "$STATIC_ONLY" = false ]; then
434
434
 
435
435
  if (
436
436
  cd "$TARGET"
437
- "$RUNNER_BIN" run "$SMOKE_RECIPE" --adapter extension --project-root "$TARGET" --cdp-port "$CDP_PORT" --artifacts-dir "$ARTIFACTS/runner-smoke" --json
437
+ "$RUNNER_BIN" run "$SMOKE_RECIPE" --adapter extension --target "$TARGET" --cdp-port "$CDP_PORT" --artifacts-dir "$ARTIFACTS/runner-smoke" --json
438
438
  ) > "$ARTIFACTS/logs/runner-smoke.log" 2>&1; then
439
439
  checks+=("{\"name\":\"runner v1 smoke\",\"status\":\"pass\"}")
440
440
  else
@@ -75,8 +75,7 @@ cp "$RUNNER_DIR/library/manifests/mobile.action-manifest.json" "$HARNESS_DIR/run
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
77
  cp "$RUNNER_DIR/library/library.json" "$HARNESS_DIR/runner/library.json"
78
- mkdir -p "$HARNESS_DIR/runner/flows"
79
- rsync -a --delete "$RUNNER_DIR/library/flows/" "$HARNESS_DIR/runner/flows/"
78
+ rm -rf "$HARNESS_DIR/runner/flows"
80
79
  cp "$RUNNER_DIR/adapters/mobile/verify.sh" "$HARNESS_DIR/scripts/verify.sh"
81
80
  cp "$RUNNER_DIR/adapters/shared/harness-path.sh" "$HARNESS_DIR/scripts/lib/harness-path.sh"
82
81
  cp "$RUNNER_DIR/adapters/shared/path-defaults.json" "$HARNESS_DIR/scripts/lib/path-defaults.json"
@@ -96,18 +95,20 @@ if [ "$GIT_EXCLUDE" = true ]; then
96
95
  fi
97
96
 
98
97
  RUNNER_REV="$(git -C "$RUNNER_DIR" rev-parse HEAD 2>/dev/null || echo unknown)"
98
+ SOURCE_FINGERPRINT="$(node "$RUNNER_DIR/adapters/shared/harness-source-fingerprint.mjs" "$RUNNER_DIR" mobile)"
99
99
  CLEANUP_Q="$(printf '%q' "$SCRIPT_DIR/cleanup.sh")"
100
100
  TARGET_CLEANUP_Q="$(printf '%q' "$TARGET")"
101
101
  CLEANUP_COMMAND="RECIPE_HARNESS_ROOT=$(printf '%q' "$HARNESS_ROOT") $CLEANUP_Q --target $TARGET_CLEANUP_Q"
102
- node - "$HARNESS_DIR/manifest.json" "$TARGET_REV" "$RUNNER_DIR" "$RUNNER_REV" "$TARGET" "$HARNESS_REL" "$HARNESS_ROOT" "$CLEANUP_COMMAND" <<'NODE'
102
+ node - "$HARNESS_DIR/manifest.json" "$TARGET_REV" "$RUNNER_DIR" "$RUNNER_REV" "$SOURCE_FINGERPRINT" "$TARGET" "$HARNESS_REL" "$HARNESS_ROOT" "$CLEANUP_COMMAND" <<'NODE'
103
103
  const fs = require('node:fs');
104
- const [manifestPath, targetRevision, runnerDir, runnerRevision, target, harnessRel, harnessRoot, cleanupCommand] = process.argv.slice(2);
104
+ const [manifestPath, targetRevision, runnerDir, runnerRevision, sourceFingerprint, target, harnessRel, harnessRoot, cleanupCommand] = process.argv.slice(2);
105
105
  fs.writeFileSync(manifestPath, JSON.stringify({
106
106
  adapter: 'mobile',
107
107
  installMode: 'product-owned',
108
108
  targetRevision,
109
109
  installedAt: new Date().toISOString(),
110
110
  source: { runnerDir, runnerRevision },
111
+ sourceFingerprint,
111
112
  target,
112
113
  protocolVersion: 'v1',
113
114
  actionManifestPath: `${harnessRel}/action-manifest.json`,
@@ -305,16 +305,14 @@ live_status_ok() {
305
305
  local device_target="$2"
306
306
  cat > "$ARTIFACTS/mobile-status-smoke.recipe.json" <<'JSON'
307
307
  {
308
- "schema_version": 1,
308
+ "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json",
309
309
  "title": "Mobile runner status smoke",
310
310
  "description": "Checks that the installed runner can read Mobile app status before live verification.",
311
- "validate": {
312
- "workflow": {
313
- "entry": "status",
314
- "nodes": {
315
- "status": { "action": "app.status", "intent": "Read Mobile app status through the runner-owned bridge", "next": "done" },
316
- "done": { "action": "end", "status": "pass" }
317
- }
311
+ "workflow": {
312
+ "entry": "status",
313
+ "nodes": {
314
+ "status": { "action": "app.status", "intent": "Read Mobile app status through the runner-owned bridge", "next": "done" },
315
+ "done": { "action": "end", "status": "pass" }
318
316
  }
319
317
  }
320
318
  }
@@ -323,7 +321,7 @@ JSON
323
321
  cd "$TARGET"
324
322
  device_args=()
325
323
  [ -n "$device_target" ] && device_args=(--device "$device_target")
326
- "$RUNNER_BIN" run "$ARTIFACTS/mobile-status-smoke.recipe.json" --adapter mobile --project-root "$TARGET" "${device_args[@]}" --artifacts-dir "$ARTIFACTS/runner-status-smoke" --json
324
+ "$RUNNER_BIN" run "$ARTIFACTS/mobile-status-smoke.recipe.json" --adapter mobile --target "$TARGET" "${device_args[@]}" --artifacts-dir "$ARTIFACTS/runner-status-smoke" --json
327
325
  ) > "$log_path" 2>&1
328
326
  }
329
327
 
@@ -389,17 +387,15 @@ if [ "$STATIC_ONLY" = false ]; then
389
387
 
390
388
  cat > "$ARTIFACTS/mobile-v1-live-smoke.recipe.json" <<'JSON'
391
389
  {
392
- "schema_version": 1,
390
+ "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json",
393
391
  "title": "Mobile v1 runner live bridge smoke",
394
392
  "description": "Verifies the installed MetaMask runner can read the React Native debug bridge without mutating wallet state.",
395
- "validate": {
396
- "workflow": {
397
- "entry": "status",
398
- "nodes": {
399
- "status": { "action": "app.status", "intent": "Read Mobile app status through the v1 runner", "next": "cdp-probe" },
400
- "cdp-probe": { "action": "cdp.target", "intent": "Verify the React Native debug bridge target is reachable", "required": true, "timeout_ms": 15000, "next": "done" },
401
- "done": { "action": "end", "status": "pass" }
402
- }
393
+ "workflow": {
394
+ "entry": "status",
395
+ "nodes": {
396
+ "status": { "action": "app.status", "intent": "Read Mobile app status through the v1 runner", "next": "cdp-probe" },
397
+ "cdp-probe": { "action": "cdp.target", "intent": "Verify the React Native debug bridge target is reachable", "require_reachable": true, "timeout_ms": 15000, "next": "done" },
398
+ "done": { "action": "end", "status": "pass" }
403
399
  }
404
400
  }
405
401
  }
@@ -415,7 +411,7 @@ JSON
415
411
  IOS_SIMULATOR="${IOS_SIMULATOR:-$ios_simulator_resolved}" \
416
412
  ADB_SERIAL="${ADB_SERIAL:-$adb_serial_resolved}" \
417
413
  ANDROID_SERIAL="${ANDROID_SERIAL:-$adb_serial_resolved}" \
418
- "$RUNNER_BIN" run "$ARTIFACTS/mobile-v1-live-smoke.recipe.json" --adapter mobile --project-root "$TARGET" "${device_args[@]}" --artifacts-dir "$ARTIFACTS/runner-live-smoke" --json
414
+ "$RUNNER_BIN" run "$ARTIFACTS/mobile-v1-live-smoke.recipe.json" --adapter mobile --target "$TARGET" "${device_args[@]}" --artifacts-dir "$ARTIFACTS/runner-live-smoke" --json
419
415
  ) > "$ARTIFACTS/logs/runner-live-smoke.log" 2>&1; then
420
416
  checks+=("{\"name\":\"runner v1 live bridge smoke\",\"status\":\"pass\"}")
421
417
  else
@@ -12,27 +12,29 @@
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import crypto from 'node:crypto';
15
+ import { pathToFileURL } from 'node:url';
15
16
 
16
- // Trees whose contents are copied into <slot>/harness by inject.mjs. Any change
17
- // here means injected slots are stale.
18
- const SOURCE_DIRS = ['adapters/extension', 'adapters/shared', 'library'];
19
-
20
- export function harnessSourceFingerprint(runnerDir) {
17
+ export function harnessSourceFingerprint(runnerDir, adapter = 'extension') {
18
+ if (adapter !== 'extension' && adapter !== 'mobile') {
19
+ throw new Error(`Unsupported harness fingerprint adapter: ${adapter}`);
20
+ }
21
+ const sourceDirs = [`adapters/${adapter}`, 'adapters/shared', 'library'];
21
22
  const entries = [];
22
- for (const rel of SOURCE_DIRS) {
23
+ for (const rel of sourceDirs) {
23
24
  collect(path.join(runnerDir, rel), runnerDir, entries);
24
25
  }
25
26
  entries.sort();
26
27
  return crypto.createHash('sha256').update(entries.join('\n')).digest('hex');
27
28
  }
28
29
 
30
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
31
+ process.stdout.write(`${harnessSourceFingerprint(path.resolve(process.argv[2] ?? '.'), process.argv[3])}\n`);
32
+ }
33
+
29
34
  function collect(dir, root, out) {
30
- let dirents;
31
- try {
32
- dirents = fs.readdirSync(dir, { withFileTypes: true });
33
- } catch {
34
- return; // absent tree contributes nothing
35
- }
35
+ // These trees are required runner inputs. Fail closed instead of producing a
36
+ // weaker fingerprint that could make a stale injected harness appear current.
37
+ const dirents = fs.readdirSync(dir, { withFileTypes: true });
36
38
  for (const dirent of dirents) {
37
39
  // Skip hidden noise (.DS_Store, editor swap files) so the fingerprint tracks
38
40
  // only real source and does not thrash re-injects.
@@ -0,0 +1,49 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { recipeHarnessPath, runnerDir } from "../paths.js";
6
+ async function ensureHarnessFresh(target, adapter) {
7
+ if (!fs.existsSync(path.join(target, "package.json")) || !hasCheckoutHead(target)) return;
8
+ let current;
9
+ try {
10
+ const mod = await import(pathToFileURL(path.join(runnerDir, "adapters/shared/harness-source-fingerprint.mjs")).href);
11
+ current = mod.harnessSourceFingerprint(runnerDir, adapter);
12
+ } catch (error) {
13
+ const detail = error instanceof Error ? `: ${error.message}` : "";
14
+ throw new Error(`could not fingerprint the ${adapter} harness source${detail}`);
15
+ }
16
+ const manifestPath = recipeHarnessPath(target, adapter, "manifest.json");
17
+ const installed = readInstalledFingerprint(manifestPath);
18
+ if (installed === current) return;
19
+ const reason = installed === null ? "harness not installed" : "runner changed";
20
+ const injector = path.join(runnerDir, `adapters/${adapter}/inject.${adapter === "extension" ? "mjs" : "sh"}`);
21
+ try {
22
+ execFileSync(adapter === "extension" ? process.execPath : "bash", [injector, "--target", target], {
23
+ stdio: ["ignore", "ignore", "inherit"]
24
+ });
25
+ console.error(`\u2192 refreshed ${adapter} harness overlay (${reason})`);
26
+ } catch (error) {
27
+ const detail = error instanceof Error ? `: ${error.message}` : "";
28
+ throw new Error(`could not refresh the ${adapter} harness overlay (${reason})${detail}`);
29
+ }
30
+ }
31
+ function hasCheckoutHead(target) {
32
+ try {
33
+ execFileSync("git", ["-C", target, "rev-parse", "--verify", "HEAD"], { stdio: "ignore" });
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+ function readInstalledFingerprint(manifestPath) {
40
+ try {
41
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
42
+ return typeof manifest.sourceFingerprint === "string" ? manifest.sourceFingerprint : null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+ export {
48
+ ensureHarnessFresh
49
+ };
package/dist/cli.js CHANGED
@@ -11,7 +11,6 @@ import { handleRun } from "./commands/run.js";
11
11
  import { handleSelfTest } from "./commands/self-test.js";
12
12
  import { handleCall } from "./commands/call.js";
13
13
  import { handleStop } from "./commands/stop.js";
14
- import { handleFlows } from "./commands/flows.js";
15
14
  import { handleCompletionCandidates, invalidateCompletionCache } from "./commands/completion-candidates.js";
16
15
  import { handleLaunch } from "./commands/launch/index.js";
17
16
  import { handleLogs } from "./commands/logs.js";
@@ -53,8 +52,6 @@ DAILY LOOP \u2014 what a teammate runs many times a day:
53
52
  PROVE \u2014 run recipes and inspect capabilities:
54
53
  run Run a recipe and write evidence (summary/trace/artifacts).
55
54
  mm-harness run recipe.json
56
- flows List/promote recipe library flows (personal/team win over canonical).
57
- mm-harness flows list
58
55
  doctor Readiness check for a checkout (no app launch).
59
56
  mm-harness doctor
60
57
  check Run bounded repo-local checks for an active diff.
@@ -92,7 +89,6 @@ async function main(argv) {
92
89
  if (command === "status" || command === "health" || command === "home") {
93
90
  return handleStatus(parseArgs(argv.slice(1), "status"));
94
91
  }
95
- if (command === "flows") return handleFlows(argv.slice(1));
96
92
  if (command === "call") return handleCall(argv.slice(1));
97
93
  if (command === "completion-candidates") return handleCompletionCandidates(argv.slice(1));
98
94
  if (command === "provision") return handleProvision(parseArgs(argv.slice(1), command));
@@ -5,7 +5,7 @@ const options = (...groups) => Object.assign({}, ...groups);
5
5
  const HELP = { "--help": bool(), "-h": bool() };
6
6
  const JSON = { "--json": bool() };
7
7
  const JSON_STREAM = { "--json-stream": bool() };
8
- const TARGET = { "--target": value(), "--project-root": value() };
8
+ const TARGET = { "--target": value() };
9
9
  const ADAPTER = { "--adapter": value(["mobile", "extension", "core"]) };
10
10
  const MOBILE_PLATFORM = { "--platform": value(["ios", "android"]) };
11
11
  const ADAPTER_OR_MOBILE_PLATFORM = { "--platform": value(["mobile", "extension", "core", "ios", "android"]) };
@@ -25,9 +25,8 @@ const RECIPE_RUNTIME = {
25
25
  "--validation-runtime-dir": value(),
26
26
  "--launch-existing-dist": bool(),
27
27
  "--record-video": optionalValue(["full-run", "off"]),
28
- "--record": bool(),
29
28
  "--source-trust": value(["trusted", "untrusted", "unknown"]),
30
- "--source-kind": value(["bundled", "operator", "task", "recipe-file", "uses-catalog", "library", "custom-adapter"]),
29
+ "--source-kind": value(["bundled", "operator", "task", "recipe-file", "library", "custom-adapter"]),
31
30
  "--source-name": value(),
32
31
  "--source-digest": value(),
33
32
  "--approve-plan": value()
@@ -46,6 +45,10 @@ const RUNWAY = {
46
45
  "--force": bool(),
47
46
  "--resolve-only": bool()
48
47
  };
48
+ const REMOVED_OPTION_REPLACEMENTS = {
49
+ "--project-root": "--target",
50
+ "--record": "--record-video"
51
+ };
49
52
  const PUBLIC_COMMAND_CONTRACTS = {
50
53
  status: {
51
54
  aliases: ["health", "home"],
@@ -100,7 +103,7 @@ const PUBLIC_COMMAND_CONTRACTS = {
100
103
  })
101
104
  },
102
105
  call: {
103
- options: options(HELP, JSON, TARGET, ADAPTER, ADAPTER_OR_MOBILE_PLATFORM, DEVICE, RUNTIME_PORTS, RECIPE_RUNTIME, {
106
+ options: options(HELP, JSON, TARGET, ADAPTER, MOBILE_PLATFORM, DEVICE, RUNTIME_PORTS, RECIPE_RUNTIME, {
104
107
  "--list": bool(),
105
108
  "--arg": value()
106
109
  }),
@@ -111,23 +114,8 @@ const PUBLIC_COMMAND_CONTRACTS = {
111
114
  leadingPositionals: 1,
112
115
  variadic: { label: "key=value", pattern: /^[^=\s]+=.*/u }
113
116
  },
114
- flows: {
115
- options: options(HELP, JSON, TARGET, {
116
- "--library": value(),
117
- "--from": value(),
118
- "--flow": value(),
119
- "--to": value(),
120
- "--domain": value(),
121
- "--run": value(),
122
- "--force": bool()
123
- }),
124
- positionals: [
125
- { label: "action", choices: ["list", "describe", "promote"] },
126
- { label: "ref" }
127
- ]
128
- },
129
117
  run: {
130
- options: options(HELP, JSON, JSON_STREAM, TARGET, ADAPTER, ADAPTER_OR_MOBILE_PLATFORM, DEVICE, RUNTIME_PORTS, RECIPE_RUNTIME, {
118
+ options: options(HELP, JSON, JSON_STREAM, TARGET, ADAPTER, MOBILE_PLATFORM, DEVICE, RUNTIME_PORTS, RECIPE_RUNTIME, {
131
119
  "--list": bool(),
132
120
  "--plan": bool(),
133
121
  "--describe": bool()
@@ -135,7 +123,8 @@ const PUBLIC_COMMAND_CONTRACTS = {
135
123
  positionals: [{ label: "recipe" }],
136
124
  minimumPositionals: 1,
137
125
  requiredUnless: ["--list"],
138
- noPositionalsWith: ["--list"]
126
+ noPositionalsWith: ["--list"],
127
+ variadic: { label: "key=value", pattern: /^[^=\s]+=.*/u }
139
128
  },
140
129
  last: {
141
130
  options: options(HELP, JSON, TARGET, {
@@ -238,7 +227,7 @@ const PUBLIC_COMMAND_CONTRACTS = {
238
227
  "--action-manifest": value()
239
228
  }),
240
229
  positionals: [{ label: "action", choices: ["init", "sync", "set", "generate", "finalize"] }],
241
- minimumPositionals: 1
230
+ minimumPositionals: 0
242
231
  }
243
232
  };
244
233
  function validatePublicInvocation(argv, examples = {}) {
@@ -285,11 +274,11 @@ function validatePublicInvocation(argv, examples = {}) {
285
274
  const inlineValue = equals === -1 ? void 0 : argument.slice(equals + 1);
286
275
  const spec = contract.options[optionName];
287
276
  if (!spec) {
288
- const suggestion = closest(optionName, validOptions);
277
+ const suggestion = REMOVED_OPTION_REPLACEMENTS[optionName] ?? closest(optionName, validOptions);
289
278
  return {
290
279
  code: "CLI_UNKNOWN_OPTION",
291
280
  command: name,
292
- message: `unknown option '${optionName}'. Valid options for mm-harness ${name}: ${formatOptions(contract)}.`,
281
+ message: `unknown option '${optionName}'.`,
293
282
  userAction: actionFor(optionName, suggestion, exampleFor(name, examples))
294
283
  };
295
284
  }
@@ -312,7 +301,7 @@ function validatePublicInvocation(argv, examples = {}) {
312
301
  return {
313
302
  code: "CLI_INVALID_OPTION_VALUE",
314
303
  command: name,
315
- message: `${optionName} must be ${formatChoiceList(spec.choices)}; received '${optionValue}'. Valid options for mm-harness ${name}: ${formatOptions(contract)}.`,
304
+ message: `${optionName} must be ${formatChoiceList(spec.choices)}; received '${optionValue}'.`,
316
305
  userAction: actionFor(optionValue, suggestion, exampleFor(name, examples))
317
306
  };
318
307
  }
@@ -320,7 +309,7 @@ function validatePublicInvocation(argv, examples = {}) {
320
309
  if (seenOptions.has("--help") || seenOptions.has("-h")) return null;
321
310
  if (contract.leadingPositionals && !contract.requiredUnless?.some((option) => seenOptions.has(option)) && tokens.slice(0, contract.leadingPositionals).some((argument) => !argument || argument.startsWith("-"))) {
322
311
  const missing = contract.positionals?.[0]?.label ?? "argument";
323
- return usageFailure("CLI_MISSING_POSITIONAL", name, `${name} requires <${missing}> first.`, contract, examples);
312
+ return missingPositionalFailure(name, `${name} requires <${missing}> first.`, contract, examples);
324
313
  }
325
314
  const forbiddingOption = contract.noPositionalsWith?.find((option) => seenOptions.has(option));
326
315
  if (forbiddingOption && positionals.length > 0) {
@@ -338,7 +327,7 @@ function validatePublicInvocation(argv, examples = {}) {
338
327
  const missing = missingSpec?.label ?? "argument";
339
328
  const valid = missingSpec?.choices?.length ? ` Choose one: ${missingSpec.choices.join("|")}.` : "";
340
329
  const usage = contract.usage ? ` Usage: ${contract.usage}.` : "";
341
- return usageFailure("CLI_MISSING_POSITIONAL", name, `missing required <${missing}>.${valid}${usage}`, contract, examples);
330
+ return missingPositionalFailure(name, `missing required <${missing}>.${valid}${usage}`, contract, examples);
342
331
  }
343
332
  const fixed = contract.positionals ?? [];
344
333
  for (let index = 0; index < Math.min(positionals.length, fixed.length); index += 1) {
@@ -376,21 +365,44 @@ function resolveCommand(token) {
376
365
  }
377
366
  return null;
378
367
  }
379
- function usageFailure(code, command, message, contract, examples) {
368
+ function usageFailure(code, command, message, _contract, examples) {
380
369
  return {
381
370
  code,
382
371
  command,
383
- message: `${message} Valid options for mm-harness ${command}: ${formatOptions(contract)}.`,
372
+ message,
384
373
  userAction: `Try: ${exampleFor(command, examples)}. Inspect options: mm-harness ${command} --help`
385
374
  };
386
375
  }
387
- function positionalFailure(command, received, spec, contract, examples) {
376
+ function missingPositionalFailure(command, message, contract, examples) {
377
+ if (command === "run") {
378
+ return {
379
+ code: "CLI_MISSING_POSITIONAL",
380
+ command,
381
+ message,
382
+ userAction: `List recipes: mm-harness run --list
383
+ Example: ${exampleFor(command, examples)}
384
+ More: mm-harness run --help`
385
+ };
386
+ }
387
+ if (command === "call") {
388
+ return {
389
+ code: "CLI_MISSING_POSITIONAL",
390
+ command,
391
+ message,
392
+ userAction: `List actions: mm-harness call --list
393
+ Example: ${exampleFor(command, examples)}
394
+ More: mm-harness call --help`
395
+ };
396
+ }
397
+ return usageFailure("CLI_MISSING_POSITIONAL", command, message, contract, examples);
398
+ }
399
+ function positionalFailure(command, received, spec, _contract, examples) {
388
400
  const valid = spec.validDescription ?? (spec.choices ? spec.choices.join(", ") : spec.pattern || spec.validate ? `a valid <${spec.label}>` : `<${spec.label}>`);
389
401
  const suggestion = spec.choices ? closest(received, spec.choices) : void 0;
390
402
  return {
391
403
  code: "CLI_INVALID_POSITIONAL",
392
404
  command,
393
- message: `invalid <${spec.label}> '${received}'. Valid values: ${valid}. Valid options for mm-harness ${command}: ${formatOptions(contract)}.`,
405
+ message: `invalid <${spec.label}> '${received}'. Valid values: ${valid}.`,
394
406
  userAction: actionFor(received, suggestion, exampleFor(command, examples))
395
407
  };
396
408
  }
@@ -400,13 +412,6 @@ function invalidPositional(value2, spec) {
400
412
  if (spec.validate && !spec.validate(value2)) return true;
401
413
  return Boolean(spec.pattern && !spec.pattern.test(value2));
402
414
  }
403
- function formatOptions(contract) {
404
- return Object.entries(contract.options).map(([name, spec]) => {
405
- if (spec.kind === "boolean") return name;
406
- const valueLabel = spec.choices?.join("|") ?? "value";
407
- return spec.kind === "optional-value" ? `${name}[=${valueLabel}]` : `${name} <${valueLabel}>`;
408
- }).join(", ");
409
- }
410
415
  function formatChoiceList(choices) {
411
416
  if (choices.length === 1) return choices[0] ?? "";
412
417
  if (choices.length === 2) return `${choices[0]} or ${choices[1]}`;
@@ -432,19 +437,25 @@ function closest(input, candidates) {
432
437
  return best.distance <= threshold ? best.candidate : void 0;
433
438
  }
434
439
  function levenshtein(left, right) {
435
- const prior = Array.from({ length: right.length + 1 }, (_, index) => index);
440
+ let twoBack;
441
+ let prior = Array.from({ length: right.length + 1 }, (_, index) => index);
436
442
  for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
437
443
  const current = [leftIndex];
438
444
  for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
439
445
  const substitution = prior[rightIndex - 1] + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1);
440
446
  current[rightIndex] = Math.min((current[rightIndex - 1] ?? 0) + 1, (prior[rightIndex] ?? 0) + 1, substitution);
447
+ if (twoBack && leftIndex > 1 && rightIndex > 1 && left[leftIndex - 1] === right[rightIndex - 2] && left[leftIndex - 2] === right[rightIndex - 1]) {
448
+ current[rightIndex] = Math.min(current[rightIndex] ?? 0, (twoBack[rightIndex - 2] ?? 0) + 1);
449
+ }
441
450
  }
442
- for (let index = 0; index < current.length; index += 1) prior[index] = current[index] ?? 0;
451
+ twoBack = prior;
452
+ prior = current;
443
453
  }
444
454
  return prior[right.length] ?? right.length;
445
455
  }
446
456
  export {
447
457
  PUBLIC_COMMAND_CONTRACTS,
458
+ closest,
448
459
  publicCommandNames,
449
460
  publicCommandTokens,
450
461
  validatePublicInvocation