mobile-automator 0.23.8

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 (61) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +167 -0
  3. package/bin/mauto-session-daemon.js +101 -0
  4. package/bin/mauto.js +13 -0
  5. package/package.json +49 -0
  6. package/src/assertion/evaluator.js +285 -0
  7. package/src/cli.js +1722 -0
  8. package/src/config/coerce.js +97 -0
  9. package/src/config/manager.js +75 -0
  10. package/src/config/schema.js +124 -0
  11. package/src/device/action-catalog.js +99 -0
  12. package/src/device/bridge.js +139 -0
  13. package/src/device/connection.js +65 -0
  14. package/src/device/device-model.js +59 -0
  15. package/src/device/device-resolver.js +34 -0
  16. package/src/device/element-model.js +82 -0
  17. package/src/device/mobile-mcp-client.js +163 -0
  18. package/src/device/resolve-connection.js +156 -0
  19. package/src/device/selection.js +80 -0
  20. package/src/device/semantic-press/index.js +38 -0
  21. package/src/device/semantic-press/labels.js +39 -0
  22. package/src/device/semantic-press/resolver.js +100 -0
  23. package/src/device/session-client.js +173 -0
  24. package/src/device/session-daemon.js +526 -0
  25. package/src/device/session-paths.js +80 -0
  26. package/src/device/session-protocol.js +60 -0
  27. package/src/device/session-spawn.js +67 -0
  28. package/src/device/tool-args.js +14 -0
  29. package/src/guide/content/execute.agnostic.md +207 -0
  30. package/src/guide/content/execute.aware.md +281 -0
  31. package/src/guide/content/execute.invariants.md +4 -0
  32. package/src/guide/content/generate.agnostic.md +236 -0
  33. package/src/guide/content/generate.aware.md +327 -0
  34. package/src/guide/content/generate.invariants.md +5 -0
  35. package/src/guide/content/setup.agnostic.md +63 -0
  36. package/src/guide/content/setup.aware.md +91 -0
  37. package/src/guide/content/setup.invariants.md +3 -0
  38. package/src/guide/emitter.js +137 -0
  39. package/src/guide/placeholders.js +132 -0
  40. package/src/init/adapters.js +162 -0
  41. package/src/init/skill-meta.js +31 -0
  42. package/src/init/skill-renderer.js +60 -0
  43. package/src/mcp/prompts.js +55 -0
  44. package/src/mcp/server.js +55 -0
  45. package/src/memory/entries.js +61 -0
  46. package/src/memory/history.js +86 -0
  47. package/src/memory/lock.js +5 -0
  48. package/src/memory/paths.js +47 -0
  49. package/src/memory/store.js +230 -0
  50. package/src/output/envelope.js +106 -0
  51. package/src/result/capability-catalog.js +144 -0
  52. package/src/result/flags.js +52 -0
  53. package/src/result/store.js +273 -0
  54. package/src/scenario/validator.js +30 -0
  55. package/src/schemas/config_schema.json +68 -0
  56. package/src/schemas/format-error.js +19 -0
  57. package/src/schemas/result_schema.json +211 -0
  58. package/src/schemas/scenario_schema.json +587 -0
  59. package/src/setup/scaffold.js +53 -0
  60. package/src/util/atomic.js +41 -0
  61. package/src/util/lock.js +85 -0
package/src/cli.js ADDED
@@ -0,0 +1,1722 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { Command } = require('commander');
5
+
6
+ const { version: PKG_VERSION } = require('../package.json');
7
+
8
+ const { ok, fail, render, exitCodeFor } = require('./output/envelope');
9
+ const { DeviceBridge } = require('./device/bridge');
10
+ const selectionStore = require('./device/selection');
11
+ const { ScenarioValidator } = require('./scenario/validator');
12
+ const { evaluate, MECHANICAL_TYPES } = require('./assertion/evaluator');
13
+ const { ResultStore } = require('./result/store');
14
+ const { OBSERVATION_TYPES, parseObservation, parseCapture, parseBool } = require('./result/flags');
15
+ const { MemoryStore } = require('./memory/store');
16
+ const { FILES: MEMORY_FILES, AGENT_KINDS } = require('./memory/paths');
17
+ const { validateEntryText, MAX_ENTRY_LEN } = require('./memory/entries');
18
+ const configManager = require('./config/manager');
19
+ const { coerceValue } = require('./config/coerce');
20
+ const { validateAt } = require('./config/schema');
21
+ const { scaffold } = require('./setup/scaffold');
22
+ const guideEmitter = require('./guide/emitter');
23
+ const { ADAPTERS } = require('./init/adapters');
24
+ const { isSemanticAction, ACTION_METHOD, selectResolver } = require('./device/semantic-press');
25
+ const connection = require('./device/connection');
26
+
27
+ // Commander throws (via exitOverride) for two very different reasons, and the
28
+ // split below is the ONLY thing that tells them apart.
29
+ //
30
+ // The set of commander outcomes that are NOT failures is closed and tiny: help
31
+ // and version already wrote their human-readable text and computed an exit
32
+ // code. The set of ways an invocation can be malformed is open — it grows with
33
+ // every commander release. So enumerate the closed set and let everything else
34
+ // default to a parse failure (#146).
35
+ //
36
+ // An allowlist of failure codes inverts that, and anything it forgets falls
37
+ // through to `internal` — which is the panic bucket, not a neutral default:
38
+ // `diagnose()` dumps a stack trace for `internal` and it maps to exit 1, so a
39
+ // missed code turns a user typo into a fake crash. That is exactly what
40
+ // happened with `commander.optionMissingArgument` (`--run-id` with no value).
41
+ //
42
+ // `commander.executeSubCommandAsync` is unreachable here by construction: it
43
+ // fires only for standalone executable subcommands (`.command('x', 'desc')`),
44
+ // and every command in this CLI is declared with an inline `.action()`.
45
+ const COMMANDER_NON_FAILURES = new Set([
46
+ 'commander.helpDisplayed', // the --help flag
47
+ 'commander.help', // an explicit .help() call
48
+ 'commander.version', // the --version flag
49
+ ]);
50
+
51
+ // True for ANY commander parse failure: bad flag, missing option value, missing
52
+ // argument, unknown subcommand, invalid choice, conflicting options, excess
53
+ // arguments — and whatever commander adds in a future release.
54
+ function isCommanderParseFailure(err) {
55
+ return Boolean(
56
+ err && err.name === 'CommanderError' && !COMMANDER_NON_FAILURES.has(err.code)
57
+ );
58
+ }
59
+
60
+ // Map the user-facing --mode flag onto the stored config mode values.
61
+ const MODE_ALIASES = {
62
+ aware: 'platform-aware',
63
+ agnostic: 'platform-agnostic',
64
+ 'platform-aware': 'platform-aware',
65
+ 'platform-agnostic': 'platform-agnostic',
66
+ };
67
+
68
+ const KNOWN_ASSERTION_TYPES = new Set([
69
+ ...MECHANICAL_TYPES,
70
+ // Tier-2 visual types the agent must judge.
71
+ 'screenshot_match',
72
+ 'visual_state',
73
+ 'element_fully_visible',
74
+ 'color_style',
75
+ 'screen_title',
76
+ 'alert_present',
77
+ 'alert_text',
78
+ 'toast_visible',
79
+ 'keyboard_visible',
80
+ 'dark_mode_active',
81
+ 'permission_dialog_shown',
82
+ 'element_state',
83
+ ]);
84
+
85
+ const SWIPE_DIRECTIONS = new Set(['up', 'down', 'left', 'right']);
86
+ const ORIENTATIONS = new Set(['portrait', 'landscape']);
87
+
88
+ // fs error codes that signal an environment/filesystem problem (perms, missing
89
+ // path, read-only FS, disk full) rather than a logic bug — surfaced as
90
+ // `environment` with a filesystem-specific hint.
91
+ const FS_ERROR_CODES = new Set(['EACCES', 'ENOENT', 'EROFS', 'ENOSPC', 'EPERM']);
92
+
93
+ // Build a human-readable usage line for a commander command, e.g.
94
+ // "Usage: mauto result add-step [options]". Walks the parent chain so a
95
+ // subcommand's hint names the full invocation path.
96
+ function usageLine(cmd) {
97
+ const names = [];
98
+ for (let c = cmd; c; c = c.parent) names.unshift(c.name());
99
+ const usage = (cmd.usage && cmd.usage()) || '';
100
+ return `Usage: ${names.join(' ')} ${usage}`.trim();
101
+ }
102
+
103
+ // Classify an UNEXPECTED thrown/rejected error — one a handler did NOT already
104
+ // convert into a `{envelope, exitKind}` — into the uniform fail envelope. This
105
+ // is the single boundary that guarantees a calling agent always receives one
106
+ // JSON line + a meaningful exit code instead of a raw stack trace on stderr.
107
+ // CommanderError (parse failure) -> invalid_input, with the usage line as hint
108
+ // SyntaxError -> invalid_input (a malformed JSON file)
109
+ // fs EACCES/ENOENT/EROFS/... -> environment, with a filesystem hint
110
+ // anything else -> internal
111
+ function toEnvelope(err) {
112
+ if (isCommanderParseFailure(err)) {
113
+ return {
114
+ envelope: fail(
115
+ 'invalid_input',
116
+ err.message || String(err),
117
+ err.usage || null
118
+ ),
119
+ exitKind: 'invalid_input',
120
+ };
121
+ }
122
+ if (err instanceof SyntaxError) {
123
+ return {
124
+ envelope: fail(
125
+ 'invalid_input',
126
+ err.message || String(err),
127
+ 'A JSON file in the workspace (config.json or a host MCP config) is malformed. Fix its syntax and retry.'
128
+ ),
129
+ exitKind: 'invalid_input',
130
+ };
131
+ }
132
+ if (err && FS_ERROR_CODES.has(err.code)) {
133
+ return {
134
+ envelope: fail(
135
+ 'environment',
136
+ err.message || String(err),
137
+ 'A filesystem operation failed (permissions, a missing path, a read-only filesystem, or no disk space). Check the workspace is writable and retry.'
138
+ ),
139
+ exitKind: 'environment',
140
+ };
141
+ }
142
+ return {
143
+ envelope: fail('internal', (err && err.message) || String(err), null),
144
+ exitKind: 'internal',
145
+ };
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Handlers — pure-ish: accept injected deps, return { envelope, exitKind }.
150
+ // No process.exit / printing here so they are trivially unit-testable.
151
+ // ---------------------------------------------------------------------------
152
+
153
+ async function handleElements({ deviceBridge }) {
154
+ try {
155
+ const elements = await deviceBridge.listElements();
156
+ return { envelope: ok(elements), exitKind: 'ok' };
157
+ } catch (err) {
158
+ return {
159
+ envelope: fail(
160
+ 'device',
161
+ err.message || String(err),
162
+ err.hint || 'Ensure a device or simulator is connected and the app is running.'
163
+ ),
164
+ exitKind: 'device',
165
+ };
166
+ }
167
+ }
168
+
169
+ async function handleScreenshot({ deviceBridge }, destPath) {
170
+ try {
171
+ const savedPath = await deviceBridge.screenshot(destPath);
172
+ return { envelope: ok({ path: savedPath }), exitKind: 'ok' };
173
+ } catch (err) {
174
+ return {
175
+ envelope: fail(
176
+ 'device',
177
+ err.message || String(err),
178
+ err.hint || 'Ensure a device or simulator is connected.'
179
+ ),
180
+ exitKind: 'device',
181
+ };
182
+ }
183
+ }
184
+
185
+ function handleValidate({ validator }, file) {
186
+ let raw;
187
+ try {
188
+ raw = fs.readFileSync(file, 'utf8');
189
+ } catch (err) {
190
+ return {
191
+ envelope: fail(
192
+ 'invalid_input',
193
+ `cannot read file: ${err.message}`,
194
+ 'Check that the path exists and is readable.'
195
+ ),
196
+ exitKind: 'invalid_input',
197
+ };
198
+ }
199
+
200
+ let scenario;
201
+ try {
202
+ scenario = JSON.parse(raw);
203
+ } catch (err) {
204
+ return {
205
+ envelope: fail(
206
+ 'invalid_input',
207
+ `file is not valid JSON: ${err.message}`,
208
+ 'Fix the JSON syntax and try again.'
209
+ ),
210
+ exitKind: 'invalid_input',
211
+ };
212
+ }
213
+
214
+ const result = validator.validate(scenario);
215
+ if (result.valid) {
216
+ return { envelope: ok({ valid: true }), exitKind: 'ok' };
217
+ }
218
+
219
+ const env = fail(
220
+ 'invalid_input',
221
+ 'scenario failed schema validation',
222
+ 'See data.errors for the specific schema violations.'
223
+ );
224
+ env.data = { valid: false, errors: result.errors };
225
+ return { envelope: env, exitKind: 'invalid_input' };
226
+ }
227
+
228
+ function deviceFail(err) {
229
+ // Respect a typed error's kind (e.g. the daemon's 'timeout') so the envelope
230
+ // and exit code reflect the real failure class; anything else defaults to
231
+ // 'device' (exit 2) as before.
232
+ const kind = err.kind || 'device';
233
+ return {
234
+ envelope: fail(
235
+ kind,
236
+ err.message || String(err),
237
+ err.hint || 'Ensure a device or simulator is connected and the app is running.'
238
+ ),
239
+ exitKind: kind,
240
+ };
241
+ }
242
+
243
+ // Parse a "x,y" coordinate string shared by tap / long-press / double-tap.
244
+ // Returns { x, y } (rounded ints) on success, or { error } carrying the
245
+ // invalid_input fail envelope so callers can short-circuit uniformly.
246
+ function parseCoordinates(raw) {
247
+ const parts = String(raw == null ? '' : raw).split(',');
248
+ if (parts.length !== 2) {
249
+ return {
250
+ error: fail('invalid_input', `expected coordinates as "x,y", got "${raw}"`, 'Pass --at <x,y>, e.g. --at 100,250.'),
251
+ };
252
+ }
253
+ // Reject empty/whitespace-only parts BEFORE Number(): Number('') is 0 (a
254
+ // finite number), so `--at 10,` would otherwise silently resolve to (10, 0).
255
+ const rawX = parts[0].trim();
256
+ const rawY = parts[1].trim();
257
+ if (rawX === '' || rawY === '') {
258
+ return {
259
+ error: fail('invalid_input', `expected coordinates as "x,y", got "${raw}"`, 'Pass --at <x,y>, e.g. --at 100,250.'),
260
+ };
261
+ }
262
+ const x = Number(rawX);
263
+ const y = Number(rawY);
264
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
265
+ return {
266
+ error: fail('invalid_input', `coordinates must be numbers, got "${raw}"`, 'Pass --at <x,y>, e.g. --at 100,250.'),
267
+ };
268
+ }
269
+ return { x: Math.round(x), y: Math.round(y) };
270
+ }
271
+
272
+ async function handleTap({ deviceBridge }, raw) {
273
+ const coords = parseCoordinates(raw);
274
+ if (coords.error) {
275
+ return { envelope: coords.error, exitKind: 'invalid_input' };
276
+ }
277
+ const { x, y } = coords;
278
+ try {
279
+ await deviceBridge.tap({ x, y });
280
+ return { envelope: ok({ tapped: [x, y] }), exitKind: 'ok' };
281
+ } catch (err) {
282
+ return deviceFail(err);
283
+ }
284
+ }
285
+
286
+ async function handleLongPress({ deviceBridge }, raw, { duration } = {}) {
287
+ const coords = parseCoordinates(raw);
288
+ if (coords.error) {
289
+ return { envelope: coords.error, exitKind: 'invalid_input' };
290
+ }
291
+ const { x, y } = coords;
292
+ let ms;
293
+ if (duration !== undefined) {
294
+ ms = Number(duration);
295
+ if (!Number.isInteger(ms) || ms <= 0) {
296
+ return {
297
+ envelope: fail('invalid_input', `duration must be a positive integer in ms, got "${duration}"`, 'Pass --duration <ms>, e.g. --duration 800.'),
298
+ exitKind: 'invalid_input',
299
+ };
300
+ }
301
+ }
302
+ try {
303
+ if (ms === undefined) {
304
+ await deviceBridge.longPress({ x, y });
305
+ } else {
306
+ await deviceBridge.longPress({ x, y, duration: ms });
307
+ }
308
+ const data = { long_pressed: [x, y] };
309
+ if (ms !== undefined) data.duration = ms;
310
+ return { envelope: ok(data), exitKind: 'ok' };
311
+ } catch (err) {
312
+ return deviceFail(err);
313
+ }
314
+ }
315
+
316
+ async function handleDoubleTap({ deviceBridge }, raw) {
317
+ const coords = parseCoordinates(raw);
318
+ if (coords.error) {
319
+ return { envelope: coords.error, exitKind: 'invalid_input' };
320
+ }
321
+ const { x, y } = coords;
322
+ try {
323
+ await deviceBridge.doubleTap({ x, y });
324
+ return { envelope: ok({ double_tapped: [x, y] }), exitKind: 'ok' };
325
+ } catch (err) {
326
+ return deviceFail(err);
327
+ }
328
+ }
329
+
330
+ async function handleLaunch({ deviceBridge }, appId) {
331
+ const id = String(appId == null ? '' : appId);
332
+ if (!id) {
333
+ return {
334
+ envelope: fail('invalid_input', 'an app id is required', 'e.g. mauto launch com.example.app'),
335
+ exitKind: 'invalid_input',
336
+ };
337
+ }
338
+ try {
339
+ await deviceBridge.launchApp(id);
340
+ return { envelope: ok({ launched: id }), exitKind: 'ok' };
341
+ } catch (err) {
342
+ return deviceFail(err);
343
+ }
344
+ }
345
+
346
+ async function handleInstall({ deviceBridge }, appPath) {
347
+ const p = String(appPath == null ? '' : appPath);
348
+ if (!p) {
349
+ return {
350
+ envelope: fail('invalid_input', 'an app path is required', 'e.g. mauto install ./app.apk'),
351
+ exitKind: 'invalid_input',
352
+ };
353
+ }
354
+ try {
355
+ await deviceBridge.installApp(p);
356
+ return { envelope: ok({ installed: p }), exitKind: 'ok' };
357
+ } catch (err) {
358
+ return deviceFail(err);
359
+ }
360
+ }
361
+
362
+ async function handleUninstall({ deviceBridge }, appId) {
363
+ const id = String(appId == null ? '' : appId);
364
+ if (!id) {
365
+ return {
366
+ envelope: fail('invalid_input', 'an app id is required', 'e.g. mauto uninstall com.example.app'),
367
+ exitKind: 'invalid_input',
368
+ };
369
+ }
370
+ try {
371
+ await deviceBridge.uninstallApp(id);
372
+ return { envelope: ok({ uninstalled: id }), exitKind: 'ok' };
373
+ } catch (err) {
374
+ return deviceFail(err);
375
+ }
376
+ }
377
+
378
+ async function handleOpenUrl({ deviceBridge }, url) {
379
+ const u = String(url == null ? '' : url);
380
+ if (!u) {
381
+ return {
382
+ envelope: fail('invalid_input', 'a url is required', 'e.g. mauto open-url https://example.com'),
383
+ exitKind: 'invalid_input',
384
+ };
385
+ }
386
+ try {
387
+ await deviceBridge.openUrl(u);
388
+ return { envelope: ok({ opened: u }), exitKind: 'ok' };
389
+ } catch (err) {
390
+ return deviceFail(err);
391
+ }
392
+ }
393
+
394
+ async function handleOrientation({ deviceBridge }, orientation) {
395
+ const o = String(orientation || '').toLowerCase();
396
+ if (!ORIENTATIONS.has(o)) {
397
+ return {
398
+ envelope: fail('invalid_input', `invalid orientation "${orientation}"`, 'Use one of: portrait, landscape.'),
399
+ exitKind: 'invalid_input',
400
+ };
401
+ }
402
+ try {
403
+ await deviceBridge.setOrientation(o);
404
+ return { envelope: ok({ orientation: o }), exitKind: 'ok' };
405
+ } catch (err) {
406
+ return deviceFail(err);
407
+ }
408
+ }
409
+
410
+ async function handleType({ deviceBridge }, text) {
411
+ const str = text == null ? '' : String(text);
412
+ try {
413
+ await deviceBridge.type(str);
414
+ return { envelope: ok({ typed: str.length }), exitKind: 'ok' };
415
+ } catch (err) {
416
+ return deviceFail(err);
417
+ }
418
+ }
419
+
420
+ async function handleSwipe({ deviceBridge }, direction) {
421
+ const dir = String(direction || '').toLowerCase();
422
+ if (!SWIPE_DIRECTIONS.has(dir)) {
423
+ return {
424
+ envelope: fail('invalid_input', `invalid swipe direction "${direction}"`, 'Use one of: up, down, left, right.'),
425
+ exitKind: 'invalid_input',
426
+ };
427
+ }
428
+ try {
429
+ await deviceBridge.swipe({ direction: dir });
430
+ return { envelope: ok({ swiped: dir }), exitKind: 'ok' };
431
+ } catch (err) {
432
+ return deviceFail(err);
433
+ }
434
+ }
435
+
436
+ async function handlePress({ deviceBridge }, button) {
437
+ const b = String(button || '');
438
+ if (!b) {
439
+ return {
440
+ envelope: fail('invalid_input', 'a button name is required', 'e.g. mauto press BACK'),
441
+ exitKind: 'invalid_input',
442
+ };
443
+ }
444
+
445
+ // Semantic actions are resolved to per-platform mechanics; a missing
446
+ // resolution is a hard error, never a bogus token forwarded to mobile-mcp.
447
+ if (isSemanticAction(b)) {
448
+ try {
449
+ const platform = await deviceBridge.getPlatform();
450
+ const resolver = selectResolver(platform, deviceBridge);
451
+ const { mechanism } = await resolver[ACTION_METHOD[b]]();
452
+ return {
453
+ envelope: ok({ pressed: b, resolved: { platform, mechanism } }),
454
+ exitKind: 'ok',
455
+ };
456
+ } catch (err) {
457
+ return deviceFail(err);
458
+ }
459
+ }
460
+
461
+ // Hardware/system button passthrough (BACK/HOME/ENTER…) — unchanged.
462
+ try {
463
+ await deviceBridge.pressButton(b);
464
+ return { envelope: ok({ pressed: b }), exitKind: 'ok' };
465
+ } catch (err) {
466
+ return deviceFail(err);
467
+ }
468
+ }
469
+
470
+ // Build an assertion object from CLI flags, fetch the current element list,
471
+ // and run the pure evaluator. A failed assertion is a valid RESULT (exit 0);
472
+ // only an unknown assertion type is a structural (invalid_input) error.
473
+ async function handleAssert({ deviceBridge }, type, flags = {}) {
474
+ if (!KNOWN_ASSERTION_TYPES.has(type)) {
475
+ return {
476
+ envelope: fail('invalid_input', `unknown assertion type "${type}"`, 'Run `mauto schema scenario` for the supported assertion types.'),
477
+ exitKind: 'invalid_input',
478
+ };
479
+ }
480
+
481
+ let elements;
482
+ try {
483
+ elements = await deviceBridge.listElements();
484
+ } catch (err) {
485
+ return deviceFail(err);
486
+ }
487
+
488
+ const assertion = { type };
489
+ if (flags.target !== undefined) assertion.target = flags.target;
490
+ if (flags.expected !== undefined) assertion.expected = flags.expected;
491
+ if (flags.operator !== undefined) assertion.operator = flags.operator;
492
+ if (flags.count !== undefined) assertion.count = Number(flags.count);
493
+ if (flags.pattern !== undefined) assertion.pattern = flags.pattern;
494
+ if (flags.variable !== undefined) assertion.variable_name = flags.variable;
495
+
496
+ const r = evaluate(assertion, { elements });
497
+ // The verdict has to be transcribed into `result add-assertion` by the agent
498
+ // (assert has no run context, and Tier-2 types are not decided here at all).
499
+ // Emitting the exact command turns transcription into copying (#140 D1).
500
+ const verdict = r.needs_agent ? '<your verdict>' : String(r.pass);
501
+ const hint =
502
+ `Record it: mauto result add-assertion --run-id <run_id> --step-id <step_id> ` +
503
+ `--type ${r.type} --pass ${verdict} --message "<why>"`;
504
+
505
+ return {
506
+ envelope: ok(
507
+ {
508
+ type: r.type,
509
+ mechanical: r.mechanical,
510
+ pass: r.pass,
511
+ needs_agent: r.needs_agent,
512
+ message: r.message,
513
+ },
514
+ hint
515
+ ),
516
+ exitKind: 'ok',
517
+ };
518
+ }
519
+
520
+ // Collect a repeatable flag's values into an array (commander's collector).
521
+ const collect = (value, previous) => previous.concat([value]);
522
+
523
+ function handleResultAddStep({ resultStoreFactory, projectRoot }, opts) {
524
+ const { runId, scenarioId, stepId, status, attempts, screenshot, errorMessage } = opts;
525
+ if (!runId || !stepId || !status) {
526
+ return {
527
+ envelope: fail('invalid_input', '--run-id, --step-id and --status are required', null),
528
+ exitKind: 'invalid_input',
529
+ };
530
+ }
531
+
532
+ // Parse every composite flag BEFORE touching the store: a half-recorded step
533
+ // (screenshot persisted, observation rejected) leaves the agent unable to
534
+ // tell which half landed, so validation is all-or-nothing.
535
+ const observations = [];
536
+ for (const spec of opts.observation || []) {
537
+ const parsed = parseObservation(spec);
538
+ if (parsed.error) {
539
+ return {
540
+ envelope: fail('invalid_input', parsed.error, `Valid types: ${OBSERVATION_TYPES.join(' | ')}.`),
541
+ exitKind: 'invalid_input',
542
+ };
543
+ }
544
+ observations.push({ ...parsed.value, step_id: stepId });
545
+ }
546
+
547
+ const captures = [];
548
+ for (const spec of opts.capture || []) {
549
+ const parsed = parseCapture(spec);
550
+ if (parsed.error) {
551
+ return {
552
+ envelope: fail('invalid_input', parsed.error, 'Use --capture <name>=<value>.'),
553
+ exitKind: 'invalid_input',
554
+ };
555
+ }
556
+ captures.push(parsed.value);
557
+ }
558
+
559
+ const store = resultStoreFactory({ runId, scenarioId, projectRoot });
560
+ const step = store.addStep({
561
+ step_id: stepId,
562
+ status,
563
+ attempts: attempts === undefined ? 1 : Number(attempts),
564
+ screenshot: screenshot === undefined ? null : screenshot,
565
+ error_message: errorMessage === undefined ? null : errorMessage,
566
+ });
567
+ // Echo what the STORE recorded, not the parsed request: if the store ever
568
+ // normalizes an observation (e.g. trims/derives a field), the envelope must
569
+ // reflect that rather than silently lying about what actually landed.
570
+ const recordedObservations = observations.map((obs) => store.addObservation(obs));
571
+ for (const cap of captures) store.captureVariable(cap.name, cap.value);
572
+
573
+ return {
574
+ envelope: ok(
575
+ {
576
+ run_id: runId,
577
+ step,
578
+ observations: recordedObservations,
579
+ captured_variables: Object.fromEntries(captures.map((c) => [c.name, c.value])),
580
+ },
581
+ storeHint(store)
582
+ ),
583
+ exitKind: 'ok',
584
+ };
585
+ }
586
+
587
+ // Persist an assertion verdict. Deliberately a separate verb rather than a
588
+ // side effect of `mauto assert`: 12 of the 27 assertion types are Tier 2
589
+ // (`needs_agent`, `pass: null`), so `assert` has no verdict to record at
590
+ // return time, and a side effect would re-record on every retry — inflating
591
+ // total_assertions past the scenario's actual assertion count (#140 D1).
592
+ function handleResultAddAssertion({ resultStoreFactory, projectRoot }, opts) {
593
+ const { runId, scenarioId, stepId, type, pass, assertionId, message, expected, actual } = opts;
594
+ if (!runId || !stepId || !type || pass === undefined) {
595
+ return {
596
+ envelope: fail('invalid_input', '--run-id, --step-id, --type and --pass are required', null),
597
+ exitKind: 'invalid_input',
598
+ };
599
+ }
600
+ if (!KNOWN_ASSERTION_TYPES.has(type)) {
601
+ return {
602
+ envelope: fail(
603
+ 'invalid_input',
604
+ `unknown assertion type "${type}"`,
605
+ 'Run `mauto schema scenario` for the supported assertion types.'
606
+ ),
607
+ exitKind: 'invalid_input',
608
+ };
609
+ }
610
+ const verdict = parseBool(pass, '--pass');
611
+ if (verdict.error) {
612
+ return { envelope: fail('invalid_input', verdict.error, null), exitKind: 'invalid_input' };
613
+ }
614
+
615
+ const store = resultStoreFactory({ runId, scenarioId, projectRoot });
616
+ const entry = store.addAssertion({
617
+ step_id: stepId,
618
+ assertion_id: assertionId,
619
+ type,
620
+ pass: verdict.value,
621
+ message,
622
+ expected: expected === undefined ? null : expected,
623
+ actual: actual === undefined ? null : actual,
624
+ });
625
+ return { envelope: ok({ run_id: runId, assertion: entry }, storeHint(store)), exitKind: 'ok' };
626
+ }
627
+
628
+ function handleResultFinalize({ resultStoreFactory, memoryStoreFactory, projectRoot }, opts) {
629
+ const { runId, scenarioId, status, duration } = opts;
630
+ if (!runId) {
631
+ return {
632
+ envelope: fail('invalid_input', '--run-id is required', null),
633
+ exitKind: 'invalid_input',
634
+ };
635
+ }
636
+
637
+ // Only provided keys: `defaultMetadata` fills the rest with 'unknown', and
638
+ // the result schema requires all five fields to be strings, so threading
639
+ // `undefined` through would break conformance.
640
+ const metadata = {};
641
+ if (opts.appVersion !== undefined) metadata.app_version = opts.appVersion;
642
+ if (opts.deviceModel !== undefined) metadata.device_model = opts.deviceModel;
643
+ if (opts.apiLevel !== undefined) metadata.api_level = opts.apiLevel;
644
+ if (opts.environment !== undefined) metadata.environment = opts.environment;
645
+
646
+ const store = resultStoreFactory({ runId, scenarioId, projectRoot });
647
+ const result = store.finalize({
648
+ status,
649
+ durationSeconds: duration === undefined ? 0 : Number(duration),
650
+ metadata,
651
+ // Undefined when --summary is omitted; ResultStore.finalize's own
652
+ // `summary || <generated default>` already treats that as "no override",
653
+ // so no provided-keys-only guard is needed here (unlike metadata, whose
654
+ // sub-fields default independently to 'unknown').
655
+ summary: opts.summary,
656
+ });
657
+
658
+ // Auto-harvest into cross-session memory. This is best-effort: a memory
659
+ // failure must never fail an otherwise-successful finalize, so its warnings
660
+ // fold into the envelope hint instead of throwing.
661
+ const hints = [storeHint(store)].filter(Boolean);
662
+ if (memoryStoreFactory) {
663
+ let mem;
664
+ try {
665
+ mem = memoryStoreFactory({ projectRoot });
666
+ mem.recordRun(result);
667
+ } catch (e) {
668
+ hints.push(`run-history not updated: ${e.message}`);
669
+ }
670
+ // Fold any warnings the store accumulated (even if recordRun later threw).
671
+ const memHint = storeHint(mem);
672
+ if (memHint) hints.push(memHint);
673
+ }
674
+
675
+ return { envelope: ok(result, hints.length ? hints.join(' ') : null), exitKind: 'ok' };
676
+ }
677
+
678
+ // Collapse any recovery warnings a ResultStore accumulated (e.g. a corrupt
679
+ // prior file preserved as a sidecar) into a single envelope hint, or null.
680
+ function storeHint(store) {
681
+ const warnings = (store && store.warnings) || [];
682
+ return warnings.length ? warnings.join(' ') : null;
683
+ }
684
+
685
+ // --- Slice 3: workspace + reasoning-delivery floor -----------------------
686
+ //
687
+ // Contract split: the action/result verbs above emit the JSON envelope. The
688
+ // content-emitting verbs `guide`/`schema`/`bootstrap` instead emit RAW content
689
+ // (markdown / JSON schema / text) on stdout, because an agent injects that text
690
+ // directly into its own context. Their handlers return `{ raw, exitKind:'ok' }`
691
+ // on success; only their ERROR paths (unknown topic/name) fall back to the
692
+ // `fail(...)` envelope + exit 3.
693
+
694
+ function handleSetup({ projectRoot }, opts = {}) {
695
+ const raw = opts.mode === undefined ? 'aware' : String(opts.mode);
696
+ const mode = MODE_ALIASES[raw];
697
+ if (!mode) {
698
+ return {
699
+ envelope: fail('invalid_input', `unknown mode "${opts.mode}"`, 'Use --mode aware or --mode agnostic.'),
700
+ exitKind: 'invalid_input',
701
+ };
702
+ }
703
+ const r = scaffold(projectRoot, { mode });
704
+ return {
705
+ envelope: ok({ created: r.created, mode: r.mode, next: 'run `mauto guide setup`' }),
706
+ exitKind: 'ok',
707
+ };
708
+ }
709
+
710
+ function handleConfigGet({ projectRoot }, key) {
711
+ const value = configManager.get(projectRoot, key);
712
+ return { envelope: ok({ key, value }), exitKind: 'ok' };
713
+ }
714
+
715
+ // Coerce the raw CLI string into the type config_schema.json declares for this
716
+ // key, then validate that one key before writing. Per-key (not whole-document)
717
+ // so unrelated pre-existing drift never blocks an otherwise-valid write.
718
+ function handleConfigSet({ projectRoot }, key, rawValue) {
719
+ const value = coerceValue(key, rawValue);
720
+ const { valid, errors } = validateAt(key, value);
721
+ if (!valid) {
722
+ return {
723
+ envelope: fail(
724
+ 'invalid_input',
725
+ `invalid value for config key "${key}": ${errors.join('; ')}`,
726
+ 'Run `mauto schema config` to see the declared type for this key. List keys accept a comma-separated value or a JSON array.'
727
+ ),
728
+ exitKind: 'invalid_input',
729
+ };
730
+ }
731
+ configManager.set(projectRoot, key, value);
732
+ return { envelope: ok({ key, value }), exitKind: 'ok' };
733
+ }
734
+
735
+ // RAW content on success; fail envelope only when the topic is unknown.
736
+ function handleGuide({ projectRoot, emitter = guideEmitter, config = configManager }, topic) {
737
+ const cfg = config.load(projectRoot);
738
+ const mode = config.resolveMode(cfg);
739
+ let raw;
740
+ try {
741
+ raw = emitter.emitGuide(topic, { mode, projectRoot });
742
+ } catch (err) {
743
+ return {
744
+ envelope: fail('invalid_input', `unknown guide topic "${topic}"`, 'Topics: generate, execute, setup.'),
745
+ exitKind: 'invalid_input',
746
+ };
747
+ }
748
+ return { raw, exitKind: 'ok' };
749
+ }
750
+
751
+ // RAW JSON schema on success; fail envelope only when the name is unknown.
752
+ function handleSchema({ emitter = guideEmitter } = {}, name) {
753
+ let raw;
754
+ try {
755
+ raw = emitter.emitSchema(name);
756
+ } catch (err) {
757
+ return {
758
+ envelope: fail('invalid_input', `unknown schema "${name}"`, 'Names: scenario, result, config.'),
759
+ exitKind: 'invalid_input',
760
+ };
761
+ }
762
+ return { raw, exitKind: 'ok' };
763
+ }
764
+
765
+ // RAW bootstrap text; no error path.
766
+ function handleBootstrap({ emitter = guideEmitter } = {}) {
767
+ return { raw: emitter.emitBootstrap(), exitKind: 'ok' };
768
+ }
769
+
770
+ // RAW markdown on success; fail envelope only when --kind is unknown.
771
+ function handleMemoryShow({ memoryStoreFactory, projectRoot }, opts = {}) {
772
+ const kind = opts.kind;
773
+ if (kind && !Object.prototype.hasOwnProperty.call(MEMORY_FILES, kind)) {
774
+ return {
775
+ envelope: fail(
776
+ 'invalid_input',
777
+ `unknown memory kind "${kind}"`,
778
+ 'Kinds: run-history, app-knowledge, preferences.'
779
+ ),
780
+ exitKind: 'invalid_input',
781
+ };
782
+ }
783
+ const store = memoryStoreFactory({ projectRoot });
784
+ const raw = store.render({ kind, scenario: opts.scenario });
785
+ return { raw, exitKind: 'ok' };
786
+ }
787
+
788
+ // Agent-authored memory verbs (`memory add`/`memory forget`) are MUTATIONS —
789
+ // unlike `memory show`, they emit the JSON envelope via `emit(...)`, not raw
790
+ // content. `--kind` is validated against the closed AGENT_KINDS set;
791
+ // run-history is machine-owned (auto-harvested on `result finalize`) and is
792
+ // rejected here.
793
+ // invalid_input fail envelope if kind isn't an agent-authorable kind, else null.
794
+ function validateAgentKind(kind) {
795
+ if (!AGENT_KINDS.includes(kind)) {
796
+ return fail(
797
+ 'invalid_input',
798
+ `--kind must be one of: ${AGENT_KINDS.join(', ')}`,
799
+ 'run-history is auto-harvested and cannot be hand-authored.'
800
+ );
801
+ }
802
+ return null;
803
+ }
804
+
805
+ function handleMemoryAdd({ memoryStoreFactory, projectRoot }, opts = {}) {
806
+ const { kind, text } = opts;
807
+ const kindErr = validateAgentKind(kind);
808
+ if (kindErr) return { envelope: kindErr, exitKind: 'invalid_input' };
809
+ const v = validateEntryText(text);
810
+ if (!v.ok) {
811
+ const hint = {
812
+ empty: 'Provide non-empty entry text.',
813
+ too_long: `Keep entries under ${MAX_ENTRY_LEN} characters.`,
814
+ placeholder: 'Entry text must not contain "{{".',
815
+ }[v.reason];
816
+ return { envelope: fail('invalid_input', `invalid entry text (${v.reason})`, hint), exitKind: 'invalid_input' };
817
+ }
818
+ const store = memoryStoreFactory({ projectRoot });
819
+ const r = store.add(kind, v.value);
820
+ const hint = r.deduped ? 'identical entry already present; skipped' : storeHint(store);
821
+ return { envelope: ok(r, hint), exitKind: 'ok' };
822
+ }
823
+
824
+ function handleMemoryForget({ memoryStoreFactory, projectRoot }, opts = {}) {
825
+ const { kind, match } = opts;
826
+ const kindErr = validateAgentKind(kind);
827
+ if (kindErr) return { envelope: kindErr, exitKind: 'invalid_input' };
828
+ if (!match || !String(match).trim()) {
829
+ return {
830
+ envelope: fail('invalid_input', '--match is required', 'Provide a substring to match entries to remove.'),
831
+ exitKind: 'invalid_input',
832
+ };
833
+ }
834
+ const store = memoryStoreFactory({ projectRoot });
835
+ const r = store.forget(kind, match);
836
+ const hint = r.removed === 0 ? 'no matching entries' : storeHint(store);
837
+ return { envelope: ok(r, hint), exitKind: 'ok' };
838
+ }
839
+
840
+ // --- Slice 7: vendor init -------------------------------------------------
841
+ //
842
+ // `mauto init --agent <claude|cursor>` writes per-vendor artifacts in the
843
+ // vendor's own namespace. Adapters are injectable for tests; the device is
844
+ // never exposed as MCP tools — the `mauto mcp` server advertises prompts only.
845
+ // Apply one adapter, converting any throw (perms, corrupt host config, missing
846
+ // asset) into a per-agent record so a mid-batch failure never escapes as a bare
847
+ // stack trace. Writers are idempotent/content-addressed, so a failed agent is
848
+ // re-converged cleanly on the next run without clobbering the succeeded ones.
849
+ // Classify an adapter failure HONESTLY rather than blanket-labeling it a
850
+ // filesystem fault. Real OS errors carry an E-prefixed errno (EACCES, ENOSPC,
851
+ // ...); our own malformed-config guard carries 'corrupt_mcp_config'; anything
852
+ // else (a TypeError, the leaked-placeholder guard) is an unexpected internal
853
+ // error and must NOT be disguised as an environment problem — that would send
854
+ // the operator to fix the filesystem for what is actually a code bug.
855
+ function adapterErrorClass(err) {
856
+ const code = err && err.code;
857
+ if (typeof code === 'string' && /^E[A-Z]+$/.test(code)) return code;
858
+ if (code === 'corrupt_mcp_config') return 'corrupt_mcp_config';
859
+ return 'internal';
860
+ }
861
+
862
+ function hintForAdapterError(cls) {
863
+ if (cls === 'corrupt_mcp_config') {
864
+ return 'An existing host config file is malformed JSON — fix it and re-run (writers are idempotent).';
865
+ }
866
+ if (/^E[A-Z]+$/.test(cls)) {
867
+ return 'A filesystem/permission error blocked init (e.g. an unwritable directory or a full disk) — fix it and re-run (writers are idempotent).';
868
+ }
869
+ return 'Unexpected error during init (possibly a bug) — see the message, then re-run after resolving (writers are idempotent).';
870
+ }
871
+
872
+ function applyOneAdapter(adapter, agent, projectRoot) {
873
+ try {
874
+ const r = adapter.apply({ projectRoot });
875
+ return { agent, ok: true, written: r.written, merged: r.merged };
876
+ } catch (err) {
877
+ return {
878
+ agent,
879
+ ok: false,
880
+ error: adapterErrorClass(err),
881
+ message: (err && err.message) || String(err),
882
+ };
883
+ }
884
+ }
885
+
886
+ function handleInit({ projectRoot, adapters = ADAPTERS }, agent) {
887
+ const known = Object.keys(adapters);
888
+ if (agent === 'all') {
889
+ // Continue-on-error: every host is attempted; the result is ONE envelope
890
+ // carrying a per-agent ok/failed map. `ok` only when all succeed.
891
+ const perAgent = known.map((a) => applyOneAdapter(adapters[a], a, projectRoot));
892
+ const failed = perAgent.filter((r) => !r.ok);
893
+ if (failed.length === 0) {
894
+ return { envelope: ok({ agents: perAgent }), exitKind: 'ok' };
895
+ }
896
+ return {
897
+ envelope: fail(
898
+ 'partial_failure',
899
+ `${failed.length}/${known.length} agents failed to initialize`,
900
+ 'See data.agents[].error; re-run to converge the failed agents (writers are idempotent).',
901
+ { agents: perAgent }
902
+ ),
903
+ exitKind: 'partial',
904
+ };
905
+ }
906
+ const adapter = adapters[agent];
907
+ if (!adapter) {
908
+ return {
909
+ envelope: fail(
910
+ 'invalid_input',
911
+ `unknown agent "${agent}"`,
912
+ `supported: ${known.join(', ')}, all`
913
+ ),
914
+ exitKind: 'invalid_input',
915
+ };
916
+ }
917
+ const r = applyOneAdapter(adapter, agent, projectRoot);
918
+ if (!r.ok) {
919
+ // A malformed host config is the user's input, not an environment fault —
920
+ // surface it as invalid_input (exit 3), matching how every other verb
921
+ // classifies bad JSON; fs/unknown adapter errors stay internal (exit 1).
922
+ const kind = r.error === 'corrupt_mcp_config' ? 'invalid_input' : 'internal';
923
+ return {
924
+ envelope: fail(
925
+ kind,
926
+ `init for "${agent}" failed: ${r.message}`,
927
+ hintForAdapterError(r.error),
928
+ { agents: [r] }
929
+ ),
930
+ exitKind: kind,
931
+ };
932
+ }
933
+ return {
934
+ envelope: ok({ agent: r.agent, written: r.written, merged: r.merged }),
935
+ exitKind: 'ok',
936
+ };
937
+ }
938
+
939
+ // --- Issue #91: persistent device session daemon -------------------------
940
+ //
941
+ // `mauto session start|status|end` manage the per-workspace daemon that holds
942
+ // ONE mobile-mcp connection and serves every one-shot device verb over a Unix
943
+ // domain socket. Device verbs still autostart a daemon transparently; these
944
+ // verbs give an agent explicit lifecycle control. The handlers own only the
945
+ // envelope shaping — the "is a daemon alive / spawn one / shut one down"
946
+ // decision lives in device/connection.js, the same owner the verb path uses, so
947
+ // there is no second inline wiring of session-client/session-spawn. Deps stay
948
+ // injectable so the handlers are unit-testable without spawning a real daemon.
949
+
950
+ async function handleSessionStart(
951
+ {
952
+ projectRoot,
953
+ spawn = require('./device/session-spawn'),
954
+ client = require('./device/session-client'),
955
+ readHandle = require('./device/resolve-connection').readHandleDevice,
956
+ },
957
+ opts = {}
958
+ ) {
959
+ const device = opts.device || null;
960
+ let idleMs;
961
+ if (opts.idle !== undefined) {
962
+ idleMs = Number(opts.idle);
963
+ if (!Number.isFinite(idleMs) || idleMs < 0) {
964
+ return {
965
+ envelope: fail('invalid_input', `invalid --idle value "${opts.idle}"`, 'Pass a non-negative number of milliseconds.'),
966
+ exitKind: 'invalid_input',
967
+ };
968
+ }
969
+ }
970
+
971
+ if (await connection.isSessionAlive(projectRoot, { client })) {
972
+ // A live daemon is already pinned to whatever device it was started with.
973
+ // Echoing back the *requested* device without comparing the handle pin is
974
+ // how #148 lied to the agent: `session start --device B` against a daemon
975
+ // pinned to A reported already_running:true, device:B while every verb
976
+ // kept driving A. Fail with a hint instead — the agent stays in control
977
+ // and decides whether to `session end` and repin.
978
+ const pinned = readHandle(projectRoot);
979
+ if (device !== null && device !== pinned) {
980
+ return {
981
+ envelope: fail(
982
+ 'device',
983
+ `a device session is already running, pinned to ${pinned || 'auto'} — not ${device}`,
984
+ `Run \`mauto session end\` first, then \`mauto session start --device ${device}\` to repin. Omit --device to reuse the running session.`,
985
+ { started: false, already_running: true, pinned, requested: device }
986
+ ),
987
+ exitKind: 'device',
988
+ };
989
+ }
990
+ return {
991
+ envelope: ok({ started: false, already_running: true, device }),
992
+ exitKind: 'ok',
993
+ };
994
+ }
995
+
996
+ const started = await connection.startSession({ projectRoot, device, idleMs, spawn });
997
+ if (!started) {
998
+ return {
999
+ envelope: fail('device', 'failed to start the device session daemon', 'Ensure a device or simulator is connected, or run verbs directly (they fall back to one-shot).'),
1000
+ exitKind: 'device',
1001
+ };
1002
+ }
1003
+ return { envelope: ok({ started: true, device }), exitKind: 'ok' };
1004
+ }
1005
+
1006
+ async function handleSessionStatus(
1007
+ { projectRoot, client = require('./device/session-client') } = {}
1008
+ ) {
1009
+ const status = await connection.sessionStatus(projectRoot, { client });
1010
+ return { envelope: ok(status), exitKind: 'ok' };
1011
+ }
1012
+
1013
+ async function handleSessionEnd(
1014
+ { projectRoot, client = require('./device/session-client') } = {}
1015
+ ) {
1016
+ const stopped = await connection.endSession(projectRoot, { client });
1017
+ return {
1018
+ envelope: ok({ stopped, already_stopped: !stopped }),
1019
+ exitKind: 'ok',
1020
+ };
1021
+ }
1022
+
1023
+ // --- Issue #92: device discovery + persisted selection -------------------
1024
+ //
1025
+ // `mauto devices` lists connected devices/simulators (id/name/platform/state)
1026
+ // via mobile-mcp. `mauto devices use <id>` persists a selection so subsequent
1027
+ // one-shot verbs don't need --device; `mauto devices clear` removes it. A
1028
+ // per-verb --device flag remains a per-call OVERRIDE that never writes the
1029
+ // store. All deps (deviceBridge, store) are injectable for testing.
1030
+
1031
+ async function handleDevices({ deviceBridge }) {
1032
+ try {
1033
+ const devices = await deviceBridge.listDevices();
1034
+ // Zero devices is a valid result, not an error (exit 0 with an empty list).
1035
+ return { envelope: ok(devices), exitKind: 'ok' };
1036
+ } catch (err) {
1037
+ return {
1038
+ envelope: fail(
1039
+ 'device',
1040
+ err.message || String(err),
1041
+ err.hint || 'Ensure a device or simulator is connected and reachable.'
1042
+ ),
1043
+ exitKind: 'device',
1044
+ };
1045
+ }
1046
+ }
1047
+
1048
+ // Persist a device selection. Validates the id against the live device list so
1049
+ // using a non-existent id (or selecting against zero devices) fails clearly
1050
+ // with a hint instead of pinning the workspace to a phantom device.
1051
+ async function handleDevicesUse({ deviceBridge, store = selectionStore, projectRoot }, id) {
1052
+ const wanted = String(id == null ? '' : id);
1053
+ if (!wanted) {
1054
+ return {
1055
+ envelope: fail('invalid_input', 'a device id is required', 'Run `mauto devices` to see ids, then `mauto devices use <id>`.'),
1056
+ exitKind: 'invalid_input',
1057
+ };
1058
+ }
1059
+
1060
+ let devices;
1061
+ try {
1062
+ devices = await deviceBridge.listDevices();
1063
+ } catch (err) {
1064
+ return {
1065
+ envelope: fail('device', err.message || String(err), err.hint || 'Ensure a device or simulator is connected and reachable.'),
1066
+ exitKind: 'device',
1067
+ };
1068
+ }
1069
+
1070
+ if (!devices.length) {
1071
+ return {
1072
+ envelope: fail('device', 'no devices are connected', 'Connect a device or simulator, then run `mauto devices`.'),
1073
+ exitKind: 'device',
1074
+ };
1075
+ }
1076
+
1077
+ const match = devices.find((d) => d.id === wanted);
1078
+ if (!match) {
1079
+ const ids = devices.map((d) => d.id).join(', ');
1080
+ return {
1081
+ envelope: fail('device', `no connected device matches id "${wanted}"`, `Choose one of: ${ids}`),
1082
+ exitKind: 'device',
1083
+ };
1084
+ }
1085
+
1086
+ store.write(projectRoot, match.id);
1087
+ return { envelope: ok({ selected: match.id, device: match }), exitKind: 'ok' };
1088
+ }
1089
+
1090
+ async function handleDevicesClear({
1091
+ store = selectionStore,
1092
+ projectRoot,
1093
+ isAlive = require('./device/connection').isSessionAlive,
1094
+ readHandle = require('./device/resolve-connection').readHandleDevice,
1095
+ } = {}) {
1096
+ const previous = store.read(projectRoot);
1097
+ store.clear(projectRoot);
1098
+ // Clearing selection.json only forgets the *persisted* choice; a running
1099
+ // daemon keeps serving the device it was started with (its handle pin is
1100
+ // the truth, and rewriting the handle would lie between file and reality).
1101
+ // Surface that so the agent knows `mauto session end` is required to switch.
1102
+ const alive = await isAlive(projectRoot);
1103
+ if (!alive) {
1104
+ return { envelope: ok({ cleared: previous || null }), exitKind: 'ok' };
1105
+ }
1106
+ const pinned = readHandle(projectRoot);
1107
+ return {
1108
+ envelope: ok(
1109
+ { cleared: previous || null, daemon_still_pinned: pinned },
1110
+ `a running device session is still pinned to ${pinned || 'auto'} — run \`mauto session end\` to switch devices`
1111
+ ),
1112
+ exitKind: 'ok',
1113
+ };
1114
+ }
1115
+
1116
+ // ---------------------------------------------------------------------------
1117
+ // Program wiring
1118
+ // ---------------------------------------------------------------------------
1119
+
1120
+ // Build a DeviceBridge via the one connection seam, which transparently reuses
1121
+ // the per-workspace device session daemon when one fits and falls back to a
1122
+ // one-shot mobile-mcp spawn otherwise. Returns { bridge, close } — the
1123
+ // `deviceBridgeFactory` seam + contract are preserved so existing handler tests
1124
+ // stay green. When the bridge is daemon-backed, close() only releases this
1125
+ // verb's socket; it never tears the shared daemon down. This is a thin alias:
1126
+ // the daemon-vs-oneshot decision lives entirely in device/connection.js.
1127
+ function realDeviceBridge(args) {
1128
+ return require('./device/connection').acquireConnection(args);
1129
+ }
1130
+
1131
+ function buildProgram(deps = {}) {
1132
+ const {
1133
+ // Factory returning { bridge, close }. Overridable in tests.
1134
+ deviceBridgeFactory = realDeviceBridge,
1135
+ validator = new ScenarioValidator(),
1136
+ // Factory for the incremental result store. Overridable in tests.
1137
+ resultStoreFactory = (args) => new ResultStore(args),
1138
+ // Factory for the cross-session memory store. Overridable in tests.
1139
+ memoryStoreFactory = (args) => new MemoryStore(args),
1140
+ // Project root used to resolve mobile-automator/results/.
1141
+ projectRoot = process.cwd(),
1142
+ // Sink used to emit the rendered envelope + drive the exit code.
1143
+ emit = defaultEmit,
1144
+ } = deps;
1145
+
1146
+ const program = new Command();
1147
+ program
1148
+ .name('mauto')
1149
+ .description('Platform-agnostic mobile automation CLI')
1150
+ // Commander owns `-V, --version` rather than a hand-rolled argv pre-scan.
1151
+ // A scan like `argv.some((a) => a === '--version')` is positionally blind:
1152
+ // it cannot honor the POSIX `--` separator, which is a state transition in
1153
+ // the parser (everything after it is an operand), not a token to match. It
1154
+ // therefore hijacked the value in `mauto type -- --version`, making a
1155
+ // literal "--version" impossible to type into a device by ANY invocation.
1156
+ // Commander tracks that state, so `--` works and the version flag still
1157
+ // resolves anywhere else. Emits `commander.version` via exitOverride, which
1158
+ // run() treats as a display outcome and exits 0.
1159
+ .version(PKG_VERSION)
1160
+ .option('--human', 'render human-readable output instead of JSON', false);
1161
+
1162
+ // Parse-level failures (unknown option, missing required option/argument,
1163
+ // unknown subcommand) must NOT bypass the envelope contract (#146). Without
1164
+ // exitOverride, commander prints bare text to stderr and calls process.exit
1165
+ // itself. exitOverride makes it throw a CommanderError instead; configureOutput
1166
+ // swallows commander's own stderr text so it never reaches the agent raw (the
1167
+ // message is carried in the envelope instead). Both are set on the ROOT before
1168
+ // any subcommand is created so every subcommand inherits them (commander copies
1169
+ // _exitCallback + _outputConfiguration to children via copyInheritedSettings).
1170
+ program.exitOverride(function (err) {
1171
+ // Attach the throwing command's usage line so the envelope hint can carry it.
1172
+ // `this` is the command that threw: commander invokes the callback as a
1173
+ // method of the throwing command even when the callback is inherited from
1174
+ // the root, so this.usage() names the exact subcommand that failed.
1175
+ if (this && typeof this.usage === 'function') {
1176
+ err.usage = usageLine(this);
1177
+ }
1178
+ throw err;
1179
+ });
1180
+ program.configureOutput({ writeErr: () => {} });
1181
+
1182
+ const humanFlag = () => Boolean(program.opts().human);
1183
+
1184
+ // The single envelope boundary. Wrap every `.action(...)` callback so any
1185
+ // error that escapes a handler (an unprotected JSON.parse, an fs write, a
1186
+ // bridge-connect rejection, ...) is converted into a fail envelope + the
1187
+ // right exit code instead of a raw stack trace. Handlers that already
1188
+ // self-classify expected errors emit first and never reach this catch; it
1189
+ // only fires on the UNEXPECTED leaks. Belt-and-suspenders for buildProgram's
1190
+ // own throws lives in run() below.
1191
+ const withEnvelope = (action) => async (...args) => {
1192
+ try {
1193
+ await action(...args);
1194
+ } catch (err) {
1195
+ const classified = toEnvelope(err);
1196
+ diagnose(err, classified.exitKind);
1197
+ emit(classified, humanFlag());
1198
+ }
1199
+ };
1200
+
1201
+ // Resolve which device a verb targets: explicit --device wins, else the
1202
+ // persisted selection, else null (mobile-mcp auto-selects a single device).
1203
+ // Keeping the no-selection fast path returning null preserves zero-config use.
1204
+ const resolveVerbDevice = (explicit) =>
1205
+ selectionStore.resolveDevice({ explicit, projectRoot, store: selectionStore }).device;
1206
+
1207
+ // Connect a DeviceBridge for an ALREADY-RESOLVED device, run `fn` against it,
1208
+ // emit its result, and always release the connection. The factory call is
1209
+ // INSIDE the try with its own catch so a connect failure (no device, daemon
1210
+ // spawn failure, socket error) lands as a `device` envelope (exit 2) — the
1211
+ // kind an agent expects — rather than escaping as a generic `internal`.
1212
+ // `deviceBridgeFactory` is realDeviceBridge -> connection.acquireConnection
1213
+ // (the single connection seam), so this keeps both the envelope boundary and
1214
+ // the acquire-seam refactor.
1215
+ const connectBridge = async (device, fn) => {
1216
+ let bridge;
1217
+ let close;
1218
+ try {
1219
+ ({ bridge, close } = await deviceBridgeFactory({ device, projectRoot }));
1220
+ } catch (err) {
1221
+ emit(deviceFail(err), humanFlag());
1222
+ return;
1223
+ }
1224
+ try {
1225
+ const r = await fn(bridge);
1226
+ emit(r, humanFlag());
1227
+ } finally {
1228
+ if (typeof close === 'function') await close();
1229
+ }
1230
+ };
1231
+
1232
+ program
1233
+ .command('elements')
1234
+ .description('List the agnostic UI elements currently on screen')
1235
+ .option('--device <id>', 'target device id')
1236
+ .action(withEnvelope((opts) =>
1237
+ connectBridge(resolveVerbDevice(opts.device), (bridge) =>
1238
+ handleElements({ deviceBridge: bridge })
1239
+ )
1240
+ ));
1241
+
1242
+ program
1243
+ .command('screenshot <path>')
1244
+ .description('Save a screenshot to the given path')
1245
+ .option('--device <id>', 'target device id')
1246
+ .action(withEnvelope((destPath, opts) =>
1247
+ connectBridge(resolveVerbDevice(opts.device), (bridge) =>
1248
+ handleScreenshot({ deviceBridge: bridge }, destPath)
1249
+ )
1250
+ ));
1251
+
1252
+ program
1253
+ .command('validate <file>')
1254
+ .description('Validate a scenario JSON file against the scenario schema')
1255
+ .action(withEnvelope((file) => {
1256
+ const r = handleValidate({ validator }, file);
1257
+ emit(r, humanFlag());
1258
+ }));
1259
+
1260
+ // --- Action verbs (one-shot; CLI owns the mechanical work) ---------------
1261
+
1262
+ const withBridge = (explicitDevice, fn) =>
1263
+ connectBridge(resolveVerbDevice(explicitDevice), fn);
1264
+
1265
+ program
1266
+ .command('tap')
1267
+ .description('Tap at absolute screen coordinates')
1268
+ .requiredOption('--at <x,y>', 'coordinates as "x,y"')
1269
+ .option('--device <id>', 'target device id')
1270
+ .action(withEnvelope((opts) => withBridge(opts.device, (bridge) => handleTap({ deviceBridge: bridge }, opts.at))));
1271
+
1272
+ program
1273
+ .command('type <text>')
1274
+ .description('Type text into the focused element')
1275
+ .option('--device <id>', 'target device id')
1276
+ .action(withEnvelope((text, opts) => withBridge(opts.device, (bridge) => handleType({ deviceBridge: bridge }, text))));
1277
+
1278
+ program
1279
+ .command('swipe')
1280
+ .description('Swipe in a cardinal direction')
1281
+ .requiredOption('--direction <dir>', 'up | down | left | right')
1282
+ .option('--device <id>', 'target device id')
1283
+ .action(withEnvelope((opts) => withBridge(opts.device, (bridge) => handleSwipe({ deviceBridge: bridge }, opts.direction))));
1284
+
1285
+ program
1286
+ .command('press <button>')
1287
+ .description('Press a system/hardware button (BACK, HOME, ENTER, ...)')
1288
+ .option('--device <id>', 'target device id')
1289
+ .action(withEnvelope((button, opts) => withBridge(opts.device, (bridge) => handlePress({ deviceBridge: bridge }, button))));
1290
+
1291
+ program
1292
+ .command('long-press')
1293
+ .description('Long-press at absolute screen coordinates')
1294
+ .requiredOption('--at <x,y>', 'coordinates as "x,y"')
1295
+ .option('--duration <ms>', 'press duration in milliseconds')
1296
+ .option('--device <id>', 'target device id')
1297
+ .action(withEnvelope((opts) => withBridge(opts.device, (bridge) => handleLongPress({ deviceBridge: bridge }, opts.at, { duration: opts.duration }))));
1298
+
1299
+ program
1300
+ .command('double-tap')
1301
+ .description('Double-tap at absolute screen coordinates')
1302
+ .requiredOption('--at <x,y>', 'coordinates as "x,y"')
1303
+ .option('--device <id>', 'target device id')
1304
+ .action(withEnvelope((opts) => withBridge(opts.device, (bridge) => handleDoubleTap({ deviceBridge: bridge }, opts.at))));
1305
+
1306
+ program
1307
+ .command('launch <appId>')
1308
+ .description('Launch an installed app by its package/bundle id')
1309
+ .option('--device <id>', 'target device id')
1310
+ .action(withEnvelope((appId, opts) => withBridge(opts.device, (bridge) => handleLaunch({ deviceBridge: bridge }, appId))));
1311
+
1312
+ program
1313
+ .command('install <path>')
1314
+ .description('Install an app from a local .apk/.app/.ipa path')
1315
+ .option('--device <id>', 'target device id')
1316
+ .action(withEnvelope((path, opts) => withBridge(opts.device, (bridge) => handleInstall({ deviceBridge: bridge }, path))));
1317
+
1318
+ program
1319
+ .command('uninstall <appId>')
1320
+ .description('Uninstall an app by its package/bundle id')
1321
+ .option('--device <id>', 'target device id')
1322
+ .action(withEnvelope((appId, opts) => withBridge(opts.device, (bridge) => handleUninstall({ deviceBridge: bridge }, appId))));
1323
+
1324
+ program
1325
+ .command('open-url <url>')
1326
+ .description('Open a URL (deep link or web) on the device')
1327
+ .option('--device <id>', 'target device id')
1328
+ .action(withEnvelope((url, opts) => withBridge(opts.device, (bridge) => handleOpenUrl({ deviceBridge: bridge }, url))));
1329
+
1330
+ program
1331
+ .command('orientation <orientation>')
1332
+ .description('Set the device orientation (portrait | landscape)')
1333
+ .option('--device <id>', 'target device id')
1334
+ .action(withEnvelope((orientation, opts) => withBridge(opts.device, (bridge) => handleOrientation({ deviceBridge: bridge }, orientation))));
1335
+
1336
+ program
1337
+ .command('assert <type>')
1338
+ .description('Evaluate an assertion against the current screen (mechanical types decided by the CLI; visual types deferred to the agent)')
1339
+ .option('--target <s>', 'element/target description')
1340
+ .option('--expected <s>', 'expected value')
1341
+ .option('--operator <op>', 'comparison operator for count assertions')
1342
+ .option('--count <n>', 'expected count')
1343
+ .option('--pattern <re>', 'regex pattern')
1344
+ .option('--variable <name>', 'captured variable name')
1345
+ .option('--device <id>', 'target device id')
1346
+ .action(withEnvelope((type, opts) =>
1347
+ withBridge(opts.device, (bridge) =>
1348
+ handleAssert({ deviceBridge: bridge }, type, {
1349
+ target: opts.target,
1350
+ expected: opts.expected,
1351
+ operator: opts.operator,
1352
+ count: opts.count,
1353
+ pattern: opts.pattern,
1354
+ variable: opts.variable,
1355
+ })
1356
+ )
1357
+ ));
1358
+
1359
+ // --- Result persistence verbs --------------------------------------------
1360
+
1361
+ const result = program.command('result').description('Incremental result file operations');
1362
+
1363
+ result
1364
+ .command('add-step')
1365
+ .description('Append a step result to the run file')
1366
+ .requiredOption('--run-id <id>', 'run identifier (run_YYYYMMDD_HHMMSS)')
1367
+ .option('--scenario-id <id>', 'scenario identifier')
1368
+ .requiredOption('--step-id <id>', 'step identifier')
1369
+ .requiredOption('--status <s>', 'pass | fail | skipped | error')
1370
+ .option('--attempts <n>', 'number of attempts (>1 records flakiness on pass)')
1371
+ .option('--screenshot <path>', 'path to the screenshot backing this step')
1372
+ .option('--error-message <text>', 'diagnostic detail for a failed step')
1373
+ .option(
1374
+ '--observation <type:message>',
1375
+ `typed observation (${OBSERVATION_TYPES.join(' | ')}); repeatable`,
1376
+ collect,
1377
+ []
1378
+ )
1379
+ .option('--capture <name=value>', 'captured variable; repeatable', collect, [])
1380
+ .action(withEnvelope((opts) => {
1381
+ const r = handleResultAddStep(
1382
+ { resultStoreFactory, projectRoot },
1383
+ {
1384
+ runId: opts.runId,
1385
+ scenarioId: opts.scenarioId,
1386
+ stepId: opts.stepId,
1387
+ status: opts.status,
1388
+ attempts: opts.attempts,
1389
+ screenshot: opts.screenshot,
1390
+ errorMessage: opts.errorMessage,
1391
+ observation: opts.observation,
1392
+ capture: opts.capture,
1393
+ }
1394
+ );
1395
+ emit(r, humanFlag());
1396
+ }));
1397
+
1398
+ result
1399
+ .command('add-assertion')
1400
+ .description('Record an assertion verdict against a step')
1401
+ .requiredOption('--run-id <id>', 'run identifier (run_YYYYMMDD_HHMMSS)')
1402
+ .option('--scenario-id <id>', 'scenario identifier')
1403
+ .requiredOption('--step-id <id>', 'step this assertion belongs to')
1404
+ .requiredOption('--type <t>', 'assertion type (see `mauto schema scenario`)')
1405
+ .requiredOption('--pass <bool>', 'true | false — the verdict being recorded')
1406
+ .option('--assertion-id <id>', 'stable assertion identifier (derived when omitted)')
1407
+ .option('--message <text>', 'human-readable justification for the verdict')
1408
+ .option('--expected <v>', 'expected value')
1409
+ .option('--actual <v>', 'observed value')
1410
+ .action(withEnvelope((opts) => {
1411
+ const r = handleResultAddAssertion(
1412
+ { resultStoreFactory, projectRoot },
1413
+ {
1414
+ runId: opts.runId,
1415
+ scenarioId: opts.scenarioId,
1416
+ stepId: opts.stepId,
1417
+ type: opts.type,
1418
+ pass: opts.pass,
1419
+ assertionId: opts.assertionId,
1420
+ message: opts.message,
1421
+ expected: opts.expected,
1422
+ actual: opts.actual,
1423
+ }
1424
+ );
1425
+ emit(r, humanFlag());
1426
+ }));
1427
+
1428
+ result
1429
+ .command('finalize')
1430
+ .description('Assemble and write the final result file')
1431
+ .requiredOption('--run-id <id>', 'run identifier')
1432
+ .option('--scenario-id <id>', 'scenario identifier')
1433
+ .option('--status <s>', 'passed | failed | error')
1434
+ .option('--duration <secs>', 'total duration in seconds')
1435
+ .option('--app-version <v>', 'app version under test')
1436
+ .option('--device-model <v>', 'device the run executed on')
1437
+ .option('--api-level <v>', 'OS API level / version')
1438
+ .option('--environment <v>', "target environment (e.g. 'staging')")
1439
+ .option('--summary <text>', 'override the generated one-line result summary')
1440
+ .action(withEnvelope((opts) => {
1441
+ const r = handleResultFinalize(
1442
+ { resultStoreFactory, memoryStoreFactory, projectRoot },
1443
+ {
1444
+ runId: opts.runId,
1445
+ scenarioId: opts.scenarioId,
1446
+ status: opts.status,
1447
+ duration: opts.duration,
1448
+ appVersion: opts.appVersion,
1449
+ deviceModel: opts.deviceModel,
1450
+ apiLevel: opts.apiLevel,
1451
+ environment: opts.environment,
1452
+ summary: opts.summary,
1453
+ }
1454
+ );
1455
+ emit(r, humanFlag());
1456
+ }));
1457
+
1458
+ // --- Slice 3 verbs --------------------------------------------------------
1459
+
1460
+ // Emit a handler result that may be either an envelope (success/error) OR
1461
+ // raw content. Raw content with exitKind 'ok' prints verbatim (exit 0);
1462
+ // anything else is an envelope error and goes through the normal emit path.
1463
+ const emitMaybeRaw = (r) => {
1464
+ if (r.raw !== undefined && r.exitKind === 'ok') {
1465
+ emitRaw(r.raw, r.exitKind);
1466
+ } else {
1467
+ emit(r, humanFlag());
1468
+ }
1469
+ };
1470
+
1471
+ program
1472
+ .command('setup')
1473
+ .description('Scaffold the workspace (mobile-automator/) and write a config')
1474
+ .option('--mode <mode>', 'aware (platform-aware, default) | agnostic (platform-agnostic)')
1475
+ .action(withEnvelope((opts) => {
1476
+ const r = handleSetup({ projectRoot }, { mode: opts.mode });
1477
+ emit(r, humanFlag());
1478
+ }));
1479
+
1480
+ const config = program.command('config').description('Read or update the workspace config');
1481
+ config
1482
+ .command('get <key>')
1483
+ .description('Get a dotted-path config value')
1484
+ .action(withEnvelope((key) => emit(handleConfigGet({ projectRoot }, key), humanFlag())));
1485
+ config
1486
+ .command('set <key> <value>')
1487
+ .description('Set a dotted-path config value (coerced to its declared type per `mauto schema config`)')
1488
+ .action(withEnvelope((key, value) => emit(handleConfigSet({ projectRoot }, key, value), humanFlag())));
1489
+
1490
+ program
1491
+ .command('guide <topic>')
1492
+ .description('Print the RAW workflow guide for a topic (generate|execute|setup)')
1493
+ .action(withEnvelope((topic) => emitMaybeRaw(handleGuide({ projectRoot }, topic))));
1494
+
1495
+ program
1496
+ .command('schema <name>')
1497
+ .description('Print the RAW JSON schema (scenario|result|config)')
1498
+ .action(withEnvelope((name) => emitMaybeRaw(handleSchema({}, name))));
1499
+
1500
+ program
1501
+ .command('bootstrap')
1502
+ .description('Print the RAW bootstrap (verb map + invariants)')
1503
+ .action(withEnvelope(() => emitMaybeRaw(handleBootstrap({}))));
1504
+
1505
+ // --- Cross-session memory verbs ------------------------------------------
1506
+
1507
+ const memory = program.command('memory').description('Cross-session memory (learning) operations');
1508
+
1509
+ memory
1510
+ .command('show')
1511
+ .description('Print RAW memory markdown (run-history[, app-knowledge, preferences])')
1512
+ .option('--kind <k>', 'run-history | app-knowledge | preferences')
1513
+ .option('--scenario <id>', 'filter run-history to one scenario')
1514
+ .action(withEnvelope((opts) =>
1515
+ emitMaybeRaw(
1516
+ handleMemoryShow({ memoryStoreFactory, projectRoot }, { kind: opts.kind, scenario: opts.scenario })
1517
+ )
1518
+ ));
1519
+
1520
+ memory
1521
+ .command('add <text>')
1522
+ .description('Record an agent-authored memory entry (app-knowledge or preferences)')
1523
+ .requiredOption('--kind <k>', 'app-knowledge | preferences')
1524
+ .action(withEnvelope((text, opts) =>
1525
+ emit(handleMemoryAdd({ memoryStoreFactory, projectRoot }, { kind: opts.kind, text }), humanFlag())
1526
+ ));
1527
+
1528
+ memory
1529
+ .command('forget')
1530
+ .description('Remove agent-authored memory entries whose text contains a substring')
1531
+ .requiredOption('--kind <k>', 'app-knowledge | preferences')
1532
+ .requiredOption('--match <substr>', 'remove entries containing this substring')
1533
+ .action(withEnvelope((opts) =>
1534
+ emit(handleMemoryForget({ memoryStoreFactory, projectRoot }, { kind: opts.kind, match: opts.match }), humanFlag())
1535
+ ));
1536
+
1537
+ // --- Slice 7: vendor init + MCP prompts server ---------------------------
1538
+
1539
+ program
1540
+ .command('init')
1541
+ .description('Install native Agent Skills (+ slash commands/rules + MCP entry) for an agent')
1542
+ .requiredOption('--agent <name>', 'claude | cursor | gemini | copilot | agents | all')
1543
+ .action(withEnvelope((opts) => {
1544
+ const r = handleInit({ projectRoot }, opts.agent);
1545
+ emit(r, humanFlag());
1546
+ }));
1547
+
1548
+ // --- Issue #91: device session lifecycle ---------------------------------
1549
+
1550
+ const session = program
1551
+ .command('session')
1552
+ .description('Manage the persistent device session daemon (one reused mobile-mcp connection)');
1553
+
1554
+ session
1555
+ .command('start')
1556
+ .description('Start the device session daemon (subsequent verbs reuse its connection)')
1557
+ .option('--device <id>', 'pin the daemon to a target device id')
1558
+ .option('--idle <ms>', 'idle timeout in milliseconds before the daemon self-reaps')
1559
+ .action(withEnvelope(async (opts) => {
1560
+ const r = await handleSessionStart({ projectRoot }, { device: opts.device, idle: opts.idle });
1561
+ emit(r, humanFlag());
1562
+ }));
1563
+
1564
+ session
1565
+ .command('status')
1566
+ .description('Report whether a device session daemon is running')
1567
+ .action(withEnvelope(async () => {
1568
+ const r = await handleSessionStatus({ projectRoot });
1569
+ emit(r, humanFlag());
1570
+ }));
1571
+
1572
+ session
1573
+ .command('end')
1574
+ .description('Stop the device session daemon and remove its socket/pidfile')
1575
+ .action(withEnvelope(async () => {
1576
+ const r = await handleSessionEnd({ projectRoot });
1577
+ emit(r, humanFlag());
1578
+ }));
1579
+
1580
+ // --- Issue #92: device discovery + persisted selection -------------------
1581
+
1582
+ const devices = program
1583
+ .command('devices')
1584
+ .description('List connected devices/simulators (id/name/platform/state)')
1585
+ .action(withEnvelope(() =>
1586
+ // device:null deliberately bypasses the persisted selection — this verb
1587
+ // lists ALL connected devices, it does not target the pinned one.
1588
+ connectBridge(null, (bridge) => handleDevices({ deviceBridge: bridge }))
1589
+ ));
1590
+
1591
+ devices
1592
+ .command('use <id>')
1593
+ .description('Persist a device selection so subsequent verbs reuse it (--device still overrides per-call)')
1594
+ .action(withEnvelope((id) =>
1595
+ connectBridge(null, (bridge) =>
1596
+ handleDevicesUse({ deviceBridge: bridge, projectRoot }, id)
1597
+ )
1598
+ ));
1599
+
1600
+ devices
1601
+ .command('clear')
1602
+ .description('Remove the persisted device selection')
1603
+ .action(withEnvelope(async () => {
1604
+ const r = await handleDevicesClear({ projectRoot });
1605
+ emit(r, humanFlag());
1606
+ }));
1607
+
1608
+ program
1609
+ .command('mcp')
1610
+ .description('Run the MCP prompts server (stdio) exposing the mauto workflows as prompts')
1611
+ .action(withEnvelope(async () => {
1612
+ // Long-lived: connect the stdio transport and serve until the client
1613
+ // closes. Loaded lazily so the MCP SDK is only required for `mauto mcp`.
1614
+ const { runServer } = require('./mcp/server');
1615
+ await runServer({ projectRoot });
1616
+ }));
1617
+
1618
+ return program;
1619
+ }
1620
+
1621
+ function defaultEmit({ envelope, exitKind }, human) {
1622
+ process.stdout.write(render(envelope, { human }) + '\n');
1623
+ process.exit(exitCodeFor(exitKind));
1624
+ }
1625
+
1626
+ // Print raw content (markdown / JSON schema / text) verbatim — no envelope
1627
+ // wrapping — then exit. Used by guide/schema/bootstrap success paths.
1628
+ function emitRaw(content, exitKind) {
1629
+ process.stdout.write(content.endsWith('\n') ? content : content + '\n');
1630
+ process.exit(exitCodeFor(exitKind));
1631
+ }
1632
+
1633
+ // Keep the stack of a genuinely-unexpected error on STDERR so a crash stays
1634
+ // debuggable. The stdout envelope (the agent's contract) is untouched, and the
1635
+ // expected/diagnosed classes (invalid_input, environment) carry an actionable
1636
+ // message + hint — so only `internal` warrants a trace.
1637
+ function diagnose(err, exitKind) {
1638
+ if (exitKind === 'internal' && err && err.stack) {
1639
+ process.stderr.write(err.stack + '\n');
1640
+ }
1641
+ }
1642
+
1643
+ // Last-resort emit for errors that escape BEFORE any action runs (so the
1644
+ // in-program withEnvelope boundary can't see them) — e.g. the ScenarioValidator
1645
+ // default-param compiling a corrupt bundled schema inside buildProgram, or a
1646
+ // stray unhandled rejection. Always one JSON envelope on stdout + the mapped
1647
+ // exit code, never a raw stack trace. `human` (opt-in --human) renders the fail
1648
+ // envelope readably instead of as JSON.
1649
+ function emitFatal(err, human = false) {
1650
+ const { envelope, exitKind } = toEnvelope(err);
1651
+ diagnose(err, exitKind);
1652
+ process.stdout.write(render(envelope, { human }) + '\n');
1653
+ process.exit(exitCodeFor(exitKind));
1654
+ }
1655
+
1656
+ async function run(argv) {
1657
+ // Belt-and-suspenders around the in-program withEnvelope boundary: a throw in
1658
+ // buildProgram() (e.g. the ScenarioValidator default-param compiling a corrupt
1659
+ // bundled schema) happens before any `.action` callback, so only this outer
1660
+ // try/catch can convert it into the envelope contract. The matching
1661
+ // `unhandledRejection` guard lives in bin/mauto.js (the process entry point),
1662
+ // not here, so calling run() in tests never installs a global exit-on-reject
1663
+ // listener that would leak across the suite.
1664
+ try {
1665
+ const program = buildProgram();
1666
+ await program.parseAsync(argv);
1667
+ } catch (err) {
1668
+ // Help/version are NOT errors: commander already wrote the human-readable
1669
+ // text to stdout. Leave them alone (no envelope) rather than wrapping them
1670
+ // (#146). Same COMMANDER_NON_FAILURES set toEnvelope classifies against, so
1671
+ // "which commander outcomes aren't failures" has exactly one answer.
1672
+ if (err && err.name === 'CommanderError' && COMMANDER_NON_FAILURES.has(err.code)) {
1673
+ // Honor commander's own exit code rather than assuming 0 — `.help()`
1674
+ // invoked with `{error:true}` computes 1.
1675
+ process.exit(err.exitCode || 0);
1676
+ return;
1677
+ }
1678
+ emitFatal(err, argv.slice(2).includes('--human'));
1679
+ }
1680
+ }
1681
+
1682
+ module.exports = {
1683
+ run,
1684
+ buildProgram,
1685
+ toEnvelope,
1686
+ diagnose,
1687
+ emitFatal,
1688
+ handleElements,
1689
+ handleScreenshot,
1690
+ handleValidate,
1691
+ handleTap,
1692
+ handleLongPress,
1693
+ handleDoubleTap,
1694
+ handleType,
1695
+ handleSwipe,
1696
+ handlePress,
1697
+ handleLaunch,
1698
+ handleInstall,
1699
+ handleUninstall,
1700
+ handleOpenUrl,
1701
+ handleOrientation,
1702
+ handleAssert,
1703
+ handleResultAddStep,
1704
+ handleResultAddAssertion,
1705
+ handleResultFinalize,
1706
+ handleSetup,
1707
+ handleConfigGet,
1708
+ handleConfigSet,
1709
+ handleGuide,
1710
+ handleSchema,
1711
+ handleBootstrap,
1712
+ handleMemoryShow,
1713
+ handleMemoryAdd,
1714
+ handleMemoryForget,
1715
+ handleInit,
1716
+ handleSessionStart,
1717
+ handleSessionStatus,
1718
+ handleSessionEnd,
1719
+ handleDevices,
1720
+ handleDevicesUse,
1721
+ handleDevicesClear,
1722
+ };