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,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" "$@"
|
package/scripts/flow.sh
ADDED
|
@@ -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
|
package/scripts/init.sh
ADDED
|
@@ -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")"
|