verikun 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,1298 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parsePoint = parsePoint;
4
+ exports.healNote = healNote;
5
+ exports.parseDuration = parseDuration;
6
+ exports.waitWindowMs = waitWindowMs;
7
+ exports.waitNote = waitNote;
8
+ exports.confineToCwd = confineToCwd;
9
+ exports.assertSafeAppId = assertSafeAppId;
10
+ exports.chooseLogOpts = chooseLogOpts;
11
+ exports.evalAssert = evalAssert;
12
+ exports.tokenizeLine = tokenizeLine;
13
+ exports.withBatchGlobals = withBatchGlobals;
14
+ exports.run = run;
15
+ const node_fs_1 = require("node:fs");
16
+ const node_path_1 = require("node:path");
17
+ const args_1 = require("./args");
18
+ const errors_1 = require("./errors");
19
+ const exec_1 = require("./exec");
20
+ const drivers_1 = require("./drivers");
21
+ const selector_1 = require("./ui/selector");
22
+ const format_1 = require("./ui/format");
23
+ const output_1 = require("./output");
24
+ const run_1 = require("./run");
25
+ const image_1 = require("./image");
26
+ const engine_1 = require("./agent/engine");
27
+ const claude_1 = require("./agent/claude");
28
+ const cache_1 = require("./agent/cache");
29
+ const cost_1 = require("./agent/cost");
30
+ const version_1 = require("./version");
31
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
32
+ function platformFromFlags(flags) {
33
+ if ((0, args_1.flagBool)(flags, 'ios'))
34
+ return 'ios';
35
+ if ((0, args_1.flagBool)(flags, 'android'))
36
+ return 'android';
37
+ const p = (0, args_1.flagStr)(flags, 'platform');
38
+ if (p === 'ios' || p === 'android')
39
+ return p;
40
+ if (p)
41
+ throw new errors_1.CliError(`Unknown platform '${p}' (use android|ios)`, 2);
42
+ return 'android';
43
+ }
44
+ function deviceFromFlags(flags, platform) {
45
+ return ((0, args_1.flagStr)(flags, 'device') ||
46
+ process.env.VERIKUN_DEVICE ||
47
+ (platform === 'android' ? process.env.ANDROID_SERIAL : undefined) ||
48
+ undefined);
49
+ }
50
+ function buildSelector(raw, flags) {
51
+ if (!raw) {
52
+ throw new errors_1.CliError('Missing selector. e.g. `@login_button`, `text:Login`, `desc:Submit`.', 2);
53
+ }
54
+ return (0, selector_1.parseSelector)(raw, { contains: (0, args_1.flagBool)(flags, 'contains'), index: (0, args_1.flagNum)(flags, 'index') });
55
+ }
56
+ function parsePoint(s) {
57
+ const m = /^(-?\d+)\s*,\s*(-?\d+)$/.exec(s.trim());
58
+ if (!m)
59
+ throw new errors_1.CliError(`Expected coordinates as x,y but got '${s}'`, 2);
60
+ return { x: +m[1], y: +m[2] };
61
+ }
62
+ /** A short note appended to action output when the selector matched non-exactly. */
63
+ function healNote(tier) {
64
+ return tier && tier !== 'exact' ? ` (healed: ${tier} match)` : '';
65
+ }
66
+ // --- Auto-wait on selector lookups -----------------------------------------
67
+ // A selector-resolving command does not fail the instant a lookup misses: it
68
+ // re-captures the hierarchy and retries until the (lenient) match succeeds or a
69
+ // wait window elapses (default 5s). A straightforward flow can then skip explicit
70
+ // `wait` calls — fewer round-trips, fewer tokens — while `--no-wait` / `--wait 0`
71
+ // restores fail-fast. Ambiguity (a present-but-plural match) is never waited on:
72
+ // the elements are already there, so it surfaces at once.
73
+ const DEFAULT_WAIT_MS = 5000;
74
+ const DEFAULT_POLL_MS = 300;
75
+ /** Parse a duration: a bare number is milliseconds (CLI convention), or `5s` / `800ms`. */
76
+ function parseDuration(raw, flag) {
77
+ const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m)?$/.exec(raw.trim());
78
+ if (!m)
79
+ throw new errors_1.CliError(`--${flag} must be a duration like 5000, 5s, 800ms, or 15m; got '${raw}'`, 2);
80
+ const n = Number(m[1]);
81
+ const scale = m[2] === 's' ? 1000 : m[2] === 'm' ? 60000 : 1;
82
+ return Math.max(0, Math.round(n * scale));
83
+ }
84
+ /** Wait window (ms) for selector lookups: `--no-wait`/`--wait 0` → 0; else `--wait <dur>`, else 5s. */
85
+ function waitWindowMs(flags) {
86
+ if ((0, args_1.flagBool)(flags, 'no-wait'))
87
+ return 0;
88
+ const v = flags['wait'];
89
+ if (v === undefined || v === true)
90
+ return DEFAULT_WAIT_MS; // absent, or bare `--wait` → default
91
+ return parseDuration(String(v), 'wait');
92
+ }
93
+ /** A short note appended to a confirmation when the action had to wait for its target. */
94
+ function waitNote(ms) {
95
+ return ms >= 100 ? ` (waited ${(ms / 1000).toFixed(1)}s)` : '';
96
+ }
97
+ /** Poll interval (ms) for auto-wait, capped so a sleep never overshoots the deadline. */
98
+ function pollStep(flags, deadline) {
99
+ const interval = (0, args_1.flagNum)(flags, 'interval') ?? DEFAULT_POLL_MS;
100
+ return Math.min(interval, Math.max(0, deadline - Date.now()));
101
+ }
102
+ /**
103
+ * matchElements with auto-wait: re-capture + re-match until at least one element
104
+ * matches or the window elapses. Returns the final result either way (empty on miss).
105
+ */
106
+ async function matchWaiting(ctx, sel, opts = {}) {
107
+ const deadline = Date.now() + waitWindowMs(ctx.flags);
108
+ for (;;) {
109
+ const res = (0, selector_1.matchElements)(ctx.driver.getElements(opts), sel);
110
+ if (res.matches.length > 0 || Date.now() >= deadline)
111
+ return res;
112
+ await sleep(pollStep(ctx.flags, deadline));
113
+ }
114
+ }
115
+ /**
116
+ * resolveOne with auto-wait: poll until exactly one element resolves. A hit (1) or
117
+ * an ambiguous (>1) match returns/throws at once via resolveOne — only an empty
118
+ * result is retried. On a final miss, throws not-found (exit 1), noting the wait.
119
+ */
120
+ async function resolveOneWaiting(ctx, sel, opts = {}) {
121
+ const windowMs = waitWindowMs(ctx.flags);
122
+ const start = Date.now();
123
+ const deadline = start + windowMs;
124
+ for (;;) {
125
+ const els = ctx.driver.getElements(opts);
126
+ if ((0, selector_1.matchElements)(els, sel).matches.length >= 1) {
127
+ const { element, tier } = (0, selector_1.resolveOne)(els, sel); // 1 → resolved; >1 → throws ambiguity
128
+ return { element, tier, waitedMs: Date.now() - start };
129
+ }
130
+ if (Date.now() >= deadline) {
131
+ const waited = windowMs > 0 ? ` after ${(windowMs / 1000).toFixed(1)}s` : '';
132
+ throw new errors_1.SelectorNotFoundError(`No element matched selector '${sel.raw}'${waited}. Run \`verikun ui\` to inspect the current screen.`);
133
+ }
134
+ await sleep(pollStep(ctx.flags, deadline));
135
+ }
136
+ }
137
+ // ---------------------------------------------------------------------------
138
+ // Commands
139
+ // ---------------------------------------------------------------------------
140
+ function cmdDevices(ctx) {
141
+ const allDevices = [];
142
+ try {
143
+ allDevices.push(...new drivers_1.AdbDriver().listDevices());
144
+ }
145
+ catch (e) {
146
+ // adb not on PATH is the common (expected) case, but surface anything else so a real
147
+ // adb listing failure isn't hidden behind a silently-empty device list.
148
+ (0, output_1.err)(`devices: adb backend unavailable (${e.message})`);
149
+ }
150
+ try {
151
+ // Only include booted simulators; always include physical devices (they carry a note)
152
+ allDevices.push(...new drivers_1.SimctlDriver().listDevices().filter((d) => d.state === 'booted' || d.note));
153
+ }
154
+ catch (e) {
155
+ (0, output_1.err)(`devices: simctl backend unavailable (${e.message})`);
156
+ }
157
+ if ((0, args_1.flagBool)(ctx.flags, 'json')) {
158
+ (0, output_1.json)(allDevices);
159
+ return 0;
160
+ }
161
+ if (!allDevices.length) {
162
+ (0, output_1.err)('No devices found.');
163
+ return 0;
164
+ }
165
+ for (const d of allDevices) {
166
+ (0, output_1.out)([d.platform, d.serial, d.state, d.model ?? '', d.product ? `(${d.product})` : '', d.note ? `[${d.note}]` : '']
167
+ .filter(Boolean)
168
+ .join('\t'));
169
+ }
170
+ return 0;
171
+ }
172
+ function cmdDoctor(ctx) {
173
+ if (ctx.platform === 'ios') {
174
+ const r = (0, exec_1.runText)('xcrun', ['simctl', 'list', 'devices', 'booted']);
175
+ (0, output_1.out)('xcrun: present');
176
+ (0, output_1.out)(r.stdout.trim() || '(no booted simulators)');
177
+ (0, output_1.out)('note: iOS screenshots + launch/stop work via simctl; tap/text/swipe/hierarchy need idb.');
178
+ return 0;
179
+ }
180
+ const adb = process.env.ADB || 'adb';
181
+ try {
182
+ (0, output_1.out)('adb: ' + (0, exec_1.runText)(adb, ['version']).stdout.split('\n')[0]);
183
+ }
184
+ catch {
185
+ (0, output_1.err)('adb: NOT FOUND on PATH');
186
+ return 3;
187
+ }
188
+ const devices = ctx.driver.listDevices();
189
+ const usable = devices.filter((d) => d.state === 'device');
190
+ (0, output_1.out)(`devices: ${devices.length} attached, ${usable.length} usable`);
191
+ for (const d of devices)
192
+ (0, output_1.out)(` ${d.serial} ${d.state}${d.model ? ` (${d.model})` : ''}`);
193
+ let ok = true;
194
+ if (usable.length !== 1 && !ctx.device) {
195
+ (0, output_1.err)(usable.length ? ' -> multiple devices: pass --device for interaction commands' : ' -> no usable device');
196
+ ok = false;
197
+ }
198
+ if (usable.length === 1 || ctx.device) {
199
+ try {
200
+ const serial = ctx.device || usable[0].serial;
201
+ const keys = ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale'];
202
+ const get = (k) => (0, exec_1.runText)(adb, ['-s', serial, 'shell', 'settings', 'get', 'global', k]).stdout.trim();
203
+ const vals = keys.map(get);
204
+ const off = vals.every((v) => v === '0' || v === '0.0');
205
+ (0, output_1.out)(`animations: ${vals.join('/')} ${off ? '(off, good)' : '(ON — flaky dumps; run `verikun doctor --fix`)'}`);
206
+ if ((0, args_1.flagBool)(ctx.flags, 'fix') && !off) {
207
+ for (const k of keys)
208
+ (0, exec_1.runText)(adb, ['-s', serial, 'shell', 'settings', 'put', 'global', k, '0']);
209
+ (0, output_1.out)('animations: disabled (good)');
210
+ }
211
+ }
212
+ catch {
213
+ (0, output_1.err)('animations: could not read device settings');
214
+ }
215
+ }
216
+ return ok ? 0 : 3;
217
+ }
218
+ function cmdUi(ctx) {
219
+ const els = ctx.driver.getElements({ all: (0, args_1.flagBool)(ctx.flags, 'all') });
220
+ if ((0, args_1.flagBool)(ctx.flags, 'json')) {
221
+ (0, output_1.json)(els.map(format_1.toJsonShape));
222
+ return 0;
223
+ }
224
+ (0, output_1.out)((0, args_1.flagBool)(ctx.flags, 'tree') ? (0, format_1.formatTree)(els) : (0, format_1.formatCompact)(els));
225
+ return 0;
226
+ }
227
+ async function cmdFind(ctx) {
228
+ const sel = buildSelector(ctx.positionals[0], ctx.flags);
229
+ const { matches, tier } = await matchWaiting(ctx, sel, { all: (0, args_1.flagBool)(ctx.flags, 'all') });
230
+ if ((0, args_1.flagBool)(ctx.flags, 'json'))
231
+ (0, output_1.json)(matches.map(format_1.toJsonShape));
232
+ else if (!matches.length)
233
+ (0, output_1.err)(`no match for '${sel.raw}'`);
234
+ else {
235
+ (0, output_1.out)((0, format_1.formatCompact)(matches));
236
+ if (tier && tier !== 'exact')
237
+ (0, output_1.err)(`(healed: matched via ${tier}, not exact)`);
238
+ }
239
+ return matches.length ? 0 : 1;
240
+ }
241
+ async function cmdTap(ctx) {
242
+ const at = (0, args_1.flagStr)(ctx.flags, 'at');
243
+ if (at) {
244
+ const p = parsePoint(at);
245
+ ctx.driver.tap(p.x, p.y);
246
+ ctx.record?.note({ message: `tapped coordinates (${p.x},${p.y})` });
247
+ (0, output_1.out)(`tapped (${p.x},${p.y})`);
248
+ return 0;
249
+ }
250
+ const raw = ctx.positionals[0];
251
+ // Bare integer == tap the element with that index from the latest `ui` snapshot.
252
+ // An index points at a specific prior dump, so it is single-shot (never waited on).
253
+ const isBareIndex = raw !== undefined &&
254
+ /^\d+$/.test(raw) &&
255
+ ctx.flags['index'] === undefined &&
256
+ !raw.startsWith('@') &&
257
+ !/^(id|text|desc|class):/.test(raw);
258
+ let target;
259
+ let tier = null;
260
+ let waitedMs = 0;
261
+ if (isBareIndex) {
262
+ const els = ctx.driver.getElements({ all: (0, args_1.flagBool)(ctx.flags, 'all') });
263
+ const idx = Number(raw);
264
+ const found = els.find((e) => e.index === idx);
265
+ if (!found)
266
+ throw new errors_1.CliError(`No element with index [${idx}] on the current screen. Run \`verikun ui\`.`, 1);
267
+ target = found;
268
+ ctx.record?.note({ element: target, message: `tapped by index [${idx}]` });
269
+ }
270
+ else {
271
+ const sel = buildSelector(raw, ctx.flags);
272
+ ({ element: target, tier, waitedMs } = await resolveOneWaiting(ctx, sel, { all: (0, args_1.flagBool)(ctx.flags, 'all') }));
273
+ ctx.record?.note({ selector: sel, tier, element: target });
274
+ }
275
+ ctx.driver.tap(target.center.x, target.center.y);
276
+ (0, output_1.out)(`tapped ${(0, format_1.formatInline)(target)}${healNote(tier)}${waitNote(waitedMs)}`);
277
+ return 0;
278
+ }
279
+ async function cmdText(ctx) {
280
+ if (ctx.positionals.length < 2) {
281
+ throw new errors_1.CliError('Usage: verikun text <selector> <text...> (use -- before text starting with "-")', 2);
282
+ }
283
+ const sel = buildSelector(ctx.positionals[0], ctx.flags);
284
+ const value = ctx.positionals.slice(1).join(' ');
285
+ const { element: target, tier, waitedMs } = await resolveOneWaiting(ctx, sel);
286
+ ctx.record?.note({
287
+ selector: sel,
288
+ tier,
289
+ element: target,
290
+ message: target.password ? 'typed «redacted»' : `typed ${JSON.stringify(value)}`,
291
+ });
292
+ ctx.driver.tap(target.center.x, target.center.y);
293
+ // Wait for field to be focused after tap
294
+ await sleep(100);
295
+ if ((0, args_1.flagBool)(ctx.flags, 'clear') && target.text) {
296
+ ctx.driver.pressKey('move_end');
297
+ for (let i = 0; i < target.text.length + 2; i++)
298
+ ctx.driver.pressKey('del');
299
+ // Wait for field to settle after clearing before typing
300
+ await sleep(200);
301
+ }
302
+ // Prime the input method with a space, then delete it, to avoid losing first character
303
+ // (workaround for adb input text behavior where first char is sometimes lost)
304
+ ctx.driver.inputText(' ');
305
+ ctx.driver.pressKey('backspace');
306
+ ctx.driver.inputText(value);
307
+ if ((0, args_1.flagBool)(ctx.flags, 'enter'))
308
+ ctx.driver.pressKey('enter');
309
+ (0, output_1.out)(`typed ${JSON.stringify(value)} into ${(0, format_1.formatInline)(target)}${healNote(tier)}${waitNote(waitedMs)}`);
310
+ return 0;
311
+ }
312
+ function cmdType(ctx) {
313
+ const value = ctx.positionals.join(' ');
314
+ if (!value)
315
+ throw new errors_1.CliError('Usage: verikun type <text...> (types into the focused field)', 2);
316
+ ctx.driver.inputText(value);
317
+ if ((0, args_1.flagBool)(ctx.flags, 'enter'))
318
+ ctx.driver.pressKey('enter');
319
+ ctx.record?.note({ message: `typed ${value.length} char(s) into focused field` });
320
+ (0, output_1.out)(`typed ${JSON.stringify(value)}`);
321
+ return 0;
322
+ }
323
+ function cmdKey(ctx) {
324
+ const name = ctx.positionals[0];
325
+ if (!name)
326
+ throw new errors_1.CliError('Usage: verikun key <name|code>', 2);
327
+ ctx.driver.pressKey(name);
328
+ ctx.record?.note({ message: `key ${name}` });
329
+ (0, output_1.out)(`key ${name}`);
330
+ return 0;
331
+ }
332
+ function quickKey(ctx, name) {
333
+ ctx.driver.pressKey(name);
334
+ ctx.record?.note({ message: `key ${name}` });
335
+ (0, output_1.out)(name);
336
+ return 0;
337
+ }
338
+ async function cmdSwipe(ctx) {
339
+ const duration = (0, args_1.flagNum)(ctx.flags, 'duration') ?? 300;
340
+ const from = (0, args_1.flagStr)(ctx.flags, 'from');
341
+ const to = (0, args_1.flagStr)(ctx.flags, 'to');
342
+ if (from && to) {
343
+ const a = parsePoint(from);
344
+ const b = parsePoint(to);
345
+ ctx.driver.swipe(a.x, a.y, b.x, b.y, duration);
346
+ ctx.record?.note({ message: `swiped (${a.x},${a.y})->(${b.x},${b.y})` });
347
+ (0, output_1.out)(`swiped (${a.x},${a.y})->(${b.x},${b.y})`);
348
+ return 0;
349
+ }
350
+ const dir = ctx.positionals[0];
351
+ if (!dir) {
352
+ throw new errors_1.CliError('Usage: verikun swipe <up|down|left|right> [--on <selector>] | --from x,y --to x,y', 2);
353
+ }
354
+ // Region the swipe happens within: the whole screen, or one element via --on.
355
+ let region;
356
+ let waitedMs = 0;
357
+ const on = (0, args_1.flagStr)(ctx.flags, 'on');
358
+ if (on) {
359
+ const onSel = (0, selector_1.parseSelector)(on, { contains: (0, args_1.flagBool)(ctx.flags, 'contains') });
360
+ const { element, waitedMs: w } = await resolveOneWaiting(ctx, onSel);
361
+ waitedMs = w;
362
+ ctx.record?.note({ selector: onSel, element });
363
+ region = element.bounds;
364
+ }
365
+ else {
366
+ const { width, height } = ctx.driver.screenSize();
367
+ region = { x1: 0, y1: 0, x2: width, y2: height };
368
+ }
369
+ const cx = Math.floor((region.x1 + region.x2) / 2);
370
+ const cy = Math.floor((region.y1 + region.y2) / 2);
371
+ const frac = Math.min(Math.max((0, args_1.flagNum)(ctx.flags, 'distance') ?? 0.6, 0.1), 0.95);
372
+ const dx = Math.floor(((region.x2 - region.x1) * frac) / 2);
373
+ const dy = Math.floor(((region.y2 - region.y1) * frac) / 2);
374
+ let a;
375
+ let b;
376
+ switch (dir) {
377
+ case 'up':
378
+ a = { x: cx, y: cy + dy };
379
+ b = { x: cx, y: cy - dy };
380
+ break;
381
+ case 'down':
382
+ a = { x: cx, y: cy - dy };
383
+ b = { x: cx, y: cy + dy };
384
+ break;
385
+ case 'left':
386
+ a = { x: cx + dx, y: cy };
387
+ b = { x: cx - dx, y: cy };
388
+ break;
389
+ case 'right':
390
+ a = { x: cx - dx, y: cy };
391
+ b = { x: cx + dx, y: cy };
392
+ break;
393
+ default:
394
+ throw new errors_1.CliError(`Unknown direction '${dir}' (use up|down|left|right)`, 2);
395
+ }
396
+ ctx.driver.swipe(a.x, a.y, b.x, b.y, duration);
397
+ ctx.record?.note({ message: `swiped ${dir}${on ? ` on ${on}` : ''}` });
398
+ (0, output_1.out)(`swiped ${dir}${waitNote(waitedMs)}`);
399
+ return 0;
400
+ }
401
+ // Screenshots are downscaled by default so an agent reading them spends fewer
402
+ // tokens (image cost scales with pixel area) — we rarely need much detail to tell
403
+ // what's on screen, and text stays legible at a small size. The cap is the
404
+ // longest edge in px: the default is deliberately small; --more bumps it up,
405
+ // --max <px> sets an exact cap, VERIKUN_SHOT_MAX_EDGE changes the default, and
406
+ // --full writes the original.
407
+ const DEFAULT_SHOT_MAX_EDGE = 700;
408
+ const MORE_SHOT_MAX_EDGE = 1400;
409
+ function shotMaxEdge() {
410
+ const env = process.env.VERIKUN_SHOT_MAX_EDGE;
411
+ if (env) {
412
+ const n = Number(env);
413
+ if (Number.isFinite(n) && n >= 1)
414
+ return n;
415
+ }
416
+ return DEFAULT_SHOT_MAX_EDGE;
417
+ }
418
+ /** Resolve an `--out` path and confine it to the working directory. A host-side write
419
+ * (a screenshot PNG, captured device logs) must never land outside cwd via a `..`
420
+ * traversal or an absolute path — including when driven by `vk ai` model output, whose
421
+ * leaf flags `validateNode` does not constrain. Exported for unit tests. */
422
+ function confineToCwd(outFlag) {
423
+ const cwd = (0, node_path_1.resolve)(process.cwd());
424
+ const path = (0, node_path_1.resolve)(cwd, outFlag);
425
+ if (path !== cwd && !path.startsWith(cwd + node_path_1.sep)) {
426
+ throw new errors_1.CliError(`--out must stay within the current directory; '${outFlag}' resolves outside it.`, 2);
427
+ }
428
+ return path;
429
+ }
430
+ /** A package / bundle id is handed to `adb shell`, which re-concatenates its args into
431
+ * one device-side command line — so a value with shell metacharacters would inject into
432
+ * the device shell. Valid Android package / iOS bundle ids are only `[A-Za-z0-9._-]`;
433
+ * reject anything else. This is the trust gate for `launch` / `stop` / `clear`, all
434
+ * reachable from `vk ai` model output. Exported for unit tests. */
435
+ function assertSafeAppId(appId) {
436
+ if (!/^[A-Za-z0-9._-]+$/.test(appId)) {
437
+ throw new errors_1.CliError(`Invalid app id '${appId}': only letters, digits, '.', '_' and '-' are allowed.`, 2);
438
+ }
439
+ return appId;
440
+ }
441
+ function cmdScreenshot(ctx) {
442
+ const raw = ctx.driver.screenshot();
443
+ // Precedence: --full (original) > --max <px> (explicit) > --more (preset) > default.
444
+ const maxEdge = (0, args_1.flagNum)(ctx.flags, 'max') ?? ((0, args_1.flagBool)(ctx.flags, 'more') ? MORE_SHOT_MAX_EDGE : shotMaxEdge());
445
+ const res = (0, args_1.flagBool)(ctx.flags, 'full') ? null : (0, image_1.downscalePng)(raw, maxEdge);
446
+ const buf = res?.scaled ? res.buf : raw;
447
+ const outFlag = (0, args_1.flagStr)(ctx.flags, 'out');
448
+ const path = outFlag ? confineToCwd(outFlag) : (0, output_1.defaultScreenshotPath)();
449
+ (0, node_fs_1.writeFileSync)(path, buf);
450
+ ctx.record?.attachImage(buf);
451
+ ctx.record?.note({ message: res?.scaled ? `${path} (${res.width}×${res.height})` : path });
452
+ // Surface the one case worth knowing about: we wanted to shrink but couldn't.
453
+ if (res && !res.scaled && res.reason?.startsWith('unsupported')) {
454
+ (0, output_1.err)(`screenshot not downscaled: ${res.reason}`);
455
+ }
456
+ if ((0, args_1.flagBool)(ctx.flags, 'json')) {
457
+ (0, output_1.json)({
458
+ path,
459
+ bytes: buf.length,
460
+ ...(res?.scaled
461
+ ? { width: res.width, height: res.height, scaledFrom: { width: res.origWidth, height: res.origHeight } }
462
+ : {}),
463
+ });
464
+ }
465
+ else {
466
+ (0, output_1.out)(path);
467
+ if (res?.scaled)
468
+ (0, output_1.err)(`scaled ${res.origWidth}×${res.origHeight} -> ${res.width}×${res.height} (max edge ${maxEdge}px; --more for detail, --full for original)`);
469
+ }
470
+ return 0;
471
+ }
472
+ // --full asks for everything; cap it large-but-finite so output stays under the
473
+ // exec MAX_BUFFER (the driver still does a single bounded dump, never a stream).
474
+ const FULL_LOG_LINES = 100000;
475
+ /**
476
+ * Pick the logcat window for `vk log`. Precedence:
477
+ * --since <marker> > -n/--lines <count> > --full > session window > last DEFAULT_LOG_LINES
478
+ * The session window (the run's device-clock start, when recording past the first
479
+ * step) is the default, so logs from before the run started are excluded.
480
+ * Exported for unit tests.
481
+ */
482
+ function chooseLogOpts(flags, ctx) {
483
+ const appId = ctx.appId;
484
+ const sinceFlag = (0, args_1.flagStr)(flags, 'since');
485
+ if (sinceFlag)
486
+ return { since: sinceFlag, appId };
487
+ const explicitLines = (0, args_1.flagNum)(flags, 'lines');
488
+ if (explicitLines !== undefined)
489
+ return { lines: explicitLines, appId };
490
+ if ((0, args_1.flagBool)(flags, 'full'))
491
+ return { lines: FULL_LOG_LINES, appId };
492
+ if (ctx.sessionSince)
493
+ return { since: ctx.sessionSince, appId };
494
+ return { appId };
495
+ }
496
+ function cmdLog(ctx) {
497
+ const opts = chooseLogOpts(ctx.flags, {
498
+ appId: ctx.positionals[0], // optional package; omitted = system-wide
499
+ sessionSince: ctx.record?.logWindowStart(),
500
+ });
501
+ const logs = ctx.driver.getLogs(opts);
502
+ // Recorded so the on-demand capture lands in the archived report (when a run is active).
503
+ ctx.record?.attachLog(logs);
504
+ const lineCount = logs === '' ? 0 : logs.replace(/\n+$/, '').split('\n').length;
505
+ const outFlag = (0, args_1.flagStr)(ctx.flags, 'out');
506
+ if (outFlag) {
507
+ // Keep --out inside the working directory (device logs can contain secrets).
508
+ const path = confineToCwd(outFlag);
509
+ (0, node_fs_1.writeFileSync)(path, logs);
510
+ if ((0, args_1.flagBool)(ctx.flags, 'json'))
511
+ (0, output_1.json)({ path, bytes: Buffer.byteLength(logs), lines: lineCount });
512
+ else
513
+ (0, output_1.out)(path);
514
+ return 0;
515
+ }
516
+ if ((0, args_1.flagBool)(ctx.flags, 'json')) {
517
+ (0, output_1.json)({ logs, lines: lineCount, ...(opts.appId ? { app: opts.appId } : {}), ...(opts.since ? { since: opts.since } : {}) });
518
+ return 0;
519
+ }
520
+ (0, output_1.out)(logs);
521
+ return 0;
522
+ }
523
+ async function cmdWait(ctx) {
524
+ const sel = buildSelector(ctx.positionals[0], ctx.flags);
525
+ const gone = (0, args_1.flagBool)(ctx.flags, 'gone');
526
+ const timeout = (0, args_1.flagNum)(ctx.flags, 'timeout') ?? 10000;
527
+ const interval = (0, args_1.flagNum)(ctx.flags, 'interval') ?? 400;
528
+ const deadline = Date.now() + timeout;
529
+ while (Date.now() < deadline) {
530
+ const { matches, tier } = (0, selector_1.matchElements)(ctx.driver.getElements(), sel);
531
+ if (gone ? matches.length === 0 : matches.length > 0) {
532
+ ctx.record?.note({ selector: sel, tier, element: matches[0], message: gone ? 'gone' : `${matches.length} match(es)` });
533
+ if (gone)
534
+ (0, output_1.out)(`gone: '${sel.raw}'`);
535
+ else
536
+ (0, output_1.out)((0, format_1.formatCompact)(matches));
537
+ return 0;
538
+ }
539
+ await sleep(interval);
540
+ }
541
+ ctx.record?.note({ selector: sel, message: `timeout after ${timeout}ms${gone ? ' (still present)' : ' (never appeared)'}` });
542
+ (0, output_1.err)(`timeout after ${timeout}ms waiting for '${sel.raw}'${gone ? ' to disappear' : ''}`);
543
+ return 1;
544
+ }
545
+ /** Evaluate an assertion against a single captured snapshot. */
546
+ function evalAssert(els, sel, flags) {
547
+ const { matches } = (0, selector_1.matchElements)(els, sel);
548
+ const gone = (0, args_1.flagBool)(flags, 'gone');
549
+ const wantText = (0, args_1.flagStr)(flags, 'text');
550
+ let pass;
551
+ let reason;
552
+ if (gone) {
553
+ pass = matches.length === 0;
554
+ reason = pass ? 'absent' : `still present (${matches.length})`;
555
+ }
556
+ else if (matches.length === 0) {
557
+ pass = false;
558
+ reason = 'not found';
559
+ }
560
+ else if (wantText !== undefined) {
561
+ const contains = (0, args_1.flagBool)(flags, 'contains');
562
+ pass = matches.some((m) => contains
563
+ ? m.text.toLowerCase().includes(wantText.toLowerCase())
564
+ : m.text.trim().toLowerCase() === wantText.trim().toLowerCase());
565
+ reason = pass ? 'text matched' : `found, but text != ${JSON.stringify(wantText)} (got ${JSON.stringify(matches.map((m) => m.text))})`;
566
+ }
567
+ else {
568
+ pass = true;
569
+ reason = `found ${matches.length}`;
570
+ }
571
+ return { pass, reason, matches };
572
+ }
573
+ async function cmdAssert(ctx) {
574
+ const sel = buildSelector(ctx.positionals[0], ctx.flags);
575
+ // Auto-wait subsumes the common "wait then assert": poll until the assertion
576
+ // passes or the window elapses. `--gone` therefore waits for disappearance.
577
+ const deadline = Date.now() + waitWindowMs(ctx.flags);
578
+ let result = evalAssert(ctx.driver.getElements(), sel, ctx.flags);
579
+ while (!result.pass && Date.now() < deadline) {
580
+ await sleep(pollStep(ctx.flags, deadline));
581
+ result = evalAssert(ctx.driver.getElements(), sel, ctx.flags);
582
+ }
583
+ const { pass, reason, matches } = result;
584
+ ctx.record?.note({ selector: sel, element: matches[0], message: `${pass ? 'PASS' : 'FAIL'} — ${reason}` });
585
+ if ((0, args_1.flagBool)(ctx.flags, 'json'))
586
+ (0, output_1.json)({ pass, selector: sel.raw, reason, matches: matches.map(format_1.toJsonShape) });
587
+ else
588
+ (0, output_1.out)(`${pass ? 'PASS' : 'FAIL'} ${sel.raw} — ${reason}`);
589
+ return pass ? 0 : 1;
590
+ }
591
+ function cmdLaunch(ctx) {
592
+ const appId = ctx.positionals[0];
593
+ if (!appId)
594
+ throw new errors_1.CliError('Usage: verikun launch <package|bundleId> [--clear] [--no-restart]', 2);
595
+ assertSafeAppId(appId);
596
+ // launch RESTARTS by default: if the app is already running/foregrounded, re-issuing
597
+ // the launch intent just delivers it to the live (possibly mid-flow, stale) instance
598
+ // instead of giving a fresh start — which makes reruns flaky. So we force-stop first.
599
+ // force-stop is a no-op when the app isn't running, so no "is it running?" probe is
600
+ // needed (and none would be portable — the iOS backend has no foreground query).
601
+ // --clear wipes data first via `pm clear` (which already force-stops) → fresh install
602
+ // --no-restart opt out of the force-stop (just bring the existing instance forward)
603
+ const cleared = (0, args_1.flagBool)(ctx.flags, 'clear');
604
+ const noRestart = (0, args_1.flagBool)(ctx.flags, 'no-restart');
605
+ if (cleared && noRestart) {
606
+ throw new errors_1.CliError('Cannot combine --clear with --no-restart: --clear wipes data and force-stops (a restart).', 2);
607
+ }
608
+ if (cleared)
609
+ ctx.driver.clearApp(appId);
610
+ else if (!noRestart)
611
+ ctx.driver.stop(appId);
612
+ ctx.driver.launch(appId);
613
+ const how = cleared ? 'cleared data + launched' : noRestart ? 'launched' : 'restarted';
614
+ ctx.record?.note({ message: `${how} ${appId}` });
615
+ if ((0, args_1.flagBool)(ctx.flags, 'json'))
616
+ (0, output_1.json)({ launched: appId, cleared, restarted: !cleared && !noRestart });
617
+ else
618
+ (0, output_1.out)(`${how} ${appId}`);
619
+ return 0;
620
+ }
621
+ function cmdStop(ctx) {
622
+ const appId = ctx.positionals[0];
623
+ if (!appId)
624
+ throw new errors_1.CliError('Usage: verikun stop <package|bundleId>', 2);
625
+ assertSafeAppId(appId);
626
+ ctx.driver.stop(appId);
627
+ ctx.record?.note({ message: `stopped ${appId}` });
628
+ (0, output_1.out)(`stopped ${appId}`);
629
+ return 0;
630
+ }
631
+ function cmdClear(ctx) {
632
+ const appId = ctx.positionals[0];
633
+ if (!appId)
634
+ throw new errors_1.CliError('Usage: verikun clear <package|bundleId>', 2);
635
+ assertSafeAppId(appId);
636
+ ctx.driver.clearApp(appId);
637
+ ctx.record?.note({ message: `cleared app data for ${appId}` });
638
+ if ((0, args_1.flagBool)(ctx.flags, 'json'))
639
+ (0, output_1.json)({ cleared: appId });
640
+ else
641
+ (0, output_1.out)(`cleared ${appId}`);
642
+ return 0;
643
+ }
644
+ function cmdCurrent(ctx) {
645
+ (0, output_1.out)(ctx.driver.currentApp());
646
+ return 0;
647
+ }
648
+ // Manage the active test run. Needs no device, so it is dispatched before the
649
+ // driver is built and is itself never recorded as a step.
650
+ function cmdRun(positionals, flags, platform, device) {
651
+ const sub = (positionals[0] ?? 'status').toLowerCase();
652
+ const asJson = (0, args_1.flagBool)(flags, 'json');
653
+ const tally = (steps) => ({
654
+ passed: steps.filter((s) => s.status === 'passed').length,
655
+ failed: steps.filter((s) => s.status !== 'passed').length,
656
+ });
657
+ switch (sub) {
658
+ case 'start': {
659
+ const state = run_1.Recorder.start(positionals[1], platform, device, (0, args_1.flagBool)(flags, 'force'));
660
+ if (asJson)
661
+ (0, output_1.json)({ started: state.id, name: state.name });
662
+ else
663
+ (0, output_1.err)(`started test run '${state.name}' (${state.id})`);
664
+ return 0;
665
+ }
666
+ case 'status': {
667
+ const state = run_1.Recorder.status();
668
+ if (asJson) {
669
+ (0, output_1.json)(state ?? { active: false });
670
+ return 0;
671
+ }
672
+ if (!state) {
673
+ (0, output_1.out)('no active test run');
674
+ return 0;
675
+ }
676
+ const { passed, failed } = tally(state.steps);
677
+ (0, output_1.out)(`run '${state.name}' (${state.id})${state.implicit ? ' [implicit]' : ''}: ${state.steps.length} step(s), ${passed} passed, ${failed} failed/error`);
678
+ (0, output_1.out)(` ${run_1.Recorder.contextLine(state)}`);
679
+ for (const s of state.steps)
680
+ (0, output_1.out)(` #${s.index} ${s.status.toUpperCase()} ${s.name} (${s.durationMs}ms)`);
681
+ return 0;
682
+ }
683
+ case 'clear':
684
+ case 'stop':
685
+ case 'discard': {
686
+ const cleared = run_1.Recorder.clear();
687
+ if (asJson)
688
+ (0, output_1.json)({ cleared: cleared?.id ?? null });
689
+ else
690
+ (0, output_1.out)(cleared ? `discarded test run '${cleared.name}' (${cleared.steps.length} step(s))` : 'no active test run');
691
+ return 0;
692
+ }
693
+ case 'archive':
694
+ case 'finish':
695
+ case 'save': {
696
+ const { dir, xmlPath, htmlPath, state } = run_1.Recorder.archive(positionals[1]);
697
+ const { passed, failed } = tally(state.steps);
698
+ if (asJson) {
699
+ (0, output_1.json)({ archived: dir, report: htmlPath, junit: xmlPath, steps: state.steps.length, passed, failed });
700
+ }
701
+ else {
702
+ (0, output_1.out)(dir); // primary result: the archived run directory
703
+ (0, output_1.err)(`archived '${state.name}': ${state.steps.length} step(s), ${passed} passed, ${failed} failed/error`);
704
+ (0, output_1.err)(` JUnit: ${xmlPath}`);
705
+ (0, output_1.err)(` HTML: ${htmlPath}`);
706
+ }
707
+ // Exit non-zero when the run contained failures, so CI can gate on it.
708
+ return failed > 0 ? 1 : 0;
709
+ }
710
+ default:
711
+ throw new errors_1.CliError(`Unknown 'run' subcommand '${sub}'. Use: start | status | archive | clear.`, 2);
712
+ }
713
+ }
714
+ // ---------------------------------------------------------------------------
715
+ // batch — run many commands from stdin or a --file, one per line
716
+ // ---------------------------------------------------------------------------
717
+ //
718
+ // Each non-blank, non-`#` line is parsed and executed exactly as if it had been
719
+ // its own `vk` invocation: same driver resolution, same auto-wait, same recording
720
+ // into the active test run, same stdout/stderr/exit semantics. Lines run in order
721
+ // and the batch STOPS at the first command that exits non-zero, propagating that
722
+ // code — a failed step means the flow's assumptions no longer hold, so continuing
723
+ // would be meaningless ("break on an irrecoverable error"). Device/output globals
724
+ // on the `batch` call are inherited by every line unless the line sets its own.
725
+ const BATCH_GLOBALS = ['device', 'platform', 'ios', 'android', 'json'];
726
+ /** Read the batch source text: --file if given, else stdin (which must be piped). */
727
+ function readBatchSource(flags) {
728
+ const file = (0, args_1.flagStr)(flags, 'file');
729
+ if (file) {
730
+ const path = (0, node_path_1.resolve)(process.cwd(), file);
731
+ try {
732
+ return (0, node_fs_1.readFileSync)(path, 'utf8');
733
+ }
734
+ catch (e) {
735
+ throw new errors_1.CliError(`batch: cannot read --file '${file}' (${e.message})`, 2);
736
+ }
737
+ }
738
+ // No --file: read newline-separated commands piped on stdin. A TTY means nothing
739
+ // was piped, so guide the caller instead of blocking forever on input.
740
+ if (process.stdin.isTTY) {
741
+ throw new errors_1.CliError('batch: no commands. Pipe them on stdin, or pass --file <path>.\n' +
742
+ " printf 'tap @login\\nassert text:Home\\n' | vk batch\n" +
743
+ ' vk batch --file flow.txt', 2);
744
+ }
745
+ try {
746
+ return (0, node_fs_1.readFileSync)(0, 'utf8'); // fd 0 == stdin
747
+ }
748
+ catch (e) {
749
+ throw new errors_1.CliError(`batch: could not read stdin (${e.message})`, 2);
750
+ }
751
+ }
752
+ /**
753
+ * Split a batch line into argv tokens with shell-like single/double quoting and
754
+ * backslash escapes — but WITHOUT a shell: this is pure string scanning, so a line
755
+ * can never spawn a host process or expand a variable (the same no-host-shell rule
756
+ * the rest of the CLI follows). Throws on an unterminated quote.
757
+ */
758
+ function tokenizeLine(line) {
759
+ const tokens = [];
760
+ let cur = '';
761
+ let started = false; // lets an empty "" / '' still produce a real empty token
762
+ for (let i = 0; i < line.length;) {
763
+ const c = line[i];
764
+ if (c === '"' || c === "'") {
765
+ started = true;
766
+ i++;
767
+ while (i < line.length && line[i] !== c) {
768
+ if (c === '"' && line[i] === '\\' && (line[i + 1] === '"' || line[i + 1] === '\\')) {
769
+ cur += line[i + 1];
770
+ i += 2;
771
+ }
772
+ else {
773
+ cur += line[i++];
774
+ }
775
+ }
776
+ if (i >= line.length) {
777
+ throw new errors_1.CliError(`batch: unterminated ${c === '"' ? 'double' : 'single'} quote in: ${line}`, 2);
778
+ }
779
+ i++; // consume the closing quote
780
+ }
781
+ else if (c === '\\' && i + 1 < line.length) {
782
+ cur += line[i + 1];
783
+ started = true;
784
+ i += 2;
785
+ }
786
+ else if (c === ' ' || c === '\t') {
787
+ if (started) {
788
+ tokens.push(cur);
789
+ cur = '';
790
+ started = false;
791
+ }
792
+ i++;
793
+ }
794
+ else {
795
+ cur += c;
796
+ started = true;
797
+ i++;
798
+ }
799
+ }
800
+ if (started)
801
+ tokens.push(cur);
802
+ return tokens;
803
+ }
804
+ /** Globals on the `batch` call become defaults for each line (the line may override). */
805
+ function withBatchGlobals(lineFlags, batchFlags) {
806
+ const merged = { ...lineFlags };
807
+ for (const k of BATCH_GLOBALS) {
808
+ if (merged[k] === undefined && batchFlags[k] !== undefined)
809
+ merged[k] = batchFlags[k];
810
+ }
811
+ return merged;
812
+ }
813
+ async function cmdBatch(positionals, batchFlags) {
814
+ let source;
815
+ try {
816
+ if (positionals.length > 0) {
817
+ throw new errors_1.CliError(`batch: unexpected argument '${positionals[0]}'. Pipe commands on stdin or pass --file <path>.`, 2);
818
+ }
819
+ source = readBatchSource(batchFlags);
820
+ }
821
+ catch (e) {
822
+ return mapError(e, batchFlags);
823
+ }
824
+ // Number lines first (so messages point at the true source line), then drop
825
+ // blank lines and `#` comments.
826
+ const all = source.split(/\r?\n/).map((text, i) => ({ n: i + 1, text: text.trim() }));
827
+ const commands = all.filter((l) => l.text.length > 0 && !l.text.startsWith('#'));
828
+ const quiet = (0, args_1.flagBool)(batchFlags, 'quiet');
829
+ if (commands.length === 0) {
830
+ (0, output_1.err)('[verikun] batch: no commands to run');
831
+ return 0;
832
+ }
833
+ for (const { n, text } of commands) {
834
+ let code;
835
+ try {
836
+ const { command, positionals: pos, flags } = (0, args_1.parseArgs)(tokenizeLine(text));
837
+ if (!command)
838
+ continue; // tokens were all flags — nothing to run
839
+ if (command === 'batch') {
840
+ throw new errors_1.CliError(`batch: a batch line may not itself be 'batch' (line ${n})`, 2);
841
+ }
842
+ if (!quiet)
843
+ (0, output_1.err)(`[verikun] batch ${n}: ${text}`);
844
+ code = await executeParsed(command, pos, withBatchGlobals(flags, batchFlags));
845
+ }
846
+ catch (e) {
847
+ // A malformed line (bad quoting, nested batch) is itself an error to halt on.
848
+ code = mapError(e, batchFlags);
849
+ }
850
+ if (code !== 0) {
851
+ (0, output_1.err)(`[verikun] batch stopped at line ${n} (\`${text}\`) — exit ${code}`);
852
+ return code;
853
+ }
854
+ }
855
+ if (!quiet)
856
+ (0, output_1.err)(`[verikun] batch: ${commands.length} command(s) ok`);
857
+ return 0;
858
+ }
859
+ // ---------------------------------------------------------------------------
860
+ // ai — compile a natural-language test to a plan IR, then run it (self-healing)
861
+ // ---------------------------------------------------------------------------
862
+ //
863
+ // `vk ai <file>` reads a plain-English test, compiles it ONCE into a deterministic
864
+ // plan IR via the model (cached by NL + app build), then replays it with NO model
865
+ // calls on the happy path. The model is woken only to repair a step that fails to
866
+ // resolve its selector; a green run persists the (possibly repaired) plan so the
867
+ // next run is free again. Cost is bounded by --max-cost-usd. Progress streams to
868
+ // stderr (CI liveness — it never goes quiet); stdout carries the final result.
869
+ async function cmdAi(positionals, flags) {
870
+ const file = positionals[0];
871
+ if (!file) {
872
+ throw new errors_1.CliError('Usage: verikun ai <file> [--model m] [--max-cost-usd n] [--timeout dur] [--show-plan] [--recompile]', 2);
873
+ }
874
+ let nl;
875
+ try {
876
+ nl = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(process.cwd(), file), 'utf8');
877
+ }
878
+ catch (e) {
879
+ throw new errors_1.CliError(`ai: cannot read '${file}' (${e.message})`, 2);
880
+ }
881
+ if (!nl.trim())
882
+ throw new errors_1.CliError(`ai: '${file}' is empty`, 2);
883
+ const platform = platformFromFlags(flags);
884
+ const device = deviceFromFlags(flags, platform);
885
+ const model = (0, cost_1.resolveModel)((0, args_1.flagStr)(flags, 'model'));
886
+ const overrideRaw = (0, args_1.flagStr)(flags, 'cost-override');
887
+ const override = overrideRaw ? (0, cost_1.parseCostOverride)(overrideRaw) : undefined;
888
+ const maxCostUsd = (0, args_1.flagNum)(flags, 'max-cost-usd') ?? cost_1.DEFAULT_MAX_COST_USD;
889
+ if (maxCostUsd <= 0)
890
+ throw new errors_1.CliError(`--max-cost-usd must be greater than 0 (got ${maxCostUsd}).`, 2);
891
+ const cost = new cost_1.CostTracker((0, cost_1.priceFor)(model, override), maxCostUsd);
892
+ // Whole-run wall-clock ceiling (default 15m) so a runaway loop/repair can't hang the run.
893
+ const timeoutFlag = (0, args_1.flagStr)(flags, 'timeout');
894
+ const timeoutMs = timeoutFlag ? parseDuration(timeoutFlag, 'timeout') : engine_1.DEFAULT_RUN_TIMEOUT_MS;
895
+ const deadline = Date.now() + timeoutMs;
896
+ const effort = (0, args_1.flagStr)(flags, 'effort');
897
+ const pkg = (0, args_1.flagStr)(flags, 'package');
898
+ const build = (0, args_1.flagStr)(flags, 'app-build');
899
+ const key = { nl, pkg, build, platform };
900
+ const recompile = (0, args_1.flagBool)(flags, 'recompile') || (0, args_1.flagBool)(flags, 'no-cache');
901
+ const showPlan = (0, args_1.flagBool)(flags, 'show-plan');
902
+ const apiKey = process.env.ANTHROPIC_API_KEY;
903
+ const provider = apiKey ? new claude_1.ClaudeProvider({ model, apiKey, effort }) : null;
904
+ // 1. Obtain the plan: a cache hit (free) or a compile (pays tokens; may seed from
905
+ // a prior build's plan to avoid a full recompile).
906
+ const cached = recompile ? null : (0, cache_1.readPlan)(key);
907
+ let plan;
908
+ if (cached) {
909
+ plan = cached.plan;
910
+ (0, output_1.err)(`[ai] plan cache hit — ${model} not called to compile`);
911
+ }
912
+ else {
913
+ if (!provider) {
914
+ throw new errors_1.CliError('ANTHROPIC_API_KEY is not set — `vk ai` needs it to compile the test. Set it and retry.', 3);
915
+ }
916
+ const seed = (0, cache_1.findSeed)(key);
917
+ if (seed)
918
+ (0, output_1.err)(`[ai] no exact cache; seeding from a prior plan (build ${seed.build ?? 'unknown'})`);
919
+ (0, output_1.err)(`[ai] compiling '${file}' with ${model} (effort ${effort ?? 'default'})…`);
920
+ const compiled = await provider.compile({ nl, pkg, platform, seed: seed?.plan });
921
+ cost.add(compiled.usage, 'compile');
922
+ plan = compiled.plan;
923
+ (0, output_1.err)(`[ai] compiled ${plan.steps.length} top-level step(s) · ${cost.summaryLine()}`);
924
+ // Cache the freshly-compiled plan right away, keyed by the test-text hash, so an
925
+ // unchanged test is never recompiled — even via --show-plan or after a failed run.
926
+ // A green run below re-persists the healed plan; a failed run leaves this clean
927
+ // compile cached (never a half-healed one).
928
+ try {
929
+ (0, cache_1.writePlan)(key, plan);
930
+ }
931
+ catch (e) {
932
+ (0, output_1.err)(`[ai] could not cache compiled plan: ${e.message}`);
933
+ }
934
+ }
935
+ // 2. --show-plan: print the compiled IR and stop (no device run).
936
+ if (showPlan) {
937
+ (0, output_1.json)(plan);
938
+ return 0;
939
+ }
940
+ // Running needs the provider for repair-on-failure; a cache hit with no key can't repair.
941
+ if (!provider) {
942
+ throw new errors_1.CliError('ANTHROPIC_API_KEY is not set — `vk ai` needs it to repair a failing step at runtime.', 3);
943
+ }
944
+ // The budget is a TOTAL-run ceiling: if the compile alone already crossed it, abort
945
+ // before running. A cache hit spends nothing, so a free replay is still allowed.
946
+ if (!cached && cost.exceeded()) {
947
+ (0, output_1.err)(`[ai] cost ceiling $${maxCostUsd} reached during compile (${cost.summaryLine()}) — not running`);
948
+ return 1;
949
+ }
950
+ // 3. One explicit run + one shared driver for the whole flow (so rollover can't
951
+ // split the test, and we don't rebuild a driver per step).
952
+ const existing = run_1.Recorder.status();
953
+ if (existing && existing.steps.length > 0) {
954
+ // Seal the pre-existing run into the archive instead of letting start(force=true)
955
+ // discard it — a manual in-progress run should never be silently lost.
956
+ const sealed = run_1.Recorder.archive();
957
+ (0, output_1.err)(`[ai] archived the active run ('${existing.name}', ${existing.steps.length} step(s)) → ${sealed.dir}`);
958
+ }
959
+ run_1.Recorder.start(`ai: ${(0, node_path_1.basename)(file)}`, platform, device, true);
960
+ const driver = (0, drivers_1.getDriver)(platform, device);
961
+ // Suppress per-step `out()` so stdout stays the one final result; progress -> stderr.
962
+ const prevQuiet = (0, output_1.setOutputQuiet)(true);
963
+ let result;
964
+ try {
965
+ result = await (0, engine_1.runPlan)(plan, {
966
+ exec: (command, pos, f) => executeOutcome(command, pos, f, driver),
967
+ getElements: () => driver.getElements(),
968
+ provider,
969
+ cost,
970
+ log: (m) => (0, output_1.err)(m),
971
+ markHealed: (m) => run_1.Recorder.markLastStepHealed(m),
972
+ maxRepairs: 3,
973
+ deadline,
974
+ });
975
+ }
976
+ catch (e) {
977
+ // An unexpected throw mid-run (e.g. an unrecoverable device error) must still
978
+ // seal the run so it is not left dangling in .verikun/run/ for the next command
979
+ // to roll over. Then let the error map to an exit code as usual.
980
+ run_1.Recorder.annotateRun({ ai: { ok: false, cost: cost.summaryLine(), modelRepairs: 0, improvements: [] } });
981
+ try {
982
+ run_1.Recorder.archive();
983
+ }
984
+ catch (sealErr) {
985
+ // Best-effort seal in an error path; surface a failure (the run state may itself be
986
+ // unreadable) but still throw the ORIGINAL error below.
987
+ (0, output_1.err)(`[ai] could not archive the run after a mid-run error (${sealErr.message})`);
988
+ }
989
+ throw e;
990
+ }
991
+ finally {
992
+ (0, output_1.setOutputQuiet)(prevQuiet);
993
+ }
994
+ // 4. Persist the (possibly repaired) plan only on a fully-green run; attach the
995
+ // cost + improvements summary to the run; archive into the report.
996
+ const costLine = cost.summaryLine();
997
+ if (result.ok) {
998
+ try {
999
+ (0, cache_1.writePlan)(key, result.plan);
1000
+ (0, output_1.err)('[ai] cached the green plan for next run');
1001
+ }
1002
+ catch (e) {
1003
+ (0, output_1.err)(`[ai] could not cache plan: ${e.message}`);
1004
+ }
1005
+ }
1006
+ run_1.Recorder.annotateRun({
1007
+ ai: { ok: result.ok, cost: costLine, modelRepairs: result.modelRepairs, improvements: result.improvements },
1008
+ });
1009
+ const { dir, xmlPath, htmlPath } = run_1.Recorder.archive();
1010
+ const status = result.ok
1011
+ ? 'PASS'
1012
+ : result.abortedForBudget
1013
+ ? `ABORTED — cost ceiling $${maxCostUsd} reached`
1014
+ : result.abortedForTimeout
1015
+ ? `ABORTED — run timeout (${Math.round(timeoutMs / 1000)}s) reached`
1016
+ : `FAIL at ${result.failure?.where}: ${result.failure?.reason}`;
1017
+ (0, output_1.err)(`[ai] ${status} · ${costLine}`);
1018
+ (0, output_1.err)(`[ai] report: ${htmlPath}`);
1019
+ if (result.improvements.length) {
1020
+ (0, output_1.err)(`[ai] ${result.improvements.length} suggested improvement(s) (also in the report):`);
1021
+ for (const imp of result.improvements)
1022
+ (0, output_1.err)(' - ' + imp);
1023
+ }
1024
+ (0, output_1.err)(`[ai] estimated total cost: $${cost.usd().toFixed(4)}`);
1025
+ if ((0, args_1.flagBool)(flags, 'json')) {
1026
+ (0, output_1.json)({
1027
+ ok: result.ok,
1028
+ model,
1029
+ cost: costLine,
1030
+ costUsd: Number(cost.usd().toFixed(4)),
1031
+ modelRepairs: result.modelRepairs,
1032
+ improvements: result.improvements,
1033
+ report: htmlPath,
1034
+ junit: xmlPath,
1035
+ runDir: dir,
1036
+ ...(result.failure ? { failure: result.failure } : {}),
1037
+ ...(result.abortedForBudget ? { abortedForBudget: true } : {}),
1038
+ ...(result.abortedForTimeout ? { abortedForTimeout: true } : {}),
1039
+ });
1040
+ }
1041
+ else {
1042
+ (0, output_1.out)(htmlPath); // primary machine result: the report path
1043
+ }
1044
+ return result.ok ? 0 : 1;
1045
+ }
1046
+ // ---------------------------------------------------------------------------
1047
+ // Dispatch
1048
+ // ---------------------------------------------------------------------------
1049
+ async function executeCommand(command, ctx) {
1050
+ switch (command) {
1051
+ case 'devices':
1052
+ return cmdDevices(ctx);
1053
+ case 'doctor':
1054
+ return cmdDoctor(ctx);
1055
+ case 'ui':
1056
+ case 'dump':
1057
+ return cmdUi(ctx);
1058
+ case 'find':
1059
+ return await cmdFind(ctx);
1060
+ case 'tap':
1061
+ case 'click':
1062
+ return await cmdTap(ctx);
1063
+ case 'text':
1064
+ return await cmdText(ctx);
1065
+ case 'type':
1066
+ return cmdType(ctx);
1067
+ case 'key':
1068
+ return cmdKey(ctx);
1069
+ case 'back':
1070
+ return quickKey(ctx, 'back');
1071
+ case 'home':
1072
+ return quickKey(ctx, 'home');
1073
+ case 'enter':
1074
+ return quickKey(ctx, 'enter');
1075
+ case 'swipe':
1076
+ case 'scroll':
1077
+ return await cmdSwipe(ctx);
1078
+ case 'screenshot':
1079
+ case 'shot':
1080
+ return cmdScreenshot(ctx);
1081
+ case 'wait':
1082
+ return await cmdWait(ctx);
1083
+ case 'assert':
1084
+ return await cmdAssert(ctx);
1085
+ case 'launch':
1086
+ case 'open':
1087
+ return cmdLaunch(ctx);
1088
+ case 'stop':
1089
+ return cmdStop(ctx);
1090
+ case 'clear':
1091
+ return cmdClear(ctx);
1092
+ case 'current':
1093
+ return cmdCurrent(ctx);
1094
+ case 'log':
1095
+ case 'logs':
1096
+ return cmdLog(ctx);
1097
+ default:
1098
+ (0, output_1.err)(`Unknown command '${command}'. Run \`verikun help\`.`);
1099
+ return 2;
1100
+ }
1101
+ }
1102
+ /** Map a thrown error to an exit code, emitting it as text or JSON per --json. */
1103
+ function mapError(e, flags) {
1104
+ if (e instanceof errors_1.CliError) {
1105
+ if ((0, args_1.flagBool)(flags, 'json'))
1106
+ (0, output_1.json)({ error: e.message, exitCode: e.exitCode });
1107
+ else
1108
+ (0, output_1.err)(e.message);
1109
+ return e.exitCode;
1110
+ }
1111
+ (0, output_1.err)('Unexpected error: ' + e.message);
1112
+ if (process.env.VERIKUN_DEBUG)
1113
+ (0, output_1.err)(e.stack ?? '');
1114
+ return 3;
1115
+ }
1116
+ /**
1117
+ * Execute one (non-meta) command and return its RAW outcome — the exit code, and
1118
+ * the thrown error if any, WITHOUT mapping it to a printed exit. The agent engine
1119
+ * (`vk ai`) uses this to tell a selector miss / ambiguity (a heal trigger: the error
1120
+ * is a SelectorNotFoundError / AmbiguousSelectorError) apart from an assertion
1121
+ * failure (`assert` *returns* exit 1, never throws — so it must never be healed, or
1122
+ * a real regression would be masked). `executeParsed` wraps it to restore the
1123
+ * print-and-exit behavior the CLI and `batch` rely on.
1124
+ *
1125
+ * An optional `sharedDriver` lets the engine reuse one device handle across many
1126
+ * commands (and its control-flow guards) instead of building a driver per call.
1127
+ */
1128
+ async function executeOutcome(command, positionals, flags, sharedDriver) {
1129
+ const platform = platformFromFlags(flags);
1130
+ const device = deviceFromFlags(flags, platform);
1131
+ // Recordable commands open a step (auto-starting an implicit run if needed);
1132
+ // the step is finalized with the outcome — and, on failure, screenshot + UI
1133
+ // hierarchy of the page are captured — whether the command returns or throws.
1134
+ const recordable = (0, run_1.isRecordable)(command);
1135
+ let driver = sharedDriver;
1136
+ let recorder = null;
1137
+ try {
1138
+ if (!driver)
1139
+ driver = (0, drivers_1.getDriver)(platform, device);
1140
+ if (recordable) {
1141
+ // Resolve the serial up front (cheap; the driver caches it) so the run can
1142
+ // detect a device change. Tolerate failure — the handler raises the real error.
1143
+ let serial;
1144
+ try {
1145
+ serial = driver.resolvedSerial();
1146
+ }
1147
+ catch {
1148
+ /* surfaced by the command handler below */
1149
+ }
1150
+ recorder = run_1.Recorder.beginStep(command, positionals, flags, platform, device, serial, driver);
1151
+ }
1152
+ const ctx = { driver, platform, device, positionals, flags, record: recorder ?? undefined };
1153
+ const code = await executeCommand(command, ctx);
1154
+ recorder?.finish(code, driver);
1155
+ return { code };
1156
+ }
1157
+ catch (e) {
1158
+ recorder?.finishError(e, driver);
1159
+ return { code: e instanceof errors_1.CliError ? e.exitCode : 3, error: e };
1160
+ }
1161
+ }
1162
+ /**
1163
+ * Run one already-parsed command for the CLI / `batch`: dispatch meta-commands,
1164
+ * else execute it and map any failure to a printed exit code. This is the shared
1165
+ * per-command entry both a top-level `run()` and each `batch` line go through, so a
1166
+ * batched command behaves identically to a standalone invocation.
1167
+ */
1168
+ async function executeParsed(command, positionals, flags) {
1169
+ const platform = platformFromFlags(flags);
1170
+ const device = deviceFromFlags(flags, platform);
1171
+ // Meta-commands manage local state / orchestrate other commands. They build no
1172
+ // driver of their own and are dispatched before the recording machinery.
1173
+ if (command === 'run')
1174
+ return cmdRun(positionals, flags, platform, device);
1175
+ if (command === 'batch')
1176
+ return cmdBatch(positionals, flags);
1177
+ // `ai` orchestrates its own steps; map its thrown CliErrors to exit codes here
1178
+ // (usage 2 / env 3 / …) so they honor the exit-code contract instead of escaping
1179
+ // to the top-level "Fatal" handler (which would force exit 3).
1180
+ if (command === 'ai') {
1181
+ try {
1182
+ return await cmdAi(positionals, flags);
1183
+ }
1184
+ catch (e) {
1185
+ return mapError(e, flags);
1186
+ }
1187
+ }
1188
+ const { code, error } = await executeOutcome(command, positionals, flags);
1189
+ return error ? mapError(error, flags) : code;
1190
+ }
1191
+ async function run(argv) {
1192
+ const { command, positionals, flags } = (0, args_1.parseArgs)(argv);
1193
+ if ((0, args_1.flagBool)(flags, 'version') || command === 'version') {
1194
+ (0, output_1.out)(version_1.VERSION);
1195
+ return 0;
1196
+ }
1197
+ if (!command || command === 'help' || (0, args_1.flagBool)(flags, 'help')) {
1198
+ (0, output_1.out)(usageText());
1199
+ return command && command !== 'help' && !(0, args_1.flagBool)(flags, 'help') ? 2 : 0;
1200
+ }
1201
+ return executeParsed(command, positionals, flags);
1202
+ }
1203
+ function usageText() {
1204
+ return `verikun ${version_1.VERSION} — drive simulators/devices for AI agents (Puppeteer-style).
1205
+
1206
+ USAGE
1207
+ verikun <command> [args] [flags]
1208
+
1209
+ INSPECT (semantic hierarchy — the core feature)
1210
+ ui [--all] [--tree] [--json] Compact list of interactive/labeled elements
1211
+ find <selector> [--json] Print elements matching a selector (exit 1 if none)
1212
+ assert <selector> [--text S] [--gone] Assertion for tests (exit 0 pass / 1 fail)
1213
+ wait <selector> [--timeout ms] [--interval ms] [--gone] Poll until match/absent
1214
+ current Foreground app/activity
1215
+ log [package] [-n lines] [--since t] [--out path] [--full] [--json] Device logs (logcat snapshot)
1216
+ In a run, defaults to logs since the run started; -n caps lines,
1217
+ --since <MM-DD HH:MM:SS.mmm> overrides, --full dumps everything.
1218
+ Scopes to a package's process (system-wide if it has crashed);
1219
+ recorded into the run so it lands in the report
1220
+
1221
+ ACT
1222
+ tap <selector|index> | --at x,y Tap an element (or raw coordinates)
1223
+ text <selector> <text...> [--clear] [--enter] Focus a field and type
1224
+ type <text...> [--enter] Type into the currently focused field
1225
+ key <name|code> | back | home | enter Send a key event
1226
+ swipe <up|down|left|right> [--on <selector>] [--distance f] [--duration ms]
1227
+ swipe --from x,y --to x,y [--duration ms]
1228
+ screenshot [--out path] [--more] [--max px] [--full] [--json] Save a PNG (default: ./.verikun/screen.png)
1229
+ Downscaled to <=700px longest edge for token-cheap, legible reads;
1230
+ --more bumps detail (1400px), --max px sets an exact cap
1231
+ (VERIKUN_SHOT_MAX_EDGE changes the default), --full keeps original
1232
+ launch <app> [--clear] [--no-restart] stop <app> App lifecycle (launch restarts by
1233
+ default — force-stops first; --clear also wipes app data)
1234
+ clear <app> Wipe app data — login/session, caches (fresh-install state)
1235
+
1236
+ BATCH (script many commands in one process)
1237
+ batch [--file path] [--quiet] Run newline-separated commands — from --file,
1238
+ else piped stdin — each exactly as its own
1239
+ command. Streams each result to stdout; stops
1240
+ and propagates the exit code on the first
1241
+ failure. Blank lines and # comments are skipped.
1242
+
1243
+ AI (run a natural-language test — compile once, replay model-free, self-heal)
1244
+ ai <file> [--model m] [--max-cost-usd n] [--cost-override in/out] [--effort e]
1245
+ [--package pkg] [--app-build id] [--show-plan] [--recompile] [--json]
1246
+ Compile a plain-English test (<file>) into a
1247
+ deterministic plan, cached by NL + app build,
1248
+ then replay it with NO model calls on the happy
1249
+ path. The model is woken only to repair a step
1250
+ that fails to resolve; a green run persists the
1251
+ (repaired) plan so the next run is free. Needs
1252
+ ANTHROPIC_API_KEY. Progress -> stderr; the report
1253
+ path -> stdout. --show-plan prints the compiled
1254
+ IR without running; --recompile ignores the cache.
1255
+ Models: claude-haiku-4-5 | claude-sonnet-4-6
1256
+ (default) | claude-opus-4-8 | claude-fable-5.
1257
+
1258
+ ENVIRONMENT
1259
+ devices [--json] List attached devices/simulators
1260
+ doctor [--fix] Diagnose adb/device; --fix disables animations
1261
+
1262
+ TEST RUNS (actions are recorded; a run auto-starts on first action)
1263
+ run start [name] [--force] Begin a named run (else one starts implicitly)
1264
+ run status Show the active run, its device/session, and steps
1265
+ run archive [name] Write JUnit + HTML report, move to ./.verikun/runs/<id>/
1266
+ run clear Discard the active run with no report
1267
+ An implicit run auto-closes (archives) and rolls over on a device change, a
1268
+ VERIKUN_SESSION change, or VERIKUN_RUN_IDLE_MIN minutes idle (default 30; 0 off).
1269
+ VERIKUN_NO_RUN=1 disables recording entirely.
1270
+
1271
+ SELECTORS
1272
+ @login shorthand for id:login
1273
+ id:login resource-id (full, suffix, or short name)
1274
+ text:Sign in visible text (exact, case-insensitive) [+ --contains for substring]
1275
+ desc:Submit content-desc / accessibility label
1276
+ class:Button type or full class name
1277
+ "Sign in" bare string == text:"Sign in"
1278
+ Modifiers: --contains (substring), --index N (pick Nth match)
1279
+
1280
+ AUTO-WAIT (selector lookups retry until they resolve)
1281
+ Selector commands (tap, text, find, assert, swipe --on) re-poll the screen for
1282
+ up to 5s when a lookup misses, so a settling UI needs no explicit \`wait\`.
1283
+ --wait <dur> override the window: 8s, 800ms, or bare ms (3000); 0 disables
1284
+ --no-wait fail fast on the first miss (same as --wait 0)
1285
+ Ambiguity is never waited on (the elements are already there). The \`wait\`
1286
+ command stays for explicit polling, including --gone, with --timeout/--interval.
1287
+
1288
+ GLOBAL FLAGS
1289
+ -d, --device <serial> target a specific device (or VERIKUN_DEVICE / ANDROID_SERIAL)
1290
+ -p, --platform <android|ios> (default: android; --ios / --android shortcuts)
1291
+ -j, --json machine-readable output
1292
+ -- end flag parsing (so text/args may start with '-')
1293
+
1294
+ EXIT CODES
1295
+ 0 success · 1 not found / assertion failed / timeout · 2 usage or ambiguous selector · 3 environment error
1296
+
1297
+ iOS: screenshots + launch/stop work today via simctl; tap/text/swipe/hierarchy need idb (planned).`;
1298
+ }