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.
- package/AGENTS.md +327 -0
- package/LICENSE +21 -0
- package/README.md +491 -0
- package/VERSION +1 -0
- package/bin/device-devtools-mcp.js +106 -0
- package/bin/devicetools +315 -0
- package/config.example.json +63 -0
- package/integrations/agent-pointer.sh +48 -0
- package/integrations/claude/SKILL.md +8 -0
- package/integrations/cursor/devicetools.mdc +8 -0
- package/integrations/gemini/GEMINI.md +5 -0
- package/integrations/mcp/README.md +184 -0
- package/integrations/mcp/mcp.json +10 -0
- package/integrations/mcp/reference.sh +119 -0
- package/integrations/mcp/selftest.sh +158 -0
- package/integrations/mcp/server.sh +343 -0
- package/package.json +50 -0
- package/scripts/android/app.sh +241 -0
- package/scripts/android/back.sh +73 -0
- package/scripts/android/controls.sh +56 -0
- package/scripts/android/devices.sh +77 -0
- package/scripts/android/doctor.sh +253 -0
- package/scripts/android/lib.sh +699 -0
- package/scripts/android/logs.sh +289 -0
- package/scripts/android/permission.sh +119 -0
- package/scripts/android/settings.sh +63 -0
- package/scripts/android/setup.sh +97 -0
- package/scripts/android/tree.awk +166 -0
- package/scripts/android/tree.sh +127 -0
- package/scripts/android/type.sh +191 -0
- package/scripts/common/find.sh +95 -0
- package/scripts/common/key.sh +65 -0
- package/scripts/common/lib.sh +14 -0
- package/scripts/common/measure.sh +170 -0
- package/scripts/common/open.sh +131 -0
- package/scripts/common/screenshot.sh +102 -0
- package/scripts/common/scroll.sh +177 -0
- package/scripts/common/snapshot.sh +69 -0
- package/scripts/common/swipe.sh +171 -0
- package/scripts/common/tap.sh +226 -0
- package/scripts/common/wait.sh +212 -0
- package/scripts/common/waypoint.sh +141 -0
- package/scripts/dispatch.sh +21 -0
- package/scripts/flow.sh +266 -0
- package/scripts/init.sh +101 -0
- package/scripts/ios/app.sh +404 -0
- package/scripts/ios/back.sh +95 -0
- package/scripts/ios/controls.sh +68 -0
- package/scripts/ios/devices.sh +80 -0
- package/scripts/ios/doctor.sh +386 -0
- package/scripts/ios/lib.sh +864 -0
- package/scripts/ios/logs.sh +272 -0
- package/scripts/ios/permission.sh +108 -0
- package/scripts/ios/settings.sh +76 -0
- package/scripts/ios/setup.sh +175 -0
- package/scripts/ios/tree.sh +128 -0
- package/scripts/ios/type.sh +178 -0
- package/scripts/lib.sh +1032 -0
- package/scripts/links.tsv +44 -0
- package/scripts/relink.sh +121 -0
- package/scripts/run.sh +415 -0
- package/scripts/selftest.sh +1709 -0
- package/scripts/snapshot.awk +362 -0
- package/scripts/verify-npm-package.js +133 -0
- package/tests/fixtures/ios-contacts-list.expected +52 -0
- package/tests/fixtures/ios-contacts-list.rows +140 -0
package/bin/devicetools
ADDED
|
@@ -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,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.
|