@tikoci/centrs 0.1.1 → 0.1.3

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.
Files changed (95) hide show
  1. package/CHANGELOG.md +107 -2
  2. package/GLOSSARY.txt +87 -0
  3. package/README.md +25 -9
  4. package/SECURITY.md +3 -3
  5. package/commands/AGENTS.md +3 -2
  6. package/commands/api/AGENTS.md +212 -0
  7. package/commands/api/README.md +177 -0
  8. package/commands/api/examples.md +428 -0
  9. package/commands/btest/README.md +56 -31
  10. package/commands/btest/examples.md +35 -10
  11. package/commands/check/README.md +371 -53
  12. package/commands/check/examples.md +390 -0
  13. package/commands/devices/README.md +177 -37
  14. package/commands/devices/examples.md +148 -0
  15. package/commands/execute/README.md +48 -7
  16. package/commands/execute/examples.md +90 -6
  17. package/commands/retrieve/README.md +37 -51
  18. package/commands/retrieve/examples.md +40 -9
  19. package/commands/settings/README.md +286 -22
  20. package/commands/settings/examples.md +408 -0
  21. package/commands/terminal/README.md +6 -0
  22. package/commands/transfer/README.md +32 -2
  23. package/commands/transfer/examples.md +66 -0
  24. package/docs/CONSTITUTION.md +40 -15
  25. package/docs/MATRIX.md +69 -142
  26. package/docs/errors/README.md +2 -1
  27. package/docs/errors/cdb/empty-selection.md +19 -0
  28. package/docs/errors/input/incomplete-gps.md +9 -0
  29. package/docs/errors/input/invalid-altitude.md +9 -0
  30. package/docs/errors/input/invalid-bbox.md +9 -0
  31. package/docs/errors/input/invalid-coordinate.md +9 -0
  32. package/docs/errors/input/invalid-radius.md +9 -0
  33. package/docs/errors/internal/settings-failed.md +9 -0
  34. package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
  35. package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
  36. package/docs/errors/settings/consequential-value.md +26 -0
  37. package/docs/errors/settings/invalid-boolean.md +21 -3
  38. package/docs/errors/settings/invalid-format.md +18 -3
  39. package/docs/errors/settings/invalid-integer.md +18 -3
  40. package/docs/errors/settings/invalid-timeout.md +20 -3
  41. package/docs/errors/settings/invalid-via.md +22 -3
  42. package/docs/errors/settings/reserved-key.md +29 -0
  43. package/docs/errors/settings/skip-env-file-active.md +25 -0
  44. package/docs/errors/settings/unknown-key.md +25 -0
  45. package/docs/errors/usage/fanout-not-supported.md +16 -0
  46. package/docs/errors/usage/invalid-method.md +23 -0
  47. package/package.json +9 -9
  48. package/src/api-fanout.ts +454 -0
  49. package/src/api.ts +1627 -0
  50. package/src/btest.ts +61 -1
  51. package/src/cli/api.ts +660 -0
  52. package/src/cli/btest.ts +20 -2
  53. package/src/cli/common.ts +75 -0
  54. package/src/cli/devices.ts +235 -6
  55. package/src/cli/discover.ts +3 -1
  56. package/src/cli/execute.ts +107 -6
  57. package/src/cli/mcp.ts +8 -1
  58. package/src/cli/retrieve.ts +135 -90
  59. package/src/cli/selection.ts +200 -0
  60. package/src/cli/settings.ts +286 -0
  61. package/src/cli/terminal.ts +22 -7
  62. package/src/cli/transfer.ts +171 -47
  63. package/src/cli.ts +29 -0
  64. package/src/core/error-catalog.ts +69 -0
  65. package/src/core/fanout.ts +301 -0
  66. package/src/core/inspect.ts +170 -0
  67. package/src/devices.ts +212 -20
  68. package/src/discover.ts +7 -2
  69. package/src/execute-fanout.ts +438 -0
  70. package/src/execute.ts +134 -70
  71. package/src/index.ts +166 -9
  72. package/src/mcp/config.ts +12 -2
  73. package/src/mcp/tools.ts +137 -2
  74. package/src/protocols/adapter.ts +357 -9
  75. package/src/protocols/btest-session.ts +279 -46
  76. package/src/protocols/btest.ts +6 -2
  77. package/src/protocols/index.ts +11 -5
  78. package/src/protocols/native-api.ts +223 -30
  79. package/src/resolver/cdb.ts +10 -2
  80. package/src/resolver/comment-kv.ts +58 -4
  81. package/src/resolver/config-file.ts +136 -0
  82. package/src/resolver/facts.ts +78 -0
  83. package/src/resolver/geo.ts +453 -0
  84. package/src/resolver/index.ts +46 -0
  85. package/src/resolver/mac.ts +12 -3
  86. package/src/resolver/selection.ts +304 -0
  87. package/src/resolver/settings.ts +54 -5
  88. package/src/resolver/target.ts +20 -2
  89. package/src/retrieve-fanout.ts +232 -268
  90. package/src/retrieve.ts +69 -94
  91. package/src/settings.ts +1078 -0
  92. package/src/terminal.ts +11 -1
  93. package/src/transfer-fanout.ts +527 -0
  94. package/src/transfer.ts +61 -11
  95. package/commands/stream/README.md +0 -70
package/src/mcp/tools.ts CHANGED
@@ -33,6 +33,8 @@ import {
33
33
  isWriteShaped,
34
34
  validateExecuteEnvelope,
35
35
  } from "../execute.ts";
36
+ import type { CommentKvUpdate } from "../resolver/comment-kv.ts";
37
+ import { parseBbox, parseNear } from "../resolver/geo.ts";
36
38
  import { parseDuration } from "../resolver/settings.ts";
37
39
  import { buildRetrieveErrorEnvelope, retrieve } from "../retrieve.ts";
38
40
  import {
@@ -416,6 +418,24 @@ export const devicesInputShape = {
416
418
  .describe(
417
419
  "Group filter for op=list; first-class group field for op=add/set.",
418
420
  ),
421
+ where: z
422
+ .array(z.string())
423
+ .optional()
424
+ .describe(
425
+ "op=list device-class filters `attr=value` (repeatable, AND-combined; e.g. board=RB5009 or lat=37.7749).",
426
+ ),
427
+ near: z
428
+ .string()
429
+ .optional()
430
+ .describe(
431
+ "op=list geo filter `<lat>,<lon>,<radius>` (lat-first; radius m/km/mi/ft, bare = km).",
432
+ ),
433
+ bbox: z
434
+ .string()
435
+ .optional()
436
+ .describe(
437
+ "op=list geo filter `<south>,<west>,<north>,<east>` (lat-first bounding box).",
438
+ ),
419
439
  user: z.string().optional().describe("CDB username for op=add/set."),
420
440
  password: z
421
441
  .string()
@@ -454,6 +474,30 @@ export const devicesInputShape = {
454
474
  .boolean()
455
475
  .optional()
456
476
  .describe("For op=set, reject comment keys outside the allowlist."),
477
+ lat: z
478
+ .number()
479
+ .optional()
480
+ .describe(
481
+ "Latitude in decimal degrees (-90..90) for op=add/set; a comment-kv fact, paired with lon (issue #146).",
482
+ ),
483
+ lon: z
484
+ .number()
485
+ .optional()
486
+ .describe(
487
+ "Longitude in decimal degrees (-180..180) for op=add/set; a comment-kv fact, paired with lat.",
488
+ ),
489
+ altitude: z
490
+ .number()
491
+ .optional()
492
+ .describe(
493
+ "Altitude in meters for op=add/set (may be negative); a comment-kv fact.",
494
+ ),
495
+ altitudeType: z
496
+ .enum(["MSL", "AGL"])
497
+ .optional()
498
+ .describe(
499
+ "Vertical datum for altitude, op=add/set (default MSL when altitude is set).",
500
+ ),
457
501
  confirm: z
458
502
  .boolean()
459
503
  .optional()
@@ -478,6 +522,56 @@ function requireDevicesTarget(
478
522
  });
479
523
  }
480
524
 
525
+ /**
526
+ * Lower the typed `lat`/`lon`/`altitude`/`altitudeType` MCP args into comment-kv
527
+ * updates — the same core surface the CLI's `--lat`/`--lon`/`--gps` flags lower
528
+ * into (`src/cli/devices.ts`). Light touch: no alias handling here (the MCP
529
+ * schema only exposes the canonical field names), range/enum/pairing
530
+ * validation happens once in `validateCommentKvUpdates` (`src/devices.ts`).
531
+ */
532
+ function buildGeoUpdates(args: DevicesArgs): CommentKvUpdate[] {
533
+ const updates: CommentKvUpdate[] = [];
534
+ if (args.lat !== undefined) {
535
+ updates.push({ key: "lat", value: String(args.lat) });
536
+ }
537
+ if (args.lon !== undefined) {
538
+ updates.push({ key: "lon", value: String(args.lon) });
539
+ }
540
+ if (args.altitude !== undefined) {
541
+ updates.push({ key: "altitude", value: String(args.altitude) });
542
+ }
543
+ if (args.altitudeType !== undefined) {
544
+ updates.push({ key: "altitude-type", value: args.altitudeType });
545
+ }
546
+ return updates;
547
+ }
548
+
549
+ /**
550
+ * Parse the MCP `where` array (`["attr=value", …]`) into device-class clauses
551
+ * for op=list. Geo-alias canonicalization happens centrally in `matchesWhere`
552
+ * (`src/resolver/facts.ts`), so this only splits on the first `=`.
553
+ */
554
+ function parseMcpWhereClauses(
555
+ where: readonly string[] | undefined,
556
+ ): { key: string; value: string }[] | undefined {
557
+ if (where === undefined) {
558
+ return undefined;
559
+ }
560
+ return where.map((clause) => {
561
+ const eq = clause.indexOf("=");
562
+ if (eq <= 0) {
563
+ throw new CentrsError({
564
+ code: "input/invalid-command",
565
+ summary: `where clause must be \`attr=value\`; received: ${clause}`,
566
+ remediation:
567
+ "Pass device-class filters like `board=RB5009` or `lat=37.7749`.",
568
+ context: { where: clause },
569
+ });
570
+ }
571
+ return { key: clause.slice(0, eq), value: clause.slice(eq + 1) };
572
+ });
573
+ }
574
+
481
575
  function resolveRecordType(recordType: string | undefined): number | undefined {
482
576
  if (recordType === undefined) {
483
577
  return undefined;
@@ -502,9 +596,49 @@ export async function handleDevices(
502
596
  ): Promise<McpEnvelope> {
503
597
  try {
504
598
  const cdb = await loadAllowlistCdb(config);
599
+ // GPS *setter* fields write via add/set only; the *query* selectors
600
+ // (where/near/bbox) select via list only. Reject the wrong-op combination
601
+ // instead of silently dropping the caller's input (mirrors the CLI's
602
+ // per-subcommand flag guard in src/cli/devices.ts).
603
+ if (
604
+ args.op !== "add" &&
605
+ args.op !== "set" &&
606
+ (args.lat !== undefined ||
607
+ args.lon !== undefined ||
608
+ args.altitude !== undefined ||
609
+ args.altitudeType !== undefined)
610
+ ) {
611
+ throw new CentrsError({
612
+ code: "input/invalid-command",
613
+ summary: `GPS fields (lat/lon/altitude/altitudeType) are not valid for op="${args.op}".`,
614
+ remediation:
615
+ "Store GPS with op=add or op=set; drop the GPS fields for list/show/groups/edit/remove.",
616
+ context: { op: args.op },
617
+ });
618
+ }
619
+ if (
620
+ args.op !== "list" &&
621
+ (args.where !== undefined ||
622
+ args.near !== undefined ||
623
+ args.bbox !== undefined)
624
+ ) {
625
+ throw new CentrsError({
626
+ code: "input/invalid-command",
627
+ summary: `Query selectors (where/near/bbox) are only valid for op="list", not op="${args.op}".`,
628
+ remediation:
629
+ "Use op=list with where/near/bbox to query the registry; for op=show name a target directly.",
630
+ context: { op: args.op },
631
+ });
632
+ }
505
633
  switch (args.op) {
506
634
  case "list":
507
- return listDevices({ cdb, group: args.group });
635
+ return listDevices({
636
+ cdb,
637
+ group: args.group,
638
+ where: parseMcpWhereClauses(args.where),
639
+ near: args.near !== undefined ? parseNear(args.near) : undefined,
640
+ bbox: args.bbox !== undefined ? parseBbox(args.bbox) : undefined,
641
+ });
508
642
  case "groups":
509
643
  return listGroups({ cdb, withMembers: true });
510
644
  case "show": {
@@ -527,6 +661,7 @@ export async function handleDevices(
527
661
  profile: args.profile,
528
662
  session: args.session,
529
663
  comment: args.comment,
664
+ commentKvUpdates: buildGeoUpdates(args),
530
665
  savedPassword: args.savedPassword,
531
666
  force: args.force,
532
667
  }),
@@ -542,7 +677,7 @@ export async function handleDevices(
542
677
  await setDevice({
543
678
  cdb,
544
679
  target,
545
- updates: args.updates,
680
+ updates: [...buildGeoUpdates(args), ...(args.updates ?? [])],
546
681
  user: args.user,
547
682
  password: args.password,
548
683
  group: args.group,
@@ -106,6 +106,54 @@ export interface ProtocolExecuteResult {
106
106
  ret?: string;
107
107
  }
108
108
 
109
+ /**
110
+ * RouterOS verb the `api` orchestrator resolved from the HTTP method (the gh-api
111
+ * `-X` map: `GET`→print, `PUT`→add, `PATCH`→set, `DELETE`→remove, `POST`→run).
112
+ * `print` covers list, get-singleton, and get-one (disambiguated by `id`).
113
+ */
114
+ export type ApiVerb = "print" | "add" | "set" | "remove" | "run";
115
+
116
+ /**
117
+ * A normalized `api` request the orchestrator hands to a transport. It is
118
+ * transport-agnostic: the REST adapter maps the verb to an HTTP method + URL
119
+ * (id-in-path, `.query`/`.proplist` body), the native adapter maps it to a
120
+ * tagged `talk` (`=.id=`, `?`-prefixed queries, `=.proplist=`). The orchestrator
121
+ * owns method→verb, validation, and the write gate; the adapter only executes.
122
+ */
123
+ export interface ProtocolApiRequest {
124
+ verb: ApiVerb;
125
+ /** Slash path without id, e.g. `/ip/address`. For `run`, the command target (`/interface/monitor-traffic`); ignored when `script` is set. */
126
+ path: string;
127
+ /** Object id (`*1`) for get-one/set/remove — REST puts it in the URL, native in `=.id=`. */
128
+ id?: string;
129
+ /** Body fields (string values), the JSON object for add/set or the args for a `run` command. */
130
+ attributes?: Record<string, string>;
131
+ /** Row filter in REST `.query` form (no leading `?`), e.g. `["type=ether", "#!"]`; native prefixes each with `?`. */
132
+ query?: readonly string[];
133
+ /** Property projection → REST `.proplist` / native `=.proplist=`. */
134
+ proplist?: readonly string[];
135
+ /** Raw console line for the `/execute` script form (`run`); when set, `path`/`attributes` are ignored. */
136
+ script?: string;
137
+ }
138
+
139
+ /** Result of an `apiRequest` round-trip: the response body shaped for the envelope. */
140
+ export interface ProtocolApiResult {
141
+ /** REST returns the body verbatim (array / object / scalar); native re-maps `!re` records to the same rest-style shape. */
142
+ data: unknown;
143
+ }
144
+
145
+ /** Options for an open-ended `listen` follow. */
146
+ export interface ProtocolListenOptions {
147
+ /** Abort the listen (sends `/cancel`); the generator then ends cleanly. */
148
+ signal?: AbortSignal;
149
+ /**
150
+ * Fired once the subscription is on the wire (connection up, listen sentence
151
+ * written) — a real "listening" barrier for callers/tests that must act only
152
+ * after the follow is established, instead of a blind timer.
153
+ */
154
+ onListening?: () => void;
155
+ }
156
+
109
157
  /**
110
158
  * Runtime transport seam. The orchestrator drives validation and data fetch
111
159
  * through these operations so REST and native-api share the same pipeline and
@@ -124,6 +172,18 @@ export interface ProtocolAdapter {
124
172
  list(path: string, options: RetrieveListOptions): Promise<unknown[]>;
125
173
  /** Run a CLI-shaped command (WP-1c write surface). */
126
174
  execute(request: ProtocolExecuteRequest): Promise<ProtocolExecuteResult>;
175
+ /** Run a normalized structured `api` request (the gh-api passthrough surface). */
176
+ apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult>;
177
+ /**
178
+ * Open-ended `/listen` follow (native-api only). Yields one rest-style change
179
+ * record per `!re` frame (deletions carry `.dead`), until cancelled via
180
+ * `options.signal` or the consumer stopping. REST and console transports
181
+ * reject with `transport/capability-unsupported`.
182
+ */
183
+ listen(
184
+ request: ProtocolApiRequest,
185
+ options: ProtocolListenOptions,
186
+ ): AsyncIterable<Record<string, unknown>>;
127
187
  /** Release any underlying connection. Safe to call when never connected. */
128
188
  close(): Promise<void>;
129
189
  }
@@ -207,10 +267,105 @@ class RestAdapter implements ProtocolAdapter {
207
267
  return normalizeRestExecute(data);
208
268
  }
209
269
 
270
+ async apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult> {
271
+ const base = request.path.replace(/\/$/, "");
272
+ switch (request.verb) {
273
+ case "print": {
274
+ const hasQuery = (request.query?.length ?? 0) > 0;
275
+ const hasProplist = (request.proplist?.length ?? 0) > 0;
276
+ if (request.id && !hasQuery && !hasProplist) {
277
+ // GET one by id: RouterOS REST addresses a single object in the URL.
278
+ return {
279
+ data: await this.requestRest("GET", `${base}/${request.id}`),
280
+ };
281
+ }
282
+ if (request.id || hasQuery || hasProplist) {
283
+ // A GET cannot carry a body, so `.query`/`.proplist` projection (and an
284
+ // id, folded into `.query` as `.id=`) rides a POST to the `/print`
285
+ // sub-endpoint — the documented REST idiom, matching native's `?`-words.
286
+ const data = await this.requestRest(
287
+ "POST",
288
+ `${base}/print`,
289
+ restQueryBody(request),
290
+ );
291
+ // An id addresses one row → unwrap to a single object, matching the
292
+ // GET-by-id shape and native's `?.id=` read.
293
+ if (request.id && Array.isArray(data)) {
294
+ return { data: data[0] ?? null };
295
+ }
296
+ return { data };
297
+ }
298
+ return { data: await this.requestRest("GET", base) };
299
+ }
300
+ case "add":
301
+ return {
302
+ data: await this.requestRest("PUT", base, request.attributes ?? {}),
303
+ };
304
+ case "set":
305
+ return {
306
+ data: await this.requestRest(
307
+ "PATCH",
308
+ `${base}/${request.id}`,
309
+ request.attributes ?? {},
310
+ ),
311
+ };
312
+ case "remove":
313
+ return {
314
+ data: await this.requestRest("DELETE", `${base}/${request.id}`),
315
+ };
316
+ case "run": {
317
+ if (request.script !== undefined) {
318
+ // Sync script run: `as-string` makes `/rest/execute` block and return the
319
+ // captured output rather than scheduling an async job (CHR-grounded).
320
+ const body = await this.requestRest("POST", "/execute", {
321
+ script: request.script,
322
+ "as-string": "",
323
+ });
324
+ const result = normalizeRestExecute(body);
325
+ return { data: result.ret ?? result.records };
326
+ }
327
+ return {
328
+ data: await this.requestRest("POST", base, request.attributes ?? {}),
329
+ };
330
+ }
331
+ default:
332
+ return exhaustiveApiVerb(request.verb);
333
+ }
334
+ }
335
+
336
+ // REST cannot hold an open-ended follow (the 60s cap); listen is native-only.
337
+ // biome-ignore lint/correctness/useYield: a throw-only async generator yields nothing.
338
+ async *listen(): AsyncGenerator<Record<string, unknown>> {
339
+ throw new CentrsError({
340
+ code: "transport/capability-unsupported",
341
+ summary: "REST cannot follow an open-ended `--stream` (60s cap).",
342
+ remediation:
343
+ "Open-ended follow is native-api only: use `--via native-api`, or drop `--stream` for a bounded one-shot.",
344
+ context: { protocol: this.protocol, capability: "listen" },
345
+ });
346
+ }
347
+
210
348
  async close(): Promise<void> {
211
349
  // REST is stateless; nothing to release.
212
350
  }
213
351
 
352
+ private async requestRest(
353
+ method: string,
354
+ path: string,
355
+ body?: Record<string, unknown>,
356
+ ): Promise<unknown> {
357
+ const init: RequestInit =
358
+ body === undefined
359
+ ? { method }
360
+ : {
361
+ method,
362
+ body: JSON.stringify(body),
363
+ headers: { "Content-Type": "application/json" },
364
+ };
365
+ const response = await this.fetchRest(path, init);
366
+ return response.data;
367
+ }
368
+
214
369
  private async restGet(path: string): Promise<unknown> {
215
370
  const response = await this.fetchRest(path, { method: "GET" });
216
371
  return response.data;
@@ -455,23 +610,135 @@ class NativeApiAdapter implements ProtocolAdapter {
455
610
  async execute(
456
611
  request: ProtocolExecuteRequest,
457
612
  ): Promise<ProtocolExecuteResult> {
458
- const command: NativeApiCommand = request.script
459
- ? { command: "/execute", attributes: { script: request.script } }
460
- : { command: `${request.path.replace(/\/$/, "")}/${request.command}` };
461
- if (
462
- !request.script &&
463
- request.attributes &&
464
- Object.keys(request.attributes).length > 0
465
- ) {
613
+ if (request.script !== undefined) {
614
+ return normalizeNativeExecute(await this.executeScript(request.script));
615
+ }
616
+ const command: NativeApiCommand = {
617
+ command: `${request.path.replace(/\/$/, "")}/${request.command}`,
618
+ };
619
+ if (request.attributes && Object.keys(request.attributes).length > 0) {
466
620
  command.attributes = request.attributes;
467
621
  }
468
- if (!request.script && request.queries && request.queries.length > 0) {
622
+ if (request.queries && request.queries.length > 0) {
469
623
  command.queries = request.queries;
470
624
  }
471
625
  const replies = await this.talk(command);
472
626
  return { records: repliesToRecords(replies) };
473
627
  }
474
628
 
629
+ /**
630
+ * Run a script through native `/execute` with `as-string`, the sole shared
631
+ * path for both the CLI-shaped `execute` command and the `api` run+script
632
+ * verb: without `as-string` the native API schedules a background job and
633
+ * returns only its id rather than the captured output (CHR-grounded —
634
+ * mirrors REST `/rest/execute`).
635
+ */
636
+ private async executeScript(
637
+ script: string,
638
+ ): Promise<Record<string, string>[]> {
639
+ const replies = await this.talk({
640
+ command: "/execute",
641
+ attributes: { script, "as-string": "" },
642
+ });
643
+ return repliesToRecords(replies);
644
+ }
645
+
646
+ async apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult> {
647
+ const base = request.path.replace(/\/$/, "");
648
+ switch (request.verb) {
649
+ case "print": {
650
+ const command: NativeApiCommand = { command: `${base}/print` };
651
+ const queries: string[] = [];
652
+ // REST `.query` words map 1:1 to native `?`-prefixed words (CHR-grounded).
653
+ for (const word of request.query ?? []) {
654
+ queries.push(`?${word}`);
655
+ }
656
+ // No native get-one-by-id shorthand: address a single row with `?.id=`.
657
+ if (request.id) {
658
+ queries.push(`?.id=${request.id}`);
659
+ }
660
+ if (queries.length > 0) {
661
+ command.queries = queries;
662
+ }
663
+ if (request.proplist && request.proplist.length > 0) {
664
+ command.proplist = request.proplist;
665
+ }
666
+ const records = repliesToRecords(await this.talk(command));
667
+ return { data: request.id ? (records[0] ?? null) : records };
668
+ }
669
+ case "add": {
670
+ const records = repliesToRecords(
671
+ await this.talk({
672
+ command: `${base}/add`,
673
+ attributes: request.attributes ?? {},
674
+ }),
675
+ );
676
+ return { data: restStyleMutationData(records) };
677
+ }
678
+ case "set": {
679
+ const records = repliesToRecords(
680
+ await this.talk({
681
+ command: `${base}/set`,
682
+ attributes: {
683
+ ...(request.attributes ?? {}),
684
+ ".id": request.id ?? "",
685
+ },
686
+ }),
687
+ );
688
+ return { data: restStyleMutationData(records) };
689
+ }
690
+ case "remove": {
691
+ const records = repliesToRecords(
692
+ await this.talk({
693
+ command: `${base}/remove`,
694
+ attributes: { ".id": request.id ?? "" },
695
+ }),
696
+ );
697
+ return { data: restStyleMutationData(records) };
698
+ }
699
+ case "run": {
700
+ if (request.script !== undefined) {
701
+ const records = await this.executeScript(request.script);
702
+ return { data: restStyleRunData(records) };
703
+ }
704
+ const command: NativeApiCommand = { command: base };
705
+ if (request.attributes && Object.keys(request.attributes).length > 0) {
706
+ command.attributes = request.attributes;
707
+ }
708
+ return { data: repliesToRecords(await this.talk(command)) };
709
+ }
710
+ default:
711
+ return exhaustiveApiVerb(request.verb);
712
+ }
713
+ }
714
+
715
+ async *listen(
716
+ request: ProtocolApiRequest,
717
+ options: ProtocolListenOptions,
718
+ ): AsyncGenerator<Record<string, unknown>> {
719
+ const base = request.path.replace(/\/$/, "");
720
+ const command: NativeApiCommand = { command: `${base}/listen` };
721
+ // A listen can filter which changes it follows with the same `?`-words /
722
+ // `=.proplist=` as print (REST `.query` word == native `?`-word minus `?`).
723
+ const queries = (request.query ?? []).map((word) => `?${word}`);
724
+ // An addressed row (`ip/address/*1 --stream`) follows just that row, the
725
+ // same `?.id=` mapping one-shot native reads use.
726
+ if (request.id) {
727
+ queries.push(`?.id=${request.id}`);
728
+ }
729
+ if (queries.length > 0) {
730
+ command.queries = queries;
731
+ }
732
+ if (request.proplist && request.proplist.length > 0) {
733
+ command.proplist = request.proplist;
734
+ }
735
+ const session = await this.connect();
736
+ for await (const reply of session.listen(command, options)) {
737
+ // Each `!re` becomes a rest-style record (string values; `.dead` preserved).
738
+ yield { ...reply.attributes };
739
+ }
740
+ }
741
+
475
742
  async close(): Promise<void> {
476
743
  this.session?.close();
477
744
  this.session = undefined;
@@ -614,6 +881,15 @@ class MacTelnetAdapter implements ProtocolAdapter {
614
881
  return { records: [], ret: output };
615
882
  }
616
883
 
884
+ apiRequest(): Promise<ProtocolApiResult> {
885
+ return Promise.reject(this.unsupported("structured api requests"));
886
+ }
887
+
888
+ // biome-ignore lint/correctness/useYield: a reject-only async generator yields nothing.
889
+ async *listen(): AsyncGenerator<Record<string, unknown>> {
890
+ throw this.unsupported("open-ended listen streaming");
891
+ }
892
+
617
893
  async close(): Promise<void> {
618
894
  this.console?.close();
619
895
  this.transport?.close();
@@ -756,11 +1032,83 @@ class SshExecAdapter implements ProtocolAdapter {
756
1032
  return { records: [], ret: output };
757
1033
  }
758
1034
 
1035
+ apiRequest(): Promise<ProtocolApiResult> {
1036
+ return Promise.reject(this.unsupported("structured api requests"));
1037
+ }
1038
+
1039
+ // biome-ignore lint/correctness/useYield: a reject-only async generator yields nothing.
1040
+ async *listen(): AsyncGenerator<Record<string, unknown>> {
1041
+ throw this.unsupported("open-ended listen streaming");
1042
+ }
1043
+
759
1044
  close(): Promise<void> {
760
1045
  return Promise.resolve();
761
1046
  }
762
1047
  }
763
1048
 
1049
+ /** REST `.query` / `.proplist` projection body for a POST `/print`. */
1050
+ function restQueryBody(request: ProtocolApiRequest): Record<string, unknown> {
1051
+ const body: Record<string, unknown> = {};
1052
+ // Fold an id into `.query` as `.id=<id>` so id+query/proplist reads work over
1053
+ // REST exactly as they do over native (`?.id=` plus the `?`-words).
1054
+ const query = [
1055
+ ...(request.id ? [`.id=${request.id}`] : []),
1056
+ ...(request.query ?? []),
1057
+ ];
1058
+ if (query.length > 0) {
1059
+ body[".query"] = query;
1060
+ }
1061
+ if (request.proplist && request.proplist.length > 0) {
1062
+ body[".proplist"] = request.proplist;
1063
+ }
1064
+ return body;
1065
+ }
1066
+
1067
+ /**
1068
+ * Re-map a native add/set/remove reply to the rest-style shape. `add` returns the
1069
+ * new id in `=ret=`; surface it as `{".id": ret}` (REST returns the created
1070
+ * object with `.id`). `set`/`remove` reply with a bare `!done` (no records) → no
1071
+ * meaningful body, mirrored as `null`.
1072
+ */
1073
+ function restStyleMutationData(
1074
+ records: readonly Record<string, string>[],
1075
+ ): unknown {
1076
+ if (records.length === 0) {
1077
+ return null;
1078
+ }
1079
+ const record = records[0] ?? {};
1080
+ const ret = record["ret"];
1081
+ if (typeof ret === "string" && /^\*[0-9A-F]+$/i.test(ret)) {
1082
+ return { ".id": ret };
1083
+ }
1084
+ return records.length === 1 ? record : records;
1085
+ }
1086
+
1087
+ /** Re-map a native `/execute` reply: prefer the captured `ret` string, else the records. */
1088
+ function restStyleRunData(records: readonly Record<string, string>[]): unknown {
1089
+ const ret = records.length === 1 ? records[0]?.["ret"] : undefined;
1090
+ if (typeof ret === "string") {
1091
+ return ret;
1092
+ }
1093
+ return records.length === 1 ? records[0] : records;
1094
+ }
1095
+
1096
+ function exhaustiveApiVerb(verb: never): never {
1097
+ throw new Error(`Unhandled api verb: ${String(verb)}`);
1098
+ }
1099
+
1100
+ /** Re-map an `as-string` native `/execute` reply into `ProtocolExecuteResult`,
1101
+ * mirroring `normalizeRestExecute`'s `ret`-vs-records split. */
1102
+ function normalizeNativeExecute(
1103
+ records: readonly Record<string, string>[],
1104
+ ): ProtocolExecuteResult {
1105
+ const ret = records.length === 1 ? records[0]?.["ret"] : undefined;
1106
+ if (typeof ret === "string") {
1107
+ return { records: [], ret };
1108
+ }
1109
+ return { records: [...records] };
1110
+ }
1111
+
764
1112
  function normalizeRestExecute(data: unknown): ProtocolExecuteResult {
765
1113
  if (Array.isArray(data)) {
766
1114
  return { records: data.filter(isPlainObject) as Record<string, string>[] };