@deeeed/metamask-harness 0.46.0 → 0.47.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +1 -1
  3. package/adapters/manifest.json +12 -4
  4. package/adapters/mobile/wait-for-bridge.cjs +550 -0
  5. package/adapters/mobile/wait-for-bridge.sh +11 -148
  6. package/bin/mm-harness +7 -2
  7. package/dist/adapters/mobile/prepare.js +26 -18
  8. package/dist/adapters/mobile/runtime-decision.js +1 -0
  9. package/dist/cli-commands.js +2 -0
  10. package/dist/command-contract.js +7 -0
  11. package/dist/commands/checklist.js +1 -0
  12. package/dist/commands/help.js +76 -0
  13. package/dist/commands/parse-args.js +2 -1
  14. package/dist/commands/recipe-quality.js +1 -1
  15. package/dist/commands/tutorial.js +46 -0
  16. package/dist/mm-harness-cli.js +45 -6
  17. package/docs/CONTRIBUTING.md +2 -0
  18. package/docs/QA.md +2 -2
  19. package/library/actions/mobile/perps/perps.mjs +1 -1
  20. package/package.json +2 -2
  21. package/scripts/site-contrast.mjs +6 -2
  22. package/site/assets/help-recipes.json +113 -0
  23. package/site/architecture.html +0 -497
  24. package/site/assets/metamask-fox.svg +0 -24
  25. package/site/assets/progress.mjs +0 -323
  26. package/site/assets/style.css +0 -1066
  27. package/site/cheatsheet.html +0 -307
  28. package/site/ecosystem.html +0 -162
  29. package/site/how-it-works.html +0 -692
  30. package/site/index.html +0 -184
  31. package/site/perps-advanced-orders-qa.html +0 -96
  32. package/site/perps.html +0 -265
  33. package/site/recipes.html +0 -423
  34. package/site/reviewers.html +0 -375
  35. package/site/tutorials/index.html +0 -181
  36. package/site/tutorials/v1.html +0 -212
  37. package/site/tutorials/v2.html +0 -207
  38. package/site/tutorials/v3.html +0 -258
  39. package/site/tutorials/v4.html +0 -196
  40. package/site/tutorials/v5.html +0 -164
  41. package/site/tutorials/v6.html +0 -166
  42. package/site/tutorials/v7.html +0 -185
@@ -1,52 +1,8 @@
1
1
  #!/usr/bin/env bash
2
- # wait-for-bridge poll the CDP bridge until a route target is registered.
3
- #
4
- # Calls cdp-bridge.cjs status in a poll loop. Emits structured diagnostics on
5
- # each probe failure: Metro reachability, React Native target visibility, bundle
6
- # progress. Fails with non-zero if the bridge is not ready within the poll limit.
7
- #
8
- # Inputs:
9
- # --target <metamask-mobile dir> (default $PWD)
10
- # --port <number> (default WATCHER_PORT env, else METRO_PORT, else 8081)
11
- # --max-polls <n> (default MOBILE_BRIDGE_READY_POLLS env, else 90; 2s each = 180s)
12
- # --platform ios|android bind the required platform: the answering target
13
- # must belong to it, regardless of ambient device env
14
- # Outputs:
15
- # Progress on stderr; exit 0 bridge ready; 1 timeout.
16
- #
17
- # Single device op: wait for bridge readiness. Does not start Metro or open the dev client.
2
+ # Resolve the checkout runtime directory, then delegate the bounded bridge wait
3
+ # to Node so one monotonic deadline covers HTTP, CDP, and retry delays.
18
4
  set -uo pipefail
19
5
 
20
- TARGET="$PWD"
21
- PORT="${WATCHER_PORT:-${METRO_PORT:-8081}}"
22
- MAX_POLLS="${MOBILE_BRIDGE_READY_POLLS:-90}"
23
- REQUIRE_PLATFORM=""
24
-
25
- while [ "$#" -gt 0 ]; do
26
- case "$1" in
27
- --target) [ "$#" -ge 2 ] || { echo "Missing value for --target" >&2; exit 2; }; TARGET="$2"; shift 2 ;;
28
- --port) [ "$#" -ge 2 ] || { echo "Missing value for --port" >&2; exit 2; }; PORT="$2"; shift 2 ;;
29
- --max-polls) [ "$#" -ge 2 ] || { echo "Missing value for --max-polls" >&2; exit 2; }; MAX_POLLS="$2"; shift 2 ;;
30
- --platform) [ "$#" -ge 2 ] || { echo "Missing value for --platform" >&2; exit 2; }; REQUIRE_PLATFORM="$2"; shift 2 ;;
31
- -h|--help)
32
- printf 'Usage: wait-for-bridge.sh [--target <dir>] [--port <n>] [--max-polls <n>] [--platform ios|android]\n'
33
- exit 0
34
- ;;
35
- *) printf 'wait-for-bridge: unknown arg: %s\n' "$1" >&2; exit 2 ;;
36
- esac
37
- done
38
-
39
- case "$MAX_POLLS" in
40
- ''|*[!0-9]*) echo "wait-for-bridge: --max-polls must be an integer of at least 2." >&2; exit 2 ;;
41
- esac
42
- [ "$MAX_POLLS" -ge 2 ] || { echo "wait-for-bridge: --max-polls must be at least 2 for stable readiness." >&2; exit 2; }
43
-
44
- TARGET="$(cd "$TARGET" && pwd -P)"
45
-
46
- # The matcher reads the required platform from the environment so the value survives
47
- # into the node subprocess that runs the shared matcher against the status output.
48
- export WAIT_FOR_BRIDGE_PLATFORM="$REQUIRE_PLATFORM"
49
-
50
6
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
51
7
  # shellcheck disable=SC1091
52
8
  . "$SCRIPT_DIR/../shared/harness-path.sh"
@@ -54,109 +10,16 @@ if ! command -v recipe_runtime_dir >/dev/null 2>&1; then
54
10
  echo "wait-for-bridge: shared lib adapters/shared/harness-path.sh not found; reinstall the runner." >&2
55
11
  exit 1
56
12
  fi
57
- LOG_DIR="$TARGET/$(recipe_runtime_dir)" || exit 1
58
- mkdir -p "$LOG_DIR"
59
-
60
- BRIDGE_CJS="$(dirname "$0")/bridge-runtime/cdp-bridge.cjs"
61
- MATCH_LIB="$(dirname "$0")/bridge-runtime/lib/match-bridge-target.cjs"
62
- STATUS_LOG="$LOG_DIR/bridge-status.log"
63
- METRO_LOG="$LOG_DIR/metro.log"
64
13
 
65
- if [ ! -f "$BRIDGE_CJS" ]; then
66
- printf 'wait-for-bridge: cdp-bridge.cjs not found at %s\n' "$BRIDGE_CJS" >&2
14
+ WAIT_CJS="$SCRIPT_DIR/wait-for-bridge.cjs"
15
+ if [ ! -f "$WAIT_CJS" ]; then
16
+ printf 'wait-for-bridge: helper not found at %s\n' "$WAIT_CJS" >&2
17
+ exit 1
18
+ fi
19
+ if ! command -v node >/dev/null 2>&1; then
20
+ echo "wait-for-bridge: node is required." >&2
67
21
  exit 1
68
22
  fi
69
23
 
70
- metro_ready() {
71
- curl -sf --max-time 2 "http://localhost:${PORT}/status" 2>/dev/null | grep -q 'packager-status:running'
72
- }
73
-
74
- bundle_progress() {
75
- local log="$1"
76
- [ -f "$log" ] || return 0
77
- local p
78
- p="$(grep -E '^[[:space:]]*(iOS|Android).*index\.(js|tsx?).*%|Bundl(ed|ing)|Finished' "$log" 2>/dev/null | tail -1 | tr -d '\r' || true)"
79
- [ -n "$p" ] && printf ' (%s)' "$p"
80
- }
81
-
82
- bridge_wait_reason() {
83
- if ! metro_ready; then
84
- printf 'Metro not reachable on port %s' "$PORT"
85
- return
86
- fi
87
- local targets
88
- targets="$(curl -sf --max-time 2 "http://localhost:${PORT}/json/list" 2>/dev/null || true)"
89
- if [ "$targets" = "[]" ] || [ -z "$targets" ]; then
90
- printf 'Metro running; no React Native debug target yet'
91
- bundle_progress "$METRO_LOG"
92
- return
93
- fi
94
- if [ -s "$STATUS_LOG" ] && grep -qxF '[]' "$STATUS_LOG"; then
95
- printf 'React Native target visible; in-app bridge not ready yet'
96
- bundle_progress "$METRO_LOG"
97
- return
98
- fi
99
- printf 'bridge probe failed'
100
- bundle_progress "$METRO_LOG"
101
- }
102
-
103
- # Matcher is the shared boundary-safe module (match-bridge-target.cjs), so the
104
- # launch ready-path confirm and this poll enforce identical platform/device rules.
105
- bridge_has_route() {
106
- (cd "$TARGET" && APP_ROOT="$TARGET" node "$BRIDGE_CJS" status > "$STATUS_LOG" 2>&1) \
107
- && node -e '
108
- const { hasMatchingRoute } = require(process.argv[1]);
109
- const fs = require("fs");
110
- try {
111
- const value = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
112
- process.exit(hasMatchingRoute(value) ? 0 : 1);
113
- } catch { process.exit(1); }
114
- ' "$MATCH_LIB" "$STATUS_LOG"
115
- }
116
-
117
- # The device/platform this run is pinned to — the bridge target must belong to it.
118
- requested_target() {
119
- node -e 'process.stdout.write(require(process.argv[1]).describeRequested())' "$MATCH_LIB" 2>/dev/null \
120
- || printf 'any platform'
121
- }
122
-
123
- # Summarise the targets that DID answer (platform / deviceName), so a timeout teaches
124
- # what responded vs what was requested — e.g. an iOS target answering an android launch.
125
- answered_targets() {
126
- [ -s "$STATUS_LOG" ] || { printf 'none'; return; }
127
- node -e '
128
- const { describeTargets } = require(process.argv[1]);
129
- const fs = require("fs");
130
- try {
131
- process.stdout.write(describeTargets(JSON.parse(fs.readFileSync(process.argv[2], "utf8"))));
132
- } catch { process.stdout.write("none"); }
133
- ' "$MATCH_LIB" "$STATUS_LOG" 2>/dev/null || printf 'none'
134
- }
135
-
136
- READY_STREAK=0
137
- for ATTEMPT in $(seq 1 "$MAX_POLLS"); do
138
- if bridge_has_route; then
139
- READY_STREAK=$((READY_STREAK + 1))
140
- # The bridge can appear briefly while React Native is still completing its
141
- # startup navigation/reload. Require two consecutive healthy snapshots so a
142
- # following command never inherits that transient gap.
143
- if [ "$READY_STREAK" -ge 2 ]; then
144
- printf 'Mobile bridge ready\n' >&2
145
- exit 0
146
- fi
147
- else
148
- READY_STREAK=0
149
- fi
150
- if [ "$ATTEMPT" = "1" ] || [ $((ATTEMPT % 5)) -eq 0 ]; then
151
- printf 'Waiting for Mobile bridge (%s/%s): %s\n' "$ATTEMPT" "$MAX_POLLS" "$(bridge_wait_reason)" >&2
152
- fi
153
- [ "$ATTEMPT" -ge "$MAX_POLLS" ] || sleep 2
154
- done
155
-
156
- cat "$STATUS_LOG" >&2 || true
157
- printf 'wait-for-bridge: no bridge target matched the requested %s on port %s (%s polls × 2s = %ss)\n' \
158
- "$(requested_target)" "$PORT" "$MAX_POLLS" "$((MAX_POLLS * 2))" >&2
159
- printf ' requested: %s\n' "$(requested_target)" >&2
160
- printf ' answered: %s\n' "$(answered_targets)" >&2
161
- printf ' Next: check %s — or run mm-harness status --json\n' "$STATUS_LOG" >&2
162
- exit 1
24
+ MM_HARNESS_WAIT_RUNTIME_DIR="$(recipe_runtime_dir)" \
25
+ exec node "$WAIT_CJS" "$@"
package/bin/mm-harness CHANGED
@@ -144,8 +144,13 @@ if [ -f "$ENTRY_DIST" ] && [ -f "$ENTRY_TS" ]; then
144
144
  done < <(find "$RUNNER_DIR/src" -name '*.ts' -newer "$ENTRY_DIST" -print 2>/dev/null)
145
145
  if [ -n "$newer_source" ]; then
146
146
  export MM_HARNESS_RUN_MODE="src"
147
- echo "mm-harness: dist/ is older than src/ — running the current source checkout." >&2
148
- echo " Next: npm run build to refresh dist/" >&2
147
+ case "${1:-}" in
148
+ ''|-h|--help|-v|--version|help|tutorial) ;;
149
+ *)
150
+ echo "mm-harness: dist/ is older than src/ — running the current source checkout." >&2
151
+ echo " Next: npm run build to refresh dist/" >&2
152
+ ;;
153
+ esac
149
154
  else
150
155
  USE_DIST=1
151
156
  export MM_HARNESS_RUN_MODE="dist"
@@ -12,7 +12,7 @@ import {
12
12
  mobileMetroEnvCheck,
13
13
  recordMobileMetroEnvBaseline
14
14
  } from "./metro-env.js";
15
- const READY_BRIDGE_CONFIRM_POLLS = 3;
15
+ const READY_BRIDGE_CONFIRM_TIMEOUT_MS = 6e3;
16
16
  const POD_PROBE_ENV = {
17
17
  FORCE_COLOR: "0",
18
18
  NO_COLOR: "1",
@@ -69,7 +69,7 @@ async function prepareMobile(target, opts = {}) {
69
69
  preflightMode,
70
70
  opts.watcherPort
71
71
  );
72
- return launch.status === 0 ? startMobileConsoleForwarder(target, platform, json, opts.watcherPort) : launch;
72
+ return launch;
73
73
  }
74
74
  if (report.decision === "ready" && clearMetro) {
75
75
  const launch = await dispatchActionSequence(
@@ -83,7 +83,7 @@ async function prepareMobile(target, opts = {}) {
83
83
  preflightMode,
84
84
  opts.watcherPort
85
85
  );
86
- return launch.status === 0 ? startMobileConsoleForwarder(target, platform, json, opts.watcherPort) : launch;
86
+ return launch;
87
87
  }
88
88
  if (report.decision === "ready") {
89
89
  if (restartApp || iosAccessibilityChanged) {
@@ -95,7 +95,7 @@ async function prepareMobile(target, opts = {}) {
95
95
  preflightMode,
96
96
  opts.watcherPort
97
97
  );
98
- return launch2.status === 0 ? startMobileConsoleForwarder(target, platform, json, opts.watcherPort) : launch2;
98
+ return launch2;
99
99
  }
100
100
  const surface = await ensureMobileHumanSurface(
101
101
  target,
@@ -104,8 +104,19 @@ async function prepareMobile(target, opts = {}) {
104
104
  opts.watcherPort
105
105
  );
106
106
  if (surface.status !== 0) return surface;
107
+ const forwarder = await startMobileConsoleForwarder(
108
+ target,
109
+ platform,
110
+ json,
111
+ opts.watcherPort
112
+ );
113
+ if (forwarder.status !== 0) return forwarder;
107
114
  const confirm = await dispatchAction(
108
- { id: "wait-for-bridge", cwd: target, argv: ["--max-polls", String(READY_BRIDGE_CONFIRM_POLLS)] },
115
+ {
116
+ id: "wait-for-bridge",
117
+ cwd: target,
118
+ argv: ["--timeout-ms", String(READY_BRIDGE_CONFIRM_TIMEOUT_MS)]
119
+ },
109
120
  target,
110
121
  platform,
111
122
  json,
@@ -113,12 +124,7 @@ async function prepareMobile(target, opts = {}) {
113
124
  opts.watcherPort
114
125
  );
115
126
  if (confirm.status === 0) {
116
- return startMobileConsoleForwarder(
117
- target,
118
- platform,
119
- json,
120
- opts.watcherPort
121
- );
127
+ return confirm;
122
128
  }
123
129
  if (!json) {
124
130
  process.stderr.write(
@@ -134,7 +140,7 @@ async function prepareMobile(target, opts = {}) {
134
140
  preflightMode,
135
141
  opts.watcherPort
136
142
  );
137
- return launch.status === 0 ? startMobileConsoleForwarder(target, platform, json, opts.watcherPort) : launch;
143
+ return launch;
138
144
  }
139
145
  if (report.decision === "unknown") {
140
146
  const msg = "mobile prepare: runtime state unknown\n Next: run mm-harness verify --adapter mobile --target <checkout>";
@@ -181,6 +187,13 @@ async function prepareMobile(target, opts = {}) {
181
187
  opts.watcherPort
182
188
  );
183
189
  if (surface.status !== 0) return surface;
190
+ const forwarder = await startMobileConsoleForwarder(
191
+ target,
192
+ platform,
193
+ json,
194
+ opts.watcherPort
195
+ );
196
+ if (forwarder.status !== 0) return forwarder;
184
197
  const bridge = await dispatchAction(
185
198
  { id: "wait-for-bridge", cwd: target },
186
199
  target,
@@ -216,12 +229,7 @@ async function prepareMobile(target, opts = {}) {
216
229
  }
217
230
  }
218
231
  }
219
- return startMobileConsoleForwarder(
220
- target,
221
- platform,
222
- json,
223
- opts.watcherPort
224
- );
232
+ return actionResult;
225
233
  }
226
234
  function startMobileConsoleForwarder(target, platform, json, watcherPort) {
227
235
  return dispatchAction(
@@ -96,6 +96,7 @@ const launchActions = (target, clearMetro = false) => {
96
96
  });
97
97
  actions.push({ id: "prewarm-bundle", cwd: target });
98
98
  actions.push({ id: "launch-mobile-runtime" });
99
+ actions.push({ id: "start-console-forwarder", cwd: target });
99
100
  actions.push({ id: "wait-for-bridge", cwd: target });
100
101
  return actions;
101
102
  };
@@ -8,6 +8,8 @@ const SPEC = {
8
8
  { name: "help", aliases: ["-h", "--help"], desc: "Show usage" }
9
9
  ],
10
10
  shared: [
11
+ { name: "help", desc: "Load version-matched recipe guidance", flags: ["--json", "--adapter", "--target"] },
12
+ { name: "tutorial", desc: "Open the visual recipe tutorial", flags: ["--json", "--no-open"] },
11
13
  { name: "setup-base", desc: "Bootstrap numbered product checkouts", flags: ["--dir", "--counts", "--only", "--dry-run", "--force", "--json", "--show-config", "--reset-config", "--skip-harness-update"] },
12
14
  { name: "status", aliases: ["health", "home"], desc: "Home status + next commands", flags: ["--json", "--fast"] },
13
15
  { name: "ports", desc: "Slot ports and runtime paths", flags: ["--json"] },
@@ -4,6 +4,7 @@ const optionalValue = (choices) => ({ kind: "optional-value", ...choices ? { cho
4
4
  const options = (...groups) => Object.assign({}, ...groups);
5
5
  const HELP = { "--help": bool(), "-h": bool() };
6
6
  const JSON = { "--json": bool() };
7
+ const NO_OPEN = { "--no-open": bool() };
7
8
  const JSON_STREAM = { "--json-stream": bool() };
8
9
  const TARGET = { "--target": value() };
9
10
  const ADAPTER = { "--adapter": value(["mobile", "extension", "core"]) };
@@ -56,6 +57,12 @@ const REMOVED_OPTION_REPLACEMENTS = {
56
57
  "--record": "--record-video"
57
58
  };
58
59
  const PUBLIC_COMMAND_CONTRACTS = {
60
+ help: {
61
+ options: options(HELP, JSON, TARGET, ADAPTER)
62
+ },
63
+ tutorial: {
64
+ options: options(HELP, JSON, NO_OPEN)
65
+ },
59
66
  "setup-base": {
60
67
  options: options(HELP, JSON, {
61
68
  "--dir": value(),
@@ -194,6 +194,7 @@ function teachRecipeCompletion(taskDir, step) {
194
194
  }
195
195
  if (missingQuality) {
196
196
  const compactPath = path.join(artifactsDir, "recipe-quality-input.json");
197
+ console.error(" First run: mm-harness help");
197
198
  console.error(` - ${qualityPath}: write ${compactPath} as:`);
198
199
  console.error(' {"verdict":"pass|warn|fail","reasons":["evidence-backed reason"]}');
199
200
  console.error(` then run: mm-harness recipe-quality build --input ${compactPath} --output ${qualityPath}`);
@@ -0,0 +1,76 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { detectAdapter } from "../harness.js";
4
+ import { optionString, parseArgs, targetPath } from "./parse-args.js";
5
+ const UNSCOPED_NEXT = "cd into a MetaMask checkout or pass --adapter <mobile|extension|core>";
6
+ function loadRecipeHelp(packageRoot) {
7
+ const file = path.join(packageRoot, "site", "assets", "help-recipes.json");
8
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
9
+ if (value.schemaVersion !== 1 || value.topic !== "recipes" || typeof value.title !== "string" || typeof value.summary !== "string" || typeof value.capabilities !== "string" || typeof value.scopeNotice !== "string" || typeof value.recipeProtocolVersion !== "string" || typeof value.safety !== "string" || !Array.isArray(value.sections) || value.sections.length !== 8 || value.sections.some(
10
+ (section) => !section || typeof section.id !== "string" || typeof section.title !== "string" || typeof section.instruction !== "string" || !Array.isArray(section.details) || section.details.some((detail) => typeof detail !== "string") || !Array.isArray(section.commands) || section.commands.some((command) => typeof command !== "string")
11
+ )) {
12
+ throw new Error(`Invalid recipe help data: ${file}`);
13
+ }
14
+ return value;
15
+ }
16
+ function recipeHelpPayload(topic, harnessVersion, adapter, target) {
17
+ const commands = Array.from(new Set(topic.sections.flatMap((section) => section.commands)));
18
+ const discoveryCommands = topic.sections.find((section) => section.id === "discover")?.commands ?? [];
19
+ return {
20
+ schemaVersion: topic.schemaVersion,
21
+ command: "help",
22
+ harnessVersion,
23
+ recipeProtocolVersion: topic.recipeProtocolVersion,
24
+ adapter,
25
+ ...adapter === "unscoped" ? { next: UNSCOPED_NEXT } : {},
26
+ target,
27
+ topic: topic.topic,
28
+ title: topic.title,
29
+ summary: topic.summary,
30
+ capabilities: topic.capabilities,
31
+ scopeNotice: topic.scopeNotice,
32
+ sections: topic.sections,
33
+ commands,
34
+ discoveryCommands,
35
+ safety: topic.safety
36
+ };
37
+ }
38
+ function renderRecipeHelp(topic, harnessVersion, adapter) {
39
+ const lines = [
40
+ `mm-harness ${harnessVersion} recipe guide`,
41
+ `Context: Recipe Protocol v${topic.recipeProtocolVersion}, adapter ${adapter}`,
42
+ topic.summary,
43
+ topic.capabilities,
44
+ topic.scopeNotice,
45
+ "Visual tutorial: mm-harness tutorial"
46
+ ];
47
+ if (adapter === "unscoped") lines.push(`No MetaMask checkout detected. Next: ${UNSCOPED_NEXT}.`);
48
+ for (const [index, section] of topic.sections.entries()) {
49
+ lines.push("", `${index + 1}. ${section.title}`, ` ${section.instruction}`);
50
+ for (const detail of section.details) lines.push(` - ${detail}`);
51
+ for (const command of section.commands) lines.push(` $ ${command}`);
52
+ }
53
+ lines.push("", `Safety: ${topic.safety}`);
54
+ return `${lines.join("\n")}
55
+ `;
56
+ }
57
+ function handleHelp(argv, context) {
58
+ const parsed = parseArgs(argv, "help");
59
+ const target = targetPath(parsed.options);
60
+ const adapter = optionString(parsed.options, "adapter") ?? detectAdapter(target) ?? "unscoped";
61
+ const topic = loadRecipeHelp(context.packageRoot);
62
+ const payload = recipeHelpPayload(topic, context.harnessVersion, adapter, target);
63
+ if (parsed.options.json === true) {
64
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}
65
+ `);
66
+ } else {
67
+ process.stdout.write(renderRecipeHelp(topic, context.harnessVersion, adapter));
68
+ }
69
+ return 0;
70
+ }
71
+ export {
72
+ handleHelp,
73
+ loadRecipeHelp,
74
+ recipeHelpPayload,
75
+ renderRecipeHelp
76
+ };
@@ -38,7 +38,8 @@ function parseArgs(argv, command) {
38
38
  "printReady",
39
39
  "fast",
40
40
  "allDevices",
41
- "help"
41
+ "help",
42
+ "noOpen"
42
43
  ]);
43
44
  const repeatableOptions = /* @__PURE__ */ new Set(["library"]);
44
45
  for (let i = 0; i < argv.length; i += 1) {
@@ -6,7 +6,7 @@ import {
6
6
  import { EXIT, usageOut } from "./shared.js";
7
7
  import { optionFlag, optionString, parseArgs } from "./parse-args.js";
8
8
  const BUILD_USAGE = "mm-harness recipe-quality build --input <compact.json> --output <path> [--json]";
9
- const SHORTHAND_USAGE = "mm-harness recipe-quality build --input <compact.json> --output <path> [--json]\n Shorthand: mm-harness recipe-quality --input <compact.json> --output <path> [--json]\n Note: this CLI builds the recipe-quality artifact from an existing compact verdict; recipe critique is performed by the fs-recipe-quality skill.";
9
+ const SHORTHAND_USAGE = "mm-harness recipe-quality build --input <compact.json> --output <path> [--json]\n Shorthand: mm-harness recipe-quality --input <compact.json> --output <path> [--json]\n Note: apply the quality bar from mm-harness help, then build its compact verdict here.";
10
10
  function errorMessage(error) {
11
11
  return error instanceof Error ? error.message : String(error);
12
12
  }
@@ -0,0 +1,46 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { parseArgs } from "./parse-args.js";
3
+ const PROTECTED_TUTORIAL_URL = "https://glowing-chainsaw-gwzrn8k.pages.github.io/recipes.html#agent-method";
4
+ function recipeTutorialUrl(runMode = process.env.MM_HARNESS_RUN_MODE) {
5
+ if (runMode === "src") {
6
+ return "http://127.0.0.1:8765/site/recipes.html#agent-method";
7
+ }
8
+ return PROTECTED_TUTORIAL_URL;
9
+ }
10
+ function openUrl(url) {
11
+ const command = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
12
+ const result = spawnSync(command[0], command.slice(1), { stdio: "ignore" });
13
+ return result.status === 0;
14
+ }
15
+ function handleTutorial(argv, context) {
16
+ const parsed = parseArgs(argv, "tutorial");
17
+ const url = recipeTutorialUrl();
18
+ const noOpen = parsed.options.noOpen === true;
19
+ const opened = noOpen ? false : (context.open ?? openUrl)(url);
20
+ const payload = {
21
+ schemaVersion: 1,
22
+ command: "tutorial",
23
+ harnessVersion: context.harnessVersion,
24
+ url,
25
+ opened
26
+ };
27
+ if (parsed.options.json === true) {
28
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}
29
+ `);
30
+ return 0;
31
+ }
32
+ if (!noOpen && !opened) {
33
+ process.stderr.write(`Could not open the browser.
34
+ Next: open ${url}
35
+ `);
36
+ return 1;
37
+ }
38
+ process.stdout.write(`${opened ? "Opened" : "Recipe tutorial"} for mm-harness ${context.harnessVersion}:
39
+ ${url}
40
+ `);
41
+ return 0;
42
+ }
43
+ export {
44
+ handleTutorial,
45
+ recipeTutorialUrl
46
+ };
@@ -8,6 +8,8 @@ import { withCommandJournal } from "./command-journal.js";
8
8
  import { JsonStreamWriter } from "./json-stream.js";
9
9
  import { handleUpdate, maybeNudge } from "./commands/update.js";
10
10
  import { handleCallHelp } from "./commands/call.js";
11
+ import { handleHelp } from "./commands/help.js";
12
+ import { handleTutorial } from "./commands/tutorial.js";
11
13
  import { handleSetupBase } from "./commands/setup-base.js";
12
14
  import { getAdapterSurface } from "./adapters/surface.js";
13
15
  import { detectAdapter } from "./harness.js";
@@ -21,6 +23,37 @@ globalThis.__MM_HARNESS_WRAPPER__ = true;
21
23
  const { main: recipeMain } = await import("./cli.js");
22
24
  const rawArgv = process.argv.slice(2);
23
25
  const REAL = [
26
+ {
27
+ name: "help",
28
+ summary: "Load the version-matched recipe guide for a person or agent.",
29
+ example: "mm-harness help",
30
+ helpText: `mm-harness help [flags]
31
+
32
+ Loads the complete recipe guide shipped with this harness.
33
+
34
+ --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
35
+ --target <path> Checkout path (default: cwd)
36
+ --json Load the same guide as structured agent context
37
+
38
+ Example:
39
+ mm-harness help
40
+ mm-harness help --json`
41
+ },
42
+ {
43
+ name: "tutorial",
44
+ summary: "Open the protected visual recipe tutorial.",
45
+ example: "mm-harness tutorial",
46
+ helpText: `mm-harness tutorial [flags]
47
+
48
+ Source checkouts open the local tutorial on port 8765. Installed releases
49
+ open the protected MetaMask GitHub Pages site.
50
+
51
+ --no-open Print the tutorial URL without opening it
52
+ --json Print version, URL, and open status as JSON
53
+
54
+ Example:
55
+ mm-harness tutorial`
56
+ },
24
57
  {
25
58
  name: "setup-base",
26
59
  summary: "Bootstrap numbered MetaMask product checkouts and install their dependencies.",
@@ -334,9 +367,9 @@ Example:
334
367
  written \u2014 an invalid input never reaches disk. Shorthand without "build" is
335
368
  also accepted when --input/--output are present.
336
369
 
337
- This CLI does not run the recipe critique itself; use the fs-recipe-quality
338
- skill to judge recipe/evidence quality, then use this command to build the
339
- canonical artifact from that compact verdict.
370
+ Apply the quality bar from mm-harness help to the real graph and
371
+ evidence, then use this command to build the canonical artifact from that
372
+ compact verdict. An optional independent reviewer may still critique it.
340
373
 
341
374
  --input <compact.json> Compact verdict JSON: { "verdict": "pass|warn|fail", "reasons": [..],
342
375
  "betterVersionGuidance"?: [..], "dimensions"?: {..}, "trainingFields"?: {..}, \u2026 }
@@ -669,7 +702,7 @@ const HELP_GROUPS = [
669
702
  {
670
703
  title: "DISCOVER",
671
704
  blurb: "discover atomic actions and reusable recipes (--json is the agent-primary form)",
672
- commands: ["actions", "call", "execution-template"]
705
+ commands: ["help", "tutorial", "actions", "call", "execution-template"]
673
706
  },
674
707
  {
675
708
  title: "PROVE",
@@ -782,7 +815,7 @@ const pkgVersion = (() => {
782
815
  }
783
816
  })();
784
817
  const program = new Command();
785
- program.name("mm-harness").description("the MetaMask recipe harness: launch the app, prove behavior, manage the runtime overlay").version(pkgVersion, "-v, --version", "Print the mm-harness version").helpOption("-h, --help", "Show grouped help").showHelpAfterError("(run `mm-harness --help` for the full surface)").configureHelp({ formatHelp: () => groupedHelp() });
818
+ program.name("mm-harness").description("the MetaMask recipe harness: launch the app, prove behavior, manage the runtime overlay").version(pkgVersion, "-v, --version", "Print the mm-harness version").addHelpCommand(false).helpOption("-h, --help", "Show grouped help").showHelpAfterError("(run `mm-harness --help` for the full surface)").configureHelp({ formatHelp: () => groupedHelp() });
786
819
  for (const command of REAL) {
787
820
  const registered = program.command(command.name).description(command.summary).allowUnknownOption().helpOption("-h, --help", "Show command help");
788
821
  if (command.aliases?.length) registered.aliases(command.aliases);
@@ -791,6 +824,12 @@ for (const command of REAL) {
791
824
  if (command.name === "update") {
792
825
  process.exit(await handleUpdate(rawArgv.slice(1)));
793
826
  }
827
+ if (command.name === "help") {
828
+ process.exit(handleHelp(rawArgv.slice(1), { packageRoot, harnessVersion: pkgVersion }));
829
+ }
830
+ if (command.name === "tutorial") {
831
+ process.exit(handleTutorial(rawArgv.slice(1), { harnessVersion: pkgVersion }));
832
+ }
794
833
  process.exit(await withCommandJournal(command.name, rawArgv, () => delegate(rawArgv)));
795
834
  });
796
835
  }
@@ -812,7 +851,7 @@ program.command("completions").description("Install/print bundled shell tab-comp
812
851
  const result = spawnSync("bash", [script, ...rawArgv.slice(1)], { stdio: "inherit" });
813
852
  process.exit(result.status ?? 1);
814
853
  });
815
- const NUDGE_SKIP = ["setup-base", "update", "completions", "completion-candidates"];
854
+ const NUDGE_SKIP = ["help", "tutorial", "setup-base", "update", "completions", "completion-candidates"];
816
855
  if (rawArgv.length > 0 && !NUDGE_SKIP.includes(rawArgv[0])) {
817
856
  maybeNudge();
818
857
  }
@@ -5,6 +5,8 @@ both the machine contract and the visible human result.
5
5
 
6
6
  ## Ownership
7
7
 
8
+ The generic packages below come from [Farmslot](https://farmslot.io).
9
+
8
10
  ```text
9
11
  bin/mm-harness
10
12
  -> src/ typed CLI and product decisions
package/docs/QA.md CHANGED
@@ -16,7 +16,7 @@ mm-harness --version
16
16
  ```
17
17
 
18
18
  - [ ] The executable and dependencies resolve inside the isolated prefix.
19
- - [ ] No dependency is a symlink or resolves through a local Farmslot checkout.
19
+ - [ ] No dependency is a symlink or resolves through a local [Farmslot](https://farmslot.io) checkout.
20
20
  - [ ] Record the tarball SHA-256 and product SHAs.
21
21
  - [ ] Product trees start and finish without tracked changes.
22
22
 
@@ -179,7 +179,7 @@ Before release:
179
179
  - [ ] Known limits are explicit: Extension requires product Infura setup;
180
180
  Mobile requires its normal dev-client/device setup; video requires optional
181
181
  `capture-helper`; the harness never invents funded fixtures.
182
- - [ ] Existing Farmslot slots remain compatible.
182
+ - [ ] Existing [Farmslot](https://farmslot.io) slots remain compatible.
183
183
 
184
184
  When a slot manager is available, repeat `runner.smoke` in one existing managed
185
185
  checkout per product and verify its profile/device, ports, processes, and fixture
@@ -1677,7 +1677,7 @@ async function assertReadyToTrade(input, config) {
1677
1677
  if (config.readyToTrade === undefined || config.readyToTrade === false) return { skipped: true };
1678
1678
  const ready = await evalAsync(
1679
1679
  input,
1680
- `(function(){ var c=Engine.context.PerpsController; var id=c.state.activeProvider; var p=c.providers && c.providers.get ? c.providers.get(id) : c.getActiveProvider && c.getActiveProvider(); if(!p || typeof p.isReadyToTrade !== 'function') return Promise.resolve(JSON.stringify({ready:false,activeProvider:id || null,error:'provider unavailable'})); return p.isReadyToTrade().then(function(r){ return JSON.stringify({ready:!!(r && r.ready), activeProvider:id || null, authenticatedAddress:(r&&r.authenticatedAddress)||null}); }); })()`,
1680
+ `(function(){ var c=Engine.context.PerpsController; var id=c.state.activeProvider; var p=c.providers && c.providers.get ? c.providers.get(id) : c.getActiveProvider && c.getActiveProvider(); if(!p || typeof p.isReadyToTrade !== 'function') return Promise.resolve(JSON.stringify({ready:false,activeProvider:id || null,error:'provider unavailable'})); return p.isReadyToTrade().then(function(r){ return JSON.stringify({ready:!!(r && r.ready), activeProvider:id || null, authenticatedAddress:(r&&r.authenticatedAddress)||null, error:(r&&r.error)||null, walletConnected:r&&typeof r.walletConnected==='boolean'?r.walletConnected:null, networkSupported:r&&typeof r.networkSupported==='boolean'?r.networkSupported:null}); }); })()`,
1681
1681
  );
1682
1682
  if (!ready.ready) throw new Error(`Perps provider is not ready to trade: ${JSON.stringify(ready)}`);
1683
1683
  return ready;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.46.0",
3
+ "version": "0.47.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -64,7 +64,7 @@
64
64
  "dist",
65
65
  "adapters",
66
66
  "library",
67
- "site",
67
+ "site/assets/help-recipes.json",
68
68
  "scripts/completions.sh",
69
69
  "scripts/install-completions.sh",
70
70
  "scripts/site-contrast.mjs",