@digital-gravy/etch-connector 0.1.4 → 0.2.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/README.md +19 -4
- package/dist/bridge-server.js +2 -1
- package/dist/cli.js +282 -150
- package/dist/guidance.js +129 -0
- package/dist/index.js +2 -0
- package/dist/protocol.js +42 -0
- package/package.json +1 -1
- package/dist/manual.js +0 -2
package/README.md
CHANGED
|
@@ -122,18 +122,31 @@ return groups.length;
|
|
|
122
122
|
`eval` keeps stdout clean so a harness can capture it as the result:
|
|
123
123
|
|
|
124
124
|
- **stdout** — the script's return value, as pretty JSON.
|
|
125
|
-
- **stderr** — forwarded `console.*` output, prefixed with the level
|
|
125
|
+
- **stderr** — forwarded `console.*` output, prefixed with the level, plus
|
|
126
|
+
connector notes that carry no such prefix (the runtime banner, warnings).
|
|
126
127
|
- **exit code** — `0` success · `2` the script threw · `1` operational failure
|
|
127
128
|
(no such tab, ambiguous target, timeout, daemon unreachable).
|
|
128
129
|
|
|
129
130
|
## For AI agents
|
|
130
131
|
|
|
132
|
+
`etch-connector tabs` reports, per tab, which Etch runtime it is and which
|
|
133
|
+
surfaces that runtime backs (read off `etch.environment`), then prints the
|
|
134
|
+
authoring rules that follow from it: the dynamic-data model (data sources vs
|
|
135
|
+
loops), the media lookup, the namespaces that throw `NOT_AVAILABLE`, and the
|
|
136
|
+
block types the runtime can construct. Not every runtime backs every surface, so
|
|
137
|
+
read that before writing scripts — and branch on a capability, never on the
|
|
138
|
+
product name. `eval` names the runtime it targets on stderr as a reminder, and
|
|
139
|
+
`tabs --quiet` drops the prose when you are only checking that a tab is still
|
|
140
|
+
connected.
|
|
141
|
+
|
|
131
142
|
The authoritative agent instructions live in **`etch-connector --help`** — read
|
|
132
143
|
the entire output before interacting with Etch. It covers the backends, commands
|
|
133
144
|
and flags, the stdout/stderr/exit-code contract, how to pull the current Etch
|
|
134
145
|
docs (Context7 MCP or <https://docs.etchwp.com/public-api>), and the conventions
|
|
135
|
-
for structuring pages (e.g. the section/container layout pattern).
|
|
136
|
-
|
|
146
|
+
for structuring pages (e.g. the section/container layout pattern). The public API
|
|
147
|
+
may also expose ready-made skills for common tasks — check the docs before
|
|
148
|
+
improvising. Project conventions for working on this repo live in
|
|
149
|
+
[`AGENTS.md`](AGENTS.md).
|
|
137
150
|
|
|
138
151
|
## CLI
|
|
139
152
|
|
|
@@ -142,7 +155,7 @@ etch-connector — drive a live Etch tab from the command line
|
|
|
142
155
|
|
|
143
156
|
Usage:
|
|
144
157
|
etch-connector serve [--ws-port 7331] [--control-port 7332] [--ws-host 127.0.0.1]
|
|
145
|
-
etch-connector tabs [--json] [--control-port 7332] [--cdp]
|
|
158
|
+
etch-connector tabs [--json] [--quiet] [--control-port 7332] [--cdp]
|
|
146
159
|
etch-connector eval [code] [-t|--tab name] [-f|--file path] [--timeout ms] [--cdp]
|
|
147
160
|
etch-connector shot [-t name] [-s|--selector css] [--full] [--jpeg] [-o|--out file] [--freeze=false] (CDP)
|
|
148
161
|
etch-connector html <selector> [-t name] (CDP)
|
|
@@ -192,6 +205,8 @@ the same one a future MCP server would call):
|
|
|
192
205
|
requests, handles timeouts and disconnects. **This is the reusable piece.**
|
|
193
206
|
- [`src/control-server.ts`](src/control-server.ts) — the HTTP control API over
|
|
194
207
|
the core.
|
|
208
|
+
- [`src/guidance.ts`](src/guidance.ts) — turns a tab's `etch.environment`
|
|
209
|
+
descriptor into the authoring instructions for that runtime.
|
|
195
210
|
- [`src/cli.ts`](src/cli.ts) — the `serve` / `tabs` / `eval` commands.
|
|
196
211
|
|
|
197
212
|
## Going to MCP later
|
package/dist/bridge-server.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { EventEmitter } from 'node:events';
|
|
15
15
|
import { randomUUID } from 'node:crypto';
|
|
16
16
|
import { WebSocketServer } from 'ws';
|
|
17
|
-
import { DEFAULT_HOST, DEFAULT_WS_PORT, parsePageMessage } from './protocol.js';
|
|
17
|
+
import { DEFAULT_HOST, DEFAULT_WS_PORT, parseEnvironment, parsePageMessage } from './protocol.js';
|
|
18
18
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
19
19
|
/**
|
|
20
20
|
* Raised for operational failures (no matching tab, ambiguous target, timeout,
|
|
@@ -208,6 +208,7 @@ export class BridgeServer extends EventEmitter {
|
|
|
208
208
|
hasEtch: hello.hasEtch,
|
|
209
209
|
apiVersion: hello.apiVersion,
|
|
210
210
|
version: hello.version,
|
|
211
|
+
environment: parseEnvironment(hello.environment),
|
|
211
212
|
meta: hello.meta ?? {},
|
|
212
213
|
connectedAt: Date.now()
|
|
213
214
|
};
|
package/dist/cli.js
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* etch-connector tabs list connected Etch tabs
|
|
8
8
|
* etch-connector eval [code] run a script in a tab; value → stdout
|
|
9
9
|
*
|
|
10
|
-
* `eval` prints forwarded console output to *stderr*
|
|
11
|
-
*
|
|
10
|
+
* `eval` prints forwarded console output to *stderr* — alongside unprefixed
|
|
11
|
+
* connector notes such as the runtime banner — and the script's return value as
|
|
12
|
+
* JSON to *stdout*, so an agent can capture stdout as the clean result.
|
|
12
13
|
* Exit codes: 0 ok · 2 the script threw · 1 everything else (no tab, timeout…).
|
|
13
14
|
*/
|
|
14
15
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -16,6 +17,7 @@ import { BridgeServer } from './bridge-server.js';
|
|
|
16
17
|
import { createControlServer, controlBaseUrl } from './control-server.js';
|
|
17
18
|
import { CdpClient, CdpError, CdpEvalError } from './cdp-client.js';
|
|
18
19
|
import { DEFAULT_CONTROL_PORT, DEFAULT_HOST, DEFAULT_WS_PORT } from './protocol.js';
|
|
20
|
+
import { backedCapabilities, environmentGuidance, runtimeBanner } from './guidance.js';
|
|
19
21
|
const DEFAULT_CDP_PORT = 9222;
|
|
20
22
|
/**
|
|
21
23
|
* Unsafe mode (scripts get full, unconstrained access to the page) is
|
|
@@ -31,54 +33,64 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
31
33
|
'full',
|
|
32
34
|
'jpeg',
|
|
33
35
|
'json',
|
|
36
|
+
'quiet',
|
|
34
37
|
'unsafe',
|
|
35
38
|
'safe',
|
|
36
39
|
'help',
|
|
37
40
|
'freeze'
|
|
38
41
|
]);
|
|
42
|
+
/** Single-letter aliases → their long flag name. */
|
|
43
|
+
const SHORT_FLAGS = {
|
|
44
|
+
t: 'tab',
|
|
45
|
+
f: 'file',
|
|
46
|
+
p: 'port',
|
|
47
|
+
s: 'selector',
|
|
48
|
+
o: 'out'
|
|
49
|
+
};
|
|
50
|
+
/** A non-flag token is a value iff it exists and isn't itself a flag. */
|
|
51
|
+
function isValueToken(next) {
|
|
52
|
+
return next !== undefined && !next.startsWith('-');
|
|
53
|
+
}
|
|
54
|
+
/** Record a `--long` flag; returns true if it consumed `next` as its value. */
|
|
55
|
+
function applyLongFlag(flags, arg, next) {
|
|
56
|
+
// Support --key=value (e.g. --freeze=false) explicitly.
|
|
57
|
+
const eq = arg.indexOf('=');
|
|
58
|
+
if (eq !== -1) {
|
|
59
|
+
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const key = arg.slice(2);
|
|
63
|
+
if (!BOOLEAN_FLAGS.has(key) && isValueToken(next)) {
|
|
64
|
+
flags[key] = next;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
flags[key] = true;
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
/** Record a `-x` short flag; returns true if it consumed `next` as its value. */
|
|
71
|
+
function applyShortFlag(flags, arg, next) {
|
|
72
|
+
const key = SHORT_FLAGS[arg.slice(1)] ?? arg.slice(1);
|
|
73
|
+
if (isValueToken(next)) {
|
|
74
|
+
flags[key] = next;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
flags[key] = true;
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
39
80
|
function parseArgs(argv) {
|
|
40
81
|
const command = argv[0] ?? 'help';
|
|
41
82
|
const positional = [];
|
|
42
83
|
const flags = {};
|
|
43
84
|
for (let i = 1; i < argv.length; i++) {
|
|
44
85
|
const arg = argv[i];
|
|
86
|
+
const next = argv[i + 1];
|
|
45
87
|
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;
|
|
88
|
+
if (applyLongFlag(flags, arg, next))
|
|
59
89
|
i++;
|
|
60
|
-
}
|
|
61
|
-
else {
|
|
62
|
-
flags[key] = true;
|
|
63
|
-
}
|
|
64
90
|
}
|
|
65
91
|
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;
|
|
92
|
+
if (applyShortFlag(flags, arg, next))
|
|
77
93
|
i++;
|
|
78
|
-
}
|
|
79
|
-
else {
|
|
80
|
-
flags[key] = true;
|
|
81
|
-
}
|
|
82
94
|
}
|
|
83
95
|
else {
|
|
84
96
|
positional.push(arg);
|
|
@@ -105,6 +117,10 @@ function bool(flag, fallback) {
|
|
|
105
117
|
return flag;
|
|
106
118
|
return !/^(false|0|no|off)$/i.test(flag);
|
|
107
119
|
}
|
|
120
|
+
/** Pretty-print a value to stdout as JSON (the clean machine-readable result). */
|
|
121
|
+
function writeJson(value) {
|
|
122
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
123
|
+
}
|
|
108
124
|
function readStdin() {
|
|
109
125
|
return new Promise((resolve) => {
|
|
110
126
|
let data = '';
|
|
@@ -113,6 +129,15 @@ function readStdin() {
|
|
|
113
129
|
process.stdin.on('end', () => resolve(data));
|
|
114
130
|
});
|
|
115
131
|
}
|
|
132
|
+
/** The startup banner printed once both servers are listening. */
|
|
133
|
+
function printServeBanner(wsHost, boundWsPort, controlHost, controlPort, defaultMode) {
|
|
134
|
+
console.error(`[etch-connector] bridge ws://${wsHost}:${boundWsPort}`);
|
|
135
|
+
console.error(`[etch-connector] control ${controlBaseUrl({ host: controlHost, port: controlPort })}`);
|
|
136
|
+
console.error(`[etch-connector] default mode: ${defaultMode}${defaultMode === 'unsafe' ? ' ⚠ scripts get full access to the page' : ' (scripts see only window.etch + JS built-ins)'}`);
|
|
137
|
+
console.error('[etch-connector] In an Etch builder tab, run:');
|
|
138
|
+
console.error(`[etch-connector] etch.connectAs('my-tab'${boundWsPort === DEFAULT_WS_PORT ? '' : `, { port: ${boundWsPort} }`})`);
|
|
139
|
+
console.error('[etch-connector] then drive it with: etch-connector eval -t my-tab "return window.etch.apiVersion"');
|
|
140
|
+
}
|
|
116
141
|
async function runServe(flags) {
|
|
117
142
|
const wsHost = str(flags['ws-host']) ?? DEFAULT_HOST;
|
|
118
143
|
const wsPort = num(flags['ws-port'], DEFAULT_WS_PORT);
|
|
@@ -129,12 +154,7 @@ async function runServe(flags) {
|
|
|
129
154
|
// Periodic liveness probe so dead tabs that never fired `close` get pruned.
|
|
130
155
|
const heartbeat = setInterval(() => bridge.pingAll(), 30_000);
|
|
131
156
|
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.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"');
|
|
157
|
+
printServeBanner(wsHost, boundWsPort, controlHost, controlPort, defaultMode);
|
|
138
158
|
const shutdown = () => {
|
|
139
159
|
clearInterval(heartbeat);
|
|
140
160
|
control.close();
|
|
@@ -149,6 +169,12 @@ function controlUrlFromFlags(flags) {
|
|
|
149
169
|
port: num(flags['control-port'], DEFAULT_CONTROL_PORT)
|
|
150
170
|
});
|
|
151
171
|
}
|
|
172
|
+
/** Hit a daemon endpoint, turning a connection failure into a friendly error. */
|
|
173
|
+
async function controlFetch(flags, path, init) {
|
|
174
|
+
return fetch(`${controlUrlFromFlags(flags)}${path}`, init).catch((err) => {
|
|
175
|
+
throw new Error(`cannot reach the connector daemon — is \`etch-connector serve\` running? (${err})`);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
152
178
|
/**
|
|
153
179
|
* CDP (Chrome remote-debugging) config when the caller opted in via `--cdp`,
|
|
154
180
|
* `--cdp-port`, `--cdp-host`, or `--remote-debugging-port`; otherwise null.
|
|
@@ -219,37 +245,85 @@ function failCdp(err, port = DEFAULT_CDP_PORT) {
|
|
|
219
245
|
}
|
|
220
246
|
process.exit(1);
|
|
221
247
|
}
|
|
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;
|
|
248
|
+
/**
|
|
249
|
+
* Run a CDP command against the required endpoint (defaulting to localhost:9222
|
|
250
|
+
* for the CDP-only commands), translating any CDP error into the right exit.
|
|
251
|
+
*/
|
|
252
|
+
async function withRequiredCdp(flags, fn) {
|
|
253
|
+
const cdp = requireCdp(flags);
|
|
254
|
+
try {
|
|
255
|
+
return await withCdp(cdp, str(flags['tab']), fn);
|
|
237
256
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
257
|
+
catch (err) {
|
|
258
|
+
failCdp(err, cdp.port);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/** Resolve a required CSS selector from `-s/--selector` or the first positional. */
|
|
262
|
+
function requireSelector(command, positional, flags) {
|
|
263
|
+
const selector = str(flags['selector']) ?? positional[0];
|
|
264
|
+
if (!selector) {
|
|
265
|
+
console.error(`${command}: pass a CSS selector (-s/--selector or positional)`);
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
return selector;
|
|
269
|
+
}
|
|
270
|
+
async function tabsViaCdp(cdp, flags) {
|
|
271
|
+
const targets = await CdpClient.listTargets(cdp).catch((e) => failCdp(e, cdp.port));
|
|
272
|
+
if (flags['json'])
|
|
273
|
+
return writeJson(targets);
|
|
274
|
+
if (targets.length === 0) {
|
|
275
|
+
console.error('No Chrome page targets open.');
|
|
244
276
|
return;
|
|
245
277
|
}
|
|
278
|
+
for (const t of targets)
|
|
279
|
+
process.stdout.write(`${t.title}\t${t.url}\n`);
|
|
280
|
+
}
|
|
281
|
+
/** The runtime column: what the tab is and which surfaces it actually backs. */
|
|
282
|
+
function describeRuntime(tab) {
|
|
283
|
+
if (!tab.hasEtch)
|
|
284
|
+
return 'no-etch';
|
|
285
|
+
if (!tab.environment)
|
|
286
|
+
return 'etch';
|
|
287
|
+
const backed = backedCapabilities(tab.environment);
|
|
288
|
+
return `${tab.environment.product}(${backed.join(',') || 'none'})`;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Instructions for the runtimes actually on the wire, to stderr so stdout stays
|
|
292
|
+
* the plain listing. Tabs sharing a runtime share one block — an agent needs the
|
|
293
|
+
* guidance once per runtime, not once per tab.
|
|
294
|
+
*/
|
|
295
|
+
function printRuntimeGuidance(tabs) {
|
|
296
|
+
const byRuntime = new Map();
|
|
297
|
+
for (const tab of tabs.filter((t) => t.hasEtch)) {
|
|
298
|
+
const guidance = environmentGuidance(tab.environment);
|
|
299
|
+
const names = byRuntime.get(guidance) ?? [];
|
|
300
|
+
names.push(tab.name);
|
|
301
|
+
byRuntime.set(guidance, names);
|
|
302
|
+
}
|
|
303
|
+
for (const [guidance, names] of byRuntime) {
|
|
304
|
+
console.error(`\nAGENT — authoring against ${names.join(', ')}:`);
|
|
305
|
+
console.error(guidance);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async function tabsViaDaemon(flags) {
|
|
309
|
+
const res = await controlFetch(flags, '/tabs');
|
|
310
|
+
const { tabs } = (await res.json());
|
|
311
|
+
if (flags['json'])
|
|
312
|
+
return writeJson(tabs);
|
|
246
313
|
if (tabs.length === 0) {
|
|
247
314
|
console.error('No Etch tabs connected.');
|
|
248
315
|
return;
|
|
249
316
|
}
|
|
250
317
|
for (const tab of tabs) {
|
|
251
|
-
process.stdout.write(`${tab.name}\t${tab
|
|
318
|
+
process.stdout.write(`${tab.name}\t${describeRuntime(tab)}\t${tab.title}\t${tab.url}\n`);
|
|
252
319
|
}
|
|
320
|
+
// `tabs` doubles as the liveness check, so let a poller opt out of the prose.
|
|
321
|
+
if (!flags['quiet'])
|
|
322
|
+
printRuntimeGuidance(tabs);
|
|
323
|
+
}
|
|
324
|
+
async function runTabs(flags) {
|
|
325
|
+
const cdp = cdpFromFlags(flags);
|
|
326
|
+
return cdp ? tabsViaCdp(cdp, flags) : tabsViaDaemon(flags);
|
|
253
327
|
}
|
|
254
328
|
async function gatherCode(positional, flags) {
|
|
255
329
|
const file = str(flags['file']);
|
|
@@ -259,132 +333,148 @@ async function gatherCode(positional, flags) {
|
|
|
259
333
|
return positional.join(' ');
|
|
260
334
|
return (await readStdin()).trim();
|
|
261
335
|
}
|
|
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'
|
|
336
|
+
/** `--timeout ms` if given a numeric value, else undefined (daemon default). */
|
|
337
|
+
function timeoutFromFlags(flags) {
|
|
338
|
+
return typeof flags['timeout'] === 'string'
|
|
269
339
|
? num(flags['timeout'], 30_000)
|
|
270
340
|
: 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;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* --safe overrides the daemon default; omitting it defers to the daemon.
|
|
344
|
+
* --unsafe is a dead path (see ALLOW_UNSAFE): it never sets unsafe mode.
|
|
345
|
+
*/
|
|
346
|
+
function resolveEvalMode(flags) {
|
|
286
347
|
if (ALLOW_UNSAFE && flags['unsafe'])
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
348
|
+
return 'unsafe';
|
|
349
|
+
if (flags['safe'])
|
|
350
|
+
return 'safe';
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
/** Forwarded console output → stderr, so stdout stays clean for the value. */
|
|
354
|
+
function printForwardedLogs(logs) {
|
|
355
|
+
for (const log of logs) {
|
|
356
|
+
const text = log.args
|
|
357
|
+
.map((a) => (typeof a === 'string' ? a : JSON.stringify(a)))
|
|
358
|
+
.join(' ');
|
|
359
|
+
console.error(`[${log.level}] ${text}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// CDP mode: eval over Chrome remote debugging (full window access, no daemon).
|
|
363
|
+
async function evalViaCdp(cdp, flags, code, timeoutMs) {
|
|
364
|
+
try {
|
|
365
|
+
const value = await withCdp(cdp, str(flags['tab']), (c) => c.eval(code, { timeoutMs }));
|
|
366
|
+
writeJson(value);
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
failCdp(err, cdp.port);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Which tab an eval will land on, mirroring the daemon's own resolution: a named
|
|
374
|
+
* tab (most recently connected wins), or the only one connected. Anything the
|
|
375
|
+
* daemon would reject resolves to nothing — the eval reports that itself.
|
|
376
|
+
*/
|
|
377
|
+
function resolveTargetTab(tabs, name) {
|
|
378
|
+
if (name === undefined)
|
|
379
|
+
return tabs.length === 1 ? tabs[0] : undefined;
|
|
380
|
+
return tabs
|
|
381
|
+
.filter((tab) => tab.name === name)
|
|
382
|
+
.sort((a, b) => b.connectedAt - a.connectedAt)[0];
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Name the runtime this eval targets, on stderr. Best-effort: a listing we
|
|
386
|
+
* cannot fetch or a target we cannot pin down just means no banner — never a
|
|
387
|
+
* failed eval.
|
|
388
|
+
*/
|
|
389
|
+
async function announceTargetRuntime(flags) {
|
|
390
|
+
try {
|
|
391
|
+
const res = await controlFetch(flags, '/tabs');
|
|
392
|
+
if (!res.ok)
|
|
393
|
+
return;
|
|
394
|
+
const { tabs } = (await res.json());
|
|
395
|
+
const target = resolveTargetTab(tabs, str(flags['tab']));
|
|
396
|
+
if (target)
|
|
397
|
+
console.error(runtimeBanner(target.environment));
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
/* the eval itself reports anything that actually matters */
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
async function evalViaDaemon(flags, code, timeoutMs) {
|
|
404
|
+
const mode = resolveEvalMode(flags);
|
|
290
405
|
if (mode === 'unsafe') {
|
|
291
406
|
console.error('⚠ running in unsafe mode — the script has full access to the page');
|
|
292
407
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
mode
|
|
298
|
-
};
|
|
299
|
-
const res = await fetch(`${controlUrlFromFlags(flags)}/eval`, {
|
|
408
|
+
// Started before the eval so naming the runtime costs no wall-clock.
|
|
409
|
+
const announced = announceTargetRuntime(flags);
|
|
410
|
+
const body = { tab: str(flags['tab']), code, timeoutMs, mode };
|
|
411
|
+
const res = await controlFetch(flags, '/eval', {
|
|
300
412
|
method: 'POST',
|
|
301
413
|
headers: { 'content-type': 'application/json' },
|
|
302
414
|
body: JSON.stringify(body)
|
|
303
|
-
}).catch((err) => {
|
|
304
|
-
throw new Error(`cannot reach the connector daemon — is \`etch-connector serve\` running? (${err})`);
|
|
305
415
|
});
|
|
416
|
+
await announced;
|
|
306
417
|
if (!res.ok) {
|
|
307
418
|
const err = (await res.json().catch(() => ({})));
|
|
308
419
|
console.error(`eval failed: ${err.error ?? res.statusText}`);
|
|
309
420
|
process.exit(1);
|
|
310
421
|
}
|
|
311
422
|
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
|
-
}
|
|
423
|
+
printForwardedLogs(result.logs);
|
|
319
424
|
if (!result.ok) {
|
|
320
425
|
console.error(`script error: ${result.error}`);
|
|
321
426
|
if (result.stack)
|
|
322
427
|
console.error(result.stack);
|
|
323
428
|
process.exit(2);
|
|
324
429
|
}
|
|
325
|
-
|
|
430
|
+
writeJson(result.value);
|
|
431
|
+
}
|
|
432
|
+
async function runEval(positional, flags) {
|
|
433
|
+
const code = await gatherCode(positional, flags);
|
|
434
|
+
if (!code) {
|
|
435
|
+
console.error('eval: no script provided (pass code as an argument, --file, or stdin)');
|
|
436
|
+
process.exit(1);
|
|
437
|
+
}
|
|
438
|
+
const timeoutMs = timeoutFromFlags(flags);
|
|
439
|
+
const cdp = cdpFromFlags(flags);
|
|
440
|
+
return cdp
|
|
441
|
+
? evalViaCdp(cdp, flags, code, timeoutMs)
|
|
442
|
+
: evalViaDaemon(flags, code, timeoutMs);
|
|
326
443
|
}
|
|
327
444
|
/** `shot` / `html` / `computed` are CDP-only; default to localhost:9222. */
|
|
328
445
|
function requireCdp(flags) {
|
|
329
446
|
return (cdpFromFlags(flags) ?? { host: DEFAULT_HOST, port: DEFAULT_CDP_PORT });
|
|
330
447
|
}
|
|
331
448
|
async function runShot(flags) {
|
|
332
|
-
const cdp = requireCdp(flags);
|
|
333
449
|
const selector = str(flags['selector']);
|
|
334
450
|
const full = Boolean(flags['full']);
|
|
335
451
|
const format = flags['jpeg'] ? 'jpeg' : 'png';
|
|
336
452
|
const freeze = bool(flags['freeze'], true); // default on (animations → deterministic)
|
|
337
453
|
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
|
-
}
|
|
454
|
+
const buf = await withRequiredCdp(flags, (c) => c.screenshot({ selector, full, format, freeze }));
|
|
455
|
+
writeFileSync(out, buf);
|
|
456
|
+
const scope = selector
|
|
457
|
+
? ` — clipped to ${selector}`
|
|
458
|
+
: full
|
|
459
|
+
? ' — full page'
|
|
460
|
+
: ' — viewport';
|
|
461
|
+
console.error(`wrote ${out} (${buf.length} bytes)${scope}${freeze ? ' (frozen)' : ''}`);
|
|
351
462
|
}
|
|
352
463
|
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
|
-
}
|
|
464
|
+
const selector = requireSelector('html', positional, flags);
|
|
465
|
+
const html = await withRequiredCdp(flags, (c) => c.outerHtml(selector));
|
|
466
|
+
process.stdout.write(`${html}\n`);
|
|
366
467
|
}
|
|
367
468
|
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
|
-
}
|
|
469
|
+
const selector = requireSelector('computed', positional, flags);
|
|
373
470
|
const props = str(flags['props'])
|
|
374
471
|
?.split(',')
|
|
375
472
|
.map((p) => p.trim())
|
|
376
473
|
.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
|
-
}
|
|
474
|
+
const style = await withRequiredCdp(flags, (c) => c.computedStyle(selector, props));
|
|
475
|
+
writeJson(style);
|
|
385
476
|
}
|
|
386
|
-
|
|
387
|
-
console.error(`etch-connector — drive a live Etch tab from the command line
|
|
477
|
+
const HELP_TEXT = `etch-connector — drive a live Etch tab from the command line
|
|
388
478
|
|
|
389
479
|
Run without installing (requires a valid, active Etch license):
|
|
390
480
|
npx @digital-gravy/etch-connector serve
|
|
@@ -396,7 +486,7 @@ Or install globally for the bare \`etch-connector\` command:
|
|
|
396
486
|
|
|
397
487
|
Usage:
|
|
398
488
|
etch-connector serve [--ws-port ${DEFAULT_WS_PORT}] [--control-port ${DEFAULT_CONTROL_PORT}] [--ws-host ${DEFAULT_HOST}]
|
|
399
|
-
etch-connector tabs [--json] [--control-port ${DEFAULT_CONTROL_PORT}] [--cdp]
|
|
489
|
+
etch-connector tabs [--json] [--quiet] [--control-port ${DEFAULT_CONTROL_PORT}] [--cdp]
|
|
400
490
|
etch-connector eval [code] [-t|--tab name] [-f|--file path] [--timeout ms] [--cdp]
|
|
401
491
|
etch-connector shot [-t name] [-s|--selector css] [--full] [--jpeg] [-o|--out file] [--freeze=false] (CDP)
|
|
402
492
|
etch-connector html <selector> [-t name] (CDP)
|
|
@@ -414,6 +504,15 @@ AGENT: before running anything, ask the user how they want to connect — bridge
|
|
|
414
504
|
(API only) or cdp (DevTools privilege: full page access). cdp grants more reach;
|
|
415
505
|
don't assume — let the user choose.
|
|
416
506
|
|
|
507
|
+
AGENT: \`etch-connector tabs\` prints, per connected tab, which runtime it is and
|
|
508
|
+
which surfaces that runtime backs (from \`etch.environment\`), followed by the
|
|
509
|
+
authoring rules for it — the dynamic-data model, the media lookup, the
|
|
510
|
+
namespaces that throw, and the block types it can construct. Read that before
|
|
511
|
+
writing scripts: not every runtime backs every surface, so the same script does
|
|
512
|
+
not work everywhere. Branch on a capability, never on the product name.
|
|
513
|
+
(\`eval\` names the runtime it targets on stderr as a reminder; \`tabs --quiet\`
|
|
514
|
+
drops the prose when you are only checking a tab is still connected.)
|
|
515
|
+
|
|
417
516
|
Connecting a bridge tab (AGENT: walk the user through activating it — the AI
|
|
418
517
|
button is the primary path; don't just wait for a tab to appear):
|
|
419
518
|
1. Tell them to open Etch Settings and enable the AI Connector.
|
|
@@ -441,6 +540,31 @@ The eval body runs as an async function, so it may 'return' a value and use
|
|
|
441
540
|
top-level 'await'. In bridge mode reference the API as "etch.*" ("window" is
|
|
442
541
|
withheld); in cdp mode the full page is available.
|
|
443
542
|
|
|
543
|
+
AI presence (AGENT: drive the etch.ai overlay so the human watching the tab
|
|
544
|
+
sees an agent is active — this connector is exactly what etch.ai is built for.
|
|
545
|
+
This overlay is for the human, NOT the user you're chatting with: NEVER mention
|
|
546
|
+
it, narrate it, or explain these state changes — just keep it accurate):
|
|
547
|
+
etch.ai.setState(state) shows/updates the presence overlay in the builder.
|
|
548
|
+
etch.ai.getState() reads the current state.
|
|
549
|
+
etch.ai.hide() removes the overlay (shorthand for setState("idle")).
|
|
550
|
+
state is one of: "idle" (overlay hidden), "thinking" (reasoning before acting),
|
|
551
|
+
"working" (a script is actually running against the canvas), "waiting"
|
|
552
|
+
(paused, needs more input from the human). Any other value throws
|
|
553
|
+
EtchApiError INVALID_ARGUMENT. Calls are synchronous, in-memory, and NOT
|
|
554
|
+
persisted — no saveAsync, nothing survives a reload.
|
|
555
|
+
When to set what:
|
|
556
|
+
• The MOMENT a user asks you to build/change something — before reading docs,
|
|
557
|
+
writing scripts, or any other step — put the overlay up: setState('thinking').
|
|
558
|
+
• setState('thinking') while you reason/plan (no script running yet).
|
|
559
|
+
• setState('working') right before an eval actually runs against the canvas;
|
|
560
|
+
back to 'thinking' between scripts if you pause to reason.
|
|
561
|
+
• hide() the instant the task is finished — never leave it up on completion.
|
|
562
|
+
• setState('waiting') ONLY when you are NOT done and are handing back to the
|
|
563
|
+
human for input/a decision; resume 'thinking'/'working' when they reply.
|
|
564
|
+
Wrap script runs in try/finally so an error still leaves the right state:
|
|
565
|
+
etch.ai.setState('working');
|
|
566
|
+
try { /* …drive the builder… */ } finally { etch.ai.setState('thinking'); }
|
|
567
|
+
|
|
444
568
|
Docs (AGENT: pull these BEFORE writing scripts against etch.* — the API surface
|
|
445
569
|
changes and training data may be stale):
|
|
446
570
|
Context7 MCP (preferred) resolve-library-id "Etch" → query-docs for the
|
|
@@ -448,6 +572,9 @@ changes and training data may be stale):
|
|
|
448
572
|
Manual fallback https://docs.etchwp.com/public-api (window.etch.*)
|
|
449
573
|
https://docs.etchwp.com (everything else)
|
|
450
574
|
|
|
575
|
+
Skills (AGENT: the public API may expose ready-made skills for common tasks —
|
|
576
|
+
check the docs before improvising).
|
|
577
|
+
|
|
451
578
|
Building Etch pages (AGENT: conventions — follow these or the builder won't
|
|
452
579
|
render what you author):
|
|
453
580
|
Images. ALWAYS use the Dynamic Image element (etch/dynamic-image, <etch:img>),
|
|
@@ -488,12 +615,17 @@ render what you author):
|
|
|
488
615
|
etch.blocks.update(instanceId, { attributes: { image: "10" } })
|
|
489
616
|
• updateAsync({ blocks }) requires styles:[] on every element block
|
|
490
617
|
(validation differs from blocks.create(), which omits styles entirely).
|
|
491
|
-
• No etch.media API.
|
|
618
|
+
• No etch.media API. Where the runtime backs the WordPress media library
|
|
619
|
+
(capability \`wpMedia\` — see \`tabs\`), resolve a URL → media ID via the WP
|
|
620
|
+
REST API:
|
|
492
621
|
curl 'http://<site>/?rest_route=/wp/v2/media&per_page=100&_fields=id,source_url'
|
|
493
622
|
(rest_route form works on plain permalinks; /wp-json/wp/v2/media on pretty
|
|
494
|
-
ones.)
|
|
623
|
+
ones.) Without it the ids are the product's own asset store and there is
|
|
624
|
+
nothing to look a URL up against.
|
|
495
625
|
|
|
496
|
-
See AGENTS.md for project conventions
|
|
626
|
+
See AGENTS.md for project conventions.`;
|
|
627
|
+
function printHelp() {
|
|
628
|
+
console.error(HELP_TEXT);
|
|
497
629
|
}
|
|
498
630
|
async function main() {
|
|
499
631
|
const { command, positional, flags } = parseArgs(process.argv.slice(2));
|
package/dist/guidance.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns a tab's `etch.environment` descriptor into the instructions for
|
|
3
|
+
* authoring against *that* runtime.
|
|
4
|
+
*
|
|
5
|
+
* The two dynamic-data models are mutually exclusive and are authored
|
|
6
|
+
* differently, the media lookup depends on whether WordPress is underneath, and
|
|
7
|
+
* several namespaces are declared everywhere but backed only somewhere — so a
|
|
8
|
+
* script written for one product silently produces nothing on the other. This
|
|
9
|
+
* is what turns the descriptor from something the connector relays into
|
|
10
|
+
* something it acts on.
|
|
11
|
+
*
|
|
12
|
+
* Every branch keys off {@link hasCapability}, never `product`: capabilities
|
|
13
|
+
* move between products, and a product check rots the day one does.
|
|
14
|
+
*/
|
|
15
|
+
import { hasCapability } from './protocol.js';
|
|
16
|
+
/** Namespaces that are declared on every runtime but backed only on some. */
|
|
17
|
+
const GATED_NAMESPACES = [
|
|
18
|
+
{ capability: 'loops', namespace: 'etch.loops.*' },
|
|
19
|
+
{ capability: 'fields', namespace: 'etch.fields.*' },
|
|
20
|
+
{ capability: 'templates', namespace: "etch.navigation.goTo('templates')" }
|
|
21
|
+
];
|
|
22
|
+
const UNKNOWN_RUNTIME = [
|
|
23
|
+
'Runtime: not reported — this Etch predates etch.environment, so what follows',
|
|
24
|
+
' is an ASSUMPTION, not a reading of the runtime: WordPress rules (loops,',
|
|
25
|
+
' fields, templates, WP media), and every block type may exist. Confirm with',
|
|
26
|
+
' the user before relying on it — a non-WordPress runtime throws on most of',
|
|
27
|
+
' that.'
|
|
28
|
+
];
|
|
29
|
+
function dynamicDataGuidance(environment) {
|
|
30
|
+
if (hasCapability(environment, 'dataSources')) {
|
|
31
|
+
return [
|
|
32
|
+
'Dynamic data — data sources. Named project sources (json, api, js) are',
|
|
33
|
+
" reached through a data('key') expression. An etch/loop block carries",
|
|
34
|
+
' ONLY a required `target`, and that target is any dynamic expression',
|
|
35
|
+
" yielding a list: data('products'), a path into one",
|
|
36
|
+
" (data('products').items), nested data from an enclosing loop",
|
|
37
|
+
' (item.tags), or a generated list (range(1, 10)). There is no',
|
|
38
|
+
' loopId/loopParams here.'
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
if (hasCapability(environment, 'loops')) {
|
|
42
|
+
return [
|
|
43
|
+
'Dynamic data — loops. etch.loops.* is a registry of named loop',
|
|
44
|
+
' definitions. An etch/loop block binds one by `loopId`, passes',
|
|
45
|
+
' $-prefixed `loopParams`, and `target` is an optional sub-path within',
|
|
46
|
+
" it. There are no data sources here — a data('key') expression",
|
|
47
|
+
' resolves to nothing.'
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
return [
|
|
51
|
+
'Dynamic data: unavailable. This runtime backs neither loops nor data',
|
|
52
|
+
' sources — do not author etch/loop blocks against it.'
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
function mediaGuidance(environment) {
|
|
56
|
+
const binding = ' Bind an etch/dynamic-image by a numeric `mediaId` attribute, never a URL.';
|
|
57
|
+
if (hasCapability(environment, 'wpMedia')) {
|
|
58
|
+
return [
|
|
59
|
+
'Media — the WordPress library. Media ids resolve against /wp/v2/media, so',
|
|
60
|
+
' a URL can be looked up:',
|
|
61
|
+
" curl '<site>/?rest_route=/wp/v2/media&per_page=100&_fields=id,source_url'",
|
|
62
|
+
binding
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
return [
|
|
66
|
+
"Media — this runtime's own asset store, NOT the WordPress library:",
|
|
67
|
+
' /wp/v2/media does not apply and there is nothing to look a URL up against.',
|
|
68
|
+
binding
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
function unavailableGuidance(environment) {
|
|
72
|
+
const throwing = GATED_NAMESPACES.filter((gated) => !hasCapability(environment, gated.capability)).map((gated) => gated.namespace);
|
|
73
|
+
if (throwing.length === 0)
|
|
74
|
+
return [];
|
|
75
|
+
return [
|
|
76
|
+
'Throws EtchApiError NOT_AVAILABLE here — do not call:',
|
|
77
|
+
` ${throwing.join(', ')}`
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
function blockTypeGuidance(environment) {
|
|
81
|
+
if (environment.blockTypes.length === 0)
|
|
82
|
+
return [];
|
|
83
|
+
return [
|
|
84
|
+
'Block types this runtime can construct — author nothing outside this list:',
|
|
85
|
+
` ${environment.blockTypes.join(', ')}`
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
/** Which capabilities a runtime actually backs, for a one-line summary. */
|
|
89
|
+
export function backedCapabilities(environment) {
|
|
90
|
+
return Object.entries(environment.capabilities)
|
|
91
|
+
.filter(([, backed]) => backed === true)
|
|
92
|
+
.map(([capability]) => capability)
|
|
93
|
+
.sort();
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* One stderr line naming the runtime a script is about to run against.
|
|
97
|
+
*
|
|
98
|
+
* `tabs` is where the full instructions live, but `-t` is optional with a single
|
|
99
|
+
* tab connected — so an agent can go straight to `eval` and never see them. This
|
|
100
|
+
* is the line that keeps that agent from silently authoring for the wrong
|
|
101
|
+
* product, and points at where the rules are.
|
|
102
|
+
*/
|
|
103
|
+
export function runtimeBanner(environment) {
|
|
104
|
+
if (!environment) {
|
|
105
|
+
return '[etch] runtime reports no capabilities — assuming WordPress; confirm before relying on it';
|
|
106
|
+
}
|
|
107
|
+
const backed = backedCapabilities(environment);
|
|
108
|
+
return (`[${environment.product}] backs ${backed.join(', ') || 'no optional surface'}` +
|
|
109
|
+
" — run 'etch-connector tabs' for the authoring rules");
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Agent-facing instructions for one runtime. An absent descriptor is a runtime
|
|
113
|
+
* too old to report one, which is always a WordPress build.
|
|
114
|
+
*/
|
|
115
|
+
export function environmentGuidance(environment) {
|
|
116
|
+
if (!environment)
|
|
117
|
+
return UNKNOWN_RUNTIME.join('\n');
|
|
118
|
+
const backed = backedCapabilities(environment);
|
|
119
|
+
const sections = [
|
|
120
|
+
[
|
|
121
|
+
`Runtime: ${environment.product} — backs ${backed.length > 0 ? backed.join(', ') : 'no optional surface'}.`
|
|
122
|
+
],
|
|
123
|
+
dynamicDataGuidance(environment),
|
|
124
|
+
mediaGuidance(environment),
|
|
125
|
+
unavailableGuidance(environment),
|
|
126
|
+
blockTypeGuidance(environment)
|
|
127
|
+
].filter((section) => section.length > 0);
|
|
128
|
+
return sections.map((section) => section.join('\n')).join('\n');
|
|
129
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -9,3 +9,5 @@
|
|
|
9
9
|
export { BridgeServer, BridgeError } from './bridge-server.js';
|
|
10
10
|
export { createControlServer, controlBaseUrl } from './control-server.js';
|
|
11
11
|
export { DEFAULT_WS_PORT, DEFAULT_CONTROL_PORT, DEFAULT_HOST } from './protocol.js';
|
|
12
|
+
export { hasCapability, parseEnvironment } from './protocol.js';
|
|
13
|
+
export { backedCapabilities, environmentGuidance, runtimeBanner } from './guidance.js';
|
package/dist/protocol.js
CHANGED
|
@@ -10,6 +10,48 @@
|
|
|
10
10
|
* server → page : EvalRequest | PingMessage
|
|
11
11
|
* page → server : HelloMessage | LogMessage | ResultMessage | ErrorMessage | PongMessage
|
|
12
12
|
*/
|
|
13
|
+
/** Every optional surface a runtime may or may not back. */
|
|
14
|
+
const CAPABILITY_NAMES = [
|
|
15
|
+
'loops',
|
|
16
|
+
'dataSources',
|
|
17
|
+
'fields',
|
|
18
|
+
'templates',
|
|
19
|
+
'wpMedia'
|
|
20
|
+
];
|
|
21
|
+
/** Whether a runtime backs a capability. Absent or unknown counts as no. */
|
|
22
|
+
export function hasCapability(environment, capability) {
|
|
23
|
+
return environment?.capabilities[capability] === true;
|
|
24
|
+
}
|
|
25
|
+
function isRecord(value) {
|
|
26
|
+
return typeof value === 'object' && value !== null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Narrow an `environment` off the wire. The page is untrusted input, so a
|
|
30
|
+
* malformed descriptor becomes `undefined` (treated as "runtime too old to
|
|
31
|
+
* report one") rather than propagating a broken shape.
|
|
32
|
+
*/
|
|
33
|
+
export function parseEnvironment(value) {
|
|
34
|
+
if (!isRecord(value))
|
|
35
|
+
return undefined;
|
|
36
|
+
const { product, capabilities, blockTypes } = value;
|
|
37
|
+
if (typeof product !== 'string')
|
|
38
|
+
return undefined;
|
|
39
|
+
if (!isRecord(capabilities))
|
|
40
|
+
return undefined;
|
|
41
|
+
if (!Array.isArray(blockTypes))
|
|
42
|
+
return undefined;
|
|
43
|
+
const known = {};
|
|
44
|
+
for (const name of CAPABILITY_NAMES) {
|
|
45
|
+
const flag = capabilities[name];
|
|
46
|
+
if (typeof flag === 'boolean')
|
|
47
|
+
known[name] = flag;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
product,
|
|
51
|
+
capabilities: known,
|
|
52
|
+
blockTypes: blockTypes.filter((type) => typeof type === 'string')
|
|
53
|
+
};
|
|
54
|
+
}
|
|
13
55
|
/** Default WebSocket port the Etch bridge dials (`ws://127.0.0.1:7331`). */
|
|
14
56
|
export const DEFAULT_WS_PORT = 7331;
|
|
15
57
|
/** Default HTTP control-API port the CLI / AI harness talks to. */
|
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";
|