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,141 @@
1
+ #!/usr/bin/env bash
2
+ # waypoint.sh — name where you are, and come back to it after a rebuild.
3
+ #
4
+ # waypoint.sh mark <name>
5
+ # waypoint.sh goto <name>
6
+ # waypoint.sh list
7
+ # waypoint.sh forget <name>
8
+ #
9
+ # Installing a new build puts the app back on its first screen. Walking a login
10
+ # and three taps back to the screen you were debugging costs more than the code
11
+ # change did, and an agent doing it from conversational memory does it
12
+ # differently the fourth time.
13
+ #
14
+ # `mark` names the current position in the session journal, which every landed
15
+ # action appends to. `goto` relaunches the app and replays the actions up to
16
+ # that position.
17
+ #
18
+ # WHAT THIS IS NOT. There are no assertions here, nothing scores anything, and
19
+ # nothing is rescued. When a step no longer resolves, goto stops and says which
20
+ # step and what it was looking for. The fingerprint machinery was deleted
21
+ # deliberately and this is not it wearing a different name — the answer to a
22
+ # changed screen is snapshot.sh, which costs almost nothing.
23
+ #
24
+ # exit 0 done
25
+ # exit 1 usage error
26
+ # exit 2 nothing is marked under that name, or the mark file is unwritable
27
+ # exit 3 the app could not be relaunched
28
+ # exit 4 replay stopped part-way
29
+
30
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
31
+
32
+ CMD="${1-}"
33
+ case "$CMD" in
34
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
35
+ esac
36
+ [ -n "$CMD" ] || die "missing subcommand (usage: waypoint.sh mark <name> | goto <name> | list | forget <name>)" 1
37
+ shift
38
+
39
+ load_config
40
+ MARKS="$(state_dir)/waypoints.json"
41
+ [ -f "$MARKS" ] || printf '{}' > "$MARKS"
42
+ JOURNAL="$(journal_path)"
43
+
44
+ need_name() {
45
+ [ -n "${1-}" ] || die "waypoint.sh $CMD needs a name" 1
46
+ case "$1" in
47
+ */*|..*) die "a waypoint name must not contain '/' or start with '..': $1" 1 ;;
48
+ esac
49
+ }
50
+
51
+ case "$CMD" in
52
+ mark)
53
+ need_name "${1-}"
54
+ [ $# -eq 1 ] || die "too many arguments (usage: waypoint.sh mark <name>)" 1
55
+ n=0
56
+ [ -f "$JOURNAL" ] && n="$(wc -l < "$JOURNAL" | tr -d ' ')"
57
+ tmp="$MARKS.new"
58
+ jq --arg k "$1" --argjson v "$n" '.[$k] = $v' "$MARKS" > "$tmp" \
59
+ && mv "$tmp" "$MARKS" || die "could not write $MARKS" 2
60
+ printf 'OK waypoint "%s" — %s action(s) between the app launching and here\n' "$1" "$n"
61
+ ;;
62
+
63
+ forget)
64
+ need_name "${1-}"
65
+ [ $# -eq 1 ] || die "too many arguments (usage: waypoint.sh forget <name>)" 1
66
+ jq -e --arg k "$1" 'has($k)' "$MARKS" >/dev/null 2>&1 \
67
+ || die "no waypoint called '$1'" 2
68
+ tmp="$MARKS.new"
69
+ jq --arg k "$1" 'del(.[$k])' "$MARKS" > "$tmp" && mv "$tmp" "$MARKS" \
70
+ || die "could not write $MARKS" 2
71
+ printf 'OK waypoint "%s" forgotten\n' "$1"
72
+ ;;
73
+
74
+ list)
75
+ [ $# -eq 0 ] || die "waypoint.sh list takes no arguments" 1
76
+ if [ "$(jq -r 'length' "$MARKS")" = "0" ]; then
77
+ printf 'OK no waypoints yet — waypoint.sh mark <name> makes one\n'
78
+ exit 0
79
+ fi
80
+ jq -r 'to_entries[] | "\(.key)\t\(.value) action(s)"' "$MARKS" | sort
81
+ ;;
82
+
83
+ goto)
84
+ need_name "${1-}"
85
+ [ $# -eq 1 ] || die "too many arguments (usage: waypoint.sh goto <name>)" 1
86
+ n="$(jq -r --arg k "$1" '.[$k] // empty' "$MARKS")"
87
+ [ -n "$n" ] || die "no waypoint called '$1' — waypoint.sh list shows what there is" 2
88
+ [ -f "$JOURNAL" ] || die "the session journal is empty; there is nothing to replay" 2
89
+ if [ "$n" -eq 0 ]; then
90
+ "$DT_HOME/scripts/app.sh" relaunch >/dev/null \
91
+ || die "could not relaunch the app — run $(as_cmd doctor)" 3
92
+ printf 'OK arrived at "%s" — it was marked at the first screen, so relaunching was the whole journey\n' "$1"
93
+ exit 0
94
+ fi
95
+
96
+ "$DT_HOME/scripts/app.sh" relaunch >/dev/null \
97
+ || die "could not relaunch the app — run $(as_cmd doctor)" 3
98
+
99
+ # Replaying must not append to the journal it is reading, or every goto
100
+ # doubles the path and the second one replays the first one's replay.
101
+ export DEVICETOOLS_JOURNAL=/dev/null
102
+
103
+ i=0
104
+ while IFS= read -r line; do
105
+ i=$((i + 1))
106
+ [ "$i" -le "$n" ] || break
107
+ verb="$(printf '%s' "$line" | jq -r '.verb')"
108
+ label="$(printf '%s' "$line" | jq -r '.label')"
109
+ eid="$(printf '%s' "$line" | jq -r '.id')"
110
+ args="$(printf '%s' "$line" | jq -r '.args')"
111
+
112
+ # The recorded identifier is preferred over the recorded label: a label is
113
+ # application copy and gets rewritten between builds, an identifier is
114
+ # chosen by an engineer and rarely does. Neither is scored — if the chosen
115
+ # one does not resolve, this stops.
116
+ if [ -n "$eid" ]; then sel="id:$eid"
117
+ elif [ -n "$label" ]; then sel="label:$label"
118
+ else sel="$args"
119
+ fi
120
+
121
+ rc=0
122
+ case "$verb" in
123
+ tap) "$DT_HOME/scripts/tap.sh" "$sel" >/dev/null 2>&1 || rc=$? ;;
124
+ type) "$DT_HOME/scripts/type.sh" "$args" >/dev/null 2>&1 || rc=$? ;;
125
+ back) "$DT_HOME/scripts/back.sh" >/dev/null 2>&1 || rc=$? ;;
126
+ scroll) "$DT_HOME/scripts/scroll.sh" $args >/dev/null 2>&1 || rc=$? ;;
127
+ swipe) "$DT_HOME/scripts/swipe.sh" $args >/dev/null 2>&1 || rc=$? ;;
128
+ open) "$DT_HOME/scripts/open.sh" "$args" >/dev/null 2>&1 || rc=$? ;;
129
+ *) rc=0 ;;
130
+ esac
131
+ if [ "$rc" -ne 0 ]; then
132
+ die "replay stopped at step $i of $n: '$verb $sel' no longer resolves (exit $rc). The screen has changed since this path was recorded — read it with snapshot.sh and walk the rest yourself" 4
133
+ fi
134
+ printf 'REPLAY %s/%s %s %s\n' "$i" "$n" "$verb" "$sel"
135
+ done < "$JOURNAL"
136
+
137
+ printf 'OK arrived at "%s" after %s step(s)\n' "$1" "$n"
138
+ ;;
139
+
140
+ *) die "unknown subcommand '$CMD' (usage: waypoint.sh mark <name> | goto <name> | list | forget <name>)" 1 ;;
141
+ esac
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ # DeviceTools — verb dispatcher.
3
+ #
4
+ # Every scripts/<verb>.sh is a symlink to this file. Invoked as scripts/tap.sh,
5
+ # it resolves the configured platform and execs scripts/<platform>/tap.sh.
6
+ #
7
+ # Callers therefore use one stable path per verb and never name a platform, and
8
+ # adding Android is a matter of dropping scripts/android/ into place. Symlinks
9
+ # rather than nine near-identical wrapper files: two copies of a dispatch rule
10
+ # is one copy too many, and nine is worse.
11
+
12
+ source "$(dirname "$0")/lib.sh"
13
+ load_config
14
+
15
+ verb="$(basename "$0")"
16
+ target="$(adapter_dir)/$verb"
17
+
18
+ [ -x "$target" ] \
19
+ || die "verb '${verb%.sh}' is not implemented for platform '$(platform)' — expected $target" 2
20
+
21
+ exec "$target" "$@"
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env bash
2
+ # flow.sh — a batch worth keeping, under a name.
3
+ #
4
+ # flow.sh save <name> --steps '<json array>' [--description S]
5
+ # flow.sh run <name> [--var NAME=VALUE]... [--vars '<json>'] [--dry-run]
6
+ # [--snapshot] [--logs]
7
+ # flow.sh <name> the same as `run <name>`
8
+ # flow.sh list
9
+ # flow.sh show <name>
10
+ # flow.sh delete <name>
11
+ #
12
+ # WHY A NAME
13
+ #
14
+ # Every flow in an application starts by logging in. Written out each time, that
15
+ # is the same twelve steps rebuilt from scratch on every session, by a caller
16
+ # that has to rediscover the screens to write them. `run` made those twelve
17
+ # steps one call; this makes them one word.
18
+ #
19
+ # WHERE THEY LIVE, AND WHY IT IS NOT config.json
20
+ #
21
+ # flows/<name>.json inside the project — paths.flows_dir moves it. A flow is
22
+ # knowledge about the app: that logging in means these steps, on these screens,
23
+ # in this order. That belongs in the app's repository, next to the app, reviewed
24
+ # like the code it describes, and shared with everyone working on it. In
25
+ # config.json it would be per-machine, gitignored, and known only to whoever
26
+ # wrote it.
27
+ #
28
+ # WHAT MAKES IT MORE THAN A RECORDED MACRO
29
+ #
30
+ # Parameters. Credentials are {{ name }} and are filled at run time from --var,
31
+ # from DEVICETOOLS_VAR_<NAME>, or from .vars in the config. A password written
32
+ # into the file is a password in the repository. The names a flow needs are
33
+ # recorded when it is saved — read out of its own steps, not declared by hand —
34
+ # and a run that cannot supply one is refused before it touches the device.
35
+ #
36
+ # A starting state it can check. On the app this was built against, "log in"
37
+ # has three different routes depending on whether the device is already
38
+ # activated, and there is no way to tell them apart except by looking. So a
39
+ # flow's first step should be an assertion:
40
+ #
41
+ # {"expect": "screen:Auth.LoginView"}
42
+ #
43
+ # which fails in three seconds and says which screen it is actually on, instead
44
+ # of tapping into a screen it was not written for and producing a session's
45
+ # worth of confident nonsense. `expect` is `wait` with a deadline that means
46
+ # "now" — the same script, one spelling for each of the two things a caller
47
+ # means by it.
48
+ #
49
+ # Composition. A step of {"flow": "login"} runs a saved flow inside this one, so
50
+ # `activate` is `login` plus the OTP, and `goto-settings` is `login` plus one
51
+ # tap, rather than three copies of logging in. Variables pass down. Nesting is
52
+ # capped, because a flow that includes itself would otherwise drive the phone
53
+ # until somebody noticed.
54
+ #
55
+ # NOT RECORDED FROM WHAT SOMEBODY DID. There is no `flow record`, deliberately.
56
+ # Watching a person tap produces a list of coordinates, which is the one
57
+ # spelling guaranteed to break the first time the layout moves. A flow is
58
+ # written in selectors, by somebody who decided what the step means.
59
+ #
60
+ # Everything `run` understands is handed straight through — --var, --vars,
61
+ # --dry-run, --snapshot, --logs — rather than re-implemented here. Two parsers
62
+ # for --var is two places for it to mean something slightly different.
63
+ #
64
+ # exit 0 done
65
+ # exit 1 usage error, or no such flow
66
+ # exit 2 the flow file is unusable
67
+ # exit * from `run`, the exit code of the step that stopped it
68
+
69
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
70
+
71
+ # A flow that includes itself is a phone being driven until somebody notices.
72
+ # Three is deeper than any real composition — login, then activate, then the
73
+ # thing you actually wanted — and shallow enough to stop a cycle quickly.
74
+ DT_FLOW_DEPTH="${DT_FLOW_DEPTH:-0}"
75
+ [ "$DT_FLOW_DEPTH" -lt 3 ] \
76
+ || die "flows are nested three deep already — one of them includes itself" 1
77
+ export DT_FLOW_DEPTH=$((DT_FLOW_DEPTH + 1))
78
+
79
+ # The subcommands, which is also the list of names a flow may not have: with
80
+ # `flow.sh <name>` meaning `run <name>`, a flow called "list" could never be run.
81
+ SUBCOMMANDS="save run list show delete"
82
+
83
+ ACTION=""
84
+ NAME=""
85
+ STEPS=""
86
+ DESCRIPTION=""
87
+ PASS=()
88
+
89
+ while [ $# -gt 0 ]; do
90
+ case "$1" in
91
+ --steps)
92
+ [ $# -ge 2 ] || die "--steps needs a JSON array, or @file" 1
93
+ case "$2" in
94
+ @*) STEPS="$(cat "$(expand_path "${2#@}")" 2>/dev/null || true)"
95
+ [ -n "$STEPS" ] || die "could not read the step list at ${2#@}" 1 ;;
96
+ *) STEPS="$2" ;;
97
+ esac
98
+ shift 2 ;;
99
+ --description)
100
+ [ $# -ge 2 ] || die "--description needs a line of text" 1
101
+ DESCRIPTION="$2"; shift 2 ;;
102
+ # Everything run.sh understands is handed straight through rather than
103
+ # re-implemented here. Two parsers for --var is two places for it to mean
104
+ # something slightly different.
105
+ --var|--vars)
106
+ [ $# -ge 2 ] || die "$1 needs a value" 1
107
+ PASS+=("$1" "$2"); shift 2 ;;
108
+ --dry-run|--snapshot|--logs) PASS+=("$1"); shift ;;
109
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
110
+ -*) die "unknown argument: $1 (usage: flow.sh save|run|list|show|delete)" 1 ;;
111
+ *)
112
+ if [ -z "$ACTION" ]; then
113
+ case " $SUBCOMMANDS " in
114
+ *" $1 "*) ACTION="$1" ;;
115
+ # A bare name is a run. `flow login` is what anybody types, and it is
116
+ # also what a {"flow": "login"} step becomes.
117
+ *) ACTION=run; NAME="$1" ;;
118
+ esac
119
+ elif [ -z "$NAME" ]; then NAME="$1"
120
+ else die "flow.sh $ACTION takes one name, got '$NAME' and '$1'" 1
121
+ fi
122
+ shift ;;
123
+ esac
124
+ done
125
+
126
+ [ -n "$ACTION" ] || die "missing action (usage: flow.sh save|run|list|show|delete)" 1
127
+ load_config
128
+
129
+ DIR="$(flows_dir)"
130
+
131
+ check_name() {
132
+ case "$1" in
133
+ "") die "missing flow name (usage: flow.sh $ACTION <name>)" 1 ;;
134
+ # The name becomes a filename, so it may not be a path. `flow run ../../etc`
135
+ # is not a flow.
136
+ *[!A-Za-z0-9_-]*) die "a flow name may only hold letters, digits, dashes and underscores — got '$1'" 1 ;;
137
+ esac
138
+ return 0
139
+ }
140
+ flow_file() { printf '%s/%s.json' "$DIR" "$1"; }
141
+
142
+ case "$ACTION" in
143
+ save)
144
+ check_name "$NAME"
145
+ case " $SUBCOMMANDS " in
146
+ *" $NAME "*) die "'$NAME' is one of this command's own words ($SUBCOMMANDS), so \`flow $NAME\` could never run it — pick another name" 1 ;;
147
+ esac
148
+ [ -n "$STEPS" ] || die "missing --steps (usage: flow.sh save $NAME --steps '[...]')" 1
149
+ printf '%s' "$STEPS" | jq -e 'type == "array" and length > 0' >/dev/null 2>&1 \
150
+ || die "--steps must be a non-empty JSON array of step objects" 1
151
+
152
+ # VALIDATED BY THE THING THAT WILL RUN IT. A dry run puts the list through
153
+ # run.sh's own checks — one verb per step, no uids, every key known — so a
154
+ # flow cannot be saved in a shape that will be refused the first time
155
+ # somebody in a hurry tries to use it. The placeholders are what a dry run
156
+ # would refuse for want of a value, so they are supplied as themselves.
157
+ names="$(printf '%s' "$STEPS" | jq -r '
158
+ [ .. | strings | match("\\{\\{ *([A-Za-z0-9_]+) *\\}\\}"; "g").captures[0].string ]
159
+ | unique | join(" ")')"
160
+
161
+ # WHAT A NESTED FLOW NEEDS, THIS FLOW NEEDS.
162
+ #
163
+ # `outer` is [{"flow": "login"}, …] and has no placeholder of its own, but it
164
+ # cannot run without login's password. Declaring only its own would make
165
+ # `flow list` under-report it and `flow run outer` fail at the step rather
166
+ # than at the door. Read from the child's own record, so there is one place
167
+ # a flow's requirements are written down.
168
+ for child in $(printf '%s' "$STEPS" | jq -r '
169
+ .[] | select(has("flow")) | .flow
170
+ | if type == "array" then .[-1] else . end'); do
171
+ cf="$(flow_file "$child")"
172
+ [ -f "$cf" ] || continue
173
+ names="$names $(jq -r '(.vars // []) | join(" ")' "$cf" 2>/dev/null || true)"
174
+ done
175
+ names="$(printf '%s' "$names" | tr ' ' '\n' | sort -u | sed '/^$/d' | join_lines ' ')"
176
+
177
+ fake='{}'
178
+ for n in $names; do
179
+ fake="$(printf '%s' "$fake" | jq -c --arg k "$n" '.[$k] = "placeholder"')"
180
+ done
181
+ out="$("$DT_HOME/scripts/run.sh" --dry-run --vars "$fake" --steps "$STEPS" 2>&1)" \
182
+ || die "these steps would not run — $(printf '%s' "$out" | join_lines '; ')" 1
183
+
184
+ mkdir -p "$DIR" || die "could not create $DIR" 2
185
+ f="$(flow_file "$NAME")"
186
+ jq -n --arg n "$NAME" --arg d "$DESCRIPTION" --argjson s "$STEPS" \
187
+ --argjson v "$(jq -cn --arg s "$names" '$s | split(" ") | map(select(. != ""))')" \
188
+ '{name: $n, description: $d, vars: $v, steps: $s}' > "$f.new" \
189
+ || { rm -f "$f.new"; die "could not write $f" 2; }
190
+ mv "$f.new" "$f" || { rm -f "$f.new"; die "could not replace $f" 2; }
191
+
192
+ printf 'OK saved %s — %s step(s)%s\n' "$NAME" \
193
+ "$(printf '%s' "$STEPS" | jq 'length')" \
194
+ "$( [ -n "$names" ] && printf ', needs: %s' "$names" )"
195
+ printf ' %s\n' "$f"
196
+
197
+ # SAYING "IN THE PROJECT" WHEN IT WENT INTO THE TOOL IS THE KIND OF CLAIM
198
+ # THIS WHOLE PROGRAM EXISTS TO NOT MAKE.
199
+ #
200
+ # DT_ROOT falls back to the checkout when no .devicetools marker is found
201
+ # above the working directory, which is most of the time — so a flow saved
202
+ # from anywhere lands inside the DeviceTools installation. That is global:
203
+ # it does not travel with the app's repository, nobody reviews it, and two
204
+ # apps on one machine collide on the name `login`. It still works, so this
205
+ # is a note and not a refusal, but it is said plainly and every time.
206
+ if [ "$DT_ROOT" = "$DT_HOME" ]; then
207
+ # NAMING A REMEDY THE CALLER CANNOT REACH IS WORSE THAN NAMING NONE.
208
+ #
209
+ # This used to say "run `init` in that repository", and over MCP there was
210
+ # no init tool and no working directory to run it in — an agent following
211
+ # its own advice into a dead end. Both halves are now callable from both
212
+ # sides: init takes the directory, every verb takes the project.
213
+ printf 'NOTE that is inside the DeviceTools checkout, not a project — every app on\n'
214
+ printf ' this machine shares it, and it is not in the app repository. Two steps:\n'
215
+ printf ' %s\n' "$(as_cmd init '<the app repository>')"
216
+ printf ' %s save %s %s\n' "$(as_cmd flow)" "$NAME" "$(as_flag project '<the same path>')"
217
+ fi
218
+ ;;
219
+
220
+ run)
221
+ check_name "$NAME"
222
+ f="$(flow_file "$NAME")"
223
+ [ -f "$f" ] || die "no flow called '$NAME' in $DIR — $(as_cmd flow list) shows what there is" 1
224
+ jq -e 'type == "object" and (.steps | type) == "array"' "$f" >/dev/null 2>&1 \
225
+ || die "$f is not a flow file — it needs a .steps array" 2
226
+
227
+ printf 'FLOW %s%s\n' "$NAME" \
228
+ "$(jq -r 'if .description == "" or .description == null then "" else " — " + .description end' "$f")"
229
+
230
+ # run.sh reports which variable is missing, and reports it against the step
231
+ # list rather than against the declaration — which is the true statement,
232
+ # since a declaration can go stale and the steps cannot.
233
+ exec "$DT_HOME/scripts/run.sh" --steps "$(jq -c '.steps' "$f")" ${PASS[@]+"${PASS[@]}"}
234
+ ;;
235
+
236
+ list)
237
+ [ -z "$NAME" ] || die "flow.sh list takes no name" 1
238
+ n=0
239
+ for f in "$DIR"/*.json; do
240
+ [ -f "$f" ] || continue
241
+ n=$((n + 1))
242
+ jq -r '"OK \(.name) \(.steps | length) step(s)"
243
+ + (if (.vars | length) > 0 then " needs: " + (.vars | join(" ")) else "" end)
244
+ + (if .description == "" or .description == null then "" else "\n " + .description end)' \
245
+ "$f" 2>/dev/null || printf 'WARN %s — not a readable flow file\n' "$f"
246
+ done
247
+ [ "$n" -gt 0 ] || printf 'OK no flows yet in %s — %s\n' "$DIR" "$(as_cmd flow 'save <name> --steps ...')"
248
+ ;;
249
+
250
+ show)
251
+ check_name "$NAME"
252
+ f="$(flow_file "$NAME")"
253
+ [ -f "$f" ] || die "no flow called '$NAME' in $DIR — $(as_cmd flow list) shows what there is" 1
254
+ jq . "$f"
255
+ ;;
256
+
257
+ delete)
258
+ check_name "$NAME"
259
+ f="$(flow_file "$NAME")"
260
+ [ -f "$f" ] || die "no flow called '$NAME' in $DIR — nothing to delete" 1
261
+ rm -f "$f" || die "could not remove $f" 2
262
+ printf 'OK deleted %s — %s\n' "$NAME" "$f"
263
+ ;;
264
+
265
+ *) die "unknown action '$ACTION' (usage: flow.sh save|run|list|show|delete)" 1 ;;
266
+ esac
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env bash
2
+ # init.sh — make a directory into a DeviceTools project.
3
+ #
4
+ # init.sh [dir]
5
+ #
6
+ # A project is where config.json, flows/, .state/ and .runs/ live. Without one,
7
+ # all of those resolve into the DeviceTools checkout — which is global: it does
8
+ # not travel with the app's repository, nobody reviews what lands there, and two
9
+ # apps on one machine collide on the name `login`.
10
+ #
11
+ # WHY THIS IS A VERB AND NOT A BRANCH OF bin/devicetools ANY MORE.
12
+ #
13
+ # It was one, and that made it invisible over MCP: an agent was told by
14
+ # `flow save` to run `init` in its repository and had no tool that could. The
15
+ # advice was correct and unreachable, which is worse than no advice. Every verb
16
+ # in scripts/ is callable from both sides; a `case` arm in the command-line
17
+ # wrapper is callable from one.
18
+ #
19
+ # IT WRITES INTO SOMEBODY ELSE'S REPOSITORY, so it never overwrites: an existing
20
+ # .devicetools, config.json or .gitignore entry is reported and left alone. The
21
+ # only new content is a config copied from the example, which is a template
22
+ # nobody has filled in yet.
23
+ #
24
+ # OVER MCP THE DIRECTORY IS REQUIRED. On the command line "no argument" sensibly
25
+ # means "here". A server has no here — its working directory is whatever the
26
+ # host set at spawn time, which is the checkout, so defaulting would quietly
27
+ # initialise DeviceTools itself.
28
+ #
29
+ # exit 0 the directory is a project
30
+ # exit 1 usage error
31
+ # exit 2 something could not be written
32
+
33
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
34
+
35
+ TARGET=""
36
+ while [ $# -gt 0 ]; do
37
+ case "$1" in
38
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
39
+ -*) die "unknown argument: $1 (usage: init.sh [dir])" 1 ;;
40
+ *)
41
+ [ -z "$TARGET" ] || die "init.sh takes one directory, got '$TARGET' and '$1'" 1
42
+ TARGET="$1"; shift ;;
43
+ esac
44
+ done
45
+
46
+ if [ -z "$TARGET" ]; then
47
+ [ -z "${DT_MCP:-}" ] \
48
+ || die "init needs the directory to initialise — there is no working directory on this side of the connection, and defaulting would make the DeviceTools checkout itself the project. Pass the absolute path of the app's repository" 1
49
+ TARGET="$PWD"
50
+ fi
51
+
52
+ TARGET="$(expand_path "$TARGET")"
53
+ [ -d "$TARGET" ] || die "no such directory: $TARGET" 1
54
+ TARGET="$(cd "$TARGET" && pwd)"
55
+
56
+ [ "$TARGET" != "$DT_HOME" ] \
57
+ || die "that is the DeviceTools checkout itself — a project is your app's repository, which is the thing a flow should travel with" 1
58
+
59
+ # THE PROJECT FORMAT THIS BUILD WRITES. A tool upgraded past a project is the
60
+ # failure nobody sees coming: it reads a config in a shape it no longer means
61
+ # and does something plausible with it. The number is what lets a later build
62
+ # refuse rather than half-read.
63
+ PROJECT_FORMAT=1
64
+
65
+ if [ -f "$TARGET/.devicetools" ]; then
66
+ printf 'SKIP .devicetools — already a project\n'
67
+ else
68
+ printf 'devicetools-project %s\n' "$PROJECT_FORMAT" > "$TARGET/.devicetools" \
69
+ || die "could not write $TARGET/.devicetools" 2
70
+ printf 'OK .devicetools\n'
71
+ fi
72
+
73
+ if [ -f "$TARGET/config.json" ]; then
74
+ printf 'SKIP config.json — exists\n'
75
+ else
76
+ cp "$DT_HOME/config.example.json" "$TARGET/config.json" \
77
+ || die "could not write $TARGET/config.json" 2
78
+ printf 'OK config.json — from config.example.json, fill it in\n'
79
+ fi
80
+
81
+ # .runs/ and .state/ are per-machine and can hold screenshots of a real account,
82
+ # and .state/ holds the session journal, which records typed text so that
83
+ # `waypoint goto` can walk back. None of it is something a project should commit.
84
+ if grep -q '^\.runs/' "$TARGET/.gitignore" 2>/dev/null; then
85
+ printf 'SKIP .gitignore — already covers .runs/\n'
86
+ else
87
+ cat >> "$TARGET/.gitignore" <<'EOF'
88
+
89
+ # DeviceTools: per-machine state and run artefacts (screenshots, trees, journals).
90
+ .runs/
91
+ .state/
92
+ config.json
93
+ EOF
94
+ printf 'OK .gitignore — ignores .runs/ .state/ config.json\n'
95
+ fi
96
+
97
+ # flows/ is the opposite: it is the reason to have a project at all, so it is
98
+ # NOT ignored. A flow is knowledge about the app and belongs in the repository.
99
+ printf 'NOTE flows/ is deliberately not ignored — a flow belongs in this repository\n'
100
+ printf 'NEXT fill in %s/config.json, then: %s\n' "$TARGET" "$(as_cmd doctor)"
101
+ printf 'NEXT every verb takes %s, which is how to reach this project\n' "$(as_flag project "$TARGET")"