@maxiaochao/pi-toolkit 0.2.6 → 0.4.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/CHANGELOG.md +8 -0
- package/README.md +28 -2
- package/bin/pi-worktree +204 -0
- package/bin/pi-worktree-completion.bash +41 -0
- package/package.json +5 -1
- package/scripts/install-agents.mjs +23 -2
- package/skills/chrome-cdp/LICENSE +21 -0
- package/skills/chrome-cdp/NOTICE +2 -0
- package/skills/chrome-cdp/SKILL.md +77 -0
- package/skills/chrome-cdp/scripts/cdp.mjs +882 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2026-09-11 - v0.4.0
|
|
2
|
+
|
|
3
|
+
- bundle the chrome-cdp skill for attaching to a live Chrome with remote debugging (derived from pasky/chrome-cdp-skill, MIT); pi-worktree: add explicit start subcommand and skip dirty-workspace prompt when reusing an existing worktree
|
|
4
|
+
|
|
5
|
+
## 2026-09-11 - v0.3.0
|
|
6
|
+
|
|
7
|
+
- add pi-worktree CLI: one-command worktree + Pi launch with list/info/out/remove/prune and bash completion (postinstall to ~/.local/bin)
|
|
8
|
+
|
|
1
9
|
## 2026-09-09 - v0.2.6
|
|
2
10
|
|
|
3
11
|
- fix cache export: segment-view tooltip and event markers now show global request numbers (aligned with the x-axis) instead of relative 1-based; e2e asserts real tooltip values
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-toolkit
|
|
2
2
|
|
|
3
|
-
Personal Pi toolkit with the `/todos`, `/btw`, and `/cache_export` extensions, bundled agent skills including Chrome/Chromium automation,
|
|
3
|
+
Personal Pi toolkit with the `/todos`, `/btw`, and `/cache_export` extensions, bundled agent skills including Chrome/Chromium automation, the Night Owl theme, and the `pi-worktree` CLI.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -10,7 +10,7 @@ Install the root package to enable the toolkit extensions globally:
|
|
|
10
10
|
pi install npm:@maxiaochao/pi-toolkit
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
The package also provides the `/btw` side-chat extension, the `web-browser`
|
|
13
|
+
The package also provides the `/btw` side-chat extension, the `web-browser` and `chrome-cdp` skills for Chrome/Chromium CDP automation, and the `nightowl` theme. On npm installation, the postinstall hook selects `nightowl` only when no theme has been configured yet; an existing Pi theme setting is preserved.
|
|
14
14
|
|
|
15
15
|
The package postinstall hook synchronizes the bundled global instructions to:
|
|
16
16
|
|
|
@@ -44,6 +44,21 @@ Use `/cache_export [path]` to write an interactive per-request cache dashboard.
|
|
|
44
44
|
|
|
45
45
|
The bundled `nightowl` theme is available to Pi through the package manifest and is selected automatically on npm installation only when no theme is already configured.
|
|
46
46
|
|
|
47
|
+
## pi-worktree CLI
|
|
48
|
+
|
|
49
|
+
The package also ships a standalone shell command, `pi-worktree`, that wraps Git worktree creation and Pi launch into one step. On installation (Unix-like systems) the postinstall hook copies it to `~/.local/bin/pi-worktree` and installs bash completion to `~/.local/share/bash-completion/completions/pi-worktree` (no `.bashrc` edits needed).
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pi-worktree start feat-ui # create/reuse .worktrees/feat-ui, branch feat-ui, launch pi inside
|
|
53
|
+
pi-worktree list # list worktrees (main checkout marked "main checkout")
|
|
54
|
+
pi-worktree info feat-ui # base commit, ahead/behind main, unique commits
|
|
55
|
+
pi-worktree out # open a shell in the main checkout (for merging)
|
|
56
|
+
pi-worktree remove feat-ui # remove the worktree (refuses unmerged branches)
|
|
57
|
+
pi-worktree prune # drop stale registrations for manually deleted dirs
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Typical loop: `pi-worktree start <name>` to start (Tab completes existing worktree names), commit inside Pi, `pi-worktree out` + `git merge <name>` to land the work, `pi-worktree remove <name>` to clean up. New branches are created from `origin/main` (falling back to `main`, then `HEAD`), and the base commit is recorded in `branch.<name>.base` config so `info` can always tell you where the branch came from. Bash completion covers subcommands and existing worktree names (`pi-worktree feat<Tab>`).
|
|
61
|
+
|
|
47
62
|
The bundled `web-browser` skill provides reusable scripts for starting Chrome/Chromium with remote debugging, navigating tabs, evaluating JavaScript, emulating devices, taking screenshots, dismissing cookie dialogs, and inspecting browser logs. It auto-detects common Chrome/Chromium installations on macOS and Linux, including Windows Chrome when Pi runs under WSL; set `BROWSER_BIN` when the binary is elsewhere.
|
|
48
63
|
|
|
49
64
|
The browser skill can be loaded explicitly with `/skill:web-browser`. Its scripts are available relative to the skill directory, for example:
|
|
@@ -53,6 +68,17 @@ node skills/web-browser/scripts/start.js --headless
|
|
|
53
68
|
node skills/web-browser/scripts/nav.js https://example.com
|
|
54
69
|
```
|
|
55
70
|
|
|
71
|
+
The bundled `chrome-cdp` skill (derived from [pasky/chrome-cdp-skill](https://github.com/pasky/chrome-cdp-skill), MIT) complements `web-browser`: instead of launching an isolated browser, it attaches to a Chrome session that already runs with remote debugging (port pre-allocated at launch, or the `chrome://inspect/#remote-debugging` toggle). It is a zero-dependency CLI (`scripts/cdp.mjs`, Node 22+) with per-tab daemons so Chrome's "Allow debugging" modal fires once:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
export CDP_PORT_FILE="/mnt/c/Users/<you>/AppData/Local/Google/Chrome/CDP-Profile/DevToolsActivePort"
|
|
75
|
+
node skills/chrome-cdp/scripts/cdp.mjs list # list open tabs → target prefix
|
|
76
|
+
node skills/chrome-cdp/scripts/cdp.mjs snap <target> # accessibility tree
|
|
77
|
+
node skills/chrome-cdp/scripts/cdp.mjs eval <target> 'document.title'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Note: Chrome does not always write the `DevToolsActivePort` file (e.g. `--remote-debugging-port` launches on Chrome 152); in that case synthesize one from `GET http://127.0.0.1:<port>/json/version` — first line the port, second line the path of `webSocketDebuggerUrl`. WSL auto-discovery of Windows Chrome profiles is not built in; set `CDP_PORT_FILE` as above.
|
|
81
|
+
|
|
56
82
|
## Tiny Subagent
|
|
57
83
|
|
|
58
84
|
The tiny subagent is a separate npm package. Install it independently when you want the `tiny_subagents` tool:
|
package/bin/pi-worktree
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# pi-worktree: create/reuse a git worktree, then launch pi inside it.
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# pi-worktree start <name> [pi args...] # enter worktree <name> and start pi
|
|
6
|
+
# pi-worktree -l | --list # list worktrees (main checkout marked "main checkout")
|
|
7
|
+
# pi-worktree -i <name> # show base commit, ahead/behind, unique commits
|
|
8
|
+
# pi-worktree -o | --out # cd back to the main checkout
|
|
9
|
+
# pi-worktree -r <name> # remove worktree <name> (must be clean & merged)
|
|
10
|
+
# pi-worktree -p | --prune # drop stale worktree registrations
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
|
|
13
|
+
# Colors: only when stdout is a TTY and NO_COLOR is unset (no-color.org)
|
|
14
|
+
if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
|
|
15
|
+
C_BOLD=$'\e[1m' C_DIM=$'\e[2m' C_RED=$'\e[31m' C_GREEN=$'\e[32m' C_YELLOW=$'\e[33m' C_CYAN=$'\e[36m' C_RESET=$'\e[0m'
|
|
16
|
+
else
|
|
17
|
+
C_BOLD='' C_DIM='' C_RED='' C_GREEN='' C_YELLOW='' C_CYAN='' C_RESET=''
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
ok() { printf '%spi-worktree:%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
|
|
21
|
+
warn() { printf '%s⚠️ %s%s\n' "$C_YELLOW" "$C_RESET" "$*"; }
|
|
22
|
+
die() { printf '%spi-worktree: %s%s\n' "$C_RED" "$C_RESET" "$*" >&2; exit 1; }
|
|
23
|
+
|
|
24
|
+
require_repo() {
|
|
25
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "not inside a git repository"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
main_checkout() {
|
|
29
|
+
git rev-parse --path-format=absolute --git-common-dir | sed 's|/.git$||'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
in_linked_worktree() {
|
|
33
|
+
local git_dir common_dir
|
|
34
|
+
git_dir=$(git rev-parse --git-dir 2>/dev/null) || return 1
|
|
35
|
+
common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || return 1
|
|
36
|
+
[[ "$(cd "$git_dir" && pwd -P)" != "$(cd "$common_dir" && pwd -P)" ]] \
|
|
37
|
+
&& [[ -z "$(git rev-parse --show-superproject-working-tree 2>/dev/null)" ]]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
wt_dir() { echo ".worktrees/$1"; }
|
|
41
|
+
|
|
42
|
+
# Best available base ref: origin/main > main > HEAD
|
|
43
|
+
pick_base() {
|
|
44
|
+
if git show-ref --verify --quiet refs/remotes/origin/main; then
|
|
45
|
+
echo origin/main
|
|
46
|
+
elif git show-ref --verify --quiet refs/heads/main; then
|
|
47
|
+
echo main
|
|
48
|
+
else
|
|
49
|
+
echo HEAD
|
|
50
|
+
fi
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
ensure_gitignore() {
|
|
54
|
+
if ! git check-ignore -q .worktrees 2>/dev/null; then
|
|
55
|
+
printf '.worktrees/\n' >> .gitignore
|
|
56
|
+
ok "added .worktrees/ to .gitignore"
|
|
57
|
+
fi
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
cmd_list() {
|
|
61
|
+
require_repo
|
|
62
|
+
local main_dir; main_dir=$(main_checkout)
|
|
63
|
+
# Annotate the primary checkout and stale (prunable) entries.
|
|
64
|
+
git worktree list --porcelain | awk -v main="$main_dir" \
|
|
65
|
+
-v D="${C_DIM}" -v Y="${C_YELLOW}" -v C="${C_CYAN}" -v R="${C_RESET}" '
|
|
66
|
+
/^worktree / { path=substr($0,10); next }
|
|
67
|
+
/^HEAD / { sha=substr($0,6); next }
|
|
68
|
+
/^branch / { b=substr($0,8); sub("refs/heads/","",b); next }
|
|
69
|
+
/^detached/ { b="(detached)"; next }
|
|
70
|
+
/^bare/ { b="(bare)"; next }
|
|
71
|
+
/^prunable/ { stale=1; next }
|
|
72
|
+
/^$/ {
|
|
73
|
+
if (b=="") b="?"
|
|
74
|
+
mark=(path==main) ? " " D "(main checkout)" R : ""
|
|
75
|
+
branch=(path==main) ? b : C b R
|
|
76
|
+
hint=(stale) ? " " Y "← stale: dir gone, run: pi-worktree prune" R : ""
|
|
77
|
+
printf "%s %s%s%s %s%s%s\n", path, D, substr(sha,1,7), R, branch, mark, hint
|
|
78
|
+
path=""; sha=""; b=""; stale=0
|
|
79
|
+
}
|
|
80
|
+
'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
subcmd_prune() {
|
|
84
|
+
require_repo
|
|
85
|
+
git worktree prune -v
|
|
86
|
+
ok "pruned stale worktree registrations"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
cmd_out() {
|
|
90
|
+
cd "$(main_checkout)" && exec "${SHELL:-/bin/bash}"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
cmd_info() {
|
|
94
|
+
require_repo
|
|
95
|
+
[[ $# -eq 1 ]] || die "usage: pi-worktree -i <name>"
|
|
96
|
+
local dir; dir="$(wt_dir "$1")"
|
|
97
|
+
[[ -d "$dir" ]] || die "worktree not found: $dir"
|
|
98
|
+
local branch; branch=$(git -C "$dir" branch --show-current)
|
|
99
|
+
echo "worktree: $dir"
|
|
100
|
+
if git -C "$dir" diff --quiet 2>/dev/null && git -C "$dir" diff --cached --quiet 2>/dev/null \
|
|
101
|
+
&& [[ -z "$(git -C "$dir" ls-files --others --exclude-standard)" ]]; then
|
|
102
|
+
echo "status: clean"
|
|
103
|
+
else
|
|
104
|
+
echo "status: DIRTY"
|
|
105
|
+
git -C "$dir" status --short | head -10
|
|
106
|
+
fi
|
|
107
|
+
echo "branch: $branch @ $(git -C "$dir" rev-parse --short HEAD)"
|
|
108
|
+
local base baseRef ahead behind
|
|
109
|
+
base=$(git config --get "branch.$branch.base" 2>/dev/null || true)
|
|
110
|
+
baseRef=$(git config --get "branch.$branch.baseRef" 2>/dev/null || true)
|
|
111
|
+
if [[ -n "$base" ]]; then
|
|
112
|
+
echo "based on: $baseRef @ $base"
|
|
113
|
+
ahead=$(git rev-list --count "$base..$branch" 2>/dev/null || echo "?")
|
|
114
|
+
echo "ahead: $ahead commits since base"
|
|
115
|
+
else
|
|
116
|
+
echo "based on: (not recorded; run: git merge-base main $branch)"
|
|
117
|
+
fi
|
|
118
|
+
if git show-ref --verify --quiet refs/heads/main && [[ "$branch" != main ]]; then
|
|
119
|
+
behind=$(git rev-list --count "$branch..main" 2>/dev/null || echo "?")
|
|
120
|
+
echo "behind: $behind commits vs main"$([[ "${behind:-0}" -gt 0 ]] && echo " ← consider rebase")
|
|
121
|
+
fi
|
|
122
|
+
if [[ -n "$base" ]]; then
|
|
123
|
+
echo "unique commits:"
|
|
124
|
+
git log --oneline "$base..$branch" | head -5 || true
|
|
125
|
+
fi
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
cmd_remove() {
|
|
129
|
+
require_repo
|
|
130
|
+
[[ $# -eq 1 ]] || die "usage: pi-worktree -r <name>"
|
|
131
|
+
local dir; dir="$(wt_dir "$1")"
|
|
132
|
+
[[ -d "$dir" ]] || die "worktree not found: $dir"
|
|
133
|
+
# refuse to remove if the branch is not merged into main
|
|
134
|
+
local branch; branch=$(git -C "$dir" branch --show-current)
|
|
135
|
+
if [[ -n "$branch" ]] && ! git merge-base --is-ancestor "$branch" main 2>/dev/null; then
|
|
136
|
+
warn "branch '$branch' does not appear merged into 'main' (or no main branch exists)."
|
|
137
|
+
read -rp "Remove anyway? [y/N] " ans
|
|
138
|
+
[[ "$ans" =~ ^[Yy]$ ]] || die "aborted"
|
|
139
|
+
fi
|
|
140
|
+
git worktree remove "$dir" && ok "removed: $dir"
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
cd_wt() {
|
|
144
|
+
local name="$1"; shift
|
|
145
|
+
require_repo
|
|
146
|
+
|
|
147
|
+
# Already isolated? Just launch pi here (superpowers Step 0).
|
|
148
|
+
if in_linked_worktree; then
|
|
149
|
+
ok "already in an isolated worktree ($(pwd)), launching pi here"
|
|
150
|
+
exec pi "$@"
|
|
151
|
+
fi
|
|
152
|
+
|
|
153
|
+
local dir; dir="$(wt_dir "$name")"
|
|
154
|
+
ensure_gitignore
|
|
155
|
+
|
|
156
|
+
if [[ -d "$dir" ]]; then
|
|
157
|
+
# Reuse: must be a registered worktree for this repo.
|
|
158
|
+
# Skip the dirty-workspace prompt — the worktree already exists, nothing new to create.
|
|
159
|
+
git -C "$dir" rev-parse --git-dir >/dev/null 2>&1 || die "$dir exists but is not a git worktree"
|
|
160
|
+
ok "reusing existing worktree $dir"
|
|
161
|
+
else
|
|
162
|
+
# Dirty workspace: uncommitted changes do NOT follow into the new worktree.
|
|
163
|
+
if ! git diff --quiet || ! git diff --cached --quiet || [[ -n "$(git ls-files --others --exclude-standard)" ]]; then
|
|
164
|
+
warn "Uncommitted changes stay in THIS directory; they will NOT appear in the worktree:"
|
|
165
|
+
git status --short | head -20
|
|
166
|
+
read -rp "Continue? [y/N] " ans
|
|
167
|
+
[[ "$ans" =~ ^[Yy]$ ]] || die "aborted"
|
|
168
|
+
fi
|
|
169
|
+
|
|
170
|
+
mkdir -p .worktrees
|
|
171
|
+
# Reuse branch if it exists, else create it
|
|
172
|
+
if git show-ref --verify --quiet "refs/heads/$name"; then
|
|
173
|
+
git worktree add "$dir" "$name"
|
|
174
|
+
ok "checked out existing branch '$name'"
|
|
175
|
+
# record a best-effort base for -i display
|
|
176
|
+
local mb; mb=$(git merge-base main "$name" 2>/dev/null || true)
|
|
177
|
+
[[ -n "$mb" ]] && git config "branch.$name.base" "$(git rev-parse --short "$mb")" \
|
|
178
|
+
&& git config "branch.$name.baseRef" main
|
|
179
|
+
else
|
|
180
|
+
local base; base=$(pick_base)
|
|
181
|
+
git worktree add "$dir" -b "$name" "$base"
|
|
182
|
+
git config "branch.$name.base" "$(git rev-parse --short "$base")"
|
|
183
|
+
git config "branch.$name.baseRef" "$base"
|
|
184
|
+
ok "created branch '$name' from $base @ $(git rev-parse --short "$base")"
|
|
185
|
+
fi
|
|
186
|
+
fi
|
|
187
|
+
|
|
188
|
+
cd "$dir"
|
|
189
|
+
exec pi "$@"
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
case "${1:-}" in
|
|
193
|
+
-l|--list|list|ls) shift; cmd_list "$@" ;;
|
|
194
|
+
-i|--info|info) shift; cmd_info "$@" ;;
|
|
195
|
+
-o|--out|out) shift; cmd_out "$@" ;;
|
|
196
|
+
-r|--remove|rm|remove) shift; cmd_remove "$@" ;;
|
|
197
|
+
-p|--prune|prune) shift; subcmd_prune ;;
|
|
198
|
+
-h|--help|help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//' ;;
|
|
199
|
+
start) shift
|
|
200
|
+
[[ $# -ge 1 ]] || die "usage: pi-worktree start <name> [pi args...]"
|
|
201
|
+
name="$1"; shift; cd_wt "$name" "$@" ;;
|
|
202
|
+
"") die "usage: pi-worktree start <name> [pi args...] (or list / info / out / remove / prune)" ;;
|
|
203
|
+
*) die "unknown command: $1 (usage: pi-worktree start <name> [pi args...])" ;;
|
|
204
|
+
esac
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# bash completion for pi-worktree
|
|
2
|
+
_pi_worktree() {
|
|
3
|
+
local cur first
|
|
4
|
+
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
5
|
+
first="${COMP_WORDS[1]}"
|
|
6
|
+
|
|
7
|
+
local SUBS="start list ls info out remove rm prune -l --list -i --info -o --out -r --remove -p --prune -h --help"
|
|
8
|
+
|
|
9
|
+
# existing worktree names (only linked worktrees, i.e. under .worktrees/)
|
|
10
|
+
local wts=""
|
|
11
|
+
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
12
|
+
local main_dir
|
|
13
|
+
main_dir=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null | sed 's|/.git$||')
|
|
14
|
+
wts=$(git worktree list --porcelain 2>/dev/null \
|
|
15
|
+
| sed -n 's|^worktree ||p' \
|
|
16
|
+
| while read -r p; do [[ "$p" != "$main_dir" ]] && basename "$p"; done || true)
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
if (( COMP_CWORD == 1 )); then
|
|
20
|
+
# first word: subcommands, flags, and existing worktree names (to enter)
|
|
21
|
+
COMPREPLY=($(compgen -W "$SUBS $wts" -- "$cur"))
|
|
22
|
+
return
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
case "$first" in
|
|
26
|
+
-i|--info|info|-r|--remove|remove|rm|start)
|
|
27
|
+
# only the second word of info/remove completes worktree names;
|
|
28
|
+
# later words offer nothing (info/remove take no further args)
|
|
29
|
+
if (( COMP_CWORD == 2 )); then
|
|
30
|
+
COMPREPLY=($(compgen -W "$wts" -- "$cur"))
|
|
31
|
+
else
|
|
32
|
+
COMPREPLY=()
|
|
33
|
+
fi
|
|
34
|
+
;;
|
|
35
|
+
*)
|
|
36
|
+
# after a name: default (file) completion for pi args
|
|
37
|
+
COMPREPLY=()
|
|
38
|
+
;;
|
|
39
|
+
esac
|
|
40
|
+
}
|
|
41
|
+
complete -F _pi_worktree pi-worktree
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxiaochao/pi-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Personal pi coding agent toolkit: /todos, /btw, /cache_export extensions, browser automation, and bundled theme/skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"extensions",
|
|
12
12
|
"themes",
|
|
13
|
+
"bin",
|
|
13
14
|
"README.md",
|
|
14
15
|
"CHANGELOG.md",
|
|
15
16
|
"global/AGENTS.md",
|
|
@@ -33,6 +34,9 @@
|
|
|
33
34
|
"./themes"
|
|
34
35
|
]
|
|
35
36
|
},
|
|
37
|
+
"bin": {
|
|
38
|
+
"pi-worktree": "bin/pi-worktree"
|
|
39
|
+
},
|
|
36
40
|
"peerDependencies": {
|
|
37
41
|
"@earendil-works/pi-ai": "*",
|
|
38
42
|
"@earendil-works/pi-coding-agent": "*",
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// postinstall hook: sync the bundled global AGENTS.md and seed the default theme
|
|
2
2
|
// without overriding a theme the user has already selected.
|
|
3
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
4
|
-
import { homedir } from "node:os";
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, chmodSync } from "node:fs";
|
|
4
|
+
import { homedir, platform } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
|
|
8
|
+
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
+
|
|
8
10
|
const agentDir = join(homedir(), ".pi", "agent");
|
|
9
11
|
const bundled = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "global", "AGENTS.md"), "utf8");
|
|
10
12
|
const agentsTarget = join(agentDir, "AGENTS.md");
|
|
@@ -41,3 +43,22 @@ if (!existsSync(settingsTarget)) {
|
|
|
41
43
|
console.warn("[pi-toolkit] could not set default theme:", error instanceof Error ? error.message : String(error));
|
|
42
44
|
}
|
|
43
45
|
}
|
|
46
|
+
|
|
47
|
+
// Install the pi-worktree CLI + bash completion on Unix-like systems.
|
|
48
|
+
// Copy (not symlink) so the running command does not depend on the repo/node_modules layout.
|
|
49
|
+
if (platform() !== "win32") {
|
|
50
|
+
try {
|
|
51
|
+
const binTarget = join(homedir(), ".local", "bin", "pi-worktree");
|
|
52
|
+
mkdirSync(dirname(binTarget), { recursive: true });
|
|
53
|
+
copyFileSync(join(packageRoot, "bin", "pi-worktree"), binTarget);
|
|
54
|
+
chmodSync(binTarget, 0o755);
|
|
55
|
+
console.log("[pi-toolkit] pi-worktree installed ->", binTarget);
|
|
56
|
+
|
|
57
|
+
const compTarget = join(homedir(), ".local", "share", "bash-completion", "completions", "pi-worktree");
|
|
58
|
+
mkdirSync(dirname(compTarget), { recursive: true });
|
|
59
|
+
copyFileSync(join(packageRoot, "bin", "pi-worktree-completion.bash"), compTarget);
|
|
60
|
+
console.log("[pi-toolkit] pi-worktree bash completion installed ->", compTarget);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.warn("[pi-toolkit] could not install pi-worktree CLI:", error instanceof Error ? error.message : String(error));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pasky
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: chrome-cdp
|
|
3
|
+
description: Interact with local Chrome browser session (only on explicit user approval after being asked to inspect, debug, or interact with a page open in Chrome)
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Chrome CDP
|
|
7
|
+
|
|
8
|
+
Lightweight Chrome DevTools Protocol CLI. Connects directly via WebSocket — no Puppeteer, works with 100+ tabs, instant connection.
|
|
9
|
+
|
|
10
|
+
## Prerequisites
|
|
11
|
+
|
|
12
|
+
- Chrome (or Chromium, Brave, Edge, Vivaldi) with remote debugging enabled: open `chrome://inspect/#remote-debugging` and toggle the switch
|
|
13
|
+
- Node.js 22+ (uses built-in WebSocket)
|
|
14
|
+
- If your browser's `DevToolsActivePort` is in a non-standard location, set `CDP_PORT_FILE` to its full path
|
|
15
|
+
|
|
16
|
+
## Commands
|
|
17
|
+
|
|
18
|
+
All commands use `scripts/cdp.mjs`. The `<target>` is a **unique** targetId prefix from `list`; copy the full prefix shown in the `list` output (for example `6BE827FA`). The CLI rejects ambiguous prefixes.
|
|
19
|
+
|
|
20
|
+
### List open pages
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
scripts/cdp.mjs list
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Take a screenshot
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
scripts/cdp.mjs shot <target> [file] # default: screenshot-<target>.png in runtime dir
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Captures the **viewport only**. Scroll first with `eval` if you need content below the fold. Output includes the page's DPR and coordinate conversion hint (see **Coordinates** below).
|
|
33
|
+
|
|
34
|
+
### Accessibility tree snapshot
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
scripts/cdp.mjs snap <target>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Evaluate JavaScript
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
scripts/cdp.mjs eval <target> <expr>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
> **Watch out:** avoid index-based selection (`querySelectorAll(...)[i]`) across multiple `eval` calls when the DOM can change between them (e.g. after clicking Ignore, card indices shift). Collect all data in one `eval` or use stable selectors.
|
|
47
|
+
|
|
48
|
+
### Other commands
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
scripts/cdp.mjs html <target> [selector] # full page or element HTML
|
|
52
|
+
scripts/cdp.mjs nav <target> <url> # navigate and wait for load
|
|
53
|
+
scripts/cdp.mjs net <target> # resource timing entries
|
|
54
|
+
scripts/cdp.mjs click <target> <selector> # click element by CSS selector
|
|
55
|
+
scripts/cdp.mjs clickxy <target> <x> <y> # click at CSS pixel coords
|
|
56
|
+
scripts/cdp.mjs type <target> <text> # Input.insertText at current focus; works in cross-origin iframes unlike eval
|
|
57
|
+
scripts/cdp.mjs loadall <target> <selector> [ms] # click "load more" until gone (default 1500ms between clicks)
|
|
58
|
+
scripts/cdp.mjs evalraw <target> <method> [json] # raw CDP command passthrough
|
|
59
|
+
scripts/cdp.mjs open [url] # open new tab (each triggers Allow prompt)
|
|
60
|
+
scripts/cdp.mjs stop [target] # stop daemon(s)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Coordinates
|
|
64
|
+
|
|
65
|
+
`shot` saves an image at native resolution: image pixels = CSS pixels × DPR. CDP Input events (`clickxy` etc.) take **CSS pixels**.
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
CSS px = screenshot image px / DPR
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`shot` prints the DPR for the current page. Typical Retina (DPR=2): divide screenshot coords by 2.
|
|
72
|
+
|
|
73
|
+
## Tips
|
|
74
|
+
|
|
75
|
+
- Prefer `snap --compact` over `html` for page structure.
|
|
76
|
+
- Use `type` (not eval) to enter text in cross-origin iframes — `click`/`clickxy` to focus first, then `type`.
|
|
77
|
+
- Chrome shows an "Allow debugging" modal once per tab on first access. A background daemon keeps the session alive so subsequent commands need no further approval. Daemons auto-exit after 20 minutes of inactivity.
|
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cdp - lightweight Chrome DevTools Protocol CLI
|
|
3
|
+
// Uses raw CDP over WebSocket, no Puppeteer dependency.
|
|
4
|
+
// Requires Node 22+ (built-in WebSocket).
|
|
5
|
+
//
|
|
6
|
+
// Per-tab persistent daemon: page commands go through a daemon that holds
|
|
7
|
+
// the CDP session open. Chrome's "Allow debugging" modal fires once per
|
|
8
|
+
// daemon (= once per tab). Daemons auto-exit after 20min idle.
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
|
|
11
|
+
import { homedir } from 'os';
|
|
12
|
+
import { resolve } from 'path';
|
|
13
|
+
import { spawn } from 'child_process';
|
|
14
|
+
import net from 'net';
|
|
15
|
+
|
|
16
|
+
const TIMEOUT = 15000;
|
|
17
|
+
const NAVIGATION_TIMEOUT = 30000;
|
|
18
|
+
const IDLE_TIMEOUT = 20 * 60 * 1000;
|
|
19
|
+
const DAEMON_CONNECT_RETRIES = 20;
|
|
20
|
+
const DAEMON_CONNECT_DELAY = 300;
|
|
21
|
+
const MIN_TARGET_PREFIX_LEN = 8;
|
|
22
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
23
|
+
if (!IS_WINDOWS) process.umask(0o077);
|
|
24
|
+
const RUNTIME_DIR = IS_WINDOWS
|
|
25
|
+
? resolve(process.env.LOCALAPPDATA || resolve(homedir(), 'AppData', 'Local'), 'cdp')
|
|
26
|
+
: process.env.XDG_RUNTIME_DIR
|
|
27
|
+
? resolve(process.env.XDG_RUNTIME_DIR, 'cdp')
|
|
28
|
+
: resolve(homedir(), '.cache', 'cdp');
|
|
29
|
+
try { mkdirSync(RUNTIME_DIR, { recursive: true, mode: 0o700 }); } catch {}
|
|
30
|
+
const PAGES_CACHE = resolve(RUNTIME_DIR, 'pages.json');
|
|
31
|
+
|
|
32
|
+
function sockPath(targetId) {
|
|
33
|
+
return IS_WINDOWS
|
|
34
|
+
? `\\\\.\\pipe\\cdp-${targetId}`
|
|
35
|
+
: resolve(RUNTIME_DIR, `cdp-${targetId}.sock`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getWsUrl() {
|
|
39
|
+
const home = homedir();
|
|
40
|
+
// macOS: ~/Library/Application Support/<name>/DevToolsActivePort
|
|
41
|
+
const macBrowsers = [
|
|
42
|
+
'Google/Chrome', 'Google/Chrome Beta', 'Google/Chrome for Testing',
|
|
43
|
+
'Chromium', 'BraveSoftware/Brave-Browser', 'Microsoft Edge',
|
|
44
|
+
];
|
|
45
|
+
// Linux: ~/.config/<name>/DevToolsActivePort
|
|
46
|
+
const linuxBrowsers = [
|
|
47
|
+
'google-chrome', 'google-chrome-beta', 'chromium',
|
|
48
|
+
'vivaldi', 'vivaldi-snapshot',
|
|
49
|
+
'BraveSoftware/Brave-Browser', 'microsoft-edge',
|
|
50
|
+
];
|
|
51
|
+
// Linux Flatpak: ~/.var/app/<app-id>/config/<name>/DevToolsActivePort
|
|
52
|
+
const flatpakBrowsers = [
|
|
53
|
+
['org.chromium.Chromium', 'chromium'],
|
|
54
|
+
['com.google.Chrome', 'google-chrome'],
|
|
55
|
+
['com.brave.Browser', 'BraveSoftware/Brave-Browser'],
|
|
56
|
+
['com.microsoft.Edge', 'microsoft-edge'],
|
|
57
|
+
['com.vivaldi.Vivaldi', 'vivaldi'],
|
|
58
|
+
];
|
|
59
|
+
const candidates = [
|
|
60
|
+
process.env.CDP_PORT_FILE,
|
|
61
|
+
...macBrowsers.flatMap(b => [
|
|
62
|
+
resolve(home, 'Library/Application Support', b, 'DevToolsActivePort'),
|
|
63
|
+
resolve(home, 'Library/Application Support', b, 'Default/DevToolsActivePort'),
|
|
64
|
+
]),
|
|
65
|
+
...linuxBrowsers.flatMap(b => [
|
|
66
|
+
resolve(home, '.config', b, 'DevToolsActivePort'),
|
|
67
|
+
resolve(home, '.config', b, 'Default/DevToolsActivePort'),
|
|
68
|
+
]),
|
|
69
|
+
...flatpakBrowsers.flatMap(([appId, name]) => [
|
|
70
|
+
resolve(home, '.var/app', appId, 'config', name, 'DevToolsActivePort'),
|
|
71
|
+
resolve(home, '.var/app', appId, 'config', name, 'Default/DevToolsActivePort'),
|
|
72
|
+
]),
|
|
73
|
+
// Windows: %LOCALAPPDATA%/<name>/User Data/DevToolsActivePort
|
|
74
|
+
...(IS_WINDOWS ? ['Google/Chrome', 'BraveSoftware/Brave-Browser', 'Microsoft/Edge'].flatMap(b => {
|
|
75
|
+
const base = process.env.LOCALAPPDATA || resolve(home, 'AppData/Local');
|
|
76
|
+
return [
|
|
77
|
+
resolve(base, b, 'User Data/DevToolsActivePort'),
|
|
78
|
+
resolve(base, b, 'User Data/Default/DevToolsActivePort'),
|
|
79
|
+
];
|
|
80
|
+
}) : []),
|
|
81
|
+
].filter(Boolean);
|
|
82
|
+
const portFile = candidates.find(p => existsSync(p));
|
|
83
|
+
if (!portFile) throw new Error('No DevToolsActivePort found. Enable remote debugging at chrome://inspect/#remote-debugging');
|
|
84
|
+
const lines = readFileSync(portFile, 'utf8').trim().split('\n');
|
|
85
|
+
if (lines.length < 2 || !lines[0] || !lines[1]) throw new Error(`Invalid DevToolsActivePort file: ${portFile}`);
|
|
86
|
+
const host = process.env.CDP_HOST || '127.0.0.1';
|
|
87
|
+
return `ws://${host}:${lines[0]}${lines[1]}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
function resolvePrefix(prefix, candidates, noun = 'target', missingHint = '') {
|
|
94
|
+
const upper = prefix.toUpperCase();
|
|
95
|
+
const matches = candidates.filter(candidate => candidate.toUpperCase().startsWith(upper));
|
|
96
|
+
if (matches.length === 0) {
|
|
97
|
+
const hint = missingHint ? ` ${missingHint}` : '';
|
|
98
|
+
throw new Error(`No ${noun} matching prefix "${prefix}".${hint}`);
|
|
99
|
+
}
|
|
100
|
+
if (matches.length > 1) {
|
|
101
|
+
throw new Error(`Ambiguous prefix "${prefix}" — matches ${matches.length} ${noun}s. Use more characters.`);
|
|
102
|
+
}
|
|
103
|
+
return matches[0];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getDisplayPrefixLength(targetIds) {
|
|
107
|
+
if (targetIds.length === 0) return MIN_TARGET_PREFIX_LEN;
|
|
108
|
+
const maxLen = Math.max(...targetIds.map(id => id.length));
|
|
109
|
+
for (let len = MIN_TARGET_PREFIX_LEN; len <= maxLen; len++) {
|
|
110
|
+
const prefixes = new Set(targetIds.map(id => id.slice(0, len).toUpperCase()));
|
|
111
|
+
if (prefixes.size === targetIds.length) return len;
|
|
112
|
+
}
|
|
113
|
+
return maxLen;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// CDP WebSocket client
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
class CDP {
|
|
121
|
+
#ws; #id = 0; #pending = new Map(); #eventHandlers = new Map(); #closeHandlers = [];
|
|
122
|
+
|
|
123
|
+
async connect(wsUrl) {
|
|
124
|
+
return new Promise((res, rej) => {
|
|
125
|
+
this.#ws = new WebSocket(wsUrl);
|
|
126
|
+
this.#ws.onopen = () => res();
|
|
127
|
+
this.#ws.onerror = (e) => rej(new Error('WebSocket error: ' + (e.message || e.type)));
|
|
128
|
+
this.#ws.onclose = () => this.#closeHandlers.forEach(h => h());
|
|
129
|
+
this.#ws.onmessage = (ev) => {
|
|
130
|
+
const msg = JSON.parse(ev.data);
|
|
131
|
+
if (msg.id && this.#pending.has(msg.id)) {
|
|
132
|
+
const { resolve, reject } = this.#pending.get(msg.id);
|
|
133
|
+
this.#pending.delete(msg.id);
|
|
134
|
+
if (msg.error) reject(new Error(msg.error.message));
|
|
135
|
+
else resolve(msg.result);
|
|
136
|
+
} else if (msg.method && this.#eventHandlers.has(msg.method)) {
|
|
137
|
+
for (const handler of [...this.#eventHandlers.get(msg.method)]) {
|
|
138
|
+
handler(msg.params || {}, msg);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
send(method, params = {}, sessionId) {
|
|
146
|
+
const id = ++this.#id;
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
this.#pending.set(id, { resolve, reject });
|
|
149
|
+
const msg = { id, method, params };
|
|
150
|
+
if (sessionId) msg.sessionId = sessionId;
|
|
151
|
+
this.#ws.send(JSON.stringify(msg));
|
|
152
|
+
setTimeout(() => {
|
|
153
|
+
if (this.#pending.has(id)) {
|
|
154
|
+
this.#pending.delete(id);
|
|
155
|
+
reject(new Error(`Timeout: ${method}`));
|
|
156
|
+
}
|
|
157
|
+
}, TIMEOUT);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
onEvent(method, handler) {
|
|
162
|
+
if (!this.#eventHandlers.has(method)) this.#eventHandlers.set(method, new Set());
|
|
163
|
+
const handlers = this.#eventHandlers.get(method);
|
|
164
|
+
handlers.add(handler);
|
|
165
|
+
return () => {
|
|
166
|
+
handlers.delete(handler);
|
|
167
|
+
if (handlers.size === 0) this.#eventHandlers.delete(method);
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
waitForEvent(method, timeout = TIMEOUT) {
|
|
172
|
+
let settled = false;
|
|
173
|
+
let off;
|
|
174
|
+
let timer;
|
|
175
|
+
const promise = new Promise((resolve, reject) => {
|
|
176
|
+
off = this.onEvent(method, (params) => {
|
|
177
|
+
if (settled) return;
|
|
178
|
+
settled = true;
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
off();
|
|
181
|
+
resolve(params);
|
|
182
|
+
});
|
|
183
|
+
timer = setTimeout(() => {
|
|
184
|
+
if (settled) return;
|
|
185
|
+
settled = true;
|
|
186
|
+
off();
|
|
187
|
+
reject(new Error(`Timeout waiting for event: ${method}`));
|
|
188
|
+
}, timeout);
|
|
189
|
+
});
|
|
190
|
+
return {
|
|
191
|
+
promise,
|
|
192
|
+
cancel() {
|
|
193
|
+
if (settled) return;
|
|
194
|
+
settled = true;
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
off?.();
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
onClose(handler) { this.#closeHandlers.push(handler); }
|
|
202
|
+
close() { this.#ws.close(); }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Command implementations — return strings, take (cdp, sessionId)
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
async function getPages(cdp) {
|
|
210
|
+
const { targetInfos } = await cdp.send('Target.getTargets');
|
|
211
|
+
return targetInfos.filter(t => t.type === 'page' && !t.url.startsWith('chrome://'));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function formatPageList(pages) {
|
|
215
|
+
const prefixLen = getDisplayPrefixLength(pages.map(p => p.targetId));
|
|
216
|
+
return pages.map(p => {
|
|
217
|
+
const id = p.targetId.slice(0, prefixLen).padEnd(prefixLen);
|
|
218
|
+
const title = p.title.substring(0, 54).padEnd(54);
|
|
219
|
+
return `${id} ${title} ${p.url}`;
|
|
220
|
+
}).join('\n');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function shouldShowAxNode(node, compact = false) {
|
|
224
|
+
const role = node.role?.value || '';
|
|
225
|
+
const name = node.name?.value ?? '';
|
|
226
|
+
const value = node.value?.value;
|
|
227
|
+
if (compact && role === 'InlineTextBox') return false;
|
|
228
|
+
return role !== 'none' && role !== 'generic' && !(name === '' && (value === '' || value == null));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function formatAxNode(node, depth) {
|
|
232
|
+
const role = node.role?.value || '';
|
|
233
|
+
const name = node.name?.value ?? '';
|
|
234
|
+
const value = node.value?.value;
|
|
235
|
+
const indent = ' '.repeat(Math.min(depth, 10));
|
|
236
|
+
let line = `${indent}[${role}]`;
|
|
237
|
+
if (name !== '') line += ` ${name}`;
|
|
238
|
+
if (!(value === '' || value == null)) line += ` = ${JSON.stringify(value)}`;
|
|
239
|
+
return line;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function orderedAxChildren(node, nodesById, childrenByParent) {
|
|
243
|
+
const children = [];
|
|
244
|
+
const seen = new Set();
|
|
245
|
+
for (const childId of node.childIds || []) {
|
|
246
|
+
const child = nodesById.get(childId);
|
|
247
|
+
if (child && !seen.has(child.nodeId)) {
|
|
248
|
+
seen.add(child.nodeId);
|
|
249
|
+
children.push(child);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
for (const child of childrenByParent.get(node.nodeId) || []) {
|
|
253
|
+
if (!seen.has(child.nodeId)) {
|
|
254
|
+
seen.add(child.nodeId);
|
|
255
|
+
children.push(child);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return children;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function snapshotStr(cdp, sid, compact = false) {
|
|
262
|
+
const { nodes } = await cdp.send('Accessibility.getFullAXTree', {}, sid);
|
|
263
|
+
const nodesById = new Map(nodes.map(node => [node.nodeId, node]));
|
|
264
|
+
const childrenByParent = new Map();
|
|
265
|
+
for (const node of nodes) {
|
|
266
|
+
if (!node.parentId) continue;
|
|
267
|
+
if (!childrenByParent.has(node.parentId)) childrenByParent.set(node.parentId, []);
|
|
268
|
+
childrenByParent.get(node.parentId).push(node);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const lines = [];
|
|
272
|
+
const visited = new Set();
|
|
273
|
+
function visit(node, depth) {
|
|
274
|
+
if (!node || visited.has(node.nodeId)) return;
|
|
275
|
+
visited.add(node.nodeId);
|
|
276
|
+
if (shouldShowAxNode(node, compact)) lines.push(formatAxNode(node, depth));
|
|
277
|
+
for (const child of orderedAxChildren(node, nodesById, childrenByParent)) {
|
|
278
|
+
visit(child, depth + 1);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const roots = nodes.filter(node => !node.parentId || !nodesById.has(node.parentId));
|
|
283
|
+
for (const root of roots) visit(root, 0);
|
|
284
|
+
for (const node of nodes) visit(node, 0);
|
|
285
|
+
|
|
286
|
+
return lines.join('\n');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function evalStr(cdp, sid, expression) {
|
|
290
|
+
await cdp.send('Runtime.enable', {}, sid);
|
|
291
|
+
const result = await cdp.send('Runtime.evaluate', {
|
|
292
|
+
expression, returnByValue: true, awaitPromise: true,
|
|
293
|
+
}, sid);
|
|
294
|
+
if (result.exceptionDetails) {
|
|
295
|
+
throw new Error(result.exceptionDetails.text || result.exceptionDetails.exception?.description);
|
|
296
|
+
}
|
|
297
|
+
const val = result.result.value;
|
|
298
|
+
return typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val ?? '');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function shotStr(cdp, sid, filePath, targetId) {
|
|
302
|
+
// Get device scale factor so we can report coordinate mapping
|
|
303
|
+
let dpr = 1;
|
|
304
|
+
try {
|
|
305
|
+
const metrics = await cdp.send('Page.getLayoutMetrics', {}, sid);
|
|
306
|
+
dpr = metrics.visualViewport?.clientWidth
|
|
307
|
+
? metrics.cssVisualViewport?.clientWidth
|
|
308
|
+
? Math.round((metrics.visualViewport.clientWidth / metrics.cssVisualViewport.clientWidth) * 100) / 100
|
|
309
|
+
: 1
|
|
310
|
+
: 1;
|
|
311
|
+
// Simpler: deviceScaleFactor is on the root Page metrics
|
|
312
|
+
const { deviceScaleFactor } = await cdp.send('Emulation.getDeviceMetricsOverride', {}, sid).catch(() => ({}));
|
|
313
|
+
if (deviceScaleFactor) dpr = deviceScaleFactor;
|
|
314
|
+
} catch {}
|
|
315
|
+
// Fallback: try to get DPR from JS
|
|
316
|
+
if (dpr === 1) {
|
|
317
|
+
try {
|
|
318
|
+
const raw = await evalStr(cdp, sid, 'window.devicePixelRatio');
|
|
319
|
+
const parsed = parseFloat(raw);
|
|
320
|
+
if (parsed > 0) dpr = parsed;
|
|
321
|
+
} catch {}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' }, sid);
|
|
325
|
+
const out = filePath || resolve(RUNTIME_DIR, `screenshot-${(targetId || 'unknown').slice(0, 8)}.png`);
|
|
326
|
+
writeFileSync(out, Buffer.from(data, 'base64'));
|
|
327
|
+
|
|
328
|
+
const lines = [out];
|
|
329
|
+
lines.push(`Screenshot saved. Device pixel ratio (DPR): ${dpr}`);
|
|
330
|
+
lines.push(`Coordinate mapping:`);
|
|
331
|
+
lines.push(` Screenshot pixels → CSS pixels (for CDP Input events): divide by ${dpr}`);
|
|
332
|
+
lines.push(` e.g. screenshot point (${Math.round(100 * dpr)}, ${Math.round(200 * dpr)}) → CSS (100, 200) → use clickxy <target> 100 200`);
|
|
333
|
+
if (dpr !== 1) {
|
|
334
|
+
lines.push(` On this ${dpr}x display: CSS px = screenshot px / ${dpr} ≈ screenshot px × ${Math.round(100/dpr)/100}`);
|
|
335
|
+
}
|
|
336
|
+
return lines.join('\n');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function htmlStr(cdp, sid, selector) {
|
|
340
|
+
const expr = selector
|
|
341
|
+
? `document.querySelector(${JSON.stringify(selector)})?.outerHTML || 'Element not found'`
|
|
342
|
+
: `document.documentElement.outerHTML`;
|
|
343
|
+
return evalStr(cdp, sid, expr);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function waitForDocumentReady(cdp, sid, timeoutMs = NAVIGATION_TIMEOUT) {
|
|
347
|
+
const deadline = Date.now() + timeoutMs;
|
|
348
|
+
let lastState = '';
|
|
349
|
+
let lastError;
|
|
350
|
+
while (Date.now() < deadline) {
|
|
351
|
+
try {
|
|
352
|
+
const state = await evalStr(cdp, sid, 'document.readyState');
|
|
353
|
+
lastState = state;
|
|
354
|
+
if (state === 'complete') return;
|
|
355
|
+
} catch (e) {
|
|
356
|
+
lastError = e;
|
|
357
|
+
}
|
|
358
|
+
await sleep(200);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (lastState) {
|
|
362
|
+
throw new Error(`Timed out waiting for navigation to finish (last readyState: ${lastState})`);
|
|
363
|
+
}
|
|
364
|
+
if (lastError) {
|
|
365
|
+
throw new Error(`Timed out waiting for navigation to finish (${lastError.message})`);
|
|
366
|
+
}
|
|
367
|
+
throw new Error('Timed out waiting for navigation to finish');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function navStr(cdp, sid, url) {
|
|
371
|
+
try {
|
|
372
|
+
const parsed = new URL(url);
|
|
373
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
374
|
+
throw new Error(`Only http/https URLs allowed, got: ${url}`);
|
|
375
|
+
} catch (e) {
|
|
376
|
+
if (e.message.startsWith('Only')) throw e;
|
|
377
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
378
|
+
}
|
|
379
|
+
await cdp.send('Page.enable', {}, sid);
|
|
380
|
+
const loadEvent = cdp.waitForEvent('Page.loadEventFired', NAVIGATION_TIMEOUT);
|
|
381
|
+
const result = await cdp.send('Page.navigate', { url }, sid);
|
|
382
|
+
if (result.errorText) {
|
|
383
|
+
loadEvent.cancel();
|
|
384
|
+
throw new Error(result.errorText);
|
|
385
|
+
}
|
|
386
|
+
if (result.loaderId) {
|
|
387
|
+
await loadEvent.promise;
|
|
388
|
+
} else {
|
|
389
|
+
loadEvent.cancel();
|
|
390
|
+
}
|
|
391
|
+
await waitForDocumentReady(cdp, sid, 5000);
|
|
392
|
+
return `Navigated to ${url}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function netStr(cdp, sid) {
|
|
396
|
+
const raw = await evalStr(cdp, sid, `JSON.stringify(performance.getEntriesByType('resource').map(e => ({
|
|
397
|
+
name: e.name.substring(0, 120), type: e.initiatorType,
|
|
398
|
+
duration: Math.round(e.duration), size: e.transferSize
|
|
399
|
+
})))`);
|
|
400
|
+
return JSON.parse(raw).map(e =>
|
|
401
|
+
`${String(e.duration).padStart(5)}ms ${String(e.size || '?').padStart(8)}B ${e.type.padEnd(8)} ${e.name}`
|
|
402
|
+
).join('\n');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Click element by CSS selector
|
|
406
|
+
async function clickStr(cdp, sid, selector) {
|
|
407
|
+
if (!selector) throw new Error('CSS selector required');
|
|
408
|
+
const expr = `
|
|
409
|
+
(function() {
|
|
410
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
411
|
+
if (!el) return { ok: false, error: 'Element not found: ' + ${JSON.stringify(selector)} };
|
|
412
|
+
el.scrollIntoView({ block: 'center' });
|
|
413
|
+
el.click();
|
|
414
|
+
return { ok: true, tag: el.tagName, text: el.textContent.trim().substring(0, 80) };
|
|
415
|
+
})()
|
|
416
|
+
`;
|
|
417
|
+
const result = await evalStr(cdp, sid, expr);
|
|
418
|
+
const r = JSON.parse(result);
|
|
419
|
+
if (!r.ok) throw new Error(r.error);
|
|
420
|
+
return `Clicked <${r.tag}> "${r.text}"`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Click at CSS pixel coordinates using Input.dispatchMouseEvent
|
|
424
|
+
async function clickXyStr(cdp, sid, x, y) {
|
|
425
|
+
const cx = parseFloat(x);
|
|
426
|
+
const cy = parseFloat(y);
|
|
427
|
+
if (isNaN(cx) || isNaN(cy)) throw new Error('x and y must be numbers (CSS pixels)');
|
|
428
|
+
const base = { x: cx, y: cy, button: 'left', clickCount: 1, modifiers: 0 };
|
|
429
|
+
await cdp.send('Input.dispatchMouseEvent', { ...base, type: 'mouseMoved' }, sid);
|
|
430
|
+
await cdp.send('Input.dispatchMouseEvent', { ...base, type: 'mousePressed' }, sid);
|
|
431
|
+
await sleep(50);
|
|
432
|
+
await cdp.send('Input.dispatchMouseEvent', { ...base, type: 'mouseReleased' }, sid);
|
|
433
|
+
return `Clicked at CSS (${cx}, ${cy})`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Type text using Input.insertText (works in cross-origin iframes, unlike eval)
|
|
437
|
+
async function typeStr(cdp, sid, text) {
|
|
438
|
+
if (text == null || text === '') throw new Error('text required');
|
|
439
|
+
await cdp.send('Input.insertText', { text }, sid);
|
|
440
|
+
return `Typed ${text.length} characters`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Load-more: repeatedly click a button/selector until it disappears
|
|
444
|
+
async function loadAllStr(cdp, sid, selector, intervalMs = 1500) {
|
|
445
|
+
if (!selector) throw new Error('CSS selector required');
|
|
446
|
+
let clicks = 0;
|
|
447
|
+
const deadline = Date.now() + 5 * 60 * 1000; // 5-minute hard cap
|
|
448
|
+
while (Date.now() < deadline) {
|
|
449
|
+
const exists = await evalStr(cdp, sid,
|
|
450
|
+
`!!document.querySelector(${JSON.stringify(selector)})`
|
|
451
|
+
);
|
|
452
|
+
if (exists !== 'true') break;
|
|
453
|
+
const clickExpr = `
|
|
454
|
+
(function() {
|
|
455
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
456
|
+
if (!el) return false;
|
|
457
|
+
el.scrollIntoView({ block: 'center' });
|
|
458
|
+
el.click();
|
|
459
|
+
return true;
|
|
460
|
+
})()
|
|
461
|
+
`;
|
|
462
|
+
const clicked = await evalStr(cdp, sid, clickExpr);
|
|
463
|
+
if (clicked !== 'true') break;
|
|
464
|
+
clicks++;
|
|
465
|
+
await sleep(intervalMs);
|
|
466
|
+
}
|
|
467
|
+
return `Clicked "${selector}" ${clicks} time(s) until it disappeared`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Send a raw CDP command and return the result as JSON
|
|
471
|
+
async function evalRawStr(cdp, sid, method, paramsJson) {
|
|
472
|
+
if (!method) throw new Error('CDP method required (e.g. "DOM.getDocument")');
|
|
473
|
+
let params = {};
|
|
474
|
+
if (paramsJson) {
|
|
475
|
+
try { params = JSON.parse(paramsJson); }
|
|
476
|
+
catch { throw new Error(`Invalid JSON params: ${paramsJson}`); }
|
|
477
|
+
}
|
|
478
|
+
const result = await cdp.send(method, params, sid);
|
|
479
|
+
return JSON.stringify(result, null, 2);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
// Per-tab daemon
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
async function runDaemon(targetId) {
|
|
487
|
+
const sp = sockPath(targetId);
|
|
488
|
+
|
|
489
|
+
const cdp = new CDP();
|
|
490
|
+
try {
|
|
491
|
+
await cdp.connect(getWsUrl());
|
|
492
|
+
} catch (e) {
|
|
493
|
+
process.stderr.write(`Daemon: cannot connect to Chrome: ${e.message}\n`);
|
|
494
|
+
process.exit(1);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
let sessionId;
|
|
498
|
+
try {
|
|
499
|
+
const res = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
|
|
500
|
+
sessionId = res.sessionId;
|
|
501
|
+
} catch (e) {
|
|
502
|
+
process.stderr.write(`Daemon: attach failed: ${e.message}\n`);
|
|
503
|
+
cdp.close();
|
|
504
|
+
process.exit(1);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Shutdown helpers
|
|
508
|
+
let alive = true;
|
|
509
|
+
function shutdown() {
|
|
510
|
+
if (!alive) return;
|
|
511
|
+
alive = false;
|
|
512
|
+
server.close();
|
|
513
|
+
if (!IS_WINDOWS) try { unlinkSync(sp); } catch {}
|
|
514
|
+
cdp.close();
|
|
515
|
+
process.exit(0);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Exit if target goes away or Chrome disconnects
|
|
519
|
+
cdp.onEvent('Target.targetDestroyed', (params) => {
|
|
520
|
+
if (params.targetId === targetId) shutdown();
|
|
521
|
+
});
|
|
522
|
+
cdp.onEvent('Target.detachedFromTarget', (params) => {
|
|
523
|
+
if (params.sessionId === sessionId) shutdown();
|
|
524
|
+
});
|
|
525
|
+
cdp.onClose(() => shutdown());
|
|
526
|
+
process.on('SIGTERM', shutdown);
|
|
527
|
+
process.on('SIGINT', shutdown);
|
|
528
|
+
|
|
529
|
+
// Idle timer
|
|
530
|
+
let idleTimer = setTimeout(shutdown, IDLE_TIMEOUT);
|
|
531
|
+
function resetIdle() {
|
|
532
|
+
clearTimeout(idleTimer);
|
|
533
|
+
idleTimer = setTimeout(shutdown, IDLE_TIMEOUT);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Handle a command
|
|
537
|
+
async function handleCommand({ cmd, args }) {
|
|
538
|
+
resetIdle();
|
|
539
|
+
try {
|
|
540
|
+
let result;
|
|
541
|
+
switch (cmd) {
|
|
542
|
+
case 'list': {
|
|
543
|
+
const pages = await getPages(cdp);
|
|
544
|
+
result = formatPageList(pages);
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
case 'list_raw': {
|
|
548
|
+
const pages = await getPages(cdp);
|
|
549
|
+
result = JSON.stringify(pages);
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
case 'snap': case 'snapshot': result = await snapshotStr(cdp, sessionId, true); break;
|
|
553
|
+
case 'eval': result = await evalStr(cdp, sessionId, args[0]); break;
|
|
554
|
+
case 'shot': case 'screenshot': result = await shotStr(cdp, sessionId, args[0], targetId); break;
|
|
555
|
+
case 'html': result = await htmlStr(cdp, sessionId, args[0]); break;
|
|
556
|
+
case 'nav': case 'navigate': result = await navStr(cdp, sessionId, args[0]); break;
|
|
557
|
+
case 'net': case 'network': result = await netStr(cdp, sessionId); break;
|
|
558
|
+
case 'click': result = await clickStr(cdp, sessionId, args[0]); break;
|
|
559
|
+
case 'clickxy': result = await clickXyStr(cdp, sessionId, args[0], args[1]); break;
|
|
560
|
+
case 'type': result = await typeStr(cdp, sessionId, args[0]); break;
|
|
561
|
+
case 'loadall': result = await loadAllStr(cdp, sessionId, args[0], args[1] ? parseInt(args[1]) : 1500); break;
|
|
562
|
+
case 'evalraw': result = await evalRawStr(cdp, sessionId, args[0], args[1]); break;
|
|
563
|
+
case 'stop': return { ok: true, result: '', stopAfter: true };
|
|
564
|
+
default: return { ok: false, error: `Unknown command: ${cmd}` };
|
|
565
|
+
}
|
|
566
|
+
return { ok: true, result: result ?? '' };
|
|
567
|
+
} catch (e) {
|
|
568
|
+
return { ok: false, error: e.message };
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Unix socket server — NDJSON protocol
|
|
573
|
+
// Wire format: each message is one JSON object followed by \n (newline-delimited JSON).
|
|
574
|
+
// Request: { "id": <number>, "cmd": "<command>", "args": ["arg1", "arg2", ...] }
|
|
575
|
+
// Response: { "id": <number>, "ok": <boolean>, "result": "<string>" }
|
|
576
|
+
// or { "id": <number>, "ok": false, "error": "<message>" }
|
|
577
|
+
const server = net.createServer((conn) => {
|
|
578
|
+
let buf = '';
|
|
579
|
+
conn.on('data', (chunk) => {
|
|
580
|
+
buf += chunk.toString();
|
|
581
|
+
const lines = buf.split('\n');
|
|
582
|
+
buf = lines.pop(); // keep incomplete last line
|
|
583
|
+
for (const line of lines) {
|
|
584
|
+
if (!line.trim()) continue;
|
|
585
|
+
let req;
|
|
586
|
+
try {
|
|
587
|
+
req = JSON.parse(line);
|
|
588
|
+
} catch {
|
|
589
|
+
conn.write(JSON.stringify({ ok: false, error: 'Invalid JSON request', id: null }) + '\n');
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
handleCommand(req).then((res) => {
|
|
593
|
+
const payload = JSON.stringify({ ...res, id: req.id }) + '\n';
|
|
594
|
+
if (res.stopAfter) conn.end(payload, shutdown);
|
|
595
|
+
else conn.write(payload);
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
server.on('error', (e) => {
|
|
602
|
+
process.stderr.write(`Daemon server listen failed: ${e.message}\n`);
|
|
603
|
+
process.exit(1);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
if (!IS_WINDOWS) try { unlinkSync(sp); } catch {}
|
|
607
|
+
server.listen(sp);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ---------------------------------------------------------------------------
|
|
611
|
+
// CLI ↔ daemon communication
|
|
612
|
+
// ---------------------------------------------------------------------------
|
|
613
|
+
|
|
614
|
+
function connectToSocket(sp) {
|
|
615
|
+
return new Promise((resolve, reject) => {
|
|
616
|
+
const conn = net.connect(sp);
|
|
617
|
+
conn.on('connect', () => resolve(conn));
|
|
618
|
+
conn.on('error', reject);
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function getOrStartTabDaemon(targetId) {
|
|
623
|
+
const sp = sockPath(targetId);
|
|
624
|
+
// Try existing daemon
|
|
625
|
+
try { return await connectToSocket(sp); } catch {}
|
|
626
|
+
|
|
627
|
+
// Clean stale socket
|
|
628
|
+
if (!IS_WINDOWS) try { unlinkSync(sp); } catch {}
|
|
629
|
+
|
|
630
|
+
// Spawn daemon
|
|
631
|
+
const child = spawn(process.execPath, [process.argv[1], '_daemon', targetId], {
|
|
632
|
+
detached: true,
|
|
633
|
+
stdio: 'ignore',
|
|
634
|
+
});
|
|
635
|
+
child.unref();
|
|
636
|
+
|
|
637
|
+
// Wait for socket (includes time for user to click Allow)
|
|
638
|
+
for (let i = 0; i < DAEMON_CONNECT_RETRIES; i++) {
|
|
639
|
+
await sleep(DAEMON_CONNECT_DELAY);
|
|
640
|
+
try { return await connectToSocket(sp); } catch {}
|
|
641
|
+
}
|
|
642
|
+
throw new Error('Daemon failed to start — did you click Allow in Chrome?');
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function sendCommand(conn, req) {
|
|
646
|
+
return new Promise((resolve, reject) => {
|
|
647
|
+
let buf = '';
|
|
648
|
+
let settled = false;
|
|
649
|
+
|
|
650
|
+
const cleanup = () => {
|
|
651
|
+
conn.off('data', onData);
|
|
652
|
+
conn.off('error', onError);
|
|
653
|
+
conn.off('end', onEnd);
|
|
654
|
+
conn.off('close', onClose);
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
const onData = (chunk) => {
|
|
658
|
+
buf += chunk.toString();
|
|
659
|
+
const idx = buf.indexOf('\n');
|
|
660
|
+
if (idx === -1) return;
|
|
661
|
+
settled = true;
|
|
662
|
+
cleanup();
|
|
663
|
+
resolve(JSON.parse(buf.slice(0, idx)));
|
|
664
|
+
conn.end();
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
const onError = (error) => {
|
|
668
|
+
if (settled) return;
|
|
669
|
+
settled = true;
|
|
670
|
+
cleanup();
|
|
671
|
+
reject(error);
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
const onEnd = () => {
|
|
675
|
+
if (settled) return;
|
|
676
|
+
settled = true;
|
|
677
|
+
cleanup();
|
|
678
|
+
reject(new Error('Connection closed before response'));
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
const onClose = () => {
|
|
682
|
+
if (settled) return;
|
|
683
|
+
settled = true;
|
|
684
|
+
cleanup();
|
|
685
|
+
reject(new Error('Connection closed before response'));
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
conn.on('data', onData);
|
|
689
|
+
conn.on('error', onError);
|
|
690
|
+
conn.on('end', onEnd);
|
|
691
|
+
conn.on('close', onClose);
|
|
692
|
+
req.id = 1;
|
|
693
|
+
conn.write(JSON.stringify(req) + '\n');
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ---------------------------------------------------------------------------
|
|
698
|
+
// Stop daemons
|
|
699
|
+
// ---------------------------------------------------------------------------
|
|
700
|
+
|
|
701
|
+
async function stopDaemons(targetPrefix) {
|
|
702
|
+
if (!existsSync(PAGES_CACHE)) return;
|
|
703
|
+
const pages = JSON.parse(readFileSync(PAGES_CACHE, 'utf8'));
|
|
704
|
+
const targets = targetPrefix
|
|
705
|
+
? [resolvePrefix(targetPrefix, pages.map(p => p.targetId), 'target')]
|
|
706
|
+
: pages.map(p => p.targetId);
|
|
707
|
+
|
|
708
|
+
for (const targetId of targets) {
|
|
709
|
+
const sp = sockPath(targetId);
|
|
710
|
+
try {
|
|
711
|
+
const conn = await connectToSocket(sp);
|
|
712
|
+
await sendCommand(conn, { cmd: 'stop' });
|
|
713
|
+
} catch {
|
|
714
|
+
if (!IS_WINDOWS) try { unlinkSync(sp); } catch {}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// ---------------------------------------------------------------------------
|
|
720
|
+
// Main
|
|
721
|
+
// ---------------------------------------------------------------------------
|
|
722
|
+
|
|
723
|
+
const USAGE = `cdp - lightweight Chrome DevTools Protocol CLI (no Puppeteer)
|
|
724
|
+
|
|
725
|
+
Usage: cdp <command> [args]
|
|
726
|
+
|
|
727
|
+
list List open pages (shows unique target prefixes)
|
|
728
|
+
snap <target> Accessibility tree snapshot
|
|
729
|
+
eval <target> <expr> Evaluate JS expression
|
|
730
|
+
shot <target> [file] Screenshot (default: screenshot-<target>.png in runtime dir); prints coordinate mapping
|
|
731
|
+
html <target> [selector] Get HTML (full page or CSS selector)
|
|
732
|
+
nav <target> <url> Navigate to URL and wait for load completion
|
|
733
|
+
net <target> Network performance entries
|
|
734
|
+
click <target> <selector> Click an element by CSS selector
|
|
735
|
+
clickxy <target> <x> <y> Click at CSS pixel coordinates (see coordinate note below)
|
|
736
|
+
type <target> <text> Type text at current focus via Input.insertText
|
|
737
|
+
Works in cross-origin iframes unlike eval-based approaches
|
|
738
|
+
loadall <target> <selector> [ms] Repeatedly click a "load more" button until it disappears
|
|
739
|
+
Optional interval in ms between clicks (default 1500)
|
|
740
|
+
evalraw <target> <method> [json] Send a raw CDP command; returns JSON result
|
|
741
|
+
e.g. evalraw <t> "DOM.getDocument" '{}'
|
|
742
|
+
open [url] Open a new tab (default: about:blank)
|
|
743
|
+
Note: each new tab triggers a fresh "Allow debugging?" prompt
|
|
744
|
+
stop [target] Stop daemon(s)
|
|
745
|
+
|
|
746
|
+
<target> is a unique targetId prefix from "cdp list". If a prefix is ambiguous,
|
|
747
|
+
use more characters.
|
|
748
|
+
|
|
749
|
+
COORDINATE SYSTEM
|
|
750
|
+
shot captures the viewport at the device's native resolution.
|
|
751
|
+
The screenshot image size = CSS pixels × DPR (device pixel ratio).
|
|
752
|
+
For CDP Input events (clickxy, etc.) you need CSS pixels, not image pixels.
|
|
753
|
+
|
|
754
|
+
CSS pixels = screenshot image pixels / DPR
|
|
755
|
+
|
|
756
|
+
shot prints the DPR and an example conversion for the current page.
|
|
757
|
+
Typical Retina (DPR=2): CSS px ≈ screenshot px × 0.5
|
|
758
|
+
If your viewer rescales the image further, account for that scaling too.
|
|
759
|
+
|
|
760
|
+
EVAL SAFETY NOTE
|
|
761
|
+
Avoid index-based DOM selection (querySelectorAll(...)[i]) across multiple
|
|
762
|
+
eval calls when the list can change between calls (e.g. after clicking
|
|
763
|
+
"Ignore" buttons on a feed — indices shift). Prefer stable selectors or
|
|
764
|
+
collect all data in a single eval.
|
|
765
|
+
|
|
766
|
+
DAEMON IPC (for advanced use / scripting)
|
|
767
|
+
Each tab runs a persistent daemon at Unix socket in the runtime dir (see below).
|
|
768
|
+
Protocol: newline-delimited JSON (one JSON object per line, UTF-8).
|
|
769
|
+
Request: {"id":<number>, "cmd":"<command>", "args":["arg1","arg2",...]}
|
|
770
|
+
Response: {"id":<number>, "ok":true, "result":"<string>"}
|
|
771
|
+
or {"id":<number>, "ok":false, "error":"<message>"}
|
|
772
|
+
Commands mirror the CLI: snap, eval, shot, html, nav, net, click, clickxy,
|
|
773
|
+
type, loadall, evalraw, stop. Use evalraw to send arbitrary CDP methods.
|
|
774
|
+
The socket disappears after 20 min of inactivity or when the tab closes.
|
|
775
|
+
`;
|
|
776
|
+
|
|
777
|
+
const NEEDS_TARGET = new Set([
|
|
778
|
+
'snap','snapshot','eval','shot','screenshot','html','nav','navigate',
|
|
779
|
+
'net','network','click','clickxy','type','loadall','evalraw',
|
|
780
|
+
]);
|
|
781
|
+
|
|
782
|
+
async function main() {
|
|
783
|
+
const [cmd, ...args] = process.argv.slice(2);
|
|
784
|
+
|
|
785
|
+
// Daemon mode (internal)
|
|
786
|
+
if (cmd === '_daemon') { await runDaemon(args[0]); return; }
|
|
787
|
+
|
|
788
|
+
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
789
|
+
console.log(USAGE); process.exit(0);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (cmd === 'list' || cmd === 'ls') {
|
|
793
|
+
const cdp = new CDP();
|
|
794
|
+
await cdp.connect(getWsUrl());
|
|
795
|
+
const pages = await getPages(cdp);
|
|
796
|
+
cdp.close();
|
|
797
|
+
writeFileSync(PAGES_CACHE, JSON.stringify(pages), { mode: 0o600 });
|
|
798
|
+
console.log(formatPageList(pages));
|
|
799
|
+
setTimeout(() => process.exit(0), 100);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Open new tab
|
|
804
|
+
if (cmd === 'open') {
|
|
805
|
+
const url = args[0] || 'about:blank';
|
|
806
|
+
const cdp = new CDP();
|
|
807
|
+
await cdp.connect(getWsUrl());
|
|
808
|
+
const { targetId } = await cdp.send('Target.createTarget', { url });
|
|
809
|
+
// Refresh cache; new tab may not appear in getTargets immediately, so add it manually
|
|
810
|
+
const pages = await getPages(cdp);
|
|
811
|
+
if (!pages.some(p => p.targetId === targetId)) {
|
|
812
|
+
pages.push({ targetId, title: url, url });
|
|
813
|
+
}
|
|
814
|
+
cdp.close();
|
|
815
|
+
writeFileSync(PAGES_CACHE, JSON.stringify(pages), { mode: 0o600 });
|
|
816
|
+
console.log(`Opened new tab: ${targetId.slice(0, 8)} ${url}`);
|
|
817
|
+
console.log('Note: this tab will need "Allow debugging?" approval on first access.');
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Stop
|
|
822
|
+
if (cmd === 'stop') {
|
|
823
|
+
await stopDaemons(args[0]);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Page commands — need target prefix
|
|
828
|
+
if (!NEEDS_TARGET.has(cmd)) {
|
|
829
|
+
console.error(`Unknown command: ${cmd}\n`);
|
|
830
|
+
console.log(USAGE);
|
|
831
|
+
process.exit(1);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const targetPrefix = args[0];
|
|
835
|
+
if (!targetPrefix) {
|
|
836
|
+
console.error('Error: target ID required. Run "cdp list" first.');
|
|
837
|
+
process.exit(1);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Resolve prefix → full targetId from pages cache
|
|
841
|
+
if (!existsSync(PAGES_CACHE)) {
|
|
842
|
+
console.error('No page list cached. Run "cdp list" first.');
|
|
843
|
+
process.exit(1);
|
|
844
|
+
}
|
|
845
|
+
const pages = JSON.parse(readFileSync(PAGES_CACHE, 'utf8'));
|
|
846
|
+
const targetId = resolvePrefix(targetPrefix, pages.map(p => p.targetId), 'target', 'Run "cdp list".');
|
|
847
|
+
|
|
848
|
+
const conn = await getOrStartTabDaemon(targetId);
|
|
849
|
+
|
|
850
|
+
const cmdArgs = args.slice(1);
|
|
851
|
+
|
|
852
|
+
if (cmd === 'eval') {
|
|
853
|
+
const expr = cmdArgs.join(' ');
|
|
854
|
+
if (!expr) { console.error('Error: expression required'); process.exit(1); }
|
|
855
|
+
cmdArgs[0] = expr;
|
|
856
|
+
} else if (cmd === 'type') {
|
|
857
|
+
// Join all remaining args as text (allows spaces)
|
|
858
|
+
const text = cmdArgs.join(' ');
|
|
859
|
+
if (!text) { console.error('Error: text required'); process.exit(1); }
|
|
860
|
+
cmdArgs[0] = text;
|
|
861
|
+
} else if (cmd === 'evalraw') {
|
|
862
|
+
// args: [method, ...jsonParts] — join json parts in case of spaces
|
|
863
|
+
if (!cmdArgs[0]) { console.error('Error: CDP method required'); process.exit(1); }
|
|
864
|
+
if (cmdArgs.length > 2) cmdArgs[1] = cmdArgs.slice(1).join(' ');
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if ((cmd === 'nav' || cmd === 'navigate') && !cmdArgs[0]) {
|
|
868
|
+
console.error('Error: URL required');
|
|
869
|
+
process.exit(1);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const response = await sendCommand(conn, { cmd, args: cmdArgs });
|
|
873
|
+
|
|
874
|
+
if (response.ok) {
|
|
875
|
+
if (response.result) console.log(response.result);
|
|
876
|
+
} else {
|
|
877
|
+
console.error('Error:', response.error);
|
|
878
|
+
process.exitCode = 1;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
main().catch(e => { console.error(e.message); process.exit(1); });
|