@digital-gravy/etch-connector 0.1.3 → 0.1.5
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/README.md +22 -32
- package/dist/cli.js +209 -145
- package/package.json +1 -1
- package/dist/manual.js +0 -2
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ Claude Code / Cowork
|
|
|
23
23
|
│ shell: etch-connector eval -t homepage "return ..."
|
|
24
24
|
▼
|
|
25
25
|
etch-connector (the `serve` daemon)
|
|
26
|
-
│ ▲ WebSocket (tab dials in via etch.
|
|
26
|
+
│ ▲ WebSocket (tab dials in via etch.connectAs)
|
|
27
27
|
▼ │
|
|
28
28
|
Etch builder tab ──► window.etch.*
|
|
29
29
|
```
|
|
@@ -32,8 +32,7 @@ Claude Code / Cowork
|
|
|
32
32
|
|
|
33
33
|
This is an **eval relay**: the harness sends a JavaScript snippet, the page runs
|
|
34
34
|
it as an async function, and the return value + console output come back. It is
|
|
35
|
-
|
|
36
|
-
thin add — see [Going to MCP](#going-to-mcp-later).
|
|
35
|
+
not an MCP server yet — see [Going to MCP](#going-to-mcp-later).
|
|
37
36
|
|
|
38
37
|
Scripts run in a **page-enforced safe mode**: the script sees only `etch` and
|
|
39
38
|
the standard JS built-ins. `window`, `document`, `fetch`, storage, `eval`, … are
|
|
@@ -75,13 +74,19 @@ etch-connector serve
|
|
|
75
74
|
|
|
76
75
|
### 2. Connect a tab
|
|
77
76
|
|
|
78
|
-
|
|
77
|
+
**Primary way — the AI button.** Enable the **AI Connector** in the Etch
|
|
78
|
+
Settings, then open an Etch builder page and click the **AI button** in the left
|
|
79
|
+
sidebar. It connects this tab for you, registering it under your site name (it
|
|
80
|
+
just calls `connectAs` under the hood). Click it again to disconnect.
|
|
81
|
+
|
|
82
|
+
**Secondary way — the console.** You can also connect manually from the browser
|
|
83
|
+
console:
|
|
79
84
|
|
|
80
85
|
```js
|
|
81
|
-
etch.
|
|
86
|
+
etch.connectAs('homepage');
|
|
82
87
|
```
|
|
83
88
|
|
|
84
|
-
(`
|
|
89
|
+
(`connectAs` is part of the public API; it opens the WebSocket back to the
|
|
85
90
|
daemon and registers this tab under the given name. Pass `{ port: <n> }` if you
|
|
86
91
|
started the daemon on a non-default WebSocket port.)
|
|
87
92
|
|
|
@@ -127,8 +132,10 @@ The authoritative agent instructions live in **`etch-connector --help`** — rea
|
|
|
127
132
|
the entire output before interacting with Etch. It covers the backends, commands
|
|
128
133
|
and flags, the stdout/stderr/exit-code contract, how to pull the current Etch
|
|
129
134
|
docs (Context7 MCP or <https://docs.etchwp.com/public-api>), and the conventions
|
|
130
|
-
for structuring pages (e.g. the section/container layout pattern).
|
|
131
|
-
|
|
135
|
+
for structuring pages (e.g. the section/container layout pattern). The public API
|
|
136
|
+
may also expose ready-made skills for common tasks — check the docs before
|
|
137
|
+
improvising. Project conventions for working on this repo live in
|
|
138
|
+
[`AGENTS.md`](AGENTS.md).
|
|
132
139
|
|
|
133
140
|
## CLI
|
|
134
141
|
|
|
@@ -144,7 +151,7 @@ Usage:
|
|
|
144
151
|
etch-connector computed <selector> [-t name] [--props a,b,c] (CDP)
|
|
145
152
|
|
|
146
153
|
Two backends:
|
|
147
|
-
bridge (default) the tab dials in via etch.
|
|
154
|
+
bridge (default) the tab dials in via etch.connectAs(); page-sandboxed.
|
|
148
155
|
Scripts see only "etch" + the standard JS built-ins.
|
|
149
156
|
cdp connect OUT to a Chrome started with --remote-debugging-port=9222.
|
|
150
157
|
No daemon. Runs at DevTools privilege: full "window", plus screenshots,
|
|
@@ -191,22 +198,10 @@ the same one a future MCP server would call):
|
|
|
191
198
|
|
|
192
199
|
## Going to MCP later
|
|
193
200
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
import { BridgeServer } from 'etch-connector';
|
|
199
|
-
|
|
200
|
-
const bridge = new BridgeServer();
|
|
201
|
-
await bridge.listen();
|
|
202
|
-
|
|
203
|
-
// list_etch_tabs -> bridge.listTabs()
|
|
204
|
-
// eval_etch_script -> bridge.evalInTab(code, { tab })
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
When you want first-class per-tool MCP tools (rather than one eval tool), have
|
|
208
|
-
the Etch tab advertise a tool manifest in its `hello` and register those
|
|
209
|
-
dynamically — keeping a single source of truth in the browser registry.
|
|
201
|
+
Today this is a CLI eval relay. A future version will expose the same bridge as
|
|
202
|
+
a proper MCP server, so an AI harness can call first-class tools (list tabs, run
|
|
203
|
+
a script) directly instead of shelling out — without changing how you connect a
|
|
204
|
+
tab.
|
|
210
205
|
|
|
211
206
|
## Security
|
|
212
207
|
|
|
@@ -223,10 +218,5 @@ hardened sandbox**: because the script shares the page's JS realm, a _determined
|
|
|
223
218
|
author can still reach globals through prototype / constructor chains (e.g.
|
|
224
219
|
`({}).constructor.constructor`). It reliably keeps casual and AI-authored
|
|
225
220
|
scripts on the `etch` surface and closes the obvious escape hatches; it does
|
|
226
|
-
**not** contain hostile code.
|
|
227
|
-
|
|
228
|
-
For a true boundary against adversarial scripts you would need to harden the
|
|
229
|
-
realm (SES `lockdown()`), which freezes all intrinsics app-wide and is out of
|
|
230
|
-
scope for this relay. The enforcement lives in the Etch page (`bridge.ts`), not
|
|
231
|
-
this connector — the connector only carries the requested mode; a string filter
|
|
232
|
-
here would be theater.
|
|
221
|
+
**not** contain hostile code. For a true boundary against adversarial scripts,
|
|
222
|
+
run the daemon only on a trusted machine.
|
package/dist/cli.js
CHANGED
|
@@ -36,49 +36,58 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
36
36
|
'help',
|
|
37
37
|
'freeze'
|
|
38
38
|
]);
|
|
39
|
+
/** Single-letter aliases → their long flag name. */
|
|
40
|
+
const SHORT_FLAGS = {
|
|
41
|
+
t: 'tab',
|
|
42
|
+
f: 'file',
|
|
43
|
+
p: 'port',
|
|
44
|
+
s: 'selector',
|
|
45
|
+
o: 'out'
|
|
46
|
+
};
|
|
47
|
+
/** A non-flag token is a value iff it exists and isn't itself a flag. */
|
|
48
|
+
function isValueToken(next) {
|
|
49
|
+
return next !== undefined && !next.startsWith('-');
|
|
50
|
+
}
|
|
51
|
+
/** Record a `--long` flag; returns true if it consumed `next` as its value. */
|
|
52
|
+
function applyLongFlag(flags, arg, next) {
|
|
53
|
+
// Support --key=value (e.g. --freeze=false) explicitly.
|
|
54
|
+
const eq = arg.indexOf('=');
|
|
55
|
+
if (eq !== -1) {
|
|
56
|
+
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const key = arg.slice(2);
|
|
60
|
+
if (!BOOLEAN_FLAGS.has(key) && isValueToken(next)) {
|
|
61
|
+
flags[key] = next;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
flags[key] = true;
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
/** Record a `-x` short flag; returns true if it consumed `next` as its value. */
|
|
68
|
+
function applyShortFlag(flags, arg, next) {
|
|
69
|
+
const key = SHORT_FLAGS[arg.slice(1)] ?? arg.slice(1);
|
|
70
|
+
if (isValueToken(next)) {
|
|
71
|
+
flags[key] = next;
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
flags[key] = true;
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
39
77
|
function parseArgs(argv) {
|
|
40
78
|
const command = argv[0] ?? 'help';
|
|
41
79
|
const positional = [];
|
|
42
80
|
const flags = {};
|
|
43
81
|
for (let i = 1; i < argv.length; i++) {
|
|
44
82
|
const arg = argv[i];
|
|
83
|
+
const next = argv[i + 1];
|
|
45
84
|
if (arg.startsWith('--')) {
|
|
46
|
-
|
|
47
|
-
const eq = arg.indexOf('=');
|
|
48
|
-
if (eq !== -1) {
|
|
49
|
-
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
const key = arg.slice(2);
|
|
53
|
-
const next = argv[i + 1];
|
|
54
|
-
if (BOOLEAN_FLAGS.has(key)) {
|
|
55
|
-
flags[key] = true;
|
|
56
|
-
}
|
|
57
|
-
else if (next !== undefined && !next.startsWith('-')) {
|
|
58
|
-
flags[key] = next;
|
|
85
|
+
if (applyLongFlag(flags, arg, next))
|
|
59
86
|
i++;
|
|
60
|
-
}
|
|
61
|
-
else {
|
|
62
|
-
flags[key] = true;
|
|
63
|
-
}
|
|
64
87
|
}
|
|
65
88
|
else if (arg.startsWith('-') && arg.length === 2) {
|
|
66
|
-
|
|
67
|
-
t: 'tab',
|
|
68
|
-
f: 'file',
|
|
69
|
-
p: 'port',
|
|
70
|
-
s: 'selector',
|
|
71
|
-
o: 'out'
|
|
72
|
-
};
|
|
73
|
-
const key = map[arg.slice(1)] ?? arg.slice(1);
|
|
74
|
-
const next = argv[i + 1];
|
|
75
|
-
if (next !== undefined && !next.startsWith('-')) {
|
|
76
|
-
flags[key] = next;
|
|
89
|
+
if (applyShortFlag(flags, arg, next))
|
|
77
90
|
i++;
|
|
78
|
-
}
|
|
79
|
-
else {
|
|
80
|
-
flags[key] = true;
|
|
81
|
-
}
|
|
82
91
|
}
|
|
83
92
|
else {
|
|
84
93
|
positional.push(arg);
|
|
@@ -105,6 +114,10 @@ function bool(flag, fallback) {
|
|
|
105
114
|
return flag;
|
|
106
115
|
return !/^(false|0|no|off)$/i.test(flag);
|
|
107
116
|
}
|
|
117
|
+
/** Pretty-print a value to stdout as JSON (the clean machine-readable result). */
|
|
118
|
+
function writeJson(value) {
|
|
119
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
120
|
+
}
|
|
108
121
|
function readStdin() {
|
|
109
122
|
return new Promise((resolve) => {
|
|
110
123
|
let data = '';
|
|
@@ -113,6 +126,15 @@ function readStdin() {
|
|
|
113
126
|
process.stdin.on('end', () => resolve(data));
|
|
114
127
|
});
|
|
115
128
|
}
|
|
129
|
+
/** The startup banner printed once both servers are listening. */
|
|
130
|
+
function printServeBanner(wsHost, boundWsPort, controlHost, controlPort, defaultMode) {
|
|
131
|
+
console.error(`[etch-connector] bridge ws://${wsHost}:${boundWsPort}`);
|
|
132
|
+
console.error(`[etch-connector] control ${controlBaseUrl({ host: controlHost, port: controlPort })}`);
|
|
133
|
+
console.error(`[etch-connector] default mode: ${defaultMode}${defaultMode === 'unsafe' ? ' ⚠ scripts get full access to the page' : ' (scripts see only window.etch + JS built-ins)'}`);
|
|
134
|
+
console.error('[etch-connector] In an Etch builder tab, run:');
|
|
135
|
+
console.error(`[etch-connector] etch.connectAs('my-tab'${boundWsPort === DEFAULT_WS_PORT ? '' : `, { port: ${boundWsPort} }`})`);
|
|
136
|
+
console.error('[etch-connector] then drive it with: etch-connector eval -t my-tab "return window.etch.apiVersion"');
|
|
137
|
+
}
|
|
116
138
|
async function runServe(flags) {
|
|
117
139
|
const wsHost = str(flags['ws-host']) ?? DEFAULT_HOST;
|
|
118
140
|
const wsPort = num(flags['ws-port'], DEFAULT_WS_PORT);
|
|
@@ -129,12 +151,7 @@ async function runServe(flags) {
|
|
|
129
151
|
// Periodic liveness probe so dead tabs that never fired `close` get pruned.
|
|
130
152
|
const heartbeat = setInterval(() => bridge.pingAll(), 30_000);
|
|
131
153
|
heartbeat.unref();
|
|
132
|
-
|
|
133
|
-
console.error(`[etch-connector] control ${controlBaseUrl({ host: controlHost, port: controlPort })}`);
|
|
134
|
-
console.error(`[etch-connector] default mode: ${defaultMode}${defaultMode === 'unsafe' ? ' ⚠ scripts get full access to the page' : ' (scripts see only window.etch + JS built-ins)'}`);
|
|
135
|
-
console.error('[etch-connector] In an Etch builder tab, run:');
|
|
136
|
-
console.error(`[etch-connector] etch.connectTo('my-tab'${boundWsPort === DEFAULT_WS_PORT ? '' : `, { port: ${boundWsPort} }`})`);
|
|
137
|
-
console.error('[etch-connector] then drive it with: etch-connector eval -t my-tab "return window.etch.apiVersion"');
|
|
154
|
+
printServeBanner(wsHost, boundWsPort, controlHost, controlPort, defaultMode);
|
|
138
155
|
const shutdown = () => {
|
|
139
156
|
clearInterval(heartbeat);
|
|
140
157
|
control.close();
|
|
@@ -149,6 +166,12 @@ function controlUrlFromFlags(flags) {
|
|
|
149
166
|
port: num(flags['control-port'], DEFAULT_CONTROL_PORT)
|
|
150
167
|
});
|
|
151
168
|
}
|
|
169
|
+
/** Hit a daemon endpoint, turning a connection failure into a friendly error. */
|
|
170
|
+
async function controlFetch(flags, path, init) {
|
|
171
|
+
return fetch(`${controlUrlFromFlags(flags)}${path}`, init).catch((err) => {
|
|
172
|
+
throw new Error(`cannot reach the connector daemon — is \`etch-connector serve\` running? (${err})`);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
152
175
|
/**
|
|
153
176
|
* CDP (Chrome remote-debugging) config when the caller opted in via `--cdp`,
|
|
154
177
|
* `--cdp-port`, `--cdp-host`, or `--remote-debugging-port`; otherwise null.
|
|
@@ -219,30 +242,44 @@ function failCdp(err, port = DEFAULT_CDP_PORT) {
|
|
|
219
242
|
}
|
|
220
243
|
process.exit(1);
|
|
221
244
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (targets.length === 0) {
|
|
231
|
-
console.error('No Chrome page targets open.');
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
for (const t of targets)
|
|
235
|
-
process.stdout.write(`${t.title}\t${t.url}\n`);
|
|
236
|
-
return;
|
|
245
|
+
/**
|
|
246
|
+
* Run a CDP command against the required endpoint (defaulting to localhost:9222
|
|
247
|
+
* for the CDP-only commands), translating any CDP error into the right exit.
|
|
248
|
+
*/
|
|
249
|
+
async function withRequiredCdp(flags, fn) {
|
|
250
|
+
const cdp = requireCdp(flags);
|
|
251
|
+
try {
|
|
252
|
+
return await withCdp(cdp, str(flags['tab']), fn);
|
|
237
253
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
254
|
+
catch (err) {
|
|
255
|
+
failCdp(err, cdp.port);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/** Resolve a required CSS selector from `-s/--selector` or the first positional. */
|
|
259
|
+
function requireSelector(command, positional, flags) {
|
|
260
|
+
const selector = str(flags['selector']) ?? positional[0];
|
|
261
|
+
if (!selector) {
|
|
262
|
+
console.error(`${command}: pass a CSS selector (-s/--selector or positional)`);
|
|
263
|
+
process.exit(1);
|
|
264
|
+
}
|
|
265
|
+
return selector;
|
|
266
|
+
}
|
|
267
|
+
async function tabsViaCdp(cdp, flags) {
|
|
268
|
+
const targets = await CdpClient.listTargets(cdp).catch((e) => failCdp(e, cdp.port));
|
|
269
|
+
if (flags['json'])
|
|
270
|
+
return writeJson(targets);
|
|
271
|
+
if (targets.length === 0) {
|
|
272
|
+
console.error('No Chrome page targets open.');
|
|
244
273
|
return;
|
|
245
274
|
}
|
|
275
|
+
for (const t of targets)
|
|
276
|
+
process.stdout.write(`${t.title}\t${t.url}\n`);
|
|
277
|
+
}
|
|
278
|
+
async function tabsViaDaemon(flags) {
|
|
279
|
+
const res = await controlFetch(flags, '/tabs');
|
|
280
|
+
const { tabs } = (await res.json());
|
|
281
|
+
if (flags['json'])
|
|
282
|
+
return writeJson(tabs);
|
|
246
283
|
if (tabs.length === 0) {
|
|
247
284
|
console.error('No Etch tabs connected.');
|
|
248
285
|
return;
|
|
@@ -251,6 +288,10 @@ async function runTabs(flags) {
|
|
|
251
288
|
process.stdout.write(`${tab.name}\t${tab.hasEtch ? 'etch' : 'no-etch'}\t${tab.title}\t${tab.url}\n`);
|
|
252
289
|
}
|
|
253
290
|
}
|
|
291
|
+
async function runTabs(flags) {
|
|
292
|
+
const cdp = cdpFromFlags(flags);
|
|
293
|
+
return cdp ? tabsViaCdp(cdp, flags) : tabsViaDaemon(flags);
|
|
294
|
+
}
|
|
254
295
|
async function gatherCode(positional, flags) {
|
|
255
296
|
const file = str(flags['file']);
|
|
256
297
|
if (file)
|
|
@@ -259,49 +300,52 @@ async function gatherCode(positional, flags) {
|
|
|
259
300
|
return positional.join(' ');
|
|
260
301
|
return (await readStdin()).trim();
|
|
261
302
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
console.error('eval: no script provided (pass code as an argument, --file, or stdin)');
|
|
266
|
-
process.exit(1);
|
|
267
|
-
}
|
|
268
|
-
const timeoutMs = typeof flags['timeout'] === 'string'
|
|
303
|
+
/** `--timeout ms` if given a numeric value, else undefined (daemon default). */
|
|
304
|
+
function timeoutFromFlags(flags) {
|
|
305
|
+
return typeof flags['timeout'] === 'string'
|
|
269
306
|
? num(flags['timeout'], 30_000)
|
|
270
307
|
: undefined;
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
catch (err) {
|
|
279
|
-
failCdp(err, cdp.port);
|
|
280
|
-
}
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
// --safe overrides the daemon default; omitting it defers to the daemon.
|
|
284
|
-
// --unsafe is a dead path (see ALLOW_UNSAFE): it never sets unsafe mode.
|
|
285
|
-
let mode;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* --safe overrides the daemon default; omitting it defers to the daemon.
|
|
311
|
+
* --unsafe is a dead path (see ALLOW_UNSAFE): it never sets unsafe mode.
|
|
312
|
+
*/
|
|
313
|
+
function resolveEvalMode(flags) {
|
|
286
314
|
if (ALLOW_UNSAFE && flags['unsafe'])
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
315
|
+
return 'unsafe';
|
|
316
|
+
if (flags['safe'])
|
|
317
|
+
return 'safe';
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
/** Forwarded console output → stderr, so stdout stays clean for the value. */
|
|
321
|
+
function printForwardedLogs(logs) {
|
|
322
|
+
for (const log of logs) {
|
|
323
|
+
const text = log.args
|
|
324
|
+
.map((a) => (typeof a === 'string' ? a : JSON.stringify(a)))
|
|
325
|
+
.join(' ');
|
|
326
|
+
console.error(`[${log.level}] ${text}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// CDP mode: eval over Chrome remote debugging (full window access, no daemon).
|
|
330
|
+
async function evalViaCdp(cdp, flags, code, timeoutMs) {
|
|
331
|
+
try {
|
|
332
|
+
const value = await withCdp(cdp, str(flags['tab']), (c) => c.eval(code, { timeoutMs }));
|
|
333
|
+
writeJson(value);
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
failCdp(err, cdp.port);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async function evalViaDaemon(flags, code, timeoutMs) {
|
|
340
|
+
const mode = resolveEvalMode(flags);
|
|
290
341
|
if (mode === 'unsafe') {
|
|
291
342
|
console.error('⚠ running in unsafe mode — the script has full access to the page');
|
|
292
343
|
}
|
|
293
|
-
const body = {
|
|
294
|
-
|
|
295
|
-
code,
|
|
296
|
-
timeoutMs,
|
|
297
|
-
mode
|
|
298
|
-
};
|
|
299
|
-
const res = await fetch(`${controlUrlFromFlags(flags)}/eval`, {
|
|
344
|
+
const body = { tab: str(flags['tab']), code, timeoutMs, mode };
|
|
345
|
+
const res = await controlFetch(flags, '/eval', {
|
|
300
346
|
method: 'POST',
|
|
301
347
|
headers: { 'content-type': 'application/json' },
|
|
302
348
|
body: JSON.stringify(body)
|
|
303
|
-
}).catch((err) => {
|
|
304
|
-
throw new Error(`cannot reach the connector daemon — is \`etch-connector serve\` running? (${err})`);
|
|
305
349
|
});
|
|
306
350
|
if (!res.ok) {
|
|
307
351
|
const err = (await res.json().catch(() => ({})));
|
|
@@ -309,82 +353,61 @@ async function runEval(positional, flags) {
|
|
|
309
353
|
process.exit(1);
|
|
310
354
|
}
|
|
311
355
|
const result = (await res.json());
|
|
312
|
-
|
|
313
|
-
for (const log of result.logs) {
|
|
314
|
-
const text = log.args
|
|
315
|
-
.map((a) => (typeof a === 'string' ? a : JSON.stringify(a)))
|
|
316
|
-
.join(' ');
|
|
317
|
-
console.error(`[${log.level}] ${text}`);
|
|
318
|
-
}
|
|
356
|
+
printForwardedLogs(result.logs);
|
|
319
357
|
if (!result.ok) {
|
|
320
358
|
console.error(`script error: ${result.error}`);
|
|
321
359
|
if (result.stack)
|
|
322
360
|
console.error(result.stack);
|
|
323
361
|
process.exit(2);
|
|
324
362
|
}
|
|
325
|
-
|
|
363
|
+
writeJson(result.value);
|
|
364
|
+
}
|
|
365
|
+
async function runEval(positional, flags) {
|
|
366
|
+
const code = await gatherCode(positional, flags);
|
|
367
|
+
if (!code) {
|
|
368
|
+
console.error('eval: no script provided (pass code as an argument, --file, or stdin)');
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
const timeoutMs = timeoutFromFlags(flags);
|
|
372
|
+
const cdp = cdpFromFlags(flags);
|
|
373
|
+
return cdp
|
|
374
|
+
? evalViaCdp(cdp, flags, code, timeoutMs)
|
|
375
|
+
: evalViaDaemon(flags, code, timeoutMs);
|
|
326
376
|
}
|
|
327
377
|
/** `shot` / `html` / `computed` are CDP-only; default to localhost:9222. */
|
|
328
378
|
function requireCdp(flags) {
|
|
329
379
|
return (cdpFromFlags(flags) ?? { host: DEFAULT_HOST, port: DEFAULT_CDP_PORT });
|
|
330
380
|
}
|
|
331
381
|
async function runShot(flags) {
|
|
332
|
-
const cdp = requireCdp(flags);
|
|
333
382
|
const selector = str(flags['selector']);
|
|
334
383
|
const full = Boolean(flags['full']);
|
|
335
384
|
const format = flags['jpeg'] ? 'jpeg' : 'png';
|
|
336
385
|
const freeze = bool(flags['freeze'], true); // default on (animations → deterministic)
|
|
337
386
|
const out = str(flags['out']) ?? `etch-shot.${format === 'jpeg' ? 'jpg' : 'png'}`;
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
console.error(`wrote ${out} (${buf.length} bytes)${scope}${freeze ? ' (frozen)' : ''}`);
|
|
347
|
-
}
|
|
348
|
-
catch (err) {
|
|
349
|
-
failCdp(err, cdp.port);
|
|
350
|
-
}
|
|
387
|
+
const buf = await withRequiredCdp(flags, (c) => c.screenshot({ selector, full, format, freeze }));
|
|
388
|
+
writeFileSync(out, buf);
|
|
389
|
+
const scope = selector
|
|
390
|
+
? ` — clipped to ${selector}`
|
|
391
|
+
: full
|
|
392
|
+
? ' — full page'
|
|
393
|
+
: ' — viewport';
|
|
394
|
+
console.error(`wrote ${out} (${buf.length} bytes)${scope}${freeze ? ' (frozen)' : ''}`);
|
|
351
395
|
}
|
|
352
396
|
async function runHtml(positional, flags) {
|
|
353
|
-
const selector =
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
process.exit(1);
|
|
357
|
-
}
|
|
358
|
-
const cdp = requireCdp(flags);
|
|
359
|
-
try {
|
|
360
|
-
const html = await withCdp(cdp, str(flags['tab']), (c) => c.outerHtml(selector));
|
|
361
|
-
process.stdout.write(`${html}\n`);
|
|
362
|
-
}
|
|
363
|
-
catch (err) {
|
|
364
|
-
failCdp(err, cdp.port);
|
|
365
|
-
}
|
|
397
|
+
const selector = requireSelector('html', positional, flags);
|
|
398
|
+
const html = await withRequiredCdp(flags, (c) => c.outerHtml(selector));
|
|
399
|
+
process.stdout.write(`${html}\n`);
|
|
366
400
|
}
|
|
367
401
|
async function runComputed(positional, flags) {
|
|
368
|
-
const selector =
|
|
369
|
-
if (!selector) {
|
|
370
|
-
console.error('computed: pass a CSS selector (-s/--selector or positional)');
|
|
371
|
-
process.exit(1);
|
|
372
|
-
}
|
|
402
|
+
const selector = requireSelector('computed', positional, flags);
|
|
373
403
|
const props = str(flags['props'])
|
|
374
404
|
?.split(',')
|
|
375
405
|
.map((p) => p.trim())
|
|
376
406
|
.filter(Boolean);
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
const style = await withCdp(cdp, str(flags['tab']), (c) => c.computedStyle(selector, props));
|
|
380
|
-
process.stdout.write(`${JSON.stringify(style, null, 2)}\n`);
|
|
381
|
-
}
|
|
382
|
-
catch (err) {
|
|
383
|
-
failCdp(err, cdp.port);
|
|
384
|
-
}
|
|
407
|
+
const style = await withRequiredCdp(flags, (c) => c.computedStyle(selector, props));
|
|
408
|
+
writeJson(style);
|
|
385
409
|
}
|
|
386
|
-
|
|
387
|
-
console.error(`etch-connector — drive a live Etch tab from the command line
|
|
410
|
+
const HELP_TEXT = `etch-connector — drive a live Etch tab from the command line
|
|
388
411
|
|
|
389
412
|
Run without installing (requires a valid, active Etch license):
|
|
390
413
|
npx @digital-gravy/etch-connector serve
|
|
@@ -403,7 +426,7 @@ Usage:
|
|
|
403
426
|
etch-connector computed <selector> [-t name] [--props a,b,c] (CDP)
|
|
404
427
|
|
|
405
428
|
Two backends:
|
|
406
|
-
bridge (default) the tab dials in via etch.
|
|
429
|
+
bridge (default) the tab dials in via etch.connectAs(); page-sandboxed.
|
|
407
430
|
Scripts see only "etch" + the standard JS built-ins.
|
|
408
431
|
cdp connect OUT to a Chrome started with --remote-debugging-port=${DEFAULT_CDP_PORT}.
|
|
409
432
|
No daemon. Runs at DevTools privilege: full "window", plus screenshots,
|
|
@@ -414,6 +437,17 @@ AGENT: before running anything, ask the user how they want to connect — bridge
|
|
|
414
437
|
(API only) or cdp (DevTools privilege: full page access). cdp grants more reach;
|
|
415
438
|
don't assume — let the user choose.
|
|
416
439
|
|
|
440
|
+
Connecting a bridge tab (AGENT: walk the user through activating it — the AI
|
|
441
|
+
button is the primary path; don't just wait for a tab to appear):
|
|
442
|
+
1. Tell them to open Etch Settings and enable the AI Connector.
|
|
443
|
+
2. Tell them to open a builder page and click the AI button in the left
|
|
444
|
+
sidebar — it connects the tab and registers it under the site name (calls
|
|
445
|
+
connectAs under the hood); clicking it again disconnects.
|
|
446
|
+
Fallback — they can connect from the browser console:
|
|
447
|
+
etch.connectAs('homepage') (add { port: <n> } for a non-default WS port)
|
|
448
|
+
cdp needs no connect step — start Chrome with --remote-debugging-port and
|
|
449
|
+
target a tab with -t.
|
|
450
|
+
|
|
417
451
|
Examples:
|
|
418
452
|
etch-connector serve
|
|
419
453
|
etch-connector tabs
|
|
@@ -430,6 +464,31 @@ The eval body runs as an async function, so it may 'return' a value and use
|
|
|
430
464
|
top-level 'await'. In bridge mode reference the API as "etch.*" ("window" is
|
|
431
465
|
withheld); in cdp mode the full page is available.
|
|
432
466
|
|
|
467
|
+
AI presence (AGENT: drive the etch.ai overlay so the human watching the tab
|
|
468
|
+
sees an agent is active — this connector is exactly what etch.ai is built for.
|
|
469
|
+
This overlay is for the human, NOT the user you're chatting with: NEVER mention
|
|
470
|
+
it, narrate it, or explain these state changes — just keep it accurate):
|
|
471
|
+
etch.ai.setState(state) shows/updates the presence overlay in the builder.
|
|
472
|
+
etch.ai.getState() reads the current state.
|
|
473
|
+
etch.ai.hide() removes the overlay (shorthand for setState("idle")).
|
|
474
|
+
state is one of: "idle" (overlay hidden), "thinking" (reasoning before acting),
|
|
475
|
+
"working" (a script is actually running against the canvas), "waiting"
|
|
476
|
+
(paused, needs more input from the human). Any other value throws
|
|
477
|
+
EtchApiError INVALID_ARGUMENT. Calls are synchronous, in-memory, and NOT
|
|
478
|
+
persisted — no saveAsync, nothing survives a reload.
|
|
479
|
+
When to set what:
|
|
480
|
+
• The MOMENT a user asks you to build/change something — before reading docs,
|
|
481
|
+
writing scripts, or any other step — put the overlay up: setState('thinking').
|
|
482
|
+
• setState('thinking') while you reason/plan (no script running yet).
|
|
483
|
+
• setState('working') right before an eval actually runs against the canvas;
|
|
484
|
+
back to 'thinking' between scripts if you pause to reason.
|
|
485
|
+
• hide() the instant the task is finished — never leave it up on completion.
|
|
486
|
+
• setState('waiting') ONLY when you are NOT done and are handing back to the
|
|
487
|
+
human for input/a decision; resume 'thinking'/'working' when they reply.
|
|
488
|
+
Wrap script runs in try/finally so an error still leaves the right state:
|
|
489
|
+
etch.ai.setState('working');
|
|
490
|
+
try { /* …drive the builder… */ } finally { etch.ai.setState('thinking'); }
|
|
491
|
+
|
|
433
492
|
Docs (AGENT: pull these BEFORE writing scripts against etch.* — the API surface
|
|
434
493
|
changes and training data may be stale):
|
|
435
494
|
Context7 MCP (preferred) resolve-library-id "Etch" → query-docs for the
|
|
@@ -437,6 +496,9 @@ changes and training data may be stale):
|
|
|
437
496
|
Manual fallback https://docs.etchwp.com/public-api (window.etch.*)
|
|
438
497
|
https://docs.etchwp.com (everything else)
|
|
439
498
|
|
|
499
|
+
Skills (AGENT: the public API may expose ready-made skills for common tasks —
|
|
500
|
+
check the docs before improvising).
|
|
501
|
+
|
|
440
502
|
Building Etch pages (AGENT: conventions — follow these or the builder won't
|
|
441
503
|
render what you author):
|
|
442
504
|
Images. ALWAYS use the Dynamic Image element (etch/dynamic-image, <etch:img>),
|
|
@@ -482,7 +544,9 @@ render what you author):
|
|
|
482
544
|
(rest_route form works on plain permalinks; /wp-json/wp/v2/media on pretty
|
|
483
545
|
ones.)
|
|
484
546
|
|
|
485
|
-
See AGENTS.md for project conventions
|
|
547
|
+
See AGENTS.md for project conventions.`;
|
|
548
|
+
function printHelp() {
|
|
549
|
+
console.error(HELP_TEXT);
|
|
486
550
|
}
|
|
487
551
|
async function main() {
|
|
488
552
|
const { command, positional, flags } = parseArgs(process.argv.slice(2));
|
package/package.json
CHANGED
package/dist/manual.js
DELETED
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
// AUTO-GENERATED from README.md by scripts/build-manual.mjs — do not edit by hand.
|
|
2
|
-
export const MANUAL = "# etch-connector\n\nA tiny local bridge that lets an AI harness — Claude Code, Cowork, or any tool\nthat can run a shell command — **drive a live Etch builder tab** by evaluating\nscripts against the [`etch-public-api`](https://www.npmjs.com/package/@digital-gravy/etch-public-api)\nsurface (`window.etch.*`).\n\nEtch's scriptable API only exists *inside the browser*. There is no server you\ncan call directly. So this connector inverts the usual direction: the browser\n**dials out** to the connector over a WebSocket (the same way Chrome remote\ndebugging works, but in reverse), and the connector relays scripts into the tab\nand streams the result back.\n\n```\nClaude Code / Cowork\n │ shell: etch-connector eval -t homepage \"return ...\"\n ▼\n etch-connector (the `serve` daemon)\n │ ▲ WebSocket (tab dials in via etch.connectTo)\n ▼ │\n Etch builder tab ──► window.etch.*\n```\n\n## Status\n\nThis is an **eval relay**: the harness sends a JavaScript snippet, the page runs\nit as an async function, and the return value + console output come back. It is\nintentionally *not* an MCP server yet, but it is built so that becoming one is a\nthin add — see [Going to MCP](#going-to-mcp-later).\n\nScripts run in one of two modes, enforced by the page:\n\n- **`safe`** (default) — the script sees only `etch` and the standard JS\n built-ins. `window`, `document`, `fetch`, storage, `eval`, … are withheld.\n- **`unsafe`** — the script runs with full access to the page. Opt in\n explicitly.\n\n## Install\n\n```bash\nbun install # or npm install\n```\n\n## Use\n\n### 1. Start the daemon (once)\n\n```bash\nbun run serve\n# bridge ws://127.0.0.1:7331\n# control http://127.0.0.1:7332\n```\n\n### 2. Connect a tab\n\nOpen an Etch builder page and, in the browser console:\n\n```js\netch.connectTo('homepage');\n```\n\n(`connectTo` is part of the public API; it opens the WebSocket back to the\ndaemon and registers this tab under the given name. Pass `{ port: <n> }` if you\nstarted the daemon on a non-default WebSocket port.)\n\n### 3. Drive it\n\n```bash\n# list connected tabs\netch-connector tabs\n\n# run a script (value → stdout, console output → stderr).\n# Default is safe mode: reference the API as `etch.*`.\netch-connector eval -t homepage \"return etch.apiVersion\"\n\n# multi-line / from a file / from stdin\netch-connector eval -t homepage -f ./script.js\necho \"return await etch.blocks.getTree()\" | etch-connector eval -t homepage\n\n# full page access (opt in)\netch-connector eval -t homepage --unsafe \"return document.title\"\n```\n\nThe script body runs as an **async function**, so it can `return` a value and\nuse top-level `await`. In safe mode write scripts against the `etch-public-api`\nsurface via the `etch` binding (`window` is not available). Examples:\n\n```js\nreturn etch.apiVersion;\nreturn await etch.blocks.getTree();\netch.blocks.select('block-id-123');\nconst groups = await etch.fields.listGroupsAsync();\nreturn groups.length;\n```\n\n### Modes\n\n| Mode | In scope | Use for |\n| --------------- | ------------------------------------- | -------------------------------- |\n| `safe` (default)| `etch` + standard JS built-ins | Driving the builder via the API |\n| `unsafe` | everything (`window`, `document`, …) | Debugging, page internals |\n\nPick per call with `--safe` / `--unsafe`, or change the daemon default with\n`serve --unsafe`. A per-call flag overrides the daemon default.\n\n### Output contract (for agents)\n\n`eval` keeps stdout clean so a harness can capture it as the result:\n\n- **stdout** — the script's return value, as pretty JSON.\n- **stderr** — forwarded `console.*` output, prefixed with the level.\n- **exit code** — `0` success · `2` the script threw · `1` operational failure\n (no such tab, ambiguous target, timeout, daemon unreachable).\n\n## CLI\n\n```\netch-connector serve [--ws-port 7331] [--control-port 7332] [--ws-host 127.0.0.1] [--unsafe]\netch-connector tabs [--json] [--control-port 7332]\netch-connector eval [code] [-t|--tab name] [-f|--file path] [--timeout ms] [--safe|--unsafe]\n```\n\nWhen exactly one tab is connected, `-t` is optional. With several tabs you must\nname one.\n\n## HTTP control API\n\nThe daemon also exposes a loopback HTTP API (this is the seam the CLI uses, and\nthe same one a future MCP server would call):\n\n| Method | Path | Body | Returns |\n| ------ | --------- | ------------------------------------ | ------------ |\n| GET | `/health` | — | `{ ok }` |\n| GET | `/tabs` | — | `{ tabs }` |\n| POST | `/eval` | `{ tab?, code, timeoutMs?, mode? }` | `EvalResult` |\n\n## Architecture\n\n- [`src/protocol.ts`](src/protocol.ts) — the wire format, mirrored from the\n Etch-side `bridge.ts`. Keep the two in sync.\n- [`src/bridge-server.ts`](src/bridge-server.ts) — `BridgeServer`, the\n transport-agnostic core: owns the sockets, tracks tabs by name, correlates\n requests, handles timeouts and disconnects. **This is the reusable piece.**\n- [`src/control-server.ts`](src/control-server.ts) — the HTTP control API over\n the core.\n- [`src/cli.ts`](src/cli.ts) — the `serve` / `tabs` / `eval` commands.\n\n## Going to MCP later\n\nNothing in the transport changes. An MCP server is `new BridgeServer()` plus two\ntool definitions over the same core:\n\n```ts\nimport { BridgeServer } from 'etch-connector';\n\nconst bridge = new BridgeServer();\nawait bridge.listen();\n\n// list_etch_tabs -> bridge.listTabs()\n// eval_etch_script -> bridge.evalInTab(code, { tab })\n```\n\nWhen you want first-class per-tool MCP tools (rather than one eval tool), have\nthe Etch tab advertise a tool manifest in its `hello` and register those\ndynamically — keeping a single source of truth in the browser registry.\n\n## Security\n\nTwo layers, neither a substitute for the other:\n\n**Transport.** Everything binds to `127.0.0.1`, so any local process can reach\nthe daemon. Run it only on a trusted machine and do not expose the ports beyond\nloopback.\n\n**Execution.** `safe` mode (the default) runs the script inside a `with`-bound\nProxy that exposes only `etch` plus the standard JS built-ins and throws on\n`window`, `document`, `fetch`, storage, `eval`, `Function`, … This is a\n**guardrail, not a hardened sandbox**: because the script shares the page's JS\nrealm, a *determined* author can still reach globals through prototype /\nconstructor chains (e.g. `({}).constructor.constructor`). Safe mode reliably\nkeeps casual and AI-authored scripts on the `etch` surface and closes the\nobvious escape hatches; it does **not** contain hostile code.\n\n`unsafe` mode runs the script with full, unconstrained access to the page — only\nuse it for trusted debugging.\n\nFor a true boundary against adversarial scripts you would need to harden the\nrealm (SES `lockdown()`), which freezes all intrinsics app-wide and is out of\nscope for this relay. The enforcement lives in the Etch page (`bridge.ts`), not\nthis connector — the connector only carries the requested mode; a string filter\nhere would be theater.\n";
|