pw-repl 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 ADDED
@@ -0,0 +1,24 @@
1
+ # Agent Instructions — playwright-repl
2
+
3
+ How to use the REPL is in `skill/SKILL.md` (the same text `pw-repl skill` prints). Read it first. In
4
+ this clone, `pw-repl` there means `bin/pw-repl.js` (from the folder this file is in) when it is not
5
+ installed globally.
6
+
7
+ ## Contributing
8
+
9
+ If you hit a limitation or write a workaround, consider adding the capability to the REPL instead.
10
+
11
+ - A command is a function in the `commands` object in `lib/commands.js`, plus an entry in `lib/help.js`
12
+ under one topic.
13
+ - `bin/pw-repl.js` is the command line; `lib/start.js` connects and runs the prompt; `lib/runner.js`
14
+ runs commands one at a time; `lib/server.js` is the opt-in server; `lib/send.js` and `lib/client.js`
15
+ are `send` and `where`; `lib/output.js` routes all output so the server can return it.
16
+ - Comment the *why* when it isn't obvious from the code.
17
+ - `npm test` runs the suite (about 40s): a private headless Chromium, a local test site, and the real
18
+ REPL with its server. Nothing is mocked, and the shared browser is never touched. It finds Chromium
19
+ through `PW_TEST_CHROME` or Playwright's installed browsers, and skips the browser tests if there is
20
+ none. Add a test with each new command or behaviour.
21
+ - For anything the tests can't reach, exercise the change in a running REPL. Use your own tmux session
22
+ and a new tab, not the user's.
23
+ - `skill/SKILL.md` is how agents learn to use the REPL: keep it in step with a change to how it is
24
+ used, and leave its Custom rules section empty.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 arcanemachine
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.
package/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # playwright-repl
2
+
3
+ A text REPL for driving an existing Chromium through Playwright over CDP. You and agents can share the same
4
+ browser: people click in it, and the REPL inspects it, fakes responses, and records what happened.
5
+
6
+ ## Getting started
7
+
8
+ ### Prerequisites
9
+
10
+ - Node.js 20 or newer (`npm test` needs 21 or newer).
11
+ - A Chromium-based browser (Chrome, Chromium, ungoogled-chromium, ...).
12
+ - Optional: tmux, to use `pw-repl send` without the command server.
13
+
14
+ ### 1. Start the browser with remote debugging
15
+
16
+ ```bash
17
+ chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.config/chrome-debug"
18
+ # or: chromium --remote-debugging-port=9222 --user-data-dir="$HOME/.config/chromium-debug"
19
+ ```
20
+
21
+ Use a separate `--user-data-dir`: recent Chrome versions do not open the debugging port on the default
22
+ profile. Check it is up with `curl http://localhost:9222/json/version`.
23
+
24
+ ### 2. Install and start the REPL
25
+
26
+ ```bash
27
+ npm install -g pw-repl
28
+ pw-repl run # connects to localhost:9222; pw-repl on its own shows the usage
29
+ ```
30
+
31
+ Without installing: `npx pw-repl` (subcommands work the same way).
32
+ From a clone: `npm install`, then `bin/pw-repl.js run` (or `npm link` to get `pw-repl`).
33
+
34
+ It lists the open tabs and shows a `pw>` prompt. To send it commands from scripts or agents later
35
+ (`pw-repl send`), run it in a tmux session named `playwright-repl` (`tmux new -s playwright-repl`), or
36
+ start it with `pw-repl serve` instead, which needs no tmux.
37
+
38
+ ### 3. Try it
39
+
40
+ ```text
41
+ pw> help # common tasks and topics; help <topic>, help <command>, help --all
42
+ pw> tab new https://example.com # open your own tab to work in
43
+ pw> snapshot # the page by role and name, with [ref=eN] labels
44
+ pw> click aria-ref=e3 # click by label (or any Playwright selector)
45
+ pw> requests # requests the tab made
46
+ pw> tab close
47
+ ```
48
+
49
+ Commands act on the selected tab (`tab` lists the tabs, with `*` on the selected one).
50
+
51
+ ## Common tasks
52
+
53
+ | I want to… | Commands |
54
+ | ------------------------------------ | --------------------------------------------------------------- |
55
+ | see where I am | `tab`, `info` |
56
+ | see what is on the page | `snapshot`, `screenshot` |
57
+ | do something on it | `click`, `fill`, `press` |
58
+ | see what the page requested | `requests`, then `body <#>` for what one got back |
59
+ | see console messages and errors | `console` |
60
+ | show an agent what I do | `watch on`, click around in the browser, then `watch` |
61
+ | see each step as I click | `watch on --live` |
62
+ | record requests and console together | `capture on`, then `capture off` |
63
+ | break the backend on purpose | `route <glob> <status> <json>` (fake a response), `network off` |
64
+ | clean up | `modes off` |
65
+
66
+ Everything else is in `help <topic>`; `help <command>` has usage and caveats.
67
+
68
+ ### Modes
69
+
70
+ `watch`, `capture`, `route` and `network off` stay on until you turn them off: `watch on|off`,
71
+ `capture on|off`, `route ...|route off`, `network off|on`. While any are on in the selected tab, the prompt
72
+ shows them: `(watch network:off routes:2) pw>`. `modes` lists them for every tab, and `modes off` turns them
73
+ all off.
74
+
75
+ `tab`, `watch`, `capture`, `route`, `network` and `modes` on their own show their state and what you can
76
+ run next.
77
+
78
+ ## Options
79
+
80
+ ```bash
81
+ pw-repl run http://localhost:3000 # connect and navigate tab [0] to a URL
82
+ pw-repl serve # also accept commands on /tmp/playwright-repl.sock (prompt: pw[serve]>)
83
+ PW_CDP_URL=http://host:9222 pw-repl run # a browser elsewhere
84
+ ```
85
+
86
+ **Browser on the host, REPL in a container:** with host networking, `localhost:9222` reaches the host's
87
+ browser directly. Otherwise set `PW_CDP_URL` to the host's address as seen from the container (for example
88
+ the Docker bridge gateway, `ip route | awk '/default/ {print $3}'`).
89
+
90
+ ## From scripts and agents
91
+
92
+ ```bash
93
+ pw-repl send info
94
+ pw-repl send help # the REPL's commands; works without a running REPL
95
+ pw-repl where # which REPL send would reach
96
+ ```
97
+
98
+ `pw-repl send` sends one command and prints its result, through the server when it is running and through the
99
+ `playwright-repl` tmux session otherwise. The server speaks HTTP with JSON:
100
+
101
+ ```bash
102
+ curl --unix-socket /tmp/playwright-repl.sock -H 'Content-Type: application/json' \
103
+ -d '{"command": "info"}' http://localhost/run
104
+ ```
105
+
106
+ It returns `{"status": "ok" | "error", "output": "..."}`, plus `"unconfirmed": true` when the command may or
107
+ may not have done what it was sent to do (it timed out, or the REPL quit while it ran and it was not
108
+ read-only). It is off unless started with `pw-repl serve`.
109
+ `pw-repl serve <port>` serves TCP on 127.0.0.1 instead; other addresses are refused.
110
+
111
+ ### A skill for agents
112
+
113
+ `pw-repl skill` prints an agent skill (`SKILL.md`) that teaches an agent to use the REPL: how to start
114
+ it, send commands, and share the browser with a person. Save it as `pw-repl/SKILL.md` in the folder
115
+ your agent reads skills from:
116
+
117
+ ```bash
118
+ mkdir -p <skills folder>/pw-repl
119
+ pw-repl skill > <skills folder>/pw-repl/SKILL.md
120
+ ```
121
+
122
+ The same text is in `skill/SKILL.md`. Working on the REPL itself: see `AGENTS.md`.
123
+
124
+ ## Tests
125
+
126
+ ```bash
127
+ npm test
128
+ ```
129
+
130
+ Runs against a private headless Chromium it starts itself (via `PW_TEST_CHROME`, or Playwright's installed
131
+ browsers: `npx playwright-core install chromium`) and a local test site; the browser tests are skipped when no
132
+ Chromium is found.
package/bin/pw-repl.js ADDED
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ // The one command: pw-repl run | serve | send | where | help | skill.
3
+
4
+ const { looksLikeEndpoint } = require('../lib/client');
5
+
6
+ const USAGE = `Usage:
7
+ pw-repl run [start-url]
8
+ connect to the browser and open the prompt
9
+
10
+ pw-repl serve [endpoint] [start-url]
11
+ the same, plus a command server (socket, port, or 127.0.0.1:port; default /tmp/playwright-repl.sock)
12
+
13
+ pw-repl send [-e endpoint | -s session] [-t seconds] <command...>
14
+ run one command in a running REPL and print its output
15
+
16
+ pw-repl where [-e endpoint | -s session]
17
+ say which REPL send would reach
18
+
19
+ pw-repl help [topic | command | --all]
20
+ this usage; with a topic or command, the REPL's help for it
21
+
22
+ pw-repl skill
23
+ print an agent skill (SKILL.md) that teaches an agent to use the REPL
24
+
25
+ send uses the server when -e, $PW_ENDPOINT, or the socket ($PW_SOCKET, default /tmp/playwright-repl.sock)
26
+ is there, and the tmux session (-s, $PW_TMUX_SESSION, default playwright-repl) otherwise. If the socket
27
+ exists but nothing answers, send fails rather than fall back.
28
+
29
+ send exit status: 0 ok, 1 command error, 2 completion not confirmed, 64 usage or unreachable.
30
+
31
+ The browser must be running with --remote-debugging-port (default http://localhost:9222; set $PW_CDP_URL).`;
32
+
33
+ const REPL_HELP = `The REPL's own commands: help at the pw> prompt, or pw-repl send help here (no REPL needed).
34
+
35
+ pw-repl help <topic | command | --all> shows one part of it.`;
36
+
37
+ function usage() {
38
+ console.error(USAGE);
39
+ process.exit(64);
40
+ }
41
+
42
+ // -e, -s and -t, then the command words (unquoted words are one command).
43
+ function parseSendArgs(args, allowCommand) {
44
+ const options = { endpoint: null, session: null, timeout: 20, command: '' };
45
+ let i = 0;
46
+ for (; i < args.length; i++) {
47
+ const arg = args[i];
48
+ if (arg === '-e' || arg === '-s' || arg === '-t') {
49
+ const value = args[++i];
50
+ if (value === undefined) usage();
51
+ if (arg === '-e') options.endpoint = value;
52
+ if (arg === '-s') options.session = value;
53
+ if (arg === '-t') {
54
+ if (!/^\d+$/.test(value) || Number(value) < 1) usage();
55
+ options.timeout = Number(value);
56
+ }
57
+ } else if (arg === '--') { i++; break; } else break;
58
+ }
59
+ const words = args.slice(i);
60
+ if (!allowCommand && words.length) usage();
61
+ options.command = words.join(' ').trim();
62
+ return options;
63
+ }
64
+
65
+ function startOptions(args, serve) {
66
+ const options = { serve, endpoint: null, startUrl: null };
67
+ const rest = [...args];
68
+ if (serve && rest[0] && looksLikeEndpoint(rest[0])) options.endpoint = rest.shift();
69
+ if (rest.length > 1 || (rest[0] && rest[0].startsWith('-'))) usage();
70
+ options.startUrl = rest[0] || null;
71
+ return options;
72
+ }
73
+
74
+ function help(topic) {
75
+ const helpText = require('../lib/help');
76
+ const text = helpText.render(topic);
77
+ if (text === null) {
78
+ console.log(`Error: No help for ${topic}. Topics: ${Object.keys(helpText.TOPICS).join(', ')}`);
79
+ process.exit(1);
80
+ }
81
+ console.log(text);
82
+ }
83
+
84
+ // Printed as is, to be saved as a skill; the hint goes to the terminal only.
85
+ function skill() {
86
+ process.stdout.write(require('fs').readFileSync(require('path').join(__dirname, '..', 'skill', 'SKILL.md'), 'utf8'));
87
+ if (process.stdout.isTTY) console.error('\nSave it as pw-repl/SKILL.md in the folder your agent reads skills from:\npw-repl skill > <skills folder>/pw-repl/SKILL.md');
88
+ }
89
+
90
+ async function main() {
91
+ let [subcommand, ...args] = process.argv.slice(2);
92
+ // Bare pw-repl explains itself rather than connect to someone's browser.
93
+ if (subcommand === undefined) return console.log(`${USAGE}\n\n${REPL_HELP}`);
94
+ switch (subcommand) {
95
+ case 'run':
96
+ case 'serve':
97
+ return require('../lib/start').start(startOptions(args, subcommand === 'serve'));
98
+ case 'send': {
99
+ const options = parseSendArgs(args, true);
100
+ // help is fixed text, so it needs no browser: answer it here.
101
+ const asksHelp = /^help(?:\s+(.*))?$/.exec(options.command);
102
+ if (asksHelp) return help((asksHelp[1] || '').trim());
103
+ process.exit(await require('../lib/send').send(options));
104
+ }
105
+ // falls through never: process.exit above
106
+ case 'where':
107
+ process.exit(await require('../lib/send').where(parseSendArgs(args, false)));
108
+ // falls through never
109
+ case 'help': {
110
+ const topic = args.join(' ').trim();
111
+ if (topic && topic !== '-h' && topic !== '--help') return help(topic);
112
+ return console.log(`${USAGE}\n\n${REPL_HELP}`);
113
+ }
114
+ case 'skill':
115
+ if (args.length) usage();
116
+ return skill();
117
+ case '-v':
118
+ case '--version':
119
+ return console.log(require('../package.json').version);
120
+ case '-h':
121
+ case '--help':
122
+ return console.log(`${USAGE}\n\n${REPL_HELP}`);
123
+ default:
124
+ return usage();
125
+ }
126
+ }
127
+
128
+ main();
package/lib/client.js ADDED
@@ -0,0 +1,78 @@
1
+ // How to reach a REPL's command server, and one request to it.
2
+ const http = require('http');
3
+ const path = require('path');
4
+
5
+ const DEFAULT_SOCKET = '/tmp/playwright-repl.sock';
6
+ const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1']);
7
+
8
+ // A port or loopback host:port is TCP; anything else is a unix socket path.
9
+ function parseEndpoint(value) {
10
+ if (!value) return { socket: DEFAULT_SOCKET };
11
+ if (/^\d+$/.test(value)) return { host: '127.0.0.1', port: Number(value) };
12
+ const tcp = /^\[?([^\]/]+?)\]?:(\d+)$/.exec(value);
13
+ if (tcp) {
14
+ if (!LOOPBACK.has(tcp[1])) throw new Error(`Refusing non-loopback address ${tcp[1]}; use 127.0.0.1, localhost, or ::1`);
15
+ return { host: tcp[1], port: Number(tcp[2]) };
16
+ }
17
+ return { socket: path.resolve(value) };
18
+ }
19
+
20
+ // Tells an endpoint argument apart from a start URL on the command line.
21
+ function looksLikeEndpoint(value) {
22
+ if (value.includes('://')) return false;
23
+ return /^\d+$/.test(value) || /:\d+$/.test(value) || value.endsWith('.sock') || value.startsWith('/') || value.startsWith('.');
24
+ }
25
+
26
+ function describe(endpoint) {
27
+ return endpoint.socket || `${endpoint.host}:${endpoint.port}`;
28
+ }
29
+
30
+ function target(endpoint) {
31
+ return endpoint.socket ? { socketPath: endpoint.socket } : { host: endpoint.host, port: endpoint.port };
32
+ }
33
+
34
+ // GET /health: does not go through the command queue or show in the pane.
35
+ function health(endpoint, timeoutMs) {
36
+ return new Promise(resolve => {
37
+ const req = http.get({ ...target(endpoint), path: '/health', timeout: timeoutMs }, res => {
38
+ res.resume();
39
+ resolve(res.statusCode === 200);
40
+ });
41
+ req.on('timeout', () => { req.destroy(); resolve(false); });
42
+ req.on('error', () => resolve(false));
43
+ });
44
+ }
45
+
46
+ // One command. Resolves to { result }, { timeout: true }, { dropped: reason }
47
+ // (the connection closed after the command was sent, so it may have run) or
48
+ // { unreachable: reason } (it was never sent).
49
+ function request(endpoint, command, timeoutMs) {
50
+ return new Promise(resolve => {
51
+ const body = JSON.stringify({ command });
52
+ let connected = false;
53
+ const req = http.request({
54
+ ...target(endpoint),
55
+ path: '/run',
56
+ method: 'POST',
57
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
58
+ }, res => {
59
+ let text = '';
60
+ res.setEncoding('utf8');
61
+ res.on('close', () => { if (!res.complete) resolve({ dropped: 'the answer was cut off' }); });
62
+ res.on('data', chunk => { text += chunk; });
63
+ res.on('end', () => {
64
+ try { resolve({ result: JSON.parse(text) }); }
65
+ catch { resolve({ timeout: true }); }
66
+ });
67
+ });
68
+ req.setTimeout(timeoutMs, () => { req.destroy(); resolve({ timeout: true }); });
69
+ req.on('socket', socket => socket.once('connect', () => { connected = true; }));
70
+ req.on('error', error => {
71
+ const reason = error.code || error.message;
72
+ resolve(connected ? { dropped: reason } : { unreachable: reason });
73
+ });
74
+ req.end(body);
75
+ });
76
+ }
77
+
78
+ module.exports = { DEFAULT_SOCKET, parseEndpoint, looksLikeEndpoint, describe, health, request };