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.
- package/LICENSE +17 -0
- package/README.md +167 -0
- package/bin/mauto-session-daemon.js +101 -0
- package/bin/mauto.js +13 -0
- package/package.json +49 -0
- package/src/assertion/evaluator.js +285 -0
- package/src/cli.js +1722 -0
- package/src/config/coerce.js +97 -0
- package/src/config/manager.js +75 -0
- package/src/config/schema.js +124 -0
- package/src/device/action-catalog.js +99 -0
- package/src/device/bridge.js +139 -0
- package/src/device/connection.js +65 -0
- package/src/device/device-model.js +59 -0
- package/src/device/device-resolver.js +34 -0
- package/src/device/element-model.js +82 -0
- package/src/device/mobile-mcp-client.js +163 -0
- package/src/device/resolve-connection.js +156 -0
- package/src/device/selection.js +80 -0
- package/src/device/semantic-press/index.js +38 -0
- package/src/device/semantic-press/labels.js +39 -0
- package/src/device/semantic-press/resolver.js +100 -0
- package/src/device/session-client.js +173 -0
- package/src/device/session-daemon.js +526 -0
- package/src/device/session-paths.js +80 -0
- package/src/device/session-protocol.js +60 -0
- package/src/device/session-spawn.js +67 -0
- package/src/device/tool-args.js +14 -0
- package/src/guide/content/execute.agnostic.md +207 -0
- package/src/guide/content/execute.aware.md +281 -0
- package/src/guide/content/execute.invariants.md +4 -0
- package/src/guide/content/generate.agnostic.md +236 -0
- package/src/guide/content/generate.aware.md +327 -0
- package/src/guide/content/generate.invariants.md +5 -0
- package/src/guide/content/setup.agnostic.md +63 -0
- package/src/guide/content/setup.aware.md +91 -0
- package/src/guide/content/setup.invariants.md +3 -0
- package/src/guide/emitter.js +137 -0
- package/src/guide/placeholders.js +132 -0
- package/src/init/adapters.js +162 -0
- package/src/init/skill-meta.js +31 -0
- package/src/init/skill-renderer.js +60 -0
- package/src/mcp/prompts.js +55 -0
- package/src/mcp/server.js +55 -0
- package/src/memory/entries.js +61 -0
- package/src/memory/history.js +86 -0
- package/src/memory/lock.js +5 -0
- package/src/memory/paths.js +47 -0
- package/src/memory/store.js +230 -0
- package/src/output/envelope.js +106 -0
- package/src/result/capability-catalog.js +144 -0
- package/src/result/flags.js +52 -0
- package/src/result/store.js +273 -0
- package/src/scenario/validator.js +30 -0
- package/src/schemas/config_schema.json +68 -0
- package/src/schemas/format-error.js +19 -0
- package/src/schemas/result_schema.json +211 -0
- package/src/schemas/scenario_schema.json +587 -0
- package/src/setup/scaffold.js +53 -0
- package/src/util/atomic.js +41 -0
- 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
|
+
};
|