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 +36 -15
- package/dist/agent/cli-provider.js +304 -0
- package/dist/agent/cost.js +7 -0
- package/dist/bin/verikun.js +0 -0
- package/dist/cli.js +77 -25
- package/dist/exec.js +35 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/drivers/simctl.js +0 -156
package/README.md
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
# verikun
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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`)
|
|
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`)
|
|
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
|
|
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
|
+
}
|
package/dist/agent/cost.js
CHANGED
|
@@ -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);
|
package/dist/bin/verikun.js
CHANGED
|
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
|
|
206
|
-
(0, output_1.out)(
|
|
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
|
|
965
|
-
|
|
966
|
-
|
|
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
|
|
969
|
-
*
|
|
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
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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(`${
|
|
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(`${
|
|
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
|
|
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 (!
|
|
1250
|
-
throw new errors_1.CliError(`${
|
|
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)
|
|
1542
|
-
|
|
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.
|
|
6
|
+
exports.VERSION = '0.10.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verikun",
|
|
3
|
-
"version": "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",
|
package/dist/drivers/simctl.js
DELETED
|
@@ -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;
|