@digital-gravy/etch-connector 0.1.5 → 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 CHANGED
@@ -122,12 +122,23 @@ 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
@@ -144,7 +155,7 @@ etch-connector — drive a live Etch tab from the command line
144
155
 
145
156
  Usage:
146
157
  etch-connector serve [--ws-port 7331] [--control-port 7332] [--ws-host 127.0.0.1]
147
- etch-connector tabs [--json] [--control-port 7332] [--cdp]
158
+ etch-connector tabs [--json] [--quiet] [--control-port 7332] [--cdp]
148
159
  etch-connector eval [code] [-t|--tab name] [-f|--file path] [--timeout ms] [--cdp]
149
160
  etch-connector shot [-t name] [-s|--selector css] [--full] [--jpeg] [-o|--out file] [--freeze=false] (CDP)
150
161
  etch-connector html <selector> [-t name] (CDP)
@@ -194,6 +205,8 @@ the same one a future MCP server would call):
194
205
  requests, handles timeouts and disconnects. **This is the reusable piece.**
195
206
  - [`src/control-server.ts`](src/control-server.ts) — the HTTP control API over
196
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.
197
210
  - [`src/cli.ts`](src/cli.ts) — the `serve` / `tabs` / `eval` commands.
198
211
 
199
212
  ## Going to MCP later
@@ -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* and the script's return
11
- * value as JSON to *stdout*, so an agent can capture stdout as the clean result.
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,6 +33,7 @@ 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',
@@ -275,6 +278,33 @@ async function tabsViaCdp(cdp, flags) {
275
278
  for (const t of targets)
276
279
  process.stdout.write(`${t.title}\t${t.url}\n`);
277
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
+ }
278
308
  async function tabsViaDaemon(flags) {
279
309
  const res = await controlFetch(flags, '/tabs');
280
310
  const { tabs } = (await res.json());
@@ -285,8 +315,11 @@ async function tabsViaDaemon(flags) {
285
315
  return;
286
316
  }
287
317
  for (const tab of tabs) {
288
- process.stdout.write(`${tab.name}\t${tab.hasEtch ? 'etch' : 'no-etch'}\t${tab.title}\t${tab.url}\n`);
318
+ process.stdout.write(`${tab.name}\t${describeRuntime(tab)}\t${tab.title}\t${tab.url}\n`);
289
319
  }
320
+ // `tabs` doubles as the liveness check, so let a poller opt out of the prose.
321
+ if (!flags['quiet'])
322
+ printRuntimeGuidance(tabs);
290
323
  }
291
324
  async function runTabs(flags) {
292
325
  const cdp = cdpFromFlags(flags);
@@ -336,17 +369,51 @@ async function evalViaCdp(cdp, flags, code, timeoutMs) {
336
369
  failCdp(err, cdp.port);
337
370
  }
338
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
+ }
339
403
  async function evalViaDaemon(flags, code, timeoutMs) {
340
404
  const mode = resolveEvalMode(flags);
341
405
  if (mode === 'unsafe') {
342
406
  console.error('⚠ running in unsafe mode — the script has full access to the page');
343
407
  }
408
+ // Started before the eval so naming the runtime costs no wall-clock.
409
+ const announced = announceTargetRuntime(flags);
344
410
  const body = { tab: str(flags['tab']), code, timeoutMs, mode };
345
411
  const res = await controlFetch(flags, '/eval', {
346
412
  method: 'POST',
347
413
  headers: { 'content-type': 'application/json' },
348
414
  body: JSON.stringify(body)
349
415
  });
416
+ await announced;
350
417
  if (!res.ok) {
351
418
  const err = (await res.json().catch(() => ({})));
352
419
  console.error(`eval failed: ${err.error ?? res.statusText}`);
@@ -419,7 +486,7 @@ Or install globally for the bare \`etch-connector\` command:
419
486
 
420
487
  Usage:
421
488
  etch-connector serve [--ws-port ${DEFAULT_WS_PORT}] [--control-port ${DEFAULT_CONTROL_PORT}] [--ws-host ${DEFAULT_HOST}]
422
- etch-connector tabs [--json] [--control-port ${DEFAULT_CONTROL_PORT}] [--cdp]
489
+ etch-connector tabs [--json] [--quiet] [--control-port ${DEFAULT_CONTROL_PORT}] [--cdp]
423
490
  etch-connector eval [code] [-t|--tab name] [-f|--file path] [--timeout ms] [--cdp]
424
491
  etch-connector shot [-t name] [-s|--selector css] [--full] [--jpeg] [-o|--out file] [--freeze=false] (CDP)
425
492
  etch-connector html <selector> [-t name] (CDP)
@@ -437,6 +504,15 @@ AGENT: before running anything, ask the user how they want to connect — bridge
437
504
  (API only) or cdp (DevTools privilege: full page access). cdp grants more reach;
438
505
  don't assume — let the user choose.
439
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
+
440
516
  Connecting a bridge tab (AGENT: walk the user through activating it — the AI
441
517
  button is the primary path; don't just wait for a tab to appear):
442
518
  1. Tell them to open Etch Settings and enable the AI Connector.
@@ -539,10 +615,13 @@ render what you author):
539
615
  etch.blocks.update(instanceId, { attributes: { image: "10" } })
540
616
  • updateAsync({ blocks }) requires styles:[] on every element block
541
617
  (validation differs from blocks.create(), which omits styles entirely).
542
- • No etch.media API. Resolve a URL → media ID via the WP REST 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:
543
621
  curl 'http://<site>/?rest_route=/wp/v2/media&per_page=100&_fields=id,source_url'
544
622
  (rest_route form works on plain permalinks; /wp-json/wp/v2/media on pretty
545
- 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.
546
625
 
547
626
  See AGENTS.md for project conventions.`;
548
627
  function printHelp() {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digital-gravy/etch-connector",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },