verikun 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/dist/agent/cache.js +128 -0
- package/dist/agent/claude.js +144 -0
- package/dist/agent/cost.js +100 -0
- package/dist/agent/engine.js +205 -0
- package/dist/agent/grammar.js +80 -0
- package/dist/agent/ir.js +212 -0
- package/dist/agent/provider.js +2 -0
- package/dist/args.js +102 -0
- package/dist/bin/verikun.js +8 -0
- package/dist/cli.js +1298 -0
- package/dist/drivers/adb.js +300 -0
- package/dist/drivers/index.js +13 -0
- package/dist/drivers/simctl.js +156 -0
- package/dist/errors.js +51 -0
- package/dist/exec.js +42 -0
- package/dist/image.js +212 -0
- package/dist/output.js +43 -0
- package/dist/report.js +223 -0
- package/dist/run.js +434 -0
- package/dist/types.js +5 -0
- package/dist/ui/android-parse.js +149 -0
- package/dist/ui/format.js +71 -0
- package/dist/ui/selector.js +117 -0
- package/dist/version.js +6 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Dikman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
# verikun
|
|
2
|
+
|
|
3
|
+
Drive a connected Android device/emulator (and, later, iOS simulators) 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.
|
|
7
|
+
|
|
8
|
+
It is a thin, deterministic, zero-runtime-dependency wrapper over `adb` (and
|
|
9
|
+
`xcrun simctl` for iOS) that turns the raw `uiautomator` dump into a compact,
|
|
10
|
+
token-efficient list of meaningful elements addressable by `resource-id`,
|
|
11
|
+
visible text, accessibility label, or class.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
$ vk ui
|
|
15
|
+
[0] TextView "Welcome back" (540,360)
|
|
16
|
+
[1] EditText @email_input (540,720) focused
|
|
17
|
+
[2] EditText @password_input (540,860) pwd
|
|
18
|
+
[3] Button "Sign in" @sign_in_btn (540,1020) tap
|
|
19
|
+
[4] TextView "Forgot password?" @forgot (540,1140) tap
|
|
20
|
+
|
|
21
|
+
$ vk tap @sign_in_btn
|
|
22
|
+
tapped [3] Button "Sign in" @sign_in_btn (540,1020) tap
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
Requires Node ≥ 18 and the Android platform-tools (`adb`) on your `PATH`.
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npm install -g verikun # installs the `verikun` and `vk` commands globally
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then run `vk doctor` to check your setup. Re-run the same command to upgrade later.
|
|
34
|
+
|
|
35
|
+
### Install as a Claude Code plugin
|
|
36
|
+
|
|
37
|
+
This repo doubles as a Claude Code [plugin marketplace](https://code.claude.com/docs/en/plugin-marketplaces). Installing the plugin gives Claude the `verikun` skill — the agent-facing usage guide — so it knows how to drive devices.
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
/plugin marketplace add ddikman/verikun # add this repo as a marketplace
|
|
41
|
+
/plugin install verikun@verikun # install the plugin (ships the skill)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
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.
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
vk doctor --fix # check adb/device; disable animations for stable dumps
|
|
50
|
+
vk devices # list attached devices
|
|
51
|
+
vk ui # semantic snapshot of the current screen
|
|
52
|
+
vk tap @login_button # tap by resource-id
|
|
53
|
+
vk text @email "me@example.com" # focus a field and type
|
|
54
|
+
vk wait text:"Welcome" --timeout 8000
|
|
55
|
+
vk screenshot # -> ./.verikun/screen.png
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Commands
|
|
59
|
+
|
|
60
|
+
### Inspect
|
|
61
|
+
| Command | Description |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `ui [--all] [--tree] [--json]` | Compact list of interactive/labeled elements. `--all` keeps layout nodes; `--tree` indents by nesting; `--json` is structured. |
|
|
64
|
+
| `find <selector> [--json] [--wait <dur>\|--no-wait]` | Print elements matching a selector. [Auto-waits](#auto-wait) up to 5s; exit 1 if still none. |
|
|
65
|
+
| `assert <selector> [--text S] [--gone] [--contains] [--wait <dur>\|--no-wait]` | Assertion for tests. [Auto-waits](#auto-wait) until it passes. Exit 0 pass / 1 fail. |
|
|
66
|
+
| `wait <selector> [--timeout ms] [--interval ms] [--gone]` | Poll the hierarchy until match (or absence). Exit 1 on timeout. (Explicit polling — distinct from the `--wait` flag.) |
|
|
67
|
+
| `current` | Best-effort foreground app/activity. |
|
|
68
|
+
| `log [package] [-n lines] [--since t] [--out path] [--full] [--json]` | Recent device logs (Android `logcat` snapshot — Android only). Prints to stdout; `--out` saves to a file, `--json` is structured. **Inside a run, defaults to logs since the run started** (so pre-session logs are excluded); `-n` caps to the last N lines instead, `--since <MM-DD HH:MM:SS.mmm>` sets an explicit start, `--full` dumps everything. A `package` scopes logs to that app's process, **falling back to system-wide when the app isn't running** (e.g. it crashed) so the crash trace is still captured. Unlike other inspection commands it **is recorded**, so its output lands in the archived report. ⚠️ logs are raw device output and may contain anything the app logged, including secrets. |
|
|
69
|
+
|
|
70
|
+
### Act
|
|
71
|
+
| Command | Description |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `tap <selector\|index>` / `tap --at x,y` | Tap an element (or raw coordinates). Selector taps [auto-wait](#auto-wait); a bare integer taps `[index]` from the latest `ui` (never waits). |
|
|
74
|
+
| `text <selector> <text…> [--clear] [--enter]` | Focus a field and type. `--clear` deletes existing text first. The field lookup [auto-waits](#auto-wait). Punctuation/symbols (e.g. emails like `bob@mail.com`) are escaped for the device shell and type verbatim — quote the value in your shell, or use [`batch`](#batch)/stdin (no host shell), so the caller's shell can't drop the `@`. |
|
|
75
|
+
| `type <text…> [--enter]` | Type into the currently focused field. |
|
|
76
|
+
| `key <name\|code>` / `back` / `home` / `enter` | Send a key event (named keys or a raw Android keycode). |
|
|
77
|
+
| `swipe <up\|down\|left\|right> [--on <selector>] [--distance f] [--duration ms]` | Directional swipe over the screen (or within an element via `--on`, whose lookup [auto-waits](#auto-wait)). `--distance` is a fraction of the region (default 0.6). |
|
|
78
|
+
| `swipe --from x,y --to x,y [--duration ms]` | Explicit swipe between two points. |
|
|
79
|
+
| `screenshot [--out path] [--more] [--max px] [--full] [--json]` | Save a PNG (default `./.verikun/screen.png`); prints the path. [Downscaled](#screenshots) to a 700px longest edge by default to save tokens; `--more` bumps detail, `--max px` sets an exact cap, `--full` keeps the original. |
|
|
80
|
+
| `launch <app> [--clear] [--no-restart]` / `stop <app>` | App lifecycle by package id (Android) / bundle id (iOS). `launch` **restarts by default** — it force-stops the app first (a no-op if it isn't running) so a rerun starts fresh instead of resurfacing a still-running instance's current screen; `--no-restart` skips that. `--clear` also wipes the app's local data (login/session, prefs, cache) for a fresh-install start. |
|
|
81
|
+
| `clear <app>` | Wipe the app's locally stored data — login/session, preferences, caches — resetting it to a just-installed state (Android `pm clear`, which also force-stops the app). iOS not supported yet. |
|
|
82
|
+
|
|
83
|
+
### Batch
|
|
84
|
+
| Command | Description |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `batch [--file <path>] [--quiet]` | Run newline-separated commands — from `--file`, else piped **stdin** — each exactly as its own command (same auto-wait, recording, exit codes). Streams each result to stdout and **stops on the first non-zero exit**, propagating that code. Blank lines and `#` comments are skipped; `--quiet` hides per-line progress. See [Batch](#batch). |
|
|
87
|
+
|
|
88
|
+
### AI
|
|
89
|
+
| Command | Description |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `ai <file> [--model m] [--max-cost-usd n] [--timeout dur] [--cost-override in/out] [--effort e] [--package pkg] [--app-build id] [--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`. See [AI](#ai--natural-language-tests). |
|
|
92
|
+
|
|
93
|
+
### Environment
|
|
94
|
+
| Command | Description |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `devices [--json]` | List attached devices/simulators. |
|
|
97
|
+
| `doctor [--fix]` | Diagnose adb + device; `--fix` sets the three animation scales to 0 for deterministic UI. |
|
|
98
|
+
|
|
99
|
+
### Test runs
|
|
100
|
+
| Command | Description |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `run start [name] [--force]` | Begin a named run. One auto-starts on the first action if you don't. |
|
|
103
|
+
| `run status` | Show the active run and its recorded steps. |
|
|
104
|
+
| `run archive [name]` | Write JUnit + HTML report to `./.verikun/runs/<id>/`; exits non-zero if any step failed. |
|
|
105
|
+
| `run clear` | Discard the active run without a report. |
|
|
106
|
+
|
|
107
|
+
## Test runs & reports
|
|
108
|
+
|
|
109
|
+
Actions are recorded into a **test run** — one auto-starts on the first action
|
|
110
|
+
(set `VERIKUN_NO_RUN=1` to disable). Every command becomes a step with its
|
|
111
|
+
timing, the selector + identifier it resolved through, and pass/fail; a failing
|
|
112
|
+
step also captures a screenshot **and** the UI hierarchy of the page. When a
|
|
113
|
+
step fails you can additionally run `vk log <package>` to pull the device logs —
|
|
114
|
+
that step records the logs **into the same run**, so the crash trace shows up in
|
|
115
|
+
the report alongside the failure.
|
|
116
|
+
|
|
117
|
+
`vk run archive` finalizes the run into `./.verikun/runs/<id>/`:
|
|
118
|
+
|
|
119
|
+
- **`report.xml`** — JUnit: one `<testcase>` per step with timings, `<failure>`
|
|
120
|
+
for failed assertions, `<error>` for environment errors, and the resolved
|
|
121
|
+
identifier in `<system-out>`. Drops straight into CI.
|
|
122
|
+
- **`report.html`** — a self-contained report: every step, the identifiers used,
|
|
123
|
+
any screenshots taken, the screenshot + hierarchy of any failed page, and any
|
|
124
|
+
device logs captured via `vk log`.
|
|
125
|
+
- **`run.json`** — the raw recording.
|
|
126
|
+
|
|
127
|
+
`vk run archive` exits non-zero when the run contained failures, so the same
|
|
128
|
+
command both produces the report and gates CI.
|
|
129
|
+
|
|
130
|
+
### Automatic rollover
|
|
131
|
+
|
|
132
|
+
So an implicit run never silently merges unrelated activity, the active run
|
|
133
|
+
**auto-closes (archives) and a fresh one starts** when the context changes:
|
|
134
|
+
|
|
135
|
+
| Trigger | Applies to | Tune with |
|
|
136
|
+
|---|---|---|
|
|
137
|
+
| Idle too long (default 30 min) | implicit runs only | `VERIKUN_RUN_IDLE_MIN` (minutes; `0` disables) |
|
|
138
|
+
| Different device serial | any run | — |
|
|
139
|
+
| Different session | any run | `VERIKUN_SESSION` (falls back to `TERM_SESSION_ID`) |
|
|
140
|
+
|
|
141
|
+
A run you named with `vk run start` is **sticky to idle** — only a hard context
|
|
142
|
+
change (device or session) rolls it over. Rollover always *archives* the old run
|
|
143
|
+
(never discards it) and prints the reason + destination to stderr. Set
|
|
144
|
+
`VERIKUN_NO_RUN=1` to disable recording entirely.
|
|
145
|
+
|
|
146
|
+
## Batch
|
|
147
|
+
|
|
148
|
+
Drive a whole flow from a single process instead of one `vk` call per step.
|
|
149
|
+
`vk batch` reads newline-separated commands — from `--file <path>`, or piped on
|
|
150
|
+
**stdin** — and runs each **exactly as if you'd typed it as its own `vk` command**:
|
|
151
|
+
the same [selector auto-wait](#auto-wait), the same
|
|
152
|
+
[test-run recording](#test-runs--reports) (every line is its own step), and the
|
|
153
|
+
same stdout/stderr split and [exit codes](#exit-codes).
|
|
154
|
+
|
|
155
|
+
```sh
|
|
156
|
+
vk batch --file login.flow # from a file
|
|
157
|
+
|
|
158
|
+
vk batch <<'EOF' # …or piped on stdin
|
|
159
|
+
launch com.example.app
|
|
160
|
+
text @email_input "user@example.com"
|
|
161
|
+
text @password_input "hunter2" --enter
|
|
162
|
+
assert text:"Welcome back" --wait 8s
|
|
163
|
+
EOF
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- **Each result streams to stdout** as the command finishes — the same bytes you'd
|
|
167
|
+
get running the line on its own.
|
|
168
|
+
- **It stops at the first command that exits non-zero**, noting where it halted (on
|
|
169
|
+
stderr) and **exiting with that command's code**. A failed `tap`/`assert` means
|
|
170
|
+
the rest of the flow can no longer be trusted, so it breaks rather than press on.
|
|
171
|
+
- **Blank lines and `#` comments** are skipped, so a flow file can be annotated.
|
|
172
|
+
- **Globals on the `batch` call carry into every line** unless the line overrides
|
|
173
|
+
them — `--device`, `--platform` / `--ios` / `--android`, and `--json`. So
|
|
174
|
+
`vk batch --ios --file f` runs the whole flow against the simulator.
|
|
175
|
+
- `--quiet` silences the per-line progress notes on stderr; stdout data is untouched.
|
|
176
|
+
|
|
177
|
+
Because each line records like an individual action, ending a batch with
|
|
178
|
+
`run archive` turns the flow into a JUnit + HTML report in one shot:
|
|
179
|
+
|
|
180
|
+
```sh
|
|
181
|
+
printf 'launch com.example.app\nassert @home_tab\nrun archive smoke\n' | vk batch
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## AI — natural-language tests
|
|
185
|
+
|
|
186
|
+
`vk ai <file>` runs a test written in plain English. It treats the model as a
|
|
187
|
+
**compiler, not a runtime**: it compiles the prose into a deterministic plan once
|
|
188
|
+
(paying tokens), caches that plan by the test text + app build, then **replays it
|
|
189
|
+
with no model calls on the happy path**. The model is woken only to *repair* a step
|
|
190
|
+
whose selector stops resolving; a green run persists the repaired plan, so the next
|
|
191
|
+
run is free again. That is what keeps a CI suite's steady-state token cost near zero.
|
|
192
|
+
Needs `ANTHROPIC_API_KEY`.
|
|
193
|
+
|
|
194
|
+
```sh
|
|
195
|
+
# onboarding.md (plain English):
|
|
196
|
+
# Launch com.example.app fresh.
|
|
197
|
+
# If a notifications permission dialog appears, allow it.
|
|
198
|
+
# Tap "Get started", then assert the home tab is visible.
|
|
199
|
+
|
|
200
|
+
vk ai onboarding.md # first run: compile, then run
|
|
201
|
+
vk ai onboarding.md # cached: replays with no model call
|
|
202
|
+
vk ai onboarding.md --show-plan # print the compiled plan, don't run
|
|
203
|
+
vk ai onboarding.md --max-cost-usd 0.50 # tighten the spend cap (default $3)
|
|
204
|
+
vk ai onboarding.md --timeout 5m # tighten the run timeout (default 15m)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The compiled plan supports **conditions** (`if-present`, for optional interstitials
|
|
208
|
+
like permission dialogs) and **bounded loops** (`repeat … until`, e.g. scroll until a
|
|
209
|
+
row appears) — control flow a flat [`batch`](#batch) script can't express. Loops carry
|
|
210
|
+
a hard iteration cap and stop early if the screen stops changing.
|
|
211
|
+
|
|
212
|
+
- **Progress streams to stderr** (so a CI job never goes silent); **stdout is the
|
|
213
|
+
report path** (or a JSON summary with `--json`). The compiled plan is logged to the
|
|
214
|
+
run before it executes, for troubleshooting.
|
|
215
|
+
- **Cost and time are bounded by default.** Each run reports `compile / repairs /
|
|
216
|
+
replay=$0 / est $…` and aborts if the estimate crosses **`--max-cost-usd` (default
|
|
217
|
+
$3)** or the wall-clock passes **`--timeout` (default 15m)** — so a runaway loop or
|
|
218
|
+
repair can't spend or hang without limit. `--cost-override <input/output>` overrides
|
|
219
|
+
the bundled per-1M price table if it drifts.
|
|
220
|
+
- **`--model`** picks the model (`claude-haiku-4-5` · `claude-sonnet-4-6` (default) ·
|
|
221
|
+
`claude-opus-4-8` · `claude-fable-5`); **`--recompile`** ignores the cache.
|
|
222
|
+
- An `ai` run records like any other flow, so it produces the same JUnit + HTML report —
|
|
223
|
+
with the cost line and any **suggested test improvements** (workarounds the model
|
|
224
|
+
applied, which you can fold back into the prose to stabilize the test and cut tokens).
|
|
225
|
+
|
|
226
|
+
## Selectors
|
|
227
|
+
|
|
228
|
+
```
|
|
229
|
+
@login shorthand for id:login
|
|
230
|
+
id:login resource-id — matches full id, idShort, or a "/login" suffix
|
|
231
|
+
text:Sign in visible text (exact, case-insensitive, trimmed)
|
|
232
|
+
desc:Submit content-desc / accessibility label
|
|
233
|
+
class:Button simplified type ("Button") or full class ("android.widget.Button")
|
|
234
|
+
"Sign in" a bare string is treated as text: (exact)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Modifiers: `--contains` makes text/desc matches substring-based; `--index N`
|
|
238
|
+
selects the Nth match (0-based) when a selector intentionally matches several.
|
|
239
|
+
If a selector for an action matches more than one element and no `--index` is
|
|
240
|
+
given, the command fails with exit code 2 and lists the candidates — it never
|
|
241
|
+
taps a guess.
|
|
242
|
+
|
|
243
|
+
## Auto-wait
|
|
244
|
+
|
|
245
|
+
A UI rarely settles the instant the previous action returns. So selector
|
|
246
|
+
commands — `tap`, `text`, `find`, `assert`, and `swipe --on` — **don't fail the
|
|
247
|
+
moment a lookup misses**: they re-capture the hierarchy and retry until it
|
|
248
|
+
resolves or a **5-second** window elapses. A straightforward flow can then skip
|
|
249
|
+
explicit `wait` calls (fewer round-trips, fewer tokens):
|
|
250
|
+
|
|
251
|
+
```sh
|
|
252
|
+
vk tap @next # waits up to 5s for @next to appear, then taps
|
|
253
|
+
vk assert text:"Done" # waits up to 5s for "Done" to show, then asserts
|
|
254
|
+
vk find @spinner --no-wait # existence probe: answer now, don't wait
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
| Flag | Effect |
|
|
258
|
+
|---|---|
|
|
259
|
+
| *(none)* | Wait up to **5s** (the default) for the lookup to resolve. |
|
|
260
|
+
| `--wait <dur>` | Override the window: `8s`, `800ms`, or a bare number of ms (`3000`). `0` disables. |
|
|
261
|
+
| `--no-wait` | Fail immediately on the first miss (identical to `--wait 0`). |
|
|
262
|
+
| `--interval <ms>` | Poll cadence while waiting (default 300 ms). |
|
|
263
|
+
|
|
264
|
+
Two deliberate boundaries:
|
|
265
|
+
|
|
266
|
+
- **Ambiguity is never waited on.** If the lookup matches more than one element,
|
|
267
|
+
they're already on screen — the command reports the candidates and exits 2 at
|
|
268
|
+
once (waiting can't disambiguate). Add `--index N` or refine the selector.
|
|
269
|
+
- **`assert --gone` waits for *disappearance*** — it polls until the element is
|
|
270
|
+
absent, so it subsumes "`wait --gone` then assert" in one call.
|
|
271
|
+
|
|
272
|
+
This is distinct from the `wait` **command**, which stays for explicit polling
|
|
273
|
+
(with its own `--timeout`/`--interval` and `--gone`) when you want to block on a
|
|
274
|
+
condition as a step in its own right.
|
|
275
|
+
|
|
276
|
+
## Global flags
|
|
277
|
+
|
|
278
|
+
| Flag | Meaning |
|
|
279
|
+
|---|---|
|
|
280
|
+
| `-d, --device <serial>` | Target a specific device (or `VERIKUN_DEVICE` / `ANDROID_SERIAL`). |
|
|
281
|
+
| `-p, --platform <android\|ios>` | Platform (default `android`). `--ios` / `--android` are shortcuts. |
|
|
282
|
+
| `-j, --json` | Machine-readable output (also serializes errors). |
|
|
283
|
+
| `--` | End flag parsing, so text/arguments may start with `-`. |
|
|
284
|
+
|
|
285
|
+
## Exit codes
|
|
286
|
+
|
|
287
|
+
| Code | Meaning |
|
|
288
|
+
|---|---|
|
|
289
|
+
| `0` | success / found / assertion passed |
|
|
290
|
+
| `1` | not found / assertion failed / wait timeout |
|
|
291
|
+
| `2` | usage error or ambiguous selector (caller must refine) |
|
|
292
|
+
| `3` | environment error (adb/simctl missing, no/multiple devices, dump failed) |
|
|
293
|
+
|
|
294
|
+
Data goes to stdout; diagnostics/errors go to stderr.
|
|
295
|
+
|
|
296
|
+
## Screenshots
|
|
297
|
+
|
|
298
|
+
A device screenshot is large (~1080×2400), and an agent that reads it back as an
|
|
299
|
+
image pays for that pixel area in tokens — yet you seldom need much detail to see
|
|
300
|
+
what's on screen. So `vk screenshot` **downscales by default** to a **700px
|
|
301
|
+
longest edge**: UI text stays legible while the image shrinks ~12× in area (and
|
|
302
|
+
proportionally in tokens).
|
|
303
|
+
|
|
304
|
+
| Flag | Effect |
|
|
305
|
+
|---|---|
|
|
306
|
+
| *(none)* | Cap the longest edge at **700px** (never upscales). |
|
|
307
|
+
| `--more` | Bump to a higher-detail **1400px** cap when 700 reads too coarse. |
|
|
308
|
+
| `--max <px>` | Use an exact cap — e.g. `--max 500` to save even more. |
|
|
309
|
+
| `--full` | Write the original, full-resolution capture. |
|
|
310
|
+
| `VERIKUN_SHOT_MAX_EDGE` | Env var to change the default cap globally. |
|
|
311
|
+
|
|
312
|
+
Precedence: `--full` > `--max <px>` > `--more` > the default.
|
|
313
|
+
|
|
314
|
+
Resizing is a dependency-free, pure-Node PNG resample (box filter). PNGs it can't
|
|
315
|
+
safely resample (palette, 16-bit, interlaced) are written through untouched, so a
|
|
316
|
+
screenshot is never corrupted — only sometimes left full-size (noted on stderr).
|
|
317
|
+
Failure-evidence captures in test-run reports stay full-resolution for debugging.
|
|
318
|
+
|
|
319
|
+
## How it works
|
|
320
|
+
|
|
321
|
+
```
|
|
322
|
+
cli.ts ──> drivers/ ──> adb / xcrun (platform I/O)
|
|
323
|
+
│ └─ produces normalized Element[]
|
|
324
|
+
├─ ui/android-parse.ts uiautomator XML -> Element[]
|
|
325
|
+
├─ ui/selector.ts @id / text: / desc: / class: matching
|
|
326
|
+
└─ ui/format.ts compact / tree / json rendering
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
The `Driver` interface (`src/types.ts`) is the seam between platforms. The
|
|
330
|
+
selector, formatting, and command layers operate only on the normalized
|
|
331
|
+
`Element[]`, so they are entirely platform-agnostic.
|
|
332
|
+
|
|
333
|
+
- **Android** (`drivers/adb.ts`): `uiautomator dump` for the hierarchy,
|
|
334
|
+
`screencap -p` for screenshots, `input tap/text/swipe/keyevent`, `wm size`.
|
|
335
|
+
- **iOS** (`drivers/simctl.ts`): `screenshot`, `launch`, `stop` work today via
|
|
336
|
+
`xcrun simctl`. Full interaction and hierarchy inspection are planned via
|
|
337
|
+
WebDriverAgent — see [iOS roadmap](#ios-roadmap) below.
|
|
338
|
+
|
|
339
|
+
Run artifacts (screenshots, dumps) are written under `./.verikun/` (gitignored).
|
|
340
|
+
|
|
341
|
+
## iOS roadmap
|
|
342
|
+
|
|
343
|
+
Today `vk --ios` supports **screenshots, launch, and stop** via `xcrun simctl`.
|
|
344
|
+
Tapping, swiping, typing, and `vk ui` hierarchy inspection are not yet wired up.
|
|
345
|
+
|
|
346
|
+
The planned backend is **[WebDriverAgent](https://github.com/appium/WebDriverAgent)**
|
|
347
|
+
(WDA) — an open-source XCTest HTTP server maintained by the Appium team. It
|
|
348
|
+
requires no Python and works on both simulators and physical devices. Once WDA
|
|
349
|
+
is running, `vk` will drive it over HTTP and the command layer stays unchanged.
|
|
350
|
+
|
|
351
|
+
**One-time setup (when this lands):**
|
|
352
|
+
1. Clone WebDriverAgent and open it in Xcode
|
|
353
|
+
2. Set your Apple developer signing team
|
|
354
|
+
3. Build & run on the target device or simulator
|
|
355
|
+
4. `vk --ios tap`, `vk --ios ui`, etc. will work automatically
|
|
356
|
+
|
|
357
|
+
Until then, running any unsupported iOS command prints an explanation and exits
|
|
358
|
+
with code 3.
|
|
359
|
+
|
|
360
|
+
## Using it from an AI agent
|
|
361
|
+
|
|
362
|
+
See [`.claude/skills/verikun/SKILL.md`](.claude/skills/verikun/SKILL.md) — the
|
|
363
|
+
companion skill that teaches the act → inspect → assert loop, selector grammar,
|
|
364
|
+
exit-code semantics, and gotchas.
|
|
365
|
+
|
|
366
|
+
### Example: full onboarding walkthrough
|
|
367
|
+
|
|
368
|
+
A Claude agent drove a multi-step Android onboarding flow end-to-end using only
|
|
369
|
+
`vk` commands — no coordinates, no hardcoded waits beyond `sleep 1` on
|
|
370
|
+
transitions.
|
|
371
|
+
|
|
372
|
+
```sh
|
|
373
|
+
# 1. See where we are
|
|
374
|
+
vk screenshot # read PNG to confirm current screen
|
|
375
|
+
|
|
376
|
+
# 2. Welcome splash
|
|
377
|
+
vk tap @get_started_button_id
|
|
378
|
+
|
|
379
|
+
# 3. Intro/explainer screens — same button each time
|
|
380
|
+
vk tap @tap_to_continue_label_id
|
|
381
|
+
vk tap @tap_to_continue_label_id
|
|
382
|
+
|
|
383
|
+
# 4. Scrollable list — scroll until the item is visible, then tap
|
|
384
|
+
vk swipe up
|
|
385
|
+
vk tap @target_item_id
|
|
386
|
+
|
|
387
|
+
# 5. Transition screen after selection
|
|
388
|
+
vk tap @tap_to_continue_label_id
|
|
389
|
+
|
|
390
|
+
# 6. Option grid — inspect to find the right index, tap it
|
|
391
|
+
vk ui # [4] ImageView desc="My preferred option"
|
|
392
|
+
vk tap 4
|
|
393
|
+
|
|
394
|
+
# 7. Final screen before sign-up
|
|
395
|
+
vk tap @tap_to_continue_label_id
|
|
396
|
+
# → sign-up screen reached; onboarding complete
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
**Cost:** $0.45 · **Wall time:** ~4 min · **Model:** Claude Sonnet 4.6 with
|
|
400
|
+
prompt-cache hits (1 M cache-read tokens kept cost low on a long conversation).
|
|
401
|
+
|
|
402
|
+
## Build from source
|
|
403
|
+
|
|
404
|
+
For local development, or to run an unreleased version, build from a clone:
|
|
405
|
+
|
|
406
|
+
```sh
|
|
407
|
+
git clone https://github.com/ddikman/verikun && cd verikun
|
|
408
|
+
npm install # installs dev deps (typescript, @types/node) and builds dist/ via the prepare hook
|
|
409
|
+
npm link # optional: put `verikun` and `vk` on your PATH
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Without `npm link`, run it as `node dist/bin/verikun.js <command>`. See [Development](#development) below for the watch/test loop.
|
|
413
|
+
|
|
414
|
+
## Development
|
|
415
|
+
|
|
416
|
+
```sh
|
|
417
|
+
npm run dev # tsc --watch
|
|
418
|
+
npm run build # one-off compile
|
|
419
|
+
npm test # type-check + run the unit suite
|
|
420
|
+
npm run test:watch # re-run the suite on change
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
Zero runtime dependencies; the only dev dependencies are `typescript` and
|
|
424
|
+
`@types/node`.
|
|
425
|
+
|
|
426
|
+
### Tests
|
|
427
|
+
|
|
428
|
+
Unit tests cover the platform-agnostic core (selector matching, the
|
|
429
|
+
`uiautomator` XML parser, formatting, the PNG downscaler, report rendering,
|
|
430
|
+
argument/duration parsing, and the device-shell escaper) and run on **Node's
|
|
431
|
+
built-in test runner** (`node:test`) — no test framework is added, in keeping
|
|
432
|
+
with the zero-runtime-dependency rule. They live in `tests/*.test.ts`, compile
|
|
433
|
+
via `tsconfig.test.json` into the gitignored `.test-build/`, and need no device.
|
|
434
|
+
Driver code that talks to `adb`/`xcrun` is verified end-to-end instead, by
|
|
435
|
+
running the built CLI against a real device (`vk doctor`, `vk ui`).
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.COMPILER_FINGERPRINT = void 0;
|
|
4
|
+
exports.nlHash = nlHash;
|
|
5
|
+
exports.planKey = planKey;
|
|
6
|
+
exports.readPlan = readPlan;
|
|
7
|
+
exports.writePlan = writePlan;
|
|
8
|
+
exports.findSeed = findSeed;
|
|
9
|
+
const node_crypto_1 = require("node:crypto");
|
|
10
|
+
const node_fs_1 = require("node:fs");
|
|
11
|
+
const node_path_1 = require("node:path");
|
|
12
|
+
const output_1 = require("../output");
|
|
13
|
+
const version_1 = require("../version");
|
|
14
|
+
const ir_1 = require("./ir");
|
|
15
|
+
const grammar_1 = require("./grammar");
|
|
16
|
+
const sha256 = (s) => (0, node_crypto_1.createHash)('sha256').update(s).digest('hex');
|
|
17
|
+
/**
|
|
18
|
+
* Fingerprint of the COMPILER that produced a plan: the verikun version PLUS the exact
|
|
19
|
+
* grammar/repair prompts handed to the model. A cached plan is replayed ONLY when this
|
|
20
|
+
* matches the running build — so updating verikun (a version bump, OR any change to the
|
|
21
|
+
* grammar/repair instructions) invalidates stale plans and forces a recompile against
|
|
22
|
+
* the current compiler, instead of silently replaying a plan the old one produced. The
|
|
23
|
+
* version alone wouldn't catch unreleased grammar edits (same `0.3.0`); folding the
|
|
24
|
+
* grammar text in does.
|
|
25
|
+
*/
|
|
26
|
+
exports.COMPILER_FINGERPRINT = sha256([version_1.VERSION, grammar_1.GRAMMAR, grammar_1.REPAIR_GRAMMAR].join(String.fromCharCode(0))).slice(0, 16);
|
|
27
|
+
function plansDir() {
|
|
28
|
+
return (0, node_path_1.join)((0, output_1.artifactDir)(), 'plans');
|
|
29
|
+
}
|
|
30
|
+
/** Hash of the NL text alone — the seed-matching identity, build-independent. */
|
|
31
|
+
function nlHash(nl) {
|
|
32
|
+
return sha256(nl);
|
|
33
|
+
}
|
|
34
|
+
/** The full cache key (filename stem): NL + package + build + platform. A new build
|
|
35
|
+
* changes this, so its plan is a distinct entry (no blind stale replay). */
|
|
36
|
+
function planKey(input) {
|
|
37
|
+
// NUL-joined: a separator that can't occur in any component, so distinct inputs never
|
|
38
|
+
// collide. We build the NUL with fromCharCode(0), not a backslash-zero literal, which
|
|
39
|
+
// some tools write as a real NUL byte (which makes the whole file read as "binary").
|
|
40
|
+
return sha256([input.nl, input.pkg ?? '', input.build ?? '', input.platform].join(String.fromCharCode(0))).slice(0, 32);
|
|
41
|
+
}
|
|
42
|
+
function entryPath(key) {
|
|
43
|
+
return (0, node_path_1.join)(plansDir(), `${key}.json`);
|
|
44
|
+
}
|
|
45
|
+
/** Read the cached plan for this exact key. Returns null on miss OR a corrupt /
|
|
46
|
+
* unparseable file (treated as a miss so a poisoned file forces one recompile,
|
|
47
|
+
* not a permanent failure). */
|
|
48
|
+
function readPlan(input) {
|
|
49
|
+
const p = entryPath(planKey(input));
|
|
50
|
+
if (!(0, node_fs_1.existsSync)(p))
|
|
51
|
+
return null;
|
|
52
|
+
try {
|
|
53
|
+
const entry = JSON.parse((0, node_fs_1.readFileSync)(p, 'utf8'));
|
|
54
|
+
// A plan compiled by a DIFFERENT verikun/grammar must not be replayed — treat it as
|
|
55
|
+
// a miss so it recompiles against the current compiler. (findSeed deliberately does
|
|
56
|
+
// NOT apply this gate: an older plan is still a fine starting point to adapt from.)
|
|
57
|
+
if (entry.compilerFingerprint !== exports.COMPILER_FINGERPRINT)
|
|
58
|
+
return null;
|
|
59
|
+
// Re-validate the plan shape — a hand-edited or partially-written file that
|
|
60
|
+
// parses as JSON but isn't a valid Plan is still a miss.
|
|
61
|
+
entry.plan = (0, ir_1.parsePlan)(entry.plan);
|
|
62
|
+
return entry;
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
// A cache file that exists but won't parse/validate is corrupt — surface it (then
|
|
66
|
+
// recompile) rather than swallow it silently.
|
|
67
|
+
(0, output_1.err)(`[ai] ignoring unreadable plan cache ${p} (${e.message}) — recompiling`);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Write a plan to the cache atomically (temp + rename). Called right after a compile
|
|
72
|
+
* (caching the clean plan, so an unchanged test never recompiles) and again after a
|
|
73
|
+
* fully-green run (caching the healed plan). A half-healed plan from a failed run is
|
|
74
|
+
* never persisted — the clean compile stays cached. */
|
|
75
|
+
function writePlan(input, plan) {
|
|
76
|
+
const dir = plansDir();
|
|
77
|
+
(0, node_fs_1.mkdirSync)(dir, { recursive: true });
|
|
78
|
+
const entry = {
|
|
79
|
+
nlHash: nlHash(input.nl),
|
|
80
|
+
pkg: input.pkg,
|
|
81
|
+
build: input.build,
|
|
82
|
+
platform: input.platform,
|
|
83
|
+
verikunVersion: version_1.VERSION,
|
|
84
|
+
compilerFingerprint: exports.COMPILER_FINGERPRINT,
|
|
85
|
+
savedAt: new Date().toISOString(),
|
|
86
|
+
plan,
|
|
87
|
+
};
|
|
88
|
+
const target = entryPath(planKey(input));
|
|
89
|
+
const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
90
|
+
(0, node_fs_1.writeFileSync)(tmp, JSON.stringify(entry, null, 2));
|
|
91
|
+
(0, node_fs_1.renameSync)(tmp, target);
|
|
92
|
+
return entry;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Find a plan to seed a new build from: the most recently saved cached plan with
|
|
96
|
+
* the same NL text and package but a DIFFERENT (or unknown) build. Returns null if
|
|
97
|
+
* there is no prior plan to adapt. Tolerant — unreadable entries are skipped.
|
|
98
|
+
*/
|
|
99
|
+
function findSeed(input) {
|
|
100
|
+
const dir = plansDir();
|
|
101
|
+
if (!(0, node_fs_1.existsSync)(dir))
|
|
102
|
+
return null;
|
|
103
|
+
const wantNl = nlHash(input.nl);
|
|
104
|
+
const exactKey = planKey(input);
|
|
105
|
+
let best = null;
|
|
106
|
+
for (const file of (0, node_fs_1.readdirSync)(dir)) {
|
|
107
|
+
if (!file.endsWith('.json') || file.startsWith('.') || `${file}` === `${exactKey}.json`)
|
|
108
|
+
continue;
|
|
109
|
+
try {
|
|
110
|
+
const entry = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, file), 'utf8'));
|
|
111
|
+
if (entry.nlHash !== wantNl)
|
|
112
|
+
continue;
|
|
113
|
+
if ((entry.pkg ?? '') !== (input.pkg ?? ''))
|
|
114
|
+
continue;
|
|
115
|
+
if (entry.platform !== input.platform)
|
|
116
|
+
continue; // never seed across platforms
|
|
117
|
+
entry.plan = (0, ir_1.parsePlan)(entry.plan); // skip if it doesn't validate
|
|
118
|
+
if (!best || entry.savedAt > best.savedAt)
|
|
119
|
+
best = entry;
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
// A seed candidate that won't parse/validate is corrupt — warn and skip it, rather
|
|
123
|
+
// than let one bad file silently vanish from seeding.
|
|
124
|
+
(0, output_1.err)(`[ai] skipping unreadable cache entry ${file} (${e.message})`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return best;
|
|
128
|
+
}
|