@digital-gravy/etch-connector 0.1.4 → 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 CHANGED
@@ -132,8 +132,10 @@ The authoritative agent instructions live in **`etch-connector --help`** — rea
132
132
  the entire output before interacting with Etch. It covers the backends, commands
133
133
  and flags, the stdout/stderr/exit-code contract, how to pull the current Etch
134
134
  docs (Context7 MCP or <https://docs.etchwp.com/public-api>), and the conventions
135
- for structuring pages (e.g. the section/container layout pattern). Project
136
- conventions for working on this repo live in [`AGENTS.md`](AGENTS.md).
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).
137
139
 
138
140
  ## CLI
139
141
 
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
- // Support --key=value (e.g. --freeze=false) explicitly.
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
- const map = {
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
- console.error(`[etch-connector] bridge ws://${wsHost}:${boundWsPort}`);
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.connectAs('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
- async function runTabs(flags) {
223
- const cdp = cdpFromFlags(flags);
224
- if (cdp) {
225
- const targets = await CdpClient.listTargets(cdp).catch((e) => failCdp(e, cdp.port));
226
- if (flags['json']) {
227
- process.stdout.write(`${JSON.stringify(targets, null, 2)}\n`);
228
- return;
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
- const res = await fetch(`${controlUrlFromFlags(flags)}/tabs`).catch((err) => {
239
- throw new Error(`cannot reach the connector daemon — is \`etch-connector serve\` running? (${err})`);
240
- });
241
- const { tabs } = (await res.json());
242
- if (flags['json']) {
243
- process.stdout.write(`${JSON.stringify(tabs, null, 2)}\n`);
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
- async function runEval(positional, flags) {
263
- const code = await gatherCode(positional, flags);
264
- if (!code) {
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
- // CDP mode: eval over Chrome remote debugging (full window access, no daemon).
272
- const cdp = cdpFromFlags(flags);
273
- if (cdp) {
274
- try {
275
- const value = await withCdp(cdp, str(flags['tab']), (c) => c.eval(code, { timeoutMs }));
276
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
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
- mode = 'unsafe';
288
- else if (flags['safe'])
289
- mode = 'safe';
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
- tab: str(flags['tab']),
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
- // Forwarded console output → stderr, so stdout stays clean for the value.
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
- process.stdout.write(`${JSON.stringify(result.value, null, 2)}\n`);
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
- try {
339
- const buf = await withCdp(cdp, str(flags['tab']), (c) => c.screenshot({ selector, full, format, freeze }));
340
- writeFileSync(out, buf);
341
- const scope = selector
342
- ? ` — clipped to ${selector}`
343
- : full
344
- ? ' — full page'
345
- : ' — viewport';
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 = str(flags['selector']) ?? positional[0];
354
- if (!selector) {
355
- console.error('html: pass a CSS selector (-s/--selector or positional)');
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 = str(flags['selector']) ?? positional[0];
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 cdp = requireCdp(flags);
378
- try {
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
- function printHelp() {
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
@@ -441,6 +464,31 @@ The eval body runs as an async function, so it may 'return' a value and use
441
464
  top-level 'await'. In bridge mode reference the API as "etch.*" ("window" is
442
465
  withheld); in cdp mode the full page is available.
443
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
+
444
492
  Docs (AGENT: pull these BEFORE writing scripts against etch.* — the API surface
445
493
  changes and training data may be stale):
446
494
  Context7 MCP (preferred) resolve-library-id "Etch" → query-docs for the
@@ -448,6 +496,9 @@ changes and training data may be stale):
448
496
  Manual fallback https://docs.etchwp.com/public-api (window.etch.*)
449
497
  https://docs.etchwp.com (everything else)
450
498
 
499
+ Skills (AGENT: the public API may expose ready-made skills for common tasks —
500
+ check the docs before improvising).
501
+
451
502
  Building Etch pages (AGENT: conventions — follow these or the builder won't
452
503
  render what you author):
453
504
  Images. ALWAYS use the Dynamic Image element (etch/dynamic-image, <etch:img>),
@@ -493,7 +544,9 @@ render what you author):
493
544
  (rest_route form works on plain permalinks; /wp-json/wp/v2/media on pretty
494
545
  ones.)
495
546
 
496
- See AGENTS.md for project conventions.`);
547
+ See AGENTS.md for project conventions.`;
548
+ function printHelp() {
549
+ console.error(HELP_TEXT);
497
550
  }
498
551
  async function main() {
499
552
  const { command, positional, flags } = parseArgs(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digital-gravy/etch-connector",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
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";