@deeeed/metamask-harness 0.27.0 → 0.29.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 (64) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +41 -0
  3. package/adapters/extension/build-lavamoat.sh +2 -1
  4. package/adapters/extension/ensure-browser.sh +82 -9
  5. package/adapters/extension/inject.mjs +1 -0
  6. package/adapters/extension/launch-browser.cjs +83 -1
  7. package/adapters/extension/lib/chrome-args.cjs +325 -1
  8. package/adapters/extension/lib/playwright-cdp.cjs +34 -0
  9. package/adapters/extension/lib/slot-title.cjs +2 -4
  10. package/adapters/extension/lib/validation-launch-supervisor.cjs +292 -0
  11. package/adapters/extension/lib/validation-process-ownership.cjs +69 -0
  12. package/adapters/extension/reattach.sh +2 -1
  13. package/adapters/extension/sidepanel-toggle.sh +14 -96
  14. package/adapters/extension/wallet-fixture-state.cjs +8 -31
  15. package/adapters/manifest.json +16 -0
  16. package/adapters/shared/private-atomic-write.cjs +47 -0
  17. package/adapters/shared/setup-base.sh +864 -0
  18. package/dist/adapters/extension/runtime.js +367 -24
  19. package/dist/adapters/extension/validation-process-ownership.js +10 -0
  20. package/dist/adapters.js +0 -11
  21. package/dist/cli-commands.js +2 -1
  22. package/dist/command-contract.js +13 -0
  23. package/dist/commands/call.js +38 -1
  24. package/dist/commands/launch/extension.js +130 -19
  25. package/dist/commands/manifest.js +201 -6
  26. package/dist/commands/parse-args.js +1 -0
  27. package/dist/commands/run.js +74 -8
  28. package/dist/commands/setup-base.js +24 -0
  29. package/dist/mm-harness-cli.js +30 -2
  30. package/dist/recipe-security.js +1 -0
  31. package/library/actions/extension/analytics/consent.mjs +203 -0
  32. package/library/actions/extension/analytics/set_consent.mjs +19 -143
  33. package/library/actions/extension/perps/perps.mjs +2 -16
  34. package/library/actions/extension/perps/state.mjs +20 -0
  35. package/library/actions/extension/ui/locators.mjs +13 -0
  36. package/library/actions/extension/wallet/list_accounts.mjs +3 -25
  37. package/library/actions/extension/wallet/read_state.mjs +3 -23
  38. package/library/actions/extension/wallet/select_account.mjs +6 -33
  39. package/library/actions/extension/wallet/setup.mjs +2 -20
  40. package/library/actions/extension/wallet/state.mjs +111 -0
  41. package/library/actions/mobile/platform/observe-ui.mjs +21 -0
  42. package/library/actions/mobile/ui/locators.mjs +17 -0
  43. package/library/actions/shared/ui/locators.mjs +160 -0
  44. package/library/manifests/extension.action-manifest.json +18 -0
  45. package/library/manifests/mobile.action-manifest.json +18 -0
  46. package/library/recipes/runner/action-validation.extension.recipe.json +1 -1
  47. package/library/recipes/runner/action-validation.mobile.recipe.json +1 -1
  48. package/package.json +7 -4
  49. package/scripts/site-contrast.mjs +538 -0
  50. package/site/architecture.html +415 -0
  51. package/site/assets/progress.mjs +272 -0
  52. package/site/assets/style.css +808 -0
  53. package/site/cheatsheet.html +305 -0
  54. package/site/index.html +643 -0
  55. package/site/recipes.html +396 -0
  56. package/site/reviewers.html +374 -0
  57. package/site/tutorials/index.html +180 -0
  58. package/site/tutorials/v1.html +211 -0
  59. package/site/tutorials/v2.html +207 -0
  60. package/site/tutorials/v3.html +214 -0
  61. package/site/tutorials/v4.html +195 -0
  62. package/site/tutorials/v5.html +163 -0
  63. package/site/tutorials/v6.html +165 -0
  64. package/site/tutorials/v7.html +184 -0
@@ -1,26 +1,8 @@
1
1
  import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import { readWalletStateExpression } from './state.mjs';
2
3
 
3
4
  runAdapter((input) => withExtensionPage(input, async (page) => {
4
- const state = await page.evaluate(`(() => {
5
- const passwordInput = Boolean(document.querySelector('input[type="password"]'));
6
- const hooks = globalThis.stateHooks;
7
- const store = hooks && hooks.store;
8
- const root = store && typeof store.getState === 'function' ? store.getState() : {};
9
- const metamask = root.metamask || {};
10
- const internal = metamask.internalAccounts || {};
11
- const accounts = internal.accounts || {};
12
- const selectedId = internal.selectedAccount || null;
13
- const selected = selectedId && accounts[selectedId] ? accounts[selectedId] : null;
14
- return {
15
- href: String(globalThis.location && globalThis.location.href || ''),
16
- passwordInput,
17
- completedOnboarding: Boolean(metamask.completedOnboarding),
18
- selectedAccount: selected ? {
19
- id: selectedId,
20
- address: selected.address || null,
21
- } : null,
22
- };
23
- })()`);
5
+ const state = await page.evaluate(readWalletStateExpression(), { awaitPromise: true });
24
6
  const seededProfilePresent = Boolean(state?.passwordInput || (state?.completedOnboarding && state?.selectedAccount?.address));
25
7
  if (!seededProfilePresent) {
26
8
  throw new Error(`Extension fixture profile is not ready; expected a locked password screen or completed onboarding with a selected account, got ${JSON.stringify(state)}`);
@@ -0,0 +1,111 @@
1
+ const walletStatePreamble = `
2
+ const readWalletState = async () => {
3
+ const hooks = globalThis.stateHooks || {};
4
+ let root = null;
5
+ if (typeof hooks.getCleanAppState === 'function') {
6
+ root = await hooks.getCleanAppState();
7
+ }
8
+ if ((!root || typeof root !== 'object') && typeof hooks.store?.getState === 'function') {
9
+ root = hooks.store.getState();
10
+ }
11
+ if (!root || typeof root !== 'object') {
12
+ throw new Error('stateHooks.getCleanAppState and stateHooks.store.getState are unavailable.');
13
+ }
14
+ const metamask = root.metamask || root;
15
+ return {
16
+ hooks,
17
+ metamask,
18
+ internal: metamask.internalAccounts || {},
19
+ };
20
+ };
21
+ `;
22
+
23
+ export function readWalletStateExpression() {
24
+ return `(async () => {
25
+ ${walletStatePreamble}
26
+ const { metamask, internal } = await readWalletState();
27
+ const accounts = internal.accounts || {};
28
+ const selectedId = internal.selectedAccount || null;
29
+ const selected = selectedId && accounts[selectedId] ? accounts[selectedId] : null;
30
+ const metadata = selected?.metadata || {};
31
+ return {
32
+ href: String(globalThis.location?.href || ''),
33
+ passwordInput: Boolean(globalThis.document?.querySelector?.('input[type="password"]')),
34
+ selectedAccount: selected ? {
35
+ id: selectedId,
36
+ address: selected.address || null,
37
+ name: metadata.name || null,
38
+ type: selected.type || null,
39
+ } : null,
40
+ completedOnboarding: Boolean(metamask.completedOnboarding),
41
+ selectedNetworkClientId: metamask.selectedNetworkClientId || null,
42
+ };
43
+ })()`;
44
+ }
45
+
46
+ export function listWalletAccountsExpression(scope) {
47
+ return `(async () => {
48
+ ${walletStatePreamble}
49
+ const { internal } = await readWalletState();
50
+ const selectedId = internal.selectedAccount || null;
51
+ const scope = ${JSON.stringify(scope)};
52
+ const accounts = Object.entries(internal.accounts || {})
53
+ .map(([id, account]) => ({
54
+ id,
55
+ address: account?.address || null,
56
+ name: account?.metadata?.name || null,
57
+ type: account?.type || null,
58
+ selected: id === selectedId,
59
+ }))
60
+ .filter((account) => {
61
+ if (scope === 'selected') return account.selected;
62
+ if (scope === 'all') return true;
63
+ if (scope === 'evm') return /^0x[0-9a-f]{40}$/iu.test(account.address || '');
64
+ return account.selected || Boolean(String(account.name || '').trim());
65
+ });
66
+ return { accounts };
67
+ })()`;
68
+ }
69
+
70
+ export function selectWalletAccountExpression(criteria, timeoutMs = 5000) {
71
+ return `(async () => {
72
+ ${walletStatePreamble}
73
+ const before = await readWalletState();
74
+ const accounts = before.internal.accounts || {};
75
+ const requestedAddress = ${JSON.stringify(criteria.address)};
76
+ const requestedId = ${JSON.stringify(criteria.id)};
77
+ const requestedName = ${JSON.stringify(criteria.name)};
78
+ const match = Object.entries(accounts).find(([id, account]) => {
79
+ const metadata = account?.metadata || {};
80
+ return (requestedId && id === requestedId) ||
81
+ (requestedAddress && String(account?.address || '').toLowerCase() === requestedAddress) ||
82
+ (requestedName && metadata.name === requestedName);
83
+ });
84
+ if (!match) throw new Error('Requested account was not found in internalAccounts.');
85
+ if (typeof before.hooks.submitRequestToBackground !== 'function') {
86
+ throw new Error('stateHooks.submitRequestToBackground is unavailable.');
87
+ }
88
+ const [id] = match;
89
+ await before.hooks.submitRequestToBackground('setSelectedInternalAccount', [id]);
90
+ const deadline = Date.now() + ${Number(timeoutMs)};
91
+ let observedId = before.internal.selectedAccount || null;
92
+ while (Date.now() <= deadline) {
93
+ const after = await readWalletState();
94
+ observedId = after.internal.selectedAccount || null;
95
+ if (observedId === id) {
96
+ const selected = after.internal.accounts?.[observedId];
97
+ return {
98
+ id: observedId,
99
+ address: selected?.address || null,
100
+ name: selected?.metadata?.name || null,
101
+ type: selected?.type || null,
102
+ };
103
+ }
104
+ await new Promise((resolve) => setTimeout(resolve, 100));
105
+ }
106
+ throw new Error(
107
+ 'Expected selected account ' + id + ', got ' + String(observedId) +
108
+ ' after ${Number(timeoutMs)}ms.',
109
+ );
110
+ })()`;
111
+ }
@@ -1,6 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
 
4
+ import { bridgeCommand } from './bridge.mjs';
4
5
  import { mobileToolRecovery, resolveMobileToolPath } from './tool-paths.mjs';
5
6
 
6
7
  const execFileAsync = promisify(execFile);
@@ -8,6 +9,7 @@ const VISIBLE_LIMIT = 50;
8
9
  const HIDDEN_LIMIT = 50;
9
10
 
10
11
  export async function observeNativeUi(payload, context) {
12
+ await hideAccessibilityMaskingHud(payload, context);
11
13
  const refs = Array.isArray(payload?.refs)
12
14
  ? payload.refs.filter((ref) => typeof ref === 'string')
13
15
  : [];
@@ -38,6 +40,25 @@ export async function observeNativeUi(payload, context) {
38
40
  }
39
41
  }
40
42
 
43
+ async function hideAccessibilityMaskingHud(payload, context) {
44
+ try {
45
+ await bridgeCommand(
46
+ {
47
+ action: 'ui.observe',
48
+ node: {
49
+ ...record(payload),
50
+ bridge_timeout_ms: 2_000,
51
+ cdp_timeout_ms: 2_000,
52
+ },
53
+ context,
54
+ },
55
+ ['hide-step'],
56
+ );
57
+ } catch {
58
+ // Native observation remains warning-only when the separate iOS HUD window is unavailable.
59
+ }
60
+ }
61
+
41
62
  async function readNativeHierarchy(payload, context) {
42
63
  const node = record(payload?.node);
43
64
  const env = {
@@ -0,0 +1,17 @@
1
+ import { observeNativeUi } from '../platform/observe-ui.mjs';
2
+ import { runAdapter } from '../platform/bridge.mjs';
3
+ import {
4
+ locatorsFromObservation,
5
+ requireVisibleObservation,
6
+ } from '../../shared/ui/locators.mjs';
7
+
8
+ runAdapter(async (input) => {
9
+ const observed = await observeNativeUi(
10
+ { refs: ['ui.visible'], node: input.node },
11
+ input.context,
12
+ );
13
+ return {
14
+ action: input.action,
15
+ ...locatorsFromObservation(requireVisibleObservation(observed)),
16
+ };
17
+ });
@@ -0,0 +1,160 @@
1
+ const LOCATOR_LIMIT = 20;
2
+ const INTERACTIVE_ROLE_PARTS = [
3
+ 'button',
4
+ 'textfield',
5
+ 'edittext',
6
+ 'searchfield',
7
+ 'textarea',
8
+ 'checkbox',
9
+ 'radiobutton',
10
+ 'switch',
11
+ 'togglebutton',
12
+ 'slider',
13
+ 'seekbar',
14
+ 'picker',
15
+ 'spinner',
16
+ 'link',
17
+ 'tab',
18
+ 'menuitem',
19
+ 'cell',
20
+ ];
21
+
22
+ export function locatorsFromObservation(observation) {
23
+ const value = record(observation);
24
+ const items = Array.isArray(value.items) ? value.items.map(record) : [];
25
+ const ranked = items
26
+ .filter(isInteractive)
27
+ .map(locatorFor)
28
+ .filter((locator) => locator.suggestions.length > 0)
29
+ .sort((left, right) => {
30
+ const leftBest = left.suggestions[0];
31
+ const rightBest = right.suggestions[0];
32
+ return (
33
+ confidenceRank(leftBest?.confidence) - confidenceRank(rightBest?.confidence) ||
34
+ strategyRank(leftBest?.strategy) - strategyRank(rightBest?.strategy)
35
+ );
36
+ });
37
+ const locators = ranked.slice(0, LOCATOR_LIMIT);
38
+ return {
39
+ provider: text(value.provider) ?? 'unknown',
40
+ observed: items.length,
41
+ count: locators.length,
42
+ truncated: value.truncated === true || ranked.length > locators.length,
43
+ locators,
44
+ };
45
+ }
46
+
47
+ export function requireVisibleObservation(result) {
48
+ const value = record(result);
49
+ const observations = record(value.observations);
50
+ const visible = observations['ui.visible'];
51
+ if (visible && typeof visible === 'object' && !Array.isArray(visible)) {
52
+ return visible;
53
+ }
54
+ const warning = Array.isArray(value.warnings)
55
+ ? value.warnings.map((entry) => text(record(entry).message)).find(Boolean)
56
+ : undefined;
57
+ throw new Error(
58
+ `ui.locators could not read the current ui.visible observation${warning ? `: ${warning}` : '.'}`,
59
+ );
60
+ }
61
+
62
+ function isInteractive(item) {
63
+ const role = text(item.role)?.toLowerCase() ?? '';
64
+ return (
65
+ Boolean(text(item.selector)) ||
66
+ INTERACTIVE_ROLE_PARTS.some((part) => role.includes(part)) ||
67
+ (item.enabled !== false && Boolean(text(item.test_id)))
68
+ );
69
+ }
70
+
71
+ function locatorFor(item) {
72
+ const testId = text(item.test_id);
73
+ const label = text(item.label);
74
+ const selector = text(item.selector);
75
+ const role = text(item.role);
76
+ const suggestions = [];
77
+ if (testId) {
78
+ suggestions.push({
79
+ strategy: 'test_id',
80
+ value: testId,
81
+ confidence: 'high',
82
+ params: { test_id: testId },
83
+ });
84
+ }
85
+ if (label && supportsTextTarget(role)) {
86
+ suggestions.push({
87
+ strategy: 'label',
88
+ value: label,
89
+ confidence: testId ? 'medium' : 'high',
90
+ params: { text: label },
91
+ });
92
+ }
93
+ if (selector && !selectorRepresentsTestId(selector, testId)) {
94
+ suggestions.push({
95
+ strategy: 'selector',
96
+ value: selector,
97
+ confidence: stableSelector(selector) ? 'high' : 'low',
98
+ params: { selector },
99
+ });
100
+ }
101
+ suggestions.sort((left, right) => (
102
+ confidenceRank(left.confidence) - confidenceRank(right.confidence) ||
103
+ strategyRank(left.strategy) - strategyRank(right.strategy)
104
+ ));
105
+ return {
106
+ description: describe(item),
107
+ role,
108
+ enabled: item.enabled !== false,
109
+ bounds: bounds(item.bounds),
110
+ suggestions,
111
+ };
112
+ }
113
+
114
+ function supportsTextTarget(role) {
115
+ const normalized = role?.toLowerCase() ?? '';
116
+ return ['button', 'link', 'tab', 'menuitem', 'cell'].some((part) =>
117
+ normalized.includes(part));
118
+ }
119
+
120
+ function describe(item) {
121
+ const parts = [text(item.role) ?? 'element'];
122
+ if (text(item.label)) parts.push(`label=${JSON.stringify(text(item.label))}`);
123
+ if (text(item.test_id)) parts.push(`test_id=${JSON.stringify(text(item.test_id))}`);
124
+ return parts.join(' ');
125
+ }
126
+
127
+ function bounds(value) {
128
+ const candidate = record(value);
129
+ const keys = ['x', 'y', 'width', 'height'];
130
+ if (!keys.every((key) => Number.isFinite(candidate[key]))) return undefined;
131
+ return Object.fromEntries(keys.map((key) => [key, candidate[key]]));
132
+ }
133
+
134
+ function selectorRepresentsTestId(selector, testId) {
135
+ return Boolean(testId && selector.includes('data-test') && selector.includes(testId));
136
+ }
137
+
138
+ function stableSelector(selector) {
139
+ return /^\[(?:id|data-test(?:id|-id))=/u.test(selector);
140
+ }
141
+
142
+ function confidenceRank(value) {
143
+ if (value === 'high') return 0;
144
+ if (value === 'medium') return 1;
145
+ return 2;
146
+ }
147
+
148
+ function strategyRank(value) {
149
+ if (value === 'test_id') return 0;
150
+ if (value === 'selector') return 1;
151
+ return 2;
152
+ }
153
+
154
+ function text(value) {
155
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
156
+ }
157
+
158
+ function record(value) {
159
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
160
+ }
@@ -716,6 +716,24 @@
716
716
  }
717
717
  ]
718
718
  },
719
+ "ui.locators": {
720
+ "description": "List ranked, copy-pasteable ui.press targets from the current ui.visible observation. Prefer high-confidence test IDs when updating durable recipe source after exploratory interaction.",
721
+ "execution_capabilities": [
722
+ "host-read-export"
723
+ ],
724
+ "schema": {
725
+ "type": "object",
726
+ "properties": {},
727
+ "additionalProperties": false
728
+ },
729
+ "examples": [
730
+ {
731
+ "action": "ui.locators",
732
+ "intent": "Find stable targets for durable recipe authoring.",
733
+ "next": "done"
734
+ }
735
+ ]
736
+ },
719
737
  "ui.screenshot": {
720
738
  "description": "Capture registered visual evidence through capture-helper, bounded Chrome-native capture, or an explicitly labeled computed-style DOM raster fallback. Always read the resulting PNG; artifact metadata records the provider.",
721
739
  "examples": [
@@ -734,6 +734,24 @@
734
734
  }
735
735
  ]
736
736
  },
737
+ "ui.locators": {
738
+ "description": "List ranked, copy-pasteable ui.press targets from the current ui.visible observation. Prefer high-confidence test IDs when updating durable recipe source after exploratory interaction.",
739
+ "execution_capabilities": [
740
+ "host-read-export"
741
+ ],
742
+ "schema": {
743
+ "type": "object",
744
+ "properties": {},
745
+ "additionalProperties": false
746
+ },
747
+ "examples": [
748
+ {
749
+ "action": "ui.locators",
750
+ "intent": "Find stable targets for durable recipe authoring.",
751
+ "next": "done"
752
+ }
753
+ ]
754
+ },
737
755
  "ui.screenshot": {
738
756
  "description": "Capture registered PNG evidence with xcrun simctl on iOS or adb screencap on Android. Artifact metadata records the native provider, command mode, and selected device; invalid or empty PNG output fails before registration.",
739
757
  "examples": [
@@ -7,7 +7,7 @@
7
7
  "nodes": {
8
8
  "prepare-files": {
9
9
  "action": "command",
10
- "cmd": "mkdir -p temp/recipe-runner.action-validation && printf 'E2E_ACTION_VALIDATION extension\\n' > temp/recipe-runner.action-validation/probe.txt && printf '{\"platform\":\"extension\",\"ok\":true}\n' > temp/recipe-runner.action-validation/probe.json && printf 'extension log E2E_ACTION_VALIDATION\\n' > temp/recipe-runner.action-validation/validation.log && echo E2E_ACTION_VALIDATION extension",
10
+ "cmd": "mkdir -p temp/recipe-runner.action-validation && printf 'E2E_ACTION_VALIDATION extension\\n' > temp/recipe-runner.action-validation/probe.txt && printf '{\"platform\":\"extension\",\"ok\":true}\n' > temp/recipe-runner.action-validation/probe.json && printf 'extension log E2E_ACTION_VALIDATION\\n' >> temp/recipe-runner.action-validation/validation.log && echo E2E_ACTION_VALIDATION extension",
11
11
  "next": "assert-command-exit",
12
12
  "intent": "Prepare runner.action-validation probe files"
13
13
  },
@@ -7,7 +7,7 @@
7
7
  "nodes": {
8
8
  "prepare-files": {
9
9
  "action": "command",
10
- "cmd": "mkdir -p temp/recipe-runner.action-validation && printf 'E2E_ACTION_VALIDATION mobile\\n' > temp/recipe-runner.action-validation/probe.txt && printf '{\"platform\":\"mobile\",\"ok\":true}\n' > temp/recipe-runner.action-validation/probe.json && printf 'mobile log E2E_ACTION_VALIDATION\\n' > temp/recipe-runner.action-validation/validation.log && echo E2E_ACTION_VALIDATION mobile",
10
+ "cmd": "mkdir -p temp/recipe-runner.action-validation && printf 'E2E_ACTION_VALIDATION mobile\\n' > temp/recipe-runner.action-validation/probe.txt && printf '{\"platform\":\"mobile\",\"ok\":true}\n' > temp/recipe-runner.action-validation/probe.json && printf 'mobile log E2E_ACTION_VALIDATION\\n' >> temp/recipe-runner.action-validation/validation.log && echo E2E_ACTION_VALIDATION mobile",
11
11
  "next": "assert-command-exit",
12
12
  "intent": "Prepare runner.action-validation probe files"
13
13
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -13,16 +13,17 @@
13
13
  "test:unit": "vitest run --config scripts/vitest.config.mjs",
14
14
  "test:coverage": "vitest run --coverage --config scripts/vitest.config.mjs",
15
15
  "qa:human": "node scripts/validate-human-outcomes.mjs",
16
+ "site:contrast": "node scripts/site-contrast.mjs",
16
17
  "self-test": "bin/mm-harness self-test",
17
18
  "manifest:mobile": "bin/mm-harness actions --raw --adapter mobile --json",
18
19
  "manifest:extension": "bin/mm-harness actions --raw --adapter extension --json",
19
20
  "check:syntax": "find . -name '*.mjs' -print0 | xargs -0 -n1 node --check"
20
21
  },
21
22
  "dependencies": {
22
- "@farmslot/agent-runtime": "^0.4.0",
23
+ "@farmslot/agent-runtime": "^0.5.0",
23
24
  "@farmslot/handoff": "^0.3.1",
24
- "@farmslot/protocol": "^0.14.0",
25
- "@farmslot/recipe-harness": "^0.10.4",
25
+ "@farmslot/protocol": "^0.15.0",
26
+ "@farmslot/recipe-harness": "^0.11.0",
26
27
  "commander": "^12.0.0",
27
28
  "es-module-lexer": "2.3.1",
28
29
  "esbuild": "0.28.1",
@@ -61,8 +62,10 @@
61
62
  "dist",
62
63
  "adapters",
63
64
  "library",
65
+ "site",
64
66
  "scripts/completions.sh",
65
67
  "scripts/install-completions.sh",
68
+ "scripts/site-contrast.mjs",
66
69
  "scripts/validate-human-outcomes.mjs",
67
70
  "!scripts/README.md",
68
71
  "docs",