verikun 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,15 +1,12 @@
1
1
  # verikun
2
2
 
3
- Drive a connected Android device/emulator or iOS simulator the way
4
- Puppeteer drives a browser — **tap, type, swipe, screenshot**, and most
5
- importantly **inspect the UI hierarchy by semantic identifiers** so an AI agent
6
- can act and then *verify* what happened.
3
+ > **Agent-driven, natural-language mobile tests — during agent development or in CI.** Self-healing and self-improving, with cost caps and test reports.
7
4
 
8
- It is a thin, deterministic, zero-runtime-dependency wrapper over `adb` (Android)
9
- and `idb` + `xcrun simctl` (iOS) that turns the raw `uiautomator` dump (Android)
10
- or `idb ui describe-all` accessibility tree (iOS) into a compact, token-efficient
11
- list of meaningful elements addressable by `resource-id` / accessibility id,
12
- visible text, accessibility label, or class.
5
+ - **Agent CLI** `vk <command>`: one-shot commands to inspect the screen as a semantic tree (or screenshot) and act on it.
6
+ - **Puppeteer for native mobile** — a thin wrapper over native Android and iOS automation runners with zero runtime dependencies.
7
+ - **Natural-language tests** — `vk ai <file>`: runs plain-English tests, compiled once and replayed model-free (~$0), calling a model only to self-heal a drifted step.
8
+ - **Self-improving** the agent runner will provide prescriptive improvements to existing scripts to help stabilise flakiness for future runs.
9
+ - **CI-ready** `vk suite` runs a folder of tests as one gated pass/fail run; `vk server` exposes a real device over an authenticated tunnel so a disposable CI runner (no phone attached) can still drive it.
13
10
 
14
11
  ```
15
12
  $ vk ui
@@ -23,6 +20,12 @@ $ vk tap @sign_in_btn
23
20
  tapped [3] Button "Sign in" @sign_in_btn (540,1020) tap
24
21
  ```
25
22
 
23
+ ## Skill/plugin instead of MCP
24
+
25
+ verikun ships as a skill and plugin, not an MCP server, and that is deliberate. A skill lets us **guide the agent on how to use verikun** — when to inspect the hierarchy, what to assert, which command fits the step, and how to read the result back. That domain knowledge travels with the tool, so the agent drives the device *well*, not just correctly.
26
+
27
+ There is also no need for an MCP here: verikun runs locally with all its dependencies, and the agent calls it through the plain `vk` CLI — no shared session, data, or authentication to broker.
28
+
26
29
  ## Install
27
30
 
28
31
  Requires Node ≥ 18 and the Android platform-tools (`adb`) on your `PATH`.
@@ -44,6 +47,16 @@ This repo doubles as a Claude Code [plugin marketplace](https://code.claude.com/
44
47
 
45
48
  The plugin ships the **skill**; the `vk` **CLI** is a separate Node package — install it with `npm install -g verikun` (see [Install](#install) above) so `vk` lands on your `PATH`. The compiled `dist/` is gitignored, so it isn't bundled into the installed plugin.
46
49
 
50
+ ### Install the skill for other agents (Cursor, Copilot, Windsurf, …)
51
+
52
+ Not using Claude Code? The skill is a plain [`SKILL.md`](.claude/skills/verikun/SKILL.md) with `name`/`description` frontmatter, so [`vercel-labs/skills`](https://github.com/vercel-labs/skills) can install it into any of the 70+ agents it supports. Install the `vk` CLI (see [Install](#install) above), then pull the skill straight from this repo:
53
+
54
+ ```sh
55
+ npx skills add ddikman/verikun --skill verikun # pick your agent when prompted
56
+ ```
57
+
58
+ Add `--agent cursor` (or `windsurf`, `github-copilot`, `opencode`, …) to target one directly, and `-g` to install it globally instead of into the current project. As with the plugin, this installs the **skill** only — the `vk` **CLI** still comes from `npm install -g verikun`.
59
+
47
60
  ## Quick start
48
61
 
49
62
  ```sh
@@ -90,7 +103,7 @@ vk screenshot # -> ./.verikun/screen.png
90
103
  ### AI
91
104
  | Command | Description |
92
105
  |---|---|
93
- | `ai <file> [--model m] [--max-cost-usd n] [--timeout dur] [--cost-override in/out] [--effort e] [--package pkg] [--app-build id] [--server url] [--show-plan] [--recompile] [--json]` | Run a plain-English test: compile it to a deterministic plan once, replay it model-free, and self-heal failures via the model. Needs `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` (per model). See [AI](#ai--natural-language-tests). |
106
+ | `ai <file> [--model m] [--max-cost-usd n] [--timeout dur] [--cost-override in/out] [--effort e] [--package pkg] [--app-build id] [--server url] [--show-plan] [--recompile] [--json]` | Run a plain-English test: compile it to a deterministic plan once, replay it model-free, and self-heal failures via the model. Needs `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` (per model), or no key with `--model codex-cli` / `cursor-cli` (a logged-in `codex` / `cursor-agent` CLI). See [AI](#ai--natural-language-tests). |
94
107
  | `suite <dir> [--app <id>] [--name n] [--server url] [--json]` (+ all `ai` flags) | Run every `*.md` in `<dir>` as one sequential suite with an overview report and a non-zero exit on failure — the CI gate. See [Suites](#suites--run-a-directory-of-tests). |
95
108
 
96
109
  ### Remote
@@ -197,7 +210,10 @@ printf 'launch com.example.app\nassert @home_tab\nrun archive smoke\n' | vk batc
197
210
  with no model calls on the happy path**. The model is woken only to *repair* a step
198
211
  whose selector stops resolving; a green run persists the repaired plan, so the next
199
212
  run is free again. That is what keeps a CI suite's steady-state token cost near zero.
200
- Needs `ANTHROPIC_API_KEY` (Claude models) or `OPENAI_API_KEY` (OpenAI models).
213
+ Needs `ANTHROPIC_API_KEY` (Claude models) or `OPENAI_API_KEY` (OpenAI models) — or **no
214
+ key** with `--model codex-cli` / `--model cursor-cli`, which drive an already-logged-in
215
+ `codex` or `cursor-agent` CLI off your ChatGPT / Cursor subscription (log in once;
216
+ verikun just needs the binary on PATH).
201
217
 
202
218
  ```sh
203
219
  # onboarding.md (plain English):
@@ -226,9 +242,14 @@ a hard iteration cap and stop early if the screen stops changing.
226
242
  repair can't spend or hang without limit. `--cost-override <input/output>` overrides
227
243
  the bundled per-1M price table if it drifts.
228
244
  - **`--model`** picks the model and its provider — Anthropic (`claude-haiku-4-5` ·
229
- `claude-sonnet-4-6` (default) · `claude-opus-4-8` · `claude-fable-5`) or OpenAI
245
+ `claude-sonnet-4-6` (default) · `claude-opus-4-8` · `claude-fable-5`), OpenAI
230
246
  (`gpt-5.4-mini` · `gpt-5.4` · `gpt-5.5`), each read from its own key
231
- (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`); **`--recompile`** ignores the cache.
247
+ (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`), or a CLI backend — **`codex-cli`** (the
248
+ logged-in `codex` binary) or **`cursor-cli`** (`cursor-agent`) — which need no key at
249
+ all: spend is on your subscription, so their cost line is `$0` and `--max-cost-usd` /
250
+ `--cost-override` are no-ops. Each CLI picks its own underlying model, and runs
251
+ read-only in a scratch directory so it never touches your working tree.
252
+ **`--recompile`** ignores the cache.
232
253
  - An `ai` run records like any other flow, so it produces the same JUnit + HTML report —
233
254
  with the cost line and any **suggested test improvements** (workarounds the model
234
255
  applied, which you can fold back into the prose to stabilize the test and cut tokens).
@@ -265,8 +286,8 @@ vk suite tests/ --app com.example.app --server "$VERIKUN_SERVER" # remote devi
265
286
  - **`index.html`** — a summary page linking every test's `report.html`.
266
287
  - **Exit code is the CI gate:** `1` if any test failed, `0` all green, `2` bad/empty
267
288
  directory. All `ai` flags (`--model`, `--max-cost-usd`, `--timeout`, …) apply to
268
- every test; the model's key (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`) is checked
269
- up front.
289
+ every test; the provider (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`, or the `codex` /
290
+ `cursor-agent` CLI for `--model codex-cli` / `cursor-cli`) is checked up front.
270
291
 
271
292
  ## Remote devices — `vk server`
272
293
 
@@ -0,0 +1,304 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CliProvider = exports.CURSOR_SPEC = exports.CODEX_SPEC = void 0;
4
+ exports.cursorResultText = cursorResultText;
5
+ exports.schemaInstruction = schemaInstruction;
6
+ exports.extractJson = extractJson;
7
+ const node_fs_1 = require("node:fs");
8
+ const node_path_1 = require("node:path");
9
+ const node_os_1 = require("node:os");
10
+ const errors_1 = require("../errors");
11
+ const format_1 = require("../ui/format");
12
+ const ir_1 = require("./ir");
13
+ const grammar_1 = require("./grammar");
14
+ const openai_1 = require("./openai");
15
+ const exec_1 = require("../exec");
16
+ // The CLI-agent provider: instead of an HTTP API + API key, drive an already-authenticated
17
+ // coding-agent CLI (codex / cursor-agent) as a one-shot text->JSON transformer. This lets a
18
+ // user compile/repair `vk ai` tests off their existing ChatGPT/Cursor SUBSCRIPTION — the CLI
19
+ // carries its own login, so verikun needs no key (just the binary on PATH). A sibling to
20
+ // claude.ts/openai.ts behind the same AgentProvider seam; like openai.ts is one class
21
+ // parameterized by baseUrl, this is one class parameterized by a CliAgentSpec per binary.
22
+ //
23
+ // Structured output: these CLIs are not HTTP endpoints, so there is no output_config /
24
+ // response_format. codex enforces a schema natively (--output-schema); a CLI without one
25
+ // gets the schema injected into the prompt. Either way parsePlan/validateNode (engine.ts)
26
+ // stays the execution trust boundary — ir.ts documents this exact "parse path when
27
+ // structured output is unavailable", so a malformed/hallucinated result is still rejected.
28
+ //
29
+ // These CLIs are AGENTS (tools, a working dir, a coding-oriented system prompt), so a
30
+ // forceful preamble + a read-only sandbox (per spec) coerce them into a pure transform that
31
+ // never touches the repo. The model runs here ONLY on compile + repair, never on replay.
32
+ // An agentic CLI compile is far slower than an HTTP call, and runText's 30s default would
33
+ // kill it mid-think. This is the per-invocation wall-clock cap (a hung spawn is killed and
34
+ // mapped to exit 3 by exec.ts); the engine's --timeout still bounds the whole run BETWEEN calls.
35
+ const DEFAULT_REQUEST_TIMEOUT_MS = 180_000;
36
+ const PREAMBLE = 'You are being used as a pure text-to-JSON transformer, NOT a coding assistant. ' +
37
+ 'Do NOT read, write, or edit any files. Do NOT run shell commands or use any tools. ' +
38
+ 'Do NOT explain, summarize, or add any commentary. Respond with ONLY a single JSON ' +
39
+ 'object as your final message, exactly matching the specification below.';
40
+ /** codex (OpenAI Codex CLI): non-interactive `codex exec` with NATIVE JSON-schema output
41
+ * (--output-schema) — the cleanest CLI path. Runs read-only in a neutral dir so it can't touch
42
+ * the verikun tree; the final (schema-shaped) message is written to --output-last-message, which
43
+ * we read back (deterministic, unlike parsing stdout, whose decoration is version-dependent). */
44
+ exports.CODEX_SPEC = {
45
+ id: 'codex',
46
+ bin: 'codex',
47
+ schema: 'file',
48
+ // codex's --output-schema is OpenAI strict Structured Outputs — adapt the shared ir.ts schema
49
+ // the same way openai.ts does (all keys required, optionals made nullable, additionalProperties
50
+ // false). parsePlan tolerates the resulting nulls (package/platform → undefined).
51
+ encodeSchema: openai_1.toStrictSchema,
52
+ usesOutputFile: true,
53
+ buildArgs(prompt, { schemaFile, outFile, cwd, model }) {
54
+ const args = [
55
+ 'exec',
56
+ '--skip-git-repo-check', // don't require (or scan) a git repo
57
+ '--cd', cwd, // root the agent in a neutral temp dir, not the verikun working tree
58
+ '--sandbox', 'read-only', // hard backstop: the agent cannot write anything
59
+ '--ephemeral', // don't persist session files for a stateless transform
60
+ ];
61
+ if (schemaFile)
62
+ args.push('--output-schema', schemaFile); // constrain the final message to the schema
63
+ if (outFile)
64
+ args.push('--output-last-message', outFile); // final message -> file we read back
65
+ if (model)
66
+ args.push('--model', model);
67
+ args.push(prompt); // prompt is the trailing positional
68
+ return args;
69
+ },
70
+ rawText: (stdout) => stdout, // fallback only; the message is read from --output-last-message
71
+ loginHint: 'run `codex login` to sign in with your ChatGPT subscription (no API key needed)',
72
+ };
73
+ /** cursor-agent (Cursor CLI): non-interactive `--print` with a JSON envelope on stdout. Unlike
74
+ * codex it has NO schema flag of any kind, so the schema is injected into the prompt and
75
+ * extractJson/parsePlan do the rest. Two flags are load-bearing beyond the obvious ones:
76
+ * `--trust`, without which a headless call dies at a "Workspace Trust Required" gate and exits 1
77
+ * before the model ever runs; and `--mode ask`, cursor's documented read-only Q&A mode, which
78
+ * stands in for codex's `--sandbox read-only` (cursor's own `--sandbox` only takes enabled/
79
+ * disabled). Plain `--print` "has access to all tools, including write and shell", so `--mode ask`
80
+ * plus the neutral `--workspace` are what keep this a pure transform — and we deliberately never
81
+ * pass --force/--yolo/--approve-mcps. */
82
+ exports.CURSOR_SPEC = {
83
+ id: 'cursor',
84
+ bin: 'cursor-agent',
85
+ schema: 'prompt', // no native schema flag — schemaInstruction() injects it into the prompt
86
+ usesOutputFile: false, // stdout is the only channel out; no --output-last-message equivalent
87
+ buildArgs(prompt, { cwd, model }) {
88
+ const args = [
89
+ '--print', // non-interactive one-shot
90
+ '--output-format', 'json', // a stable envelope instead of TTY-decorated text
91
+ '--mode', 'ask', // read-only Q&A mode: no edits, no shell
92
+ '--workspace', cwd, // root the agent in a neutral temp dir, not the verikun working tree
93
+ '--trust', // required: else headless stops at the workspace-trust prompt
94
+ ];
95
+ if (model)
96
+ args.push('--model', model);
97
+ args.push(prompt); // prompt is the trailing positional
98
+ return args;
99
+ },
100
+ rawText: cursorResultText,
101
+ loginHint: 'run `cursor-agent login` to sign in with your Cursor subscription (no API key needed)',
102
+ };
103
+ /** Peel the model's final message out of cursor-agent's `--output-format json` envelope:
104
+ * `{type:"result", subtype:"success", is_error:false, result:"<final text>", …}`.
105
+ * Two things a plain `JSON.parse(s).result` would get wrong:
106
+ * - cursor can exit 0 while reporting failure via `is_error:true` (e.g. it hit a turn limit), which
107
+ * CliProvider's exit-code check cannot see. Left alone, the error prose would flow into
108
+ * extractJson and surface as a misleading "did not return parseable JSON", so map it to the same
109
+ * exit 3 a non-zero exit gets.
110
+ * - an envelope shape drift (or a future default of --output-format text) falls back to the raw
111
+ * stdout, so extractJson's tolerant scan still gets a chance instead of failing outright. */
112
+ function cursorResultText(stdout) {
113
+ let envelope;
114
+ try {
115
+ const parsed = JSON.parse(stdout.trim());
116
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
117
+ envelope = parsed;
118
+ }
119
+ catch {
120
+ /* not JSON at all (e.g. --output-format text) — fall through to the raw stdout */
121
+ }
122
+ if (!envelope)
123
+ return stdout;
124
+ if (envelope.is_error === true) {
125
+ const detail = typeof envelope.result === 'string' ? tail(envelope.result) : '';
126
+ throw new errors_1.CliError(`\`cursor-agent\` reported an error: ${detail || '(no detail)'}`, 3);
127
+ }
128
+ // Only peel when this really IS the envelope. Parsing alone isn't enough: if cursor ever returns
129
+ // the plan object bare (or renames the field), treating any JSON object as an envelope would
130
+ // blank it to '' and report "returned an empty response" — so anything without a string
131
+ // `result` falls through to the raw stdout, where extractJson can still find the object.
132
+ return typeof envelope.result === 'string' ? envelope.result : stdout;
133
+ }
134
+ // Collision-free temp-file names within a process without needing Math.random() (which the
135
+ // plan-cache/version paths keep deterministic); pid + a counter is enough.
136
+ let tempCounter = 0;
137
+ class CliProvider {
138
+ opts;
139
+ run;
140
+ baseTmp;
141
+ timeoutMs;
142
+ constructor(opts) {
143
+ this.opts = opts;
144
+ this.run = opts.runImpl ?? exec_1.runText;
145
+ this.baseTmp = opts.tmpDir ?? (0, node_os_1.tmpdir)();
146
+ this.timeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
147
+ }
148
+ async compile(input) {
149
+ const parts = [];
150
+ if (input.pkg)
151
+ parts.push(`App package: ${input.pkg}`);
152
+ parts.push(`Platform: ${input.platform}`);
153
+ if (input.seed) {
154
+ parts.push('A plan compiled for a PREVIOUS build of this app follows. Reuse it where the test still holds; ' +
155
+ 'change only what the test now requires. PRIOR PLAN:\n' +
156
+ JSON.stringify(input.seed, null, 2));
157
+ }
158
+ parts.push('NATURAL-LANGUAGE TEST:\n' + input.nl);
159
+ const json = this.call(grammar_1.GRAMMAR, parts.join('\n\n'), ir_1.PLAN_JSON_SCHEMA);
160
+ // usage:{} — a CLI is billed to the user's subscription, not per token, so cost is $0
161
+ // (documented no-op for --max-cost-usd). The run is still bounded by maxRepairs + --timeout.
162
+ return { plan: (0, ir_1.parsePlan)(json), usage: {} };
163
+ }
164
+ async repair(ctx) {
165
+ const parts = ['FAILED STEP: ' + JSON.stringify(ctx.failedStep), 'FAILURE: ' + ctx.reason];
166
+ if (ctx.candidates && ctx.candidates.length) {
167
+ parts.push(`The selector matched ${ctx.candidates.length} elements (ambiguous) — pick a more specific selector for the SAME intended element, or give_up if none of them is it.`);
168
+ }
169
+ parts.push('CURRENT SCREEN:\n' + (0, format_1.formatCompact)(ctx.hierarchy));
170
+ const json = this.call(grammar_1.REPAIR_GRAMMAR, parts.join('\n\n'), ir_1.REPAIR_DECISION_JSON_SCHEMA);
171
+ const decision = (json ?? {});
172
+ if (decision.decision === 'give_up') {
173
+ return {
174
+ replaceStep: null,
175
+ declineReason: decision.reason?.trim() || 'no element on the current screen matches the step intent',
176
+ usage: {},
177
+ };
178
+ }
179
+ // Hand the proposed leaf back UNVALIDATED — engine.ts validates every repair against the
180
+ // grammar before splicing (it is the execution trust boundary), exactly like the API providers.
181
+ return { replaceStep: (decision.step ?? null), usage: {} };
182
+ }
183
+ /** Spawn the CLI once and return the parsed JSON object it produced. Synchronous (spawnSync);
184
+ * the async method wrappers satisfy the Promise-returning AgentProvider seam. */
185
+ call(system, user, schema) {
186
+ const spec = this.opts.spec;
187
+ const promptParts = [PREAMBLE, system];
188
+ if (spec.schema === 'prompt')
189
+ promptParts.push(schemaInstruction(schema));
190
+ promptParts.push(user);
191
+ const prompt = promptParts.join('\n\n');
192
+ let schemaFile;
193
+ let outFile;
194
+ try {
195
+ if (spec.schema === 'file') {
196
+ const encoded = spec.encodeSchema ? spec.encodeSchema(schema) : schema;
197
+ schemaFile = this.writeTemp('schema', '.json', JSON.stringify(encoded));
198
+ }
199
+ if (spec.usesOutputFile)
200
+ outFile = this.tempPath('out', '.txt'); // path only; the CLI writes it
201
+ const args = spec.buildArgs(prompt, { schemaFile, outFile, cwd: this.baseTmp, model: this.opts.model });
202
+ // runText throws CliError(exit 3) for ENOENT / timeout / spawn failure — let it propagate.
203
+ const res = this.run(spec.bin, args, { timeout: this.timeoutMs, cwd: this.baseTmp });
204
+ if (res.code !== 0) {
205
+ // Lead with the CLI's own stderr — it carries the real reason (usage limit, auth, a bad
206
+ // flag). Only fall back to the login hint when stderr said nothing, so we don't
207
+ // mis-suggest a re-login for e.g. a quota error.
208
+ const detail = tail(res.stderr);
209
+ const suffix = detail ? `: ${detail}` : ` — ${spec.loginHint}`;
210
+ throw new errors_1.CliError(`\`${spec.bin}\` exited ${res.code}${suffix}`, 3);
211
+ }
212
+ // Prefer the message file (deterministic); fall back to stdout if the CLI wrote nothing there.
213
+ const fromFile = outFile ? readIfExists(outFile).trim() : '';
214
+ const text = fromFile || spec.rawText(res.stdout).trim();
215
+ if (!text)
216
+ throw new errors_1.CliError(`\`${spec.bin}\` returned an empty response.`, 1);
217
+ return extractJson(text);
218
+ }
219
+ finally {
220
+ for (const f of [schemaFile, outFile]) {
221
+ if (!f)
222
+ continue;
223
+ try {
224
+ (0, node_fs_1.unlinkSync)(f);
225
+ }
226
+ catch {
227
+ /* best-effort cleanup — a leftover temp file is harmless */
228
+ }
229
+ }
230
+ }
231
+ }
232
+ tempPath(kind, ext) {
233
+ return (0, node_path_1.join)(this.baseTmp, `verikun-${this.opts.spec.id}-${kind}-${process.pid}-${tempCounter++}${ext}`);
234
+ }
235
+ writeTemp(kind, ext, content) {
236
+ const file = this.tempPath(kind, ext);
237
+ (0, node_fs_1.writeFileSync)(file, content, 'utf8');
238
+ return file;
239
+ }
240
+ }
241
+ exports.CliProvider = CliProvider;
242
+ /** Read a file, returning '' if it does not exist / can't be read — lets the message-file path
243
+ * fall back to stdout when a CLI didn't populate --output-last-message. */
244
+ function readIfExists(path) {
245
+ try {
246
+ return (0, node_fs_1.readFileSync)(path, 'utf8');
247
+ }
248
+ catch {
249
+ return '';
250
+ }
251
+ }
252
+ /** For a CLI with no native schema flag (schema:'prompt'): describe the required output shape
253
+ * inline. parsePlan/validateNode still re-checks whatever comes back. */
254
+ function schemaInstruction(schema) {
255
+ return ('Your entire response MUST be a single JSON object matching this JSON Schema exactly, ' +
256
+ 'with no prose and no code fences:\n' + JSON.stringify(schema));
257
+ }
258
+ /** Tolerantly pull a JSON object out of a CLI's stdout. codex's --output-schema output is
259
+ * already clean JSON; a schema-in-prompt CLI may wrap it in ```fences``` or a sentence. The
260
+ * brace scanner is string/escape aware, so it finds the object even inside a fence or after a
261
+ * "Here is the plan:" preamble. Throws CliError(exit 1) on failure — parsePlan is still the gate. */
262
+ function extractJson(text) {
263
+ const candidate = firstBalancedObject(text) ?? text.trim();
264
+ try {
265
+ return JSON.parse(candidate);
266
+ }
267
+ catch {
268
+ throw new errors_1.CliError('the CLI provider did not return parseable JSON.', 1);
269
+ }
270
+ }
271
+ /** The first balanced `{...}` in `text`, honoring string literals + backslash escapes so a
272
+ * brace inside a JSON string value doesn't throw off the depth count. null if there is none. */
273
+ function firstBalancedObject(text) {
274
+ const start = text.indexOf('{');
275
+ if (start < 0)
276
+ return null;
277
+ let depth = 0;
278
+ let inStr = false;
279
+ let esc = false;
280
+ for (let i = start; i < text.length; i++) {
281
+ const c = text[i];
282
+ if (inStr) {
283
+ if (esc)
284
+ esc = false;
285
+ else if (c === '\\')
286
+ esc = true;
287
+ else if (c === '"')
288
+ inStr = false;
289
+ continue;
290
+ }
291
+ if (c === '"')
292
+ inStr = true;
293
+ else if (c === '{')
294
+ depth++;
295
+ else if (c === '}' && --depth === 0)
296
+ return text.slice(start, i + 1);
297
+ }
298
+ return null;
299
+ }
300
+ /** A trimmed, size-capped tail of a CLI's stderr for error messages ('' when it wrote nothing). */
301
+ function tail(stderr, n = 500) {
302
+ const t = stderr.trim();
303
+ return t.length > n ? '…' + t.slice(-n) : t;
304
+ }
@@ -21,6 +21,13 @@ const MODELS = {
21
21
  'gpt-5.4-mini': { input: 0.75, output: 4.5, provider: 'openai' },
22
22
  'gpt-5.4': { input: 2.5, output: 15, provider: 'openai' },
23
23
  'gpt-5.5': { input: 5, output: 30, provider: 'openai' },
24
+ // CLI-agent backends: billed to the user's ChatGPT/Cursor subscription via an already-logged-in
25
+ // CLI, not per token — so price is $0 and --max-cost-usd/--cost-override are inert no-ops (the
26
+ // run is bounded by maxRepairs + --timeout instead). The `-cli` suffix reads clearly as "the
27
+ // CLI" and keeps these from colliding with the CLIs' own model aliases — cursor in particular
28
+ // offers `gpt-5.3-codex`, `gpt-5.4-high`, `claude-opus-4-8-thinking-high` and friends.
29
+ 'codex-cli': { input: 0, output: 0, provider: 'codex' },
30
+ 'cursor-cli': { input: 0, output: 0, provider: 'cursor' },
24
31
  };
25
32
  exports.MODEL_PRICES = Object.fromEntries(Object.entries(MODELS).map(([m, s]) => [m, { input: s.input, output: s.output }]));
26
33
  exports.ALLOWED_MODELS = Object.keys(MODELS);
File without changes
package/dist/cli.js CHANGED
@@ -40,6 +40,7 @@ exports.healNote = healNote;
40
40
  exports.parseDuration = parseDuration;
41
41
  exports.waitWindowMs = waitWindowMs;
42
42
  exports.waitNote = waitNote;
43
+ exports.formatDeviceTable = formatDeviceTable;
43
44
  exports.confineToCwd = confineToCwd;
44
45
  exports.assertSafeAppId = assertSafeAppId;
45
46
  exports.chooseLogOpts = chooseLogOpts;
@@ -62,6 +63,7 @@ const image_1 = require("./image");
62
63
  const engine_1 = require("./agent/engine");
63
64
  const claude_1 = require("./agent/claude");
64
65
  const openai_1 = require("./agent/openai");
66
+ const cli_provider_1 = require("./agent/cli-provider");
65
67
  const cache_1 = require("./agent/cache");
66
68
  const cost_1 = require("./agent/cost");
67
69
  const remote_1 = require("./agent/remote");
@@ -202,13 +204,34 @@ function cmdDevices(ctx) {
202
204
  (0, output_1.err)('No devices found.');
203
205
  return 0;
204
206
  }
205
- for (const d of allDevices) {
206
- (0, output_1.out)([d.platform, d.serial, d.state, d.model ?? '', d.product ? `(${d.product})` : '', d.note ? `[${d.note}]` : '']
207
- .filter(Boolean)
208
- .join('\t'));
209
- }
207
+ for (const line of formatDeviceTable(allDevices))
208
+ (0, output_1.out)(line);
210
209
  return 0;
211
210
  }
211
+ /**
212
+ * Render the device list as an aligned, headed table (header line first, then one
213
+ * line per device). Optional columns (MODEL/PRODUCT/NOTE) are dropped when no device
214
+ * populates them; every shown cell is padded to its column width so columns line up
215
+ * regardless of which cells are empty — the previous `.filter(Boolean).join('\t')`
216
+ * dropped empty cells, sliding later cells into earlier tab stops. Exported for unit
217
+ * testing.
218
+ */
219
+ function formatDeviceTable(devices) {
220
+ const columns = [
221
+ { header: 'PLATFORM', get: (d) => d.platform },
222
+ { header: 'SERIAL', get: (d) => d.serial },
223
+ { header: 'STATE', get: (d) => d.state },
224
+ { header: 'MODEL', get: (d) => d.model ?? '', optional: true },
225
+ { header: 'PRODUCT', get: (d) => d.product ?? '', optional: true },
226
+ { header: 'NOTE', get: (d) => d.note ?? '', optional: true },
227
+ ];
228
+ // Drop optional columns that no device populates (e.g. NOTE for an Android-only list).
229
+ const shown = columns.filter((c) => !c.optional || devices.some((d) => c.get(d) !== ''));
230
+ const rows = [shown.map((c) => c.header), ...devices.map((d) => shown.map((c) => c.get(d)))];
231
+ const widths = shown.map((_, i) => Math.max(...rows.map((r) => r[i].length)));
232
+ // Pad every cell except the last shown column (no trailing whitespace); join with 2 spaces.
233
+ return rows.map((r) => r.map((cell, i) => (i === r.length - 1 ? cell : cell.padEnd(widths[i]))).join(' ').trimEnd());
234
+ }
212
235
  function cmdDoctor(ctx) {
213
236
  if (ctx.platform === 'ios') {
214
237
  try {
@@ -961,20 +984,43 @@ function readAiTest(file) {
961
984
  throw new errors_1.CliError(`ai: '${file}' is empty`, 2);
962
985
  return nl;
963
986
  }
964
- /** The env var carrying the API key for a model's provider (per-provider keys). */
965
- function keyEnvFor(model) {
966
- return (0, cost_1.providerFor)(model) === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
987
+ /** The CLI-agent backends, by ProviderId. Adding a CLI provider is one entry here rather than a
988
+ * new arm in each of the three functions below — they all ask the same question of it. */
989
+ const CLI_SPECS = { codex: cli_provider_1.CODEX_SPEC, cursor: cli_provider_1.CURSOR_SPEC };
990
+ /** Is the backend for `model` usable right now? HTTP providers need their API key in env;
991
+ * a CLI provider needs its binary on PATH (auth lives in the CLI's own login, not an env key). */
992
+ function providerAvailable(model) {
993
+ const id = (0, cost_1.providerFor)(model);
994
+ const spec = CLI_SPECS[id];
995
+ if (spec)
996
+ return (0, exec_1.commandExists)(spec.bin);
997
+ return id === 'openai' ? !!process.env.OPENAI_API_KEY : !!process.env.ANTHROPIC_API_KEY;
998
+ }
999
+ /** What's missing when a provider is unavailable — the tail of the preflight error message. */
1000
+ function providerRequirement(model) {
1001
+ const id = (0, cost_1.providerFor)(model);
1002
+ const spec = CLI_SPECS[id];
1003
+ // Reuse the spec's own loginHint rather than restating it, so the two can't drift apart.
1004
+ if (spec)
1005
+ return `the \`${spec.bin}\` CLI was not found on PATH — install it and ${spec.loginHint}`;
1006
+ return id === 'openai' ? 'OPENAI_API_KEY is not set' : 'ANTHROPIC_API_KEY is not set';
967
1007
  }
968
- /** Route the model to its backend; each provider reads its own key. A missing key
969
- * means no provider (compile/repair unavailable) the same graceful degradation
970
- * as before. */
1008
+ /** Route the model to its backend. HTTP providers read their own key; a CLI provider shells
1009
+ * out to its logged-in binary. Unavailable → null (compile/repair off), the same graceful
1010
+ * degradation as before: a cached plan can still replay for free without any provider.
1011
+ * CLI providers get no `model`, so the CLI picks its own default — the "I just have a
1012
+ * subscription" path; --effort is likewise inapplicable to them. */
971
1013
  function makeProvider(opts) {
972
- const apiKey = process.env[keyEnvFor(opts.model)];
973
- if (!apiKey)
974
- return null;
975
- return (0, cost_1.providerFor)(opts.model) === 'openai'
976
- ? new openai_1.OpenAiProvider({ model: opts.model, apiKey, effort: opts.effort })
977
- : new claude_1.ClaudeProvider({ model: opts.model, apiKey, effort: opts.effort });
1014
+ const id = (0, cost_1.providerFor)(opts.model);
1015
+ const spec = CLI_SPECS[id];
1016
+ if (spec)
1017
+ return (0, exec_1.commandExists)(spec.bin) ? new cli_provider_1.CliProvider({ spec }) : null;
1018
+ if (id === 'openai') {
1019
+ const apiKey = process.env.OPENAI_API_KEY;
1020
+ return apiKey ? new openai_1.OpenAiProvider({ model: opts.model, apiKey, effort: opts.effort }) : null;
1021
+ }
1022
+ const apiKey = process.env.ANTHROPIC_API_KEY;
1023
+ return apiKey ? new claude_1.ClaudeProvider({ model: opts.model, apiKey, effort: opts.effort }) : null;
978
1024
  }
979
1025
  /** Obtain the plan: a cache hit (free) or a compile (pays tokens; may seed from a
980
1026
  * prior build's plan to avoid a full recompile). The fresh compile is cached right
@@ -987,7 +1033,7 @@ async function obtainPlan(key, file, opts, cost, provider) {
987
1033
  return { plan: cached.plan, cached: true };
988
1034
  }
989
1035
  if (!provider) {
990
- throw new errors_1.CliError(`${keyEnvFor(opts.model)} is not set \`vk ai\` needs it to compile the test (model ${opts.model}). Set it and retry.`, 3);
1036
+ throw new errors_1.CliError(`${providerRequirement(opts.model)} — needed to compile the test (model ${opts.model}).`, 3);
991
1037
  }
992
1038
  const seed = (0, cache_1.findSeed)(key);
993
1039
  if (seed)
@@ -1058,7 +1104,7 @@ async function runAiTest(file, opts, backend, platform, device) {
1058
1104
  const { plan, cached } = await obtainPlan(key, file, opts, cost, provider);
1059
1105
  // Running needs the provider for repair-on-failure; a cache hit with no key can't repair.
1060
1106
  if (!provider) {
1061
- throw new errors_1.CliError(`${keyEnvFor(opts.model)} is not set \`vk ai\` needs it to repair a failing step at runtime (model ${opts.model}).`, 3);
1107
+ throw new errors_1.CliError(`${providerRequirement(opts.model)} — needed to repair a failing step at runtime (model ${opts.model}).`, 3);
1062
1108
  }
1063
1109
  // The budget is a TOTAL-run ceiling: if the compile alone already crossed it, abort
1064
1110
  // before running. A cache hit spends nothing, so a free replay is still allowed.
@@ -1244,10 +1290,10 @@ async function cmdSuiteEntry(positionals, flags) {
1244
1290
  if (!dirArg)
1245
1291
  throw new errors_1.CliError('Usage: verikun suite <dir> [--app <id>] [--server url] [--name n] [--json]', 2);
1246
1292
  const opts = parseAiOptions(flags);
1247
- // Pre-flight the model key BEFORE touching any device/server: every test needs it
1293
+ // Pre-flight the provider BEFORE touching any device/server: every test needs it
1248
1294
  // to compile (on a cache miss) or to repair at runtime.
1249
- if (!process.env[keyEnvFor(opts.model)]) {
1250
- throw new errors_1.CliError(`${keyEnvFor(opts.model)} is not set \`vk suite\` needs it to compile/repair tests (model ${opts.model}).`, 3);
1295
+ if (!providerAvailable(opts.model)) {
1296
+ throw new errors_1.CliError(`${providerRequirement(opts.model)} — needed to compile/repair tests (model ${opts.model}).`, 3);
1251
1297
  }
1252
1298
  const reqPlatform = platformFromFlags(flags);
1253
1299
  const { backend, platform, device } = await resolveBackend(reqPlatform, deviceFromFlags(flags, reqPlatform), flags);
@@ -1538,13 +1584,19 @@ AI (run a natural-language test — compile once, replay model-free, self-heal)
1538
1584
  path. The model is woken only to repair a step
1539
1585
  that fails to resolve; a green run persists the
1540
1586
  (repaired) plan so the next run is free. Needs
1541
- ANTHROPIC_API_KEY (Claude) or OPENAI_API_KEY
1542
- (gpt-5.x). Progress -> stderr; the report path ->
1587
+ ANTHROPIC_API_KEY (Claude), OPENAI_API_KEY (gpt-5.x),
1588
+ or a logged-in agent CLI no API key: --model
1589
+ codex-cli uses your 'codex login' ChatGPT
1590
+ subscription, cursor-cli your 'cursor-agent login'
1591
+ Cursor one (cost is $0 for both, so
1592
+ --max-cost-usd/--cost-override are no-ops).
1593
+ Progress -> stderr; the report path ->
1543
1594
  stdout. --show-plan prints the compiled IR without
1544
1595
  running; --recompile ignores the cache.
1545
1596
  Models: claude-haiku-4-5 | claude-sonnet-4-6
1546
1597
  (default) | claude-opus-4-8 | claude-fable-5 |
1547
- gpt-5.4-mini | gpt-5.4 | gpt-5.5.
1598
+ gpt-5.4-mini | gpt-5.4 | gpt-5.5 | codex-cli |
1599
+ cursor-cli.
1548
1600
 
1549
1601
  SUITE (run a directory of natural-language tests as one gated suite)
1550
1602
  suite <dir> [--app <id>] [--name n] [--json] (+ all \`ai\` flags, incl. --server)
package/dist/exec.js CHANGED
@@ -1,8 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runText = runText;
4
+ exports.commandExists = commandExists;
4
5
  exports.runBinary = runBinary;
5
6
  const node_child_process_1 = require("node:child_process");
7
+ const node_fs_1 = require("node:fs");
8
+ const node_path_1 = require("node:path");
6
9
  const errors_1 = require("./errors");
7
10
  const MAX_BUFFER = 64 * 1024 * 1024; // screenshots can be a few MB
8
11
  function describeError(cmd, args, err) {
@@ -14,18 +17,49 @@ function describeError(cmd, args, err) {
14
17
  }
15
18
  return new errors_1.CliError(`Failed to run '${cmd}': ${err.message}`, 3);
16
19
  }
17
- /** Run a command and capture stdout/stderr as UTF-8 text. */
20
+ /** Run a command and capture stdout/stderr as UTF-8 text. `cwd` runs it rooted elsewhere
21
+ * (the CLI-agent providers run in a neutral temp dir so they never touch the working tree). */
18
22
  function runText(cmd, args, opts = {}) {
19
23
  const r = (0, node_child_process_1.spawnSync)(cmd, args, {
20
24
  encoding: 'utf8',
21
25
  timeout: opts.timeout ?? 30000,
22
26
  input: opts.input,
27
+ cwd: opts.cwd,
23
28
  maxBuffer: MAX_BUFFER,
24
29
  });
25
30
  if (r.error)
26
31
  throw describeError(cmd, args, r.error);
27
32
  return { code: r.status ?? 0, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
28
33
  }
34
+ /** Is `bin` an executable on PATH (or a direct path to one)? Used to decide a CLI provider is
35
+ * available without invoking the agent — a cheap, pure PATH scan (no spawn, no runtime dep). */
36
+ function commandExists(bin) {
37
+ if (bin.includes('/') || bin.includes('\\'))
38
+ return isExecutableFile(bin);
39
+ const isWin = process.platform === 'win32';
40
+ const exts = isWin ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';') : [''];
41
+ for (const dir of (process.env.PATH ?? '').split(node_path_1.delimiter)) {
42
+ if (!dir)
43
+ continue;
44
+ for (const ext of exts)
45
+ if (isExecutableFile((0, node_path_1.join)(dir, bin + ext)))
46
+ return true;
47
+ }
48
+ return false;
49
+ }
50
+ function isExecutableFile(p) {
51
+ try {
52
+ if (!(0, node_fs_1.statSync)(p).isFile())
53
+ return false;
54
+ if (process.platform === 'win32')
55
+ return true; // Windows has no X bit; a matching file is enough
56
+ (0, node_fs_1.accessSync)(p, node_fs_1.constants.X_OK);
57
+ return true;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
29
63
  /** Run a command and capture stdout as raw bytes (e.g. PNG screenshots). */
30
64
  function runBinary(cmd, args, opts = {}) {
31
65
  const r = (0, node_child_process_1.spawnSync)(cmd, args, {
package/dist/version.js CHANGED
@@ -3,4 +3,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
4
  // GENERATED by scripts/gen-version.mjs from package.json's "version" at build time
5
5
  // (the `prebuild` script). Do NOT edit by hand; bump package.json instead.
6
- exports.VERSION = '0.8.0';
6
+ exports.VERSION = '0.10.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verikun",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Drive Android emulators/devices and iOS simulators for AI agents: tap, type, swipe, screenshot, and inspect the UI hierarchy by semantic identifiers — like Puppeteer for native apps.",
5
5
  "keywords": [
6
6
  "android",
@@ -1,156 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SimctlDriver = void 0;
4
- const node_os_1 = require("node:os");
5
- const node_path_1 = require("node:path");
6
- const node_fs_1 = require("node:fs");
7
- const errors_1 = require("../errors");
8
- const exec_1 = require("../exec");
9
- const XCRUN = 'xcrun';
10
- function listPhysicalDevices() {
11
- const r = (0, exec_1.runText)(XCRUN, ['devicectl', 'list', 'devices']);
12
- if (r.code !== 0)
13
- return [];
14
- const lines = r.stdout.split('\n');
15
- const headerIdx = lines.findIndex((l) => l.includes('Identifier'));
16
- if (headerIdx < 0)
17
- return [];
18
- const header = lines[headerIdx];
19
- const nameCol = header.indexOf('Name');
20
- const hostCol = header.indexOf('Hostname');
21
- const idCol = header.indexOf('Identifier');
22
- const stateCol = header.indexOf('State');
23
- const modelCol = header.indexOf('Model');
24
- const devices = [];
25
- for (const line of lines.slice(headerIdx + 2)) {
26
- if (!line.trim() || line.startsWith('-'))
27
- continue;
28
- const identifier = line.slice(idCol, stateCol).trim();
29
- const state = line.slice(stateCol, modelCol).trim();
30
- const name = line.slice(nameCol, hostCol).trim();
31
- const model = line.slice(modelCol).trim();
32
- if (!identifier)
33
- continue;
34
- const productMatch = model.match(/\(([^)]+)\)$/);
35
- devices.push({
36
- serial: identifier,
37
- state,
38
- model: name,
39
- product: productMatch?.[1],
40
- platform: 'ios',
41
- note: 'physical — screenshot only, tap/ui not supported',
42
- });
43
- }
44
- return devices;
45
- }
46
- // iOS support is intentionally partial. `simctl` covers screenshots, launch,
47
- // and stop. Full interaction (tap, swipe, type) and the accessibility hierarchy
48
- // require WebDriverAgent (https://github.com/appium/WebDriverAgent) — the
49
- // planned next step. WDA is an open-source XCTest HTTP server: build it once
50
- // in Xcode, sign it with your Apple developer certificate, and run it on the
51
- // target device or simulator. No Python required; works on both simulators and
52
- // physical devices. The Driver seam is already in place so the command layer
53
- // won't change when WDA support is added.
54
- function notSupported(feature) {
55
- throw new errors_1.CliError(`iOS ${feature} is not supported yet.\n` +
56
- 'Screenshots, launch, and stop work today via xcrun simctl.\n' +
57
- 'Full interaction (tap / swipe / type) and UI hierarchy inspection are planned\n' +
58
- 'via WebDriverAgent — build it once in Xcode and vk will drive it over HTTP.\n' +
59
- 'See: https://github.com/appium/WebDriverAgent', 3);
60
- }
61
- class SimctlDriver {
62
- platform = 'ios';
63
- udid;
64
- constructor(device) {
65
- this.udid = device || 'booted';
66
- }
67
- listDevices() {
68
- const devices = [];
69
- // Simulators via simctl
70
- const { stdout } = (0, exec_1.runText)(XCRUN, ['simctl', 'list', 'devices', 'available', '--json']);
71
- try {
72
- const data = JSON.parse(stdout);
73
- for (const [runtime, list] of Object.entries(data.devices)) {
74
- for (const d of list) {
75
- devices.push({
76
- serial: d.udid,
77
- state: d.state.toLowerCase(),
78
- model: d.name,
79
- product: runtime.split('.').pop(),
80
- platform: 'ios',
81
- });
82
- }
83
- }
84
- }
85
- catch {
86
- /* tolerate unexpected simctl output */
87
- }
88
- // Physical devices via devicectl (Xcode 15+)
89
- devices.push(...listPhysicalDevices());
90
- return devices;
91
- }
92
- resolvedSerial() {
93
- return this.udid;
94
- }
95
- screenshot() {
96
- const tmp = (0, node_path_1.join)((0, node_os_1.tmpdir)(), `verikun-ios-${process.pid}.png`);
97
- const r = (0, exec_1.runText)(XCRUN, ['simctl', 'io', this.udid, 'screenshot', tmp]);
98
- if (r.code !== 0)
99
- throw new errors_1.CliError(`simctl screenshot failed: ${r.stderr.trim()}`, 3);
100
- const buf = (0, node_fs_1.readFileSync)(tmp);
101
- try {
102
- (0, node_fs_1.unlinkSync)(tmp);
103
- }
104
- catch {
105
- /* best-effort cleanup */
106
- }
107
- return buf;
108
- }
109
- launch(appId) {
110
- const r = (0, exec_1.runText)(XCRUN, ['simctl', 'launch', this.udid, appId]);
111
- if (r.code !== 0)
112
- throw new errors_1.CliError(`simctl launch failed: ${r.stderr.trim()}`, 3);
113
- }
114
- stop(appId) {
115
- const r = (0, exec_1.runText)(XCRUN, ['simctl', 'terminate', this.udid, appId]);
116
- if (r.code !== 0)
117
- throw new errors_1.CliError(`simctl terminate failed: ${r.stderr.trim()}`, 3);
118
- }
119
- clearApp(appId) {
120
- // simctl has no per-app data reset. The manual equivalent is to uninstall and
121
- // reinstall, which also removes the app itself — so we don't do it implicitly.
122
- throw new errors_1.CliError(`iOS app-data clearing is not supported yet (requested for '${appId}').\n` +
123
- 'simctl has no per-app data reset; uninstall + reinstall ' +
124
- '(`xcrun simctl uninstall <udid> <bundleId>`) is the manual equivalent, but it removes the app too.', 3);
125
- }
126
- getElements() {
127
- return notSupported('hierarchy inspection');
128
- }
129
- screenSize() {
130
- return notSupported('screen size');
131
- }
132
- tap() {
133
- notSupported('tap');
134
- }
135
- swipe() {
136
- notSupported('swipe');
137
- }
138
- inputText() {
139
- notSupported('text input');
140
- }
141
- pressKey() {
142
- notSupported('key events');
143
- }
144
- currentApp() {
145
- return notSupported('current app');
146
- }
147
- getLogs() {
148
- // Eventually: `xcrun simctl spawn <udid> log show` (simulators) / devicectl
149
- // (physical). Heavier (predicate language, large output) — deferred with WDA.
150
- return notSupported('log capture');
151
- }
152
- deviceTime() {
153
- return ''; // no log window to anchor while iOS log capture is unsupported
154
- }
155
- }
156
- exports.SimctlDriver = SimctlDriver;