device-devtools-mcp 0.1.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.
Files changed (66) hide show
  1. package/AGENTS.md +327 -0
  2. package/LICENSE +21 -0
  3. package/README.md +491 -0
  4. package/VERSION +1 -0
  5. package/bin/device-devtools-mcp.js +106 -0
  6. package/bin/devicetools +315 -0
  7. package/config.example.json +63 -0
  8. package/integrations/agent-pointer.sh +48 -0
  9. package/integrations/claude/SKILL.md +8 -0
  10. package/integrations/cursor/devicetools.mdc +8 -0
  11. package/integrations/gemini/GEMINI.md +5 -0
  12. package/integrations/mcp/README.md +184 -0
  13. package/integrations/mcp/mcp.json +10 -0
  14. package/integrations/mcp/reference.sh +119 -0
  15. package/integrations/mcp/selftest.sh +158 -0
  16. package/integrations/mcp/server.sh +343 -0
  17. package/package.json +50 -0
  18. package/scripts/android/app.sh +241 -0
  19. package/scripts/android/back.sh +73 -0
  20. package/scripts/android/controls.sh +56 -0
  21. package/scripts/android/devices.sh +77 -0
  22. package/scripts/android/doctor.sh +253 -0
  23. package/scripts/android/lib.sh +699 -0
  24. package/scripts/android/logs.sh +289 -0
  25. package/scripts/android/permission.sh +119 -0
  26. package/scripts/android/settings.sh +63 -0
  27. package/scripts/android/setup.sh +97 -0
  28. package/scripts/android/tree.awk +166 -0
  29. package/scripts/android/tree.sh +127 -0
  30. package/scripts/android/type.sh +191 -0
  31. package/scripts/common/find.sh +95 -0
  32. package/scripts/common/key.sh +65 -0
  33. package/scripts/common/lib.sh +14 -0
  34. package/scripts/common/measure.sh +170 -0
  35. package/scripts/common/open.sh +131 -0
  36. package/scripts/common/screenshot.sh +102 -0
  37. package/scripts/common/scroll.sh +177 -0
  38. package/scripts/common/snapshot.sh +69 -0
  39. package/scripts/common/swipe.sh +171 -0
  40. package/scripts/common/tap.sh +226 -0
  41. package/scripts/common/wait.sh +212 -0
  42. package/scripts/common/waypoint.sh +141 -0
  43. package/scripts/dispatch.sh +21 -0
  44. package/scripts/flow.sh +266 -0
  45. package/scripts/init.sh +101 -0
  46. package/scripts/ios/app.sh +404 -0
  47. package/scripts/ios/back.sh +95 -0
  48. package/scripts/ios/controls.sh +68 -0
  49. package/scripts/ios/devices.sh +80 -0
  50. package/scripts/ios/doctor.sh +386 -0
  51. package/scripts/ios/lib.sh +864 -0
  52. package/scripts/ios/logs.sh +272 -0
  53. package/scripts/ios/permission.sh +108 -0
  54. package/scripts/ios/settings.sh +76 -0
  55. package/scripts/ios/setup.sh +175 -0
  56. package/scripts/ios/tree.sh +128 -0
  57. package/scripts/ios/type.sh +178 -0
  58. package/scripts/lib.sh +1032 -0
  59. package/scripts/links.tsv +44 -0
  60. package/scripts/relink.sh +121 -0
  61. package/scripts/run.sh +415 -0
  62. package/scripts/selftest.sh +1709 -0
  63. package/scripts/snapshot.awk +362 -0
  64. package/scripts/verify-npm-package.js +133 -0
  65. package/tests/fixtures/ios-contacts-list.expected +52 -0
  66. package/tests/fixtures/ios-contacts-list.rows +140 -0
@@ -0,0 +1,315 @@
1
+ #!/usr/bin/env bash
2
+ # devicetools — the one command.
3
+ #
4
+ # devicetools <verb> [args...] run a verb (snapshot, tap, type, logs, …)
5
+ # devicetools config set <k> <v> edit config.json without opening it
6
+ # devicetools init [dir] make a directory into a DeviceTools project
7
+ # devicetools version what is installed, and what it is pointed at
8
+ # devicetools help the verbs
9
+ #
10
+ # WHY THIS FILE EXISTS
11
+ #
12
+ # Until now the only way to use DeviceTools was to cd into the checkout and call
13
+ # ./scripts/<verb>.sh, which quietly made the tool and the work the same
14
+ # directory: your tests lived in our repository. That is fine for developing
15
+ # DeviceTools and wrong for using it. This entry point is the seam — it can be
16
+ # symlinked onto PATH from anywhere, it resolves its own checkout through the
17
+ # symlink, and the project it acts on is wherever you are standing.
18
+ #
19
+ # It adds no behaviour of its own beyond config/init/update/version. `devicetools tap` is
20
+ # exactly `scripts/tap.sh`: same arguments, same output, same exit codes. A
21
+ # wrapper that reinterpreted either would be a second contract to keep true.
22
+ set -euo pipefail
23
+
24
+ # Resolve this file through any chain of symlinks. macOS has no `readlink -f`
25
+ # and no `realpath` in the base system, so the loop is the portable spelling.
26
+ self="${BASH_SOURCE[0]}"
27
+ while [ -L "$self" ]; do
28
+ dir="$(cd "$(dirname "$self")" && pwd)"
29
+ self="$(readlink "$self")"
30
+ case "$self" in /*) ;; *) self="$dir/$self" ;; esac
31
+ done
32
+ HOME_DIR="$(cd "$(dirname "$self")/.." && pwd)"
33
+
34
+ # THE PROJECT FORMAT THIS BUILD UNDERSTANDS.
35
+ #
36
+ # A project is marked by a .devicetools file whose first line is
37
+ # "devicetools-project <n>". The number is not decoration: a tool upgraded past a
38
+ # project is the failure mode nobody sees coming — it reads a config or a
39
+ # baseline in a shape it no longer means and does something plausible with it.
40
+ # So the number is checked, and a project from the future is refused rather
41
+ # than half-read.
42
+ PROJECT_FORMAT=1
43
+
44
+ # A package manager drops every symlink in scripts/, which is the whole verb
45
+ # surface. Restored here for the same reason the npx entry point does it, and by
46
+ # the same script — on a git checkout this finds nothing to do. See relink.sh.
47
+ if [ ! -e "$HOME_DIR/scripts/tap.sh" ] && [ -f "$HOME_DIR/scripts/links.tsv" ]; then
48
+ "$HOME_DIR/scripts/relink.sh" >&2 || true
49
+ fi
50
+
51
+ # So a verb can spell its own remedies back the way you called it: `devicetools
52
+ # doctor --recover` when you typed `devicetools`, `scripts/doctor.sh --recover`
53
+ # when you ran the script. A message naming a path that is not how the reader
54
+ # invokes anything is a message they have to translate.
55
+ #
56
+ # Set here rather than beside the exec at the bottom, because `init` execs from
57
+ # a case arm above it and was printing `scripts/doctor.sh` at somebody who had
58
+ # typed `devicetools`.
59
+ export DT_CLI=1
60
+
61
+ project_dir() {
62
+ if [ -n "${DEVICETOOLS_PROJECT:-}" ]; then printf '%s' "$DEVICETOOLS_PROJECT"; return 0; fi
63
+ d="$PWD"
64
+ while [ -n "$d" ] && [ "$d" != / ]; do
65
+ [ -f "$d/.devicetools" ] && { printf '%s' "$d"; return 0; }
66
+ d="$(dirname "$d")"
67
+ done
68
+ return 1
69
+ }
70
+
71
+ # project_format_check <dir> — prints a line, returns non-zero when unusable.
72
+ project_format_check() {
73
+ local f="$1/.devicetools" n
74
+ [ -f "$f" ] || { printf 'PROJECT ? no .devicetools in %s\n' "$1"; return 1; }
75
+ n="$(awk '{ print $2; exit }' "$f" 2>/dev/null)"
76
+ case "$n" in
77
+ ''|*[!0-9]*) printf 'PROJECT ? %s does not name a format version — expected "devicetools-project %s"\n' "$f" "$PROJECT_FORMAT"; return 1 ;;
78
+ esac
79
+ if [ "$n" -gt "$PROJECT_FORMAT" ]; then
80
+ printf 'PROJECT ! format %s, and this build understands %s — upgrade DeviceTools, or use the version that wrote it\n' "$n" "$PROJECT_FORMAT"
81
+ return 1
82
+ fi
83
+ printf 'PROJECT ok format %s\n' "$n"
84
+ return 0
85
+ }
86
+
87
+ usage() {
88
+ cat <<'EOF'
89
+ usage: devicetools <command> [args...]
90
+
91
+ see snapshot, screenshot, measure, tree, controls, find
92
+ act tap, type, swipe, scroll, back, key, open, wait
93
+ several at once run, flow
94
+ the device devices, doctor, app, logs, settings, permission
95
+ come back waypoint
96
+
97
+ setup install the driver this platform needs
98
+ config print, or: config get <key> | set <key> <value>
99
+ init [dir] make a directory into a DeviceTools project
100
+ update [--check] pull a newer DeviceTools, and check the project still fits
101
+ version print the version, the checkout and the project
102
+ help this
103
+
104
+ Every verb is `devicetools <verb> --help`. Exit codes are uniform:
105
+ 0 ok, 1 usage, 2 environment, 3 connectivity, 4 the screen did not satisfy.
106
+ EOF
107
+ }
108
+
109
+ cmd="${1-}"
110
+ [ $# -gt 0 ] && shift || true
111
+
112
+ case "$cmd" in
113
+ ""|help|-h|--help) usage; [ -n "$cmd" ] && exit 0 || exit 1 ;;
114
+
115
+ version|--version)
116
+ # Sourcing lib.sh for this would require a config, and "what version is
117
+ # this" must answer on a machine that has not been configured yet.
118
+ v="$(cat "$HOME_DIR/VERSION" 2>/dev/null || printf 'unknown')"
119
+ c="$(git -C "$HOME_DIR" rev-parse --short HEAD 2>/dev/null || true)"
120
+ [ -n "$c" ] && v="$v+$c"
121
+ printf 'VERSION %s\n' "$v"
122
+ printf 'HOME %s\n' "$HOME_DIR"
123
+ if [ -n "${DEVICETOOLS_PROJECT:-}" ]; then
124
+ printf 'PROJECT %s (DEVICETOOLS_PROJECT)\n' "$DEVICETOOLS_PROJECT"
125
+ else
126
+ d="$PWD"; p=""
127
+ while [ -n "$d" ] && [ "$d" != / ]; do
128
+ [ -f "$d/.devicetools" ] && { p="$d"; break; }
129
+ d="$(dirname "$d")"
130
+ done
131
+ if [ -n "$p" ]; then printf 'PROJECT %s\n' "$p"
132
+ else printf 'PROJECT %s (no .devicetools found — the checkout is the project)\n' "$HOME_DIR"; fi
133
+ fi
134
+ printf 'CONFIG %s\n' "${DEVICETOOLS_CONFIG:-<project>/config.json}"
135
+ exit 0
136
+ ;;
137
+
138
+ init)
139
+ # ONE IMPLEMENTATION, IN scripts/. It used to live here as a `case` arm,
140
+ # which made it invisible over MCP — `flow save` told an agent to run `init`
141
+ # in its repository and there was no tool that could. Every verb in scripts/
142
+ # is reachable from both sides; a branch of this wrapper is reachable from
143
+ # one.
144
+ exec "$HOME_DIR/scripts/init.sh" "$@"
145
+ ;;
146
+
147
+ update|--update)
148
+ # `git pull` on a directory somebody else owns, so it says what it is about
149
+ # to do and refuses when the answer is not obvious.
150
+ check_only=0
151
+ [ "${1-}" = --check ] && check_only=1
152
+ git -C "$HOME_DIR" rev-parse --git-dir >/dev/null 2>&1 \
153
+ || { printf 'not a git checkout: %s — update it the way you installed it\n' "$HOME_DIR" >&2; exit 2; }
154
+
155
+ before="$(git -C "$HOME_DIR" rev-parse --short HEAD 2>/dev/null || printf '?')"
156
+ dirty="$(git -C "$HOME_DIR" status --porcelain 2>/dev/null | head -5)"
157
+ branch="$(git -C "$HOME_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?')"
158
+
159
+ git -C "$HOME_DIR" fetch --quiet 2>/dev/null || { printf 'could not reach the remote\n' >&2; exit 2; }
160
+ behind="$(git -C "$HOME_DIR" rev-list --count "HEAD..@{u}" 2>/dev/null || printf 0)"
161
+ ahead="$(git -C "$HOME_DIR" rev-list --count "@{u}..HEAD" 2>/dev/null || printf 0)"
162
+
163
+ printf 'VERSION %s at %s on %s\n' "$(cat "$HOME_DIR/VERSION" 2>/dev/null || printf '?')" "$before" "$branch"
164
+ if [ "${behind:-0}" -eq 0 ]; then
165
+ printf 'UPDATE none — already at the latest commit on %s\n' "$branch"
166
+ exit 0
167
+ fi
168
+ printf 'UPDATE %s commit(s) behind\n' "$behind"
169
+ git -C "$HOME_DIR" log --oneline "HEAD..@{u}" 2>/dev/null | head -10 | sed 's/^/ /'
170
+ [ "${ahead:-0}" -gt 0 ] && printf 'NOTE and %s commit(s) ahead — a pull here would merge, not fast-forward\n' "$ahead"
171
+
172
+ if [ "$check_only" -eq 1 ]; then
173
+ printf 'NEXT devicetools update\n'
174
+ exit 0
175
+ fi
176
+ # LOCAL EDITS ARE SOMEBODY'S WORK. THEY ARE NOT AN OBSTACLE TO ROUTE AROUND.
177
+ if [ -n "$dirty" ]; then
178
+ printf '%s\n' "$dirty" | sed 's/^/ /'
179
+ printf 'refusing to pull over uncommitted changes in %s — commit or stash them first\n' "$HOME_DIR" >&2
180
+ exit 2
181
+ fi
182
+ git -C "$HOME_DIR" pull --ff-only --quiet 2>/dev/null \
183
+ || { printf 'pull failed — resolve it in %s by hand\n' "$HOME_DIR" >&2; exit 2; }
184
+ printf 'UPDATE %s -> %s\n' "$before" "$(git -C "$HOME_DIR" rev-parse --short HEAD)"
185
+
186
+ # An upgraded tool against an older project is the case worth checking, and
187
+ # it is checked here rather than left for the first verb to trip over.
188
+ if p="$(project_dir)"; then project_format_check "$p" || exit 2; fi
189
+ exit 0
190
+ ;;
191
+
192
+ config)
193
+ # Read and edit config.json without opening it.
194
+ #
195
+ # config print it
196
+ # config get <key> one value
197
+ # config set <key> <value> write one value
198
+ #
199
+ # A key is a dotted path: app.bundle_id, device.udid, wda.team_id. One way to
200
+ # write a value, not two — a shorthand for the app id would be a second
201
+ # spelling of `set` that has to stay in step with it forever.
202
+ #
203
+ # NOT A VERB, so not an MCP tool. Verbs drive a device; this rewrites the
204
+ # file that says which device. An agent that can repoint the tool at another
205
+ # app mid-session can also do it by accident, and the next twenty actions
206
+ # would land on the wrong app while every one of them reported success.
207
+ command -v jq >/dev/null 2>&1 || { printf 'jq is required\n' >&2; exit 2; }
208
+
209
+ if [ -n "${DEVICETOOLS_CONFIG:-}" ]; then
210
+ cfile="$DEVICETOOLS_CONFIG"
211
+ elif p="$(project_dir)"; then
212
+ cfile="$p/config.json"
213
+ else
214
+ cfile="$HOME_DIR/config.json"
215
+ fi
216
+ [ -f "$cfile" ] || { printf 'no config at %s\n' "$cfile" >&2; exit 2; }
217
+ jq -e . "$cfile" >/dev/null 2>&1 || { printf '%s is not valid JSON\n' "$cfile" >&2; exit 2; }
218
+
219
+ # A dotted key as a jq path expression. Refused rather than escaped when it
220
+ # holds anything but names and dots: the value goes into a jq program, and a
221
+ # key is not the place to accept punctuation.
222
+ jqpath() {
223
+ case "$1" in
224
+ ""|*[!a-zA-Z0-9_.]*|.*|*.) printf 'bad key: %s (use dotted names, e.g. app.bundle_id)\n' "$1" >&2; return 1 ;;
225
+ esac
226
+ printf '.%s' "$1"
227
+ }
228
+
229
+ action="${1-}"
230
+ [ $# -gt 0 ] && shift || true
231
+
232
+ case "$action" in
233
+ ""|show) jq . "$cfile"; exit 0 ;;
234
+
235
+ get)
236
+ [ $# -ge 1 ] || { printf 'usage: devicetools config get <key>\n' >&2; exit 1; }
237
+ path="$(jqpath "$1")" || exit 1
238
+ jq -r "$path // empty" "$cfile"
239
+ exit 0
240
+ ;;
241
+
242
+ set)
243
+ [ $# -ge 2 ] || { printf 'usage: devicetools config set <key> <value>\n' >&2; exit 1; }
244
+ key="$1"; val="$2"
245
+ path="$(jqpath "$key")" || exit 1
246
+
247
+ # A KEY THAT DOES NOT EXIST YET IS A TYPO UNTIL PROVEN OTHERWISE.
248
+ #
249
+ # `set app.bundleid` would otherwise write a key nothing reads, report
250
+ # success, and leave someone looking at the device wondering why the
251
+ # app never changed. --new is how you say you meant it.
252
+ if [ "$(jq -r "$path | type" "$cfile" 2>/dev/null || printf 'null')" = null ] \
253
+ && [ "${3-}" != --new ] && [ "${2-}" != --new ]; then
254
+ printf 'no such key: %s\n' "$key" >&2
255
+ printf 'existing keys under %s:\n' "${key%.*}" >&2
256
+ jq -r --arg p "${key%.*}" '
257
+ getpath($p | split(".")) // {} | if type == "object" then keys[] else empty end
258
+ ' "$cfile" 2>/dev/null | sed 's/^/ /' >&2
259
+ printf "pass --new to create it anyway\n" >&2
260
+ exit 1
261
+ fi
262
+
263
+ # The existing value decides the type of the new one. Writing "30" as a
264
+ # string where a number lived produces a config that still parses and a
265
+ # comparison that quietly stops working.
266
+ was="$(jq -r "$path | type" "$cfile" 2>/dev/null || printf 'null')"
267
+ case "$was" in
268
+ number) case "$val" in ''|*[!0-9]*) printf '%s holds a number, got: %s\n' "$key" "$val" >&2; exit 1 ;; esac
269
+ new="$(jq --argjson v "$val" "setpath(\"$key\" | split(\".\"); \$v)" "$cfile")" ;;
270
+ boolean) case "$val" in true|false) ;; *) printf '%s holds a boolean, got: %s\n' "$key" "$val" >&2; exit 1 ;; esac
271
+ new="$(jq --argjson v "$val" "setpath(\"$key\" | split(\".\"); \$v)" "$cfile")" ;;
272
+ *) new="$(jq --arg v "$val" "setpath(\"$key\" | split(\".\"); \$v)" "$cfile")" ;;
273
+ esac
274
+ [ -n "$new" ] || { printf 'could not set %s\n' "$key" >&2; exit 2; }
275
+
276
+ # Written beside the original and moved into place, so an interrupted
277
+ # write cannot leave a half-file where the config was.
278
+ tmp="$cfile.tmp.$$"
279
+ printf '%s\n' "$new" > "$tmp" || { rm -f "$tmp"; printf 'could not write %s\n' "$tmp" >&2; exit 2; }
280
+ mv "$tmp" "$cfile" || { rm -f "$tmp"; printf 'could not replace %s\n' "$cfile" >&2; exit 2; }
281
+ printf '%-24s %s\n' "$key" "$(jq -r "$path" "$cfile")"
282
+
283
+ # BOTH APP KEYS EXIST IN EVERY CONFIG, AND ONLY ONE OF THEM IS READ.
284
+ #
285
+ # So `set app.package` against an iOS config succeeds, prints the value
286
+ # back, and changes nothing about which app gets driven — the "no such
287
+ # key" guard cannot catch it, because the key is there. Said out loud
288
+ # here, while the person who typed it is still looking.
289
+ plat="$(jq -r '.platform // "ios"' "$cfile")"
290
+ case "$plat:$key" in
291
+ android:app.bundle_id) printf 'note: platform is android, which reads app.package\n' >&2 ;;
292
+ ios:app.package) printf 'note: platform is ios, which reads app.bundle_id\n' >&2 ;;
293
+ esac
294
+ exit 0
295
+ ;;
296
+
297
+ *) printf 'usage: devicetools config [get <key> | set <key> <value>]\n' >&2; exit 1 ;;
298
+ esac
299
+ ;;
300
+ esac
301
+
302
+ # Everything else is a verb. Reject anything that is not one by name rather than
303
+ # letting a typo become a path.
304
+ case "$cmd" in
305
+ */*|.*|-*) printf 'not a verb: %s\n' "$cmd" >&2; exit 1 ;;
306
+ esac
307
+
308
+ target="$HOME_DIR/scripts/${cmd%.sh}.sh"
309
+ if [ ! -x "$target" ]; then
310
+ printf 'no such command: %s\n' "$cmd" >&2
311
+ printf "run 'devicetools help' for the list\n" >&2
312
+ exit 1
313
+ fi
314
+
315
+ exec "$target" "$@"
@@ -0,0 +1,63 @@
1
+ {
2
+ "_comment": "Copy to config.json and edit. `platform` selects which adapter runs; the device, wda and ua2 blocks belonging to the other platform are ignored. `device.kind` is auto, device, simulator (iOS) or emulator (Android).",
3
+ "platform": "ios",
4
+ "device": {
5
+ "udid": "00000000-0000000000000000",
6
+ "name": "iPhone",
7
+ "ios_version": "15.1.1",
8
+ "serial": "emulator-5554",
9
+ "kind": "auto"
10
+ },
11
+ "wda": {
12
+ "project": "~/tools/WebDriverAgent/WebDriverAgent.xcodeproj",
13
+ "scheme": "WebDriverAgentRunner",
14
+ "derived_data": "~/tools/wda-derived",
15
+ "team_id": "XXXXXXXXXX",
16
+ "bundle_id_prefix": "com.example",
17
+ "runner_bundle_id": "com.example.WebDriverAgentRunner.xctrunner",
18
+ "host": "127.0.0.1",
19
+ "local_port": 8100,
20
+ "device_port": 8100
21
+ },
22
+ "ua2": {
23
+ "version": "v10.6.2",
24
+ "host": "127.0.0.1",
25
+ "local_port": 6790,
26
+ "device_port": 6790,
27
+ "apk_dir": "~/tools/uiautomator2"
28
+ },
29
+ "app": {
30
+ "bundle_id": "",
31
+ "project": "",
32
+ "workspace": "",
33
+ "scheme": "",
34
+ "configuration": "Debug",
35
+ "build_command": "",
36
+ "package": "",
37
+ "activity": "",
38
+ "apk": ""
39
+ },
40
+ "paths": {
41
+ "runs_dir": ".runs",
42
+ "state_dir": ".state",
43
+ "flows_dir": "flows"
44
+ },
45
+ "tree": {
46
+ "max_lines": 60
47
+ },
48
+ "timeouts": {
49
+ "http_seconds": 10,
50
+ "wait_seconds": 15,
51
+ "poll_ms": 400,
52
+ "app_seconds": 90
53
+ },
54
+ "logs": {
55
+ "max_lines": 50,
56
+ "max_lines_kept": 200000,
57
+ "network_pattern": "(^|[^A-Za-z0-9_])(GET|POST|PUT|PATCH|DELETE)[[:space:]]+(https?://|/)|HTTP/[0-9]",
58
+ "exclude_subsystems": "AXRuntime|UIAccessibility|AccessibilityUtilities|CoreMotion|UIKitCore|RunningBoardServices|CoreFoundation|CoreBrightness|CoreBluetooth|CoreTelephony|WirelessProximity|BackBoardHIDEventProcessors|MediaSafetyNet|WPDaemon|libsystem_network"
59
+ },
60
+ "scroll": {
61
+ "max_swipes": 15
62
+ }
63
+ }
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env bash
2
+ # agent-pointer.sh — put a pointer to DeviceTools into another repository.
3
+ #
4
+ # integrations/agent-pointer.sh <target-repo> [claude|cursor|gemini|codex|mcp|all] [--force]
5
+ #
6
+ # Each pointer says where AGENTS.md is and nothing else, so there is nothing
7
+ # here that can drift out of date with the contract. {{DEVICETOOLS}} is replaced
8
+ # with this checkout's absolute path, so the pointer resolves from anywhere.
9
+ #
10
+ # `mcp` is deliberately not part of `all`. A host that can run shell commands
11
+ # already has everything through the pointer; adding the MCP server as well
12
+ # gives it two ways to do the same thing and a decision to make on every step.
13
+ # Install it for the hosts that have no shell.
14
+ #
15
+ # An existing file is never overwritten without --force: the Codex pointer lands
16
+ # on AGENTS.md, and a repository may well already have one of its own.
17
+ set -euo pipefail
18
+
19
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20
+ ROOT="$(cd "$HERE/.." && pwd)"
21
+ TARGET="${1-}"; HOST="${2-all}"; FORCE=0
22
+ [ "${3-}" = --force ] && FORCE=1
23
+ [ "$HOST" = --force ] && { HOST=all; FORCE=1; }
24
+
25
+ usage() { echo "usage: agent-pointer.sh <target-repo> [claude|cursor|gemini|codex|mcp|all] [--force]" >&2; exit 1; }
26
+ [ -n "$TARGET" ] || usage
27
+ [ -d "$TARGET" ] || { echo "no such directory: $TARGET" >&2; exit 1; }
28
+
29
+ place() { # place <template> <destination-relative-to-target>
30
+ local dest="$TARGET/$2"
31
+ if [ -e "$dest" ] && [ "$FORCE" -eq 0 ]; then
32
+ echo "SKIP $2 — already exists (--force to replace)"
33
+ return 0
34
+ fi
35
+ mkdir -p "$(dirname "$dest")"
36
+ sed "s|{{DEVICETOOLS}}|$ROOT|g" "$HERE/$1" > "$dest"
37
+ echo "OK $2"
38
+ }
39
+
40
+ did=0
41
+ case "$HOST" in claude|all) place claude/SKILL.md .claude/skills/devicetools/SKILL.md; did=1 ;; esac
42
+ case "$HOST" in cursor|all) place cursor/devicetools.mdc .cursor/rules/devicetools.mdc; did=1 ;; esac
43
+ case "$HOST" in gemini|all) place gemini/GEMINI.md GEMINI.md; did=1 ;; esac
44
+ # Codex, Zed, Copilot and Jules all read AGENTS.md natively.
45
+ case "$HOST" in codex|all) place gemini/GEMINI.md AGENTS.md; did=1 ;; esac
46
+ case "$HOST" in mcp) place mcp/mcp.json .mcp.json; did=1 ;; esac
47
+
48
+ [ "$did" -eq 1 ] || { echo "unknown host: $HOST" >&2; usage; }
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: devicetools
3
+ description: Operate and inspect a real iOS or Android app on a physical device — read the screen with its geometry, tap, type, watch what the app logs. Use when asked to check how something looks or behaves in the app, to reproduce a UI bug, or to verify a change on the device after building it.
4
+ ---
5
+
6
+ Read `{{DEVICETOOLS}}/AGENTS.md`. It is the contract and it is complete.
7
+
8
+ Then run `{{DEVICETOOLS}}/scripts/doctor.sh` before your first action.
@@ -0,0 +1,8 @@
1
+ ---
2
+ description: Drive and inspect a real mobile app on a physical device — read the screen, tap, type, watch the logs.
3
+ alwaysApply: false
4
+ ---
5
+
6
+ Read `{{DEVICETOOLS}}/AGENTS.md`. It is the contract and it is complete.
7
+
8
+ Then run `{{DEVICETOOLS}}/scripts/doctor.sh` before your first action.
@@ -0,0 +1,5 @@
1
+ # DeviceTools
2
+
3
+ Read `{{DEVICETOOLS}}/AGENTS.md`. It is the contract and it is complete.
4
+
5
+ Then run `{{DEVICETOOLS}}/scripts/doctor.sh` before your first action.
@@ -0,0 +1,184 @@
1
+ # DeviceTools over MCP
2
+
3
+ `server.sh` exposes the verbs to any host that speaks MCP over stdio. It is a
4
+ wrapper and nothing else: it runs `scripts/<verb>.sh` and hands back stdout and
5
+ the exit code, unchanged.
6
+
7
+ **If your host can run shell commands, you may not need this.** Point it at
8
+ `AGENTS.md` with `integrations/agent-pointer.sh` and it will drive DeviceTools directly,
9
+ with one less process in the way. MCP is for hosts that cannot: a desktop chat
10
+ client, an IDE panel, an agent framework with no shell — and for the one thing
11
+ the shell cannot do, which is hand back an image.
12
+
13
+ ## Registering it
14
+
15
+ Claude Code, from the repository you are working in:
16
+
17
+ ```sh
18
+ claude mcp add devicetools -- /path/to/devicetools/integrations/mcp/server.sh
19
+ ```
20
+
21
+ Or write a project-scoped `.mcp.json` — the same file, filled in for you:
22
+
23
+ ```sh
24
+ integrations/agent-pointer.sh /path/to/your/repo mcp
25
+ ```
26
+
27
+ Claude Desktop, Cursor and anything else that reads an `mcpServers` block take
28
+ the same shape; `mcp.json` in this directory is the template.
29
+
30
+ ```json
31
+ {
32
+ "mcpServers": {
33
+ "devicetools": {
34
+ "command": "/path/to/devicetools/integrations/mcp/server.sh",
35
+ "env": { "DEVICETOOLS_CONFIG": "/path/to/devicetools/config.json" }
36
+ }
37
+ }
38
+ }
39
+ ```
40
+
41
+ ## Eighteen tools
42
+
43
+ Each one's schema, generated from this server: [`docs/tool-reference.md`](../../docs/tool-reference.md).
44
+
45
+ | Tool | What it is for |
46
+ | --- | --- |
47
+ | `doctor` | device, driver and app all reachable. First call of every session |
48
+ | `devices` | what is attached, and which one the config selects |
49
+ | `open` | launch, relaunch, kill or reset the app, or open a URL in it |
50
+ | `snapshot` | **the default way to see.** Numbered elements with rectangles, plus geometry warnings |
51
+ | `screenshot` | an image, for what a tree cannot say — icon, colour, gradient |
52
+ | `measure` | one element in full: parent, neighbours, gaps, overlaps, hit area |
53
+ | `tap` | a uid, or a selector. Refuses rather than guesses |
54
+ | `type` | type into the focused field, and verify something changed |
55
+ | `swipe` / `scroll` | gestures; `scroll` can run until something appears |
56
+ | `key` | put the keyboard away, so `tap` stops refusing |
57
+ | `back` | one screen back; fails if the screen does not move |
58
+ | `wait` | until something appears or disappears |
59
+ | `logs` | the Console panel. `action=start` once per session to begin collecting |
60
+ | `waypoint` | mark where you are; `goto` walks back after a reinstall |
61
+ | `settings` | appearance, text size, orientation — conditions invisible in the tree |
62
+ | `permission` | grant, revoke or reset a system permission |
63
+
64
+ `setup` is deliberately absent. It needs an Xcode sign-in and a device
65
+ registration that must not be faked, and an agent should not be running it
66
+ unattended. Run it from a terminal, once.
67
+
68
+ ### A tool's name is its script's name
69
+
70
+ `snapshot` runs `scripts/snapshot.sh`. There is no mapping table anywhere in
71
+ this directory, because a mapping table is a second list to keep in sync and it
72
+ would not be kept in sync. A verb that does not exist as a script is not a tool.
73
+
74
+ ### Why eighteen, when it used to be two
75
+
76
+ The earlier design sent one `devicetools` tool with the verb as an enum, plus a
77
+ `devicetools_docs` tool, and the reasoning was sound: a schema is billed on every
78
+ request, and the whole surface fitted in 1.8 KB against roughly 10 KB for
79
+ per-verb tools.
80
+
81
+ It was right for the job at the time — a long authoring session that could
82
+ afford to read a 30 KB contract before its first action. It is wrong for this
83
+ one. The job now is a handful of calls in the middle of somebody's debugging,
84
+ where fetching documentation before acting is a wasted round trip, and where a
85
+ verb passed as a bare enum string invites wrong arguments with no schema to
86
+ catch them.
87
+
88
+ Measured, not estimated: the eighteen schemas come to **8,730 bytes**, about
89
+ 1,700 tokens, on every request. That is the price of the agent being right on
90
+ its first call.
91
+
92
+ `devicetools_docs` and the `devicetools://agents.md` resource went with the enum. They
93
+ existed to compensate for a surface that did not describe itself.
94
+
95
+ ## Arguments are an object
96
+
97
+ ```json
98
+ {"name": "tap", "arguments": {"target": "7"}}
99
+ {"name": "snapshot", "arguments": {"grep": "Đăng nhập"}}
100
+ {"name": "screenshot", "arguments": {"full": true}}
101
+ ```
102
+
103
+ The server turns that into a command line. `action`, `name`, `uid`, `target`,
104
+ `text`, `direction`, `key` and `value` are positional, in that order, because
105
+ that is the order the scripts read them in; everything else becomes
106
+ `--key value`. A boolean becomes the presence or absence of `--key` — never
107
+ `--key false`, which every one of these scripts would read as a stray positional
108
+ argument and refuse.
109
+
110
+ Nothing passes through a shell. Arguments are emitted NUL-separated and read
111
+ straight into an array, so an argument is an argument whatever is in it: no
112
+ quoting to satisfy, none to get wrong, and no command injection surface. The app
113
+ on the other end of this is somebody's production app.
114
+
115
+ ## What comes back
116
+
117
+ stdout, verbatim. On a non-zero exit, stderr and a final line naming the code:
118
+
119
+ ```
120
+ uid 999 was never assigned — the last snapshot printed 41 element(s)
121
+ EXIT 4 — the command ran but the screen did not satisfy it
122
+ ```
123
+
124
+ `isError` is set for every non-zero exit, including 4. Those are ordinary
125
+ outcomes rather than faults — the host marks them, and the model reads the text
126
+ and decides what they mean.
127
+
128
+ | Code | Meaning |
129
+ | --- | --- |
130
+ | 0 | done |
131
+ | 1 | usage error |
132
+ | 2 | environment or configuration problem |
133
+ | 3 | device unreachable — run `doctor` |
134
+ | 4 | the command ran but the screen did not satisfy it |
135
+ | 124 | the verb was killed after `DEVICETOOLS_MCP_TIMEOUT` (default 300s) |
136
+
137
+ 124 is the one code that does not exist on the command line. macOS ships no
138
+ `timeout`, so the server carries its own: a hung verb is killed along with
139
+ whatever it spawned, rather than wedging the host until someone notices.
140
+
141
+ There is no 5.
142
+
143
+ ### An image comes back as an image
144
+
145
+ `screenshot` prints a filesystem path, which is a string a model cannot open.
146
+ When a verb's entire output is one line naming a readable `.png`, the server
147
+ sends MCP `image` content instead of the path.
148
+
149
+ The rule is written against the output rather than against the verb's name, so
150
+ there is no second list of "verbs that produce images" to fall out of date.
151
+
152
+ ## Checking it without a device
153
+
154
+ ```sh
155
+ integrations/mcp/selftest.sh config.json
156
+ ```
157
+
158
+ Sixteen checks over the handshake, the tool list, the argument mapping, the
159
+ exit-code contract and the ways a request can be malformed. The two verbs it
160
+ runs touch nothing: `waypoint list` reads a file, and `measure abc` fails on its
161
+ argument before it loads a config. Both answer the same on a machine with no
162
+ phone attached, which is what lets this run in CI.
163
+
164
+ Run it after touching anything in this directory — the protocol layer is the one
165
+ part of DeviceTools that a device cannot tell you is broken.
166
+
167
+ ## Requests are handled one at a time
168
+
169
+ There is one device on the other end. Two verbs touching one screen at once is a
170
+ race with a real tap in it, so the loop is serial by construction.
171
+
172
+ ## Diagnostics
173
+
174
+ stdout carries JSON-RPC and nothing else. Everything else goes to stderr, which
175
+ the host keeps in its MCP log:
176
+
177
+ ```
178
+ device-devtools-mcp: ready — /path/to/devicetools, config config.json, timeout 300s
179
+ device-devtools-mcp: tap (1 args) -> 0
180
+ device-devtools-mcp: screenshot -> image, 110856 bytes
181
+ ```
182
+
183
+ The count, not the arguments. `type` carries whatever is typed, and on this app
184
+ that is a credential.
@@ -0,0 +1,10 @@
1
+ {
2
+ "mcpServers": {
3
+ "devicetools": {
4
+ "command": "{{DEVICETOOLS}}/integrations/mcp/server.sh",
5
+ "env": {
6
+ "DEVICETOOLS_CONFIG": "{{DEVICETOOLS}}/config.json"
7
+ }
8
+ }
9
+ }
10
+ }