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,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"