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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# tree.sh — the accessibility tree, reduced to something an agent can afford to read.
|
|
3
|
+
#
|
|
4
|
+
# tree.sh [--all] [--depth N] [--grep S]
|
|
5
|
+
#
|
|
6
|
+
# One line per element:
|
|
7
|
+
#
|
|
8
|
+
# depth | type | id | label | value | x,y,w,h
|
|
9
|
+
#
|
|
10
|
+
# Raw /source JSON for a single screen measured 107 KB (~27k tokens) on the
|
|
11
|
+
# device this was built against. Reduced, the same screen is ~1.7 KB (~430
|
|
12
|
+
# tokens). Raw JSON must never reach the agent, so this script is the only
|
|
13
|
+
# supported way to read the tree.
|
|
14
|
+
#
|
|
15
|
+
# By default an element is shown when it is visible AND it either carries an
|
|
16
|
+
# identifier, a label or a value, or is of a type a user can act on. An
|
|
17
|
+
# invisible element with a label is noise; an unlabelled button is not, because
|
|
18
|
+
# it can still be reached by coordinate or by structure, and a screen whose only
|
|
19
|
+
# three controls are unlabelled would otherwise read as having nothing to tap.
|
|
20
|
+
#
|
|
21
|
+
# --all every element, no filter and no cap
|
|
22
|
+
# --full do not truncate strings at 48 characters
|
|
23
|
+
# --depth N only elements at depth <= N
|
|
24
|
+
# --grep S case-insensitive substring of id, label, value or type.
|
|
25
|
+
# Searches the whole tree, including elements the default filter
|
|
26
|
+
# hides, so it can find something that is present but off-screen.
|
|
27
|
+
#
|
|
28
|
+
# Output is capped at tree.max_lines from the config and always ends with
|
|
29
|
+
# … N more elements (--all)
|
|
30
|
+
# when truncated, so a partial tree can never be mistaken for a complete one.
|
|
31
|
+
#
|
|
32
|
+
# exit 0 at least one element printed
|
|
33
|
+
# exit 1 usage error
|
|
34
|
+
# exit 3 WebDriverAgent unreachable — run doctor.sh
|
|
35
|
+
# exit 4 nothing matched
|
|
36
|
+
|
|
37
|
+
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
|
38
|
+
|
|
39
|
+
ALL=0
|
|
40
|
+
FULL=0
|
|
41
|
+
DEPTH=-1
|
|
42
|
+
GREP=""
|
|
43
|
+
|
|
44
|
+
while [ $# -gt 0 ]; do
|
|
45
|
+
case "$1" in
|
|
46
|
+
--all) ALL=1; shift ;;
|
|
47
|
+
# Repair needs exact strings. A label cut at 48 characters cannot be turned
|
|
48
|
+
# into a selector that matches, so a caller capturing a failure dump asks for
|
|
49
|
+
# this while everything an agent reads interactively keeps the cap.
|
|
50
|
+
--full) FULL=1; shift ;;
|
|
51
|
+
--depth)
|
|
52
|
+
[ $# -ge 2 ] || die "--depth needs a number" 1
|
|
53
|
+
case "$2" in (*[!0-9]*|"") die "--depth needs a non-negative integer, got: $2" 1 ;; esac
|
|
54
|
+
DEPTH="$2"; shift 2 ;;
|
|
55
|
+
--grep)
|
|
56
|
+
[ $# -ge 2 ] || die "--grep needs a string" 1
|
|
57
|
+
GREP="$2"; shift 2 ;;
|
|
58
|
+
-h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
|
|
59
|
+
*) die "unknown argument: $1 (usage: tree.sh [--all] [--full] [--depth N] [--grep S])" 1 ;;
|
|
60
|
+
esac
|
|
61
|
+
done
|
|
62
|
+
|
|
63
|
+
load_config
|
|
64
|
+
MAXLINES="$(cfg '.tree.max_lines' '60')"
|
|
65
|
+
|
|
66
|
+
src="$(wda_get '/source?format=json')"
|
|
67
|
+
printf '%s' "$src" | jq -e '.value' >/dev/null 2>&1 \
|
|
68
|
+
|| die "WebDriverAgent returned no tree — run $(as_cmd doctor)" 3
|
|
69
|
+
|
|
70
|
+
# One jq pass: flatten, sanitise, filter, format.
|
|
71
|
+
#
|
|
72
|
+
# Labels come from application copy and can contain newlines and pipes, both of
|
|
73
|
+
# which would break the line-oriented contract. They are neutralised here rather
|
|
74
|
+
# than downstream, so every consumer sees the same shape. Long strings are
|
|
75
|
+
# truncated for the same reason the whole script exists: an unbounded label is
|
|
76
|
+
# an unbounded token cost.
|
|
77
|
+
rows="$(printf '%s' "$src" | jq -r --argjson all "$ALL" --argjson depth "$DEPTH" --arg grep "$GREP" --argjson full "$FULL" --arg tappable "$DT_TAPPABLE_TYPES" '
|
|
78
|
+
def clean:
|
|
79
|
+
if . == null then ""
|
|
80
|
+
elif type != "string" then tostring
|
|
81
|
+
else gsub("[\n\r\t]+"; " ") | gsub("\\|"; "/") | gsub(" +"; " ") | ltrimstr(" ") | rtrimstr(" ")
|
|
82
|
+
end;
|
|
83
|
+
def cut($n): if $full == 1 then . elif (. | length) > $n then (.[0:$n] + "…") else . end;
|
|
84
|
+
|
|
85
|
+
def walk($d):
|
|
86
|
+
[ { d: $d,
|
|
87
|
+
t: (.type | clean),
|
|
88
|
+
id: (.rawIdentifier | clean),
|
|
89
|
+
lb: (.label | clean),
|
|
90
|
+
v: (.value | clean),
|
|
91
|
+
x: (.rect.x // 0), y: (.rect.y // 0),
|
|
92
|
+
w: (.rect.width // 0), h: (.rect.height // 0),
|
|
93
|
+
vis: ((.isVisible // "") == "1")
|
|
94
|
+
} ]
|
|
95
|
+
+ ([ (.children // [])[] | walk($d + 1) ] | add // []);
|
|
96
|
+
|
|
97
|
+
(.value | walk(0))
|
|
98
|
+
| map(select($depth < 0 or .d <= $depth))
|
|
99
|
+
| map(select(
|
|
100
|
+
$grep != "" or $all == 1
|
|
101
|
+
or (.vis and (.id != "" or .lb != "" or .v != ""
|
|
102
|
+
or (.t | test("^(" + $tappable + ")$"))))
|
|
103
|
+
))
|
|
104
|
+
| map(select(
|
|
105
|
+
$grep == ""
|
|
106
|
+
or (([.id, .lb, .v, .t] | join(" ") | ascii_downcase)
|
|
107
|
+
| contains($grep | ascii_downcase))
|
|
108
|
+
))
|
|
109
|
+
| .[]
|
|
110
|
+
| "\(.d) | \(.t) | \(.id | cut(48)) | \(.lb | cut(48)) | \(.v | cut(48)) | \(.x),\(.y),\(.w),\(.h)"
|
|
111
|
+
')"
|
|
112
|
+
|
|
113
|
+
if [ -z "$rows" ]; then
|
|
114
|
+
total="$(printf '%s' "$src" | jq -r '[.. | objects | select(has("type"))] | length' 2>/dev/null || echo '?')"
|
|
115
|
+
if [ -n "$GREP" ]; then
|
|
116
|
+
die "no elements matched --grep '$GREP' ($total elements in tree)" 4
|
|
117
|
+
fi
|
|
118
|
+
die "no elements matched (tree has $total elements, try --all)" 4
|
|
119
|
+
fi
|
|
120
|
+
|
|
121
|
+
n="$(printf '%s\n' "$rows" | wc -l | tr -d ' ')"
|
|
122
|
+
|
|
123
|
+
if [ "$ALL" -eq 1 ] || [ "$n" -le "$MAXLINES" ]; then
|
|
124
|
+
printf '%s\n' "$rows"
|
|
125
|
+
else
|
|
126
|
+
printf '%s\n' "$rows" | take_lines "$MAXLINES"
|
|
127
|
+
printf '… %s more elements (--all)\n' "$(( n - MAXLINES ))"
|
|
128
|
+
fi
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# type.sh — type into whatever currently has keyboard focus, then prove it.
|
|
3
|
+
#
|
|
4
|
+
# type.sh <text>
|
|
5
|
+
#
|
|
6
|
+
# Verification is the point of this script. Sending keystrokes is one HTTP call;
|
|
7
|
+
# knowing they landed is what stops an agent from building a chain of reasoning
|
|
8
|
+
# on a field that never received the text.
|
|
9
|
+
#
|
|
10
|
+
# WHY IT VERIFIES BY DIFF RATHER THAN BY FOCUS
|
|
11
|
+
#
|
|
12
|
+
# The obvious implementation reads the focused element and checks its value.
|
|
13
|
+
# That does not work here: WebDriverAgent reports `isFocused` on every element,
|
|
14
|
+
# and on the app this was built against zero elements report it as true even
|
|
15
|
+
# while a field visibly holds the caret. So instead this snapshots the value of
|
|
16
|
+
# every text input before and after typing, and reports which one changed.
|
|
17
|
+
#
|
|
18
|
+
# Verification is tiered, because a masked field cannot echo back what you typed:
|
|
19
|
+
#
|
|
20
|
+
# verified the field now reads exactly what was typed
|
|
21
|
+
# verified by change the field changed but reads something else — a masked
|
|
22
|
+
# secure field, or an input mask reformatting as you go
|
|
23
|
+
# screen changed no text input in the tree at all, which is how OTP and
|
|
24
|
+
# PIN fields are normally built; something moved
|
|
25
|
+
# nothing changed exit 4; the keystrokes went nowhere
|
|
26
|
+
#
|
|
27
|
+
# What was typed is reported as a length, not as text, because this runs against
|
|
28
|
+
# a production app and stdout ends up in CI logs. The field's own contents are
|
|
29
|
+
# echoed only when they differ from what was typed, which is the case where a
|
|
30
|
+
# human debugging a failure genuinely needs to see them.
|
|
31
|
+
#
|
|
32
|
+
# --snapshot prints the screen this left behind and renumbers the uids, so the
|
|
33
|
+
# read that would have been the next call is folded into this one. In a measured
|
|
34
|
+
# session — log in, open Settings — twelve calls did the work and six of them
|
|
35
|
+
# were snapshots taken only to find out what the previous action produced.
|
|
36
|
+
#
|
|
37
|
+
# exit 0 typed, and something changed to prove it
|
|
38
|
+
# exit 1 usage error
|
|
39
|
+
# exit 3 WebDriverAgent unreachable — run doctor.sh
|
|
40
|
+
# exit 4 no keyboard on screen, or nothing changed after typing
|
|
41
|
+
|
|
42
|
+
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
|
43
|
+
|
|
44
|
+
TEXT=""
|
|
45
|
+
HAVE_TEXT=0
|
|
46
|
+
while [ $# -gt 0 ]; do
|
|
47
|
+
case "$1" in
|
|
48
|
+
--snapshot) act_snapshot_on; shift ;;
|
|
49
|
+
-h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
|
|
50
|
+
# Text that begins with a dash is a real thing to type — a negative amount,
|
|
51
|
+
# a date — and so is a mistyped flag. Refusing the ambiguous case and
|
|
52
|
+
# offering `--` is the only reading where a typo cannot end up keyed into a
|
|
53
|
+
# password field.
|
|
54
|
+
--) shift
|
|
55
|
+
[ $# -ge 1 ] || die "-- must be followed by the text to type" 1
|
|
56
|
+
[ "$HAVE_TEXT" -eq 0 ] || die "type.sh takes one text argument; quote text containing spaces" 1
|
|
57
|
+
TEXT="$1"; HAVE_TEXT=1; shift ;;
|
|
58
|
+
-*) die "unknown argument: $1 (usage: type.sh <text> [--snapshot]; use -- before text starting with a dash)" 1 ;;
|
|
59
|
+
*)
|
|
60
|
+
[ "$HAVE_TEXT" -eq 0 ] || die "type.sh takes one text argument; quote text containing spaces" 1
|
|
61
|
+
TEXT="$1"; HAVE_TEXT=1; shift ;;
|
|
62
|
+
esac
|
|
63
|
+
done
|
|
64
|
+
[ "$HAVE_TEXT" -eq 1 ] || die "missing text (usage: type.sh <text> [--snapshot])" 1
|
|
65
|
+
|
|
66
|
+
load_config
|
|
67
|
+
|
|
68
|
+
# rect -> value, one line per text input. The rect is the key because it is the
|
|
69
|
+
# only property of an input that reliably survives the value changing.
|
|
70
|
+
snapshot_inputs() {
|
|
71
|
+
printf '%s' "$1" | jq -r "$JQ_CLEAN"'
|
|
72
|
+
(.value | walk(0))
|
|
73
|
+
| map(select(.vis and .w > 0 and .h > 0))
|
|
74
|
+
| map(select(.t | test("TextField|SecureTextField|TextView|SearchField")))
|
|
75
|
+
| .[] | "\(.x),\(.y),\(.w),\(.h)\t\(.t)\t\(.v)"
|
|
76
|
+
'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Fetches are assigned to their own variable rather than nested inside another
|
|
80
|
+
# substitution. A failure in `f "$(g)"` is discarded — the exit status seen is
|
|
81
|
+
# f's, not g's — so nesting a call that can fail hides the failure.
|
|
82
|
+
src="$(fetch_source)"
|
|
83
|
+
|
|
84
|
+
# THE KEYBOARD IS THE PRECONDITION. A TEXT INPUT IN THE TREE IS NOT.
|
|
85
|
+
#
|
|
86
|
+
# Typing with nothing focused makes WebDriverAgent hang until the HTTP timeout
|
|
87
|
+
# rather than returning an error, so the keyboard is checked first. Ten seconds
|
|
88
|
+
# of silence followed by "stuck on this request" is a much worse answer than
|
|
89
|
+
# this one, and this one is also true.
|
|
90
|
+
if ! printf '%s' "$src" | jq -e '[.. | objects | select(.type? == "Keyboard")] | length > 0' >/dev/null 2>&1; then
|
|
91
|
+
die "no keyboard on screen — tap a field to focus it before typing" 4
|
|
92
|
+
fi
|
|
93
|
+
|
|
94
|
+
# There used to be a second guard above this one: refuse unless the tree holds
|
|
95
|
+
# something recognisable as a text input. It refused a real OTP sheet with the
|
|
96
|
+
# number pad open and the field holding focus.
|
|
97
|
+
#
|
|
98
|
+
# The reason is a pattern, not an accident. An OTP or PIN field is routinely a
|
|
99
|
+
# real UITextField with `alpha = 0` sitting behind drawn boxes, inside a
|
|
100
|
+
# container that sets isAccessibilityElement = true and so collapses its whole
|
|
101
|
+
# subtree into one node. XCUITest skips a zero-alpha view and reports the
|
|
102
|
+
# container, so the tree shows `Other "8 8 8 8"` and no input anywhere. The
|
|
103
|
+
# keystrokes would have landed; the guard was checking for a thing whose absence
|
|
104
|
+
# is normal.
|
|
105
|
+
#
|
|
106
|
+
# So it is gone, and verification below drops a tier rather than refusing.
|
|
107
|
+
before="$(snapshot_inputs "$src")"
|
|
108
|
+
before_digest="$(content_digest "$src")"
|
|
109
|
+
|
|
110
|
+
act_before
|
|
111
|
+
payload="$(jq -nc --arg t "$TEXT" '{value: [$t]}')"
|
|
112
|
+
resp="$(session_post '/wda/keys' "$payload")"
|
|
113
|
+
err="$(session_error "$resp")"
|
|
114
|
+
[ -z "$err" ] || die "typing failed — $err" 3
|
|
115
|
+
|
|
116
|
+
src="$(fetch_source)"
|
|
117
|
+
after="$(snapshot_inputs "$src")"
|
|
118
|
+
after_digest="$(content_digest "$src")"
|
|
119
|
+
|
|
120
|
+
# Which input changed, if any. Keyed on rect, so a field that moved is treated
|
|
121
|
+
# as a different field rather than silently mismatched.
|
|
122
|
+
#
|
|
123
|
+
# The blank-line guard is load bearing: `printf '%s\n' "$empty"` emits one empty
|
|
124
|
+
# line, which awk counts as a record. Without it, an empty "after" manufactures
|
|
125
|
+
# a phantom changed input and reports success.
|
|
126
|
+
changed="$(awk -F'\t' -v OFS='\t' '
|
|
127
|
+
$1 == "" { next }
|
|
128
|
+
NR == FNR { was[$1] = $3; seen[$1] = 1; next }
|
|
129
|
+
{ if (!seen[$1] || was[$1] != $3) print $1, $2, was[$1], $3 }
|
|
130
|
+
' <(printf '%s\n' "$before") <(printf '%s\n' "$after"))"
|
|
131
|
+
|
|
132
|
+
# VERIFICATION DROPS A TIER, IT DOES NOT REFUSE.
|
|
133
|
+
#
|
|
134
|
+
# an input changed the strongest evidence, and the usual case
|
|
135
|
+
# the screen changed no input in the tree, but something moved — which
|
|
136
|
+
# is all an invisible OTP field can ever offer
|
|
137
|
+
# nothing changed at all the keystrokes went nowhere; exit 4
|
|
138
|
+
if [ -n "$changed" ]; then
|
|
139
|
+
n="$(printf '%s\n' "$changed" | wc -l | tr -d ' ')"
|
|
140
|
+
line="$(first_line "$changed")"
|
|
141
|
+
rect="$(printf '%s' "$line" | cut -f1)"
|
|
142
|
+
# NOT `type`. It is a shell builtin, so the failure below read
|
|
143
|
+
# `type: unbound variable` and looked like a missing command rather than a
|
|
144
|
+
# missing value.
|
|
145
|
+
field_type="$(printf '%s' "$line" | cut -f2)"
|
|
146
|
+
now="$(printf '%s' "$line" | cut -f4)"
|
|
147
|
+
|
|
148
|
+
if [ "$now" = "$TEXT" ]; then
|
|
149
|
+
verdict="verified"
|
|
150
|
+
else
|
|
151
|
+
case "$now" in
|
|
152
|
+
*"$TEXT") verdict="verified, appended" ;;
|
|
153
|
+
*) verdict="verified by change only — field reads '$now', which is expected for a masked or reformatting input" ;;
|
|
154
|
+
esac
|
|
155
|
+
fi
|
|
156
|
+
[ "$n" -gt 1 ] && verdict="$verdict ($n inputs changed, reporting the first)"
|
|
157
|
+
act_report TYPE "${#TEXT} char(s) — $field_type at $rect, $verdict"
|
|
158
|
+
elif [ "$before_digest" != "$after_digest" ]; then
|
|
159
|
+
act_report TYPE "${#TEXT} char(s) — no text input in the tree, and the screen changed. Typical of an OTP or PIN field: the value cannot be read back from here, so this is evidence the keystrokes landed, not proof of what they were"
|
|
160
|
+
else
|
|
161
|
+
# THE LENGTH, NOT THE TEXT. The success path has always reported a character
|
|
162
|
+
# count because this drives a production app and stdout ends up in CI logs;
|
|
163
|
+
# the failure path printed the string itself, where a password would have
|
|
164
|
+
# gone. A failure is not a reason to say more than a success may.
|
|
165
|
+
die "typed ${#TEXT} char(s) and nothing on screen changed — the keyboard was up but the keystrokes went nowhere; tap the field again" 4
|
|
166
|
+
fi
|
|
167
|
+
# `${field_type-}`, NOT `$field_type`. This line is shared by all three
|
|
168
|
+
# outcomes, and only the first of them sets it — the OTP path reports "no text
|
|
169
|
+
# input in the tree" precisely because there was none to name. Under `set -u`
|
|
170
|
+
# that printed `type: unbound variable` on stderr after a step that had
|
|
171
|
+
# succeeded. It has been here since before the batch verb existed and only
|
|
172
|
+
# became reachable when `type` stopped being refused inside one.
|
|
173
|
+
#
|
|
174
|
+
# The typed text is recorded, and has to be: `waypoint goto` replays it to walk
|
|
175
|
+
# back to where you were, and it cannot retype a password it does not have. It
|
|
176
|
+
# lives in .state/, which is gitignored and per-machine, and is the one place in
|
|
177
|
+
# this tool that holds a credential in the clear.
|
|
178
|
+
journal_append type "$TEXT" "${field_type-}" "" "" "" 0 0 0 0 "$DT_ACT_HASH"
|