@tikoci/centrs 0.1.7 → 0.1.8

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 (82) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +2 -2
  4. package/SECURITY.md +1 -1
  5. package/commands/AGENTS.md +2 -1
  6. package/commands/api/AGENTS.md +55 -30
  7. package/commands/api/README.md +123 -41
  8. package/commands/api/examples.md +205 -17
  9. package/commands/devices/README.md +11 -3
  10. package/commands/execute/README.md +31 -3
  11. package/commands/execute/examples.md +136 -1
  12. package/commands/explain/README.md +30 -4
  13. package/commands/mcp/README.md +5 -1
  14. package/commands/mcp/examples.md +18 -1
  15. package/commands/retrieve/README.md +348 -4
  16. package/commands/retrieve/examples.md +422 -5
  17. package/commands/terminal/README.md +14 -4
  18. package/commands/terminal/examples.md +22 -3
  19. package/commands/transfer/README.md +55 -9
  20. package/commands/transfer/examples.md +106 -7
  21. package/docs/CLI.md +28 -20
  22. package/docs/CONSTITUTION.md +30 -10
  23. package/docs/MATRIX.md +38 -1
  24. package/docs/errors/README.md +3 -1
  25. package/docs/errors/input/invalid-query.md +36 -0
  26. package/docs/errors/input/local-destination.md +20 -0
  27. package/docs/errors/input/unsupported-query.md +19 -0
  28. package/docs/errors/internal/byte-string.md +13 -0
  29. package/docs/errors/settings/invalid-timeout.md +6 -0
  30. package/docs/errors/transport/stream-overflow.md +21 -0
  31. package/docs/errors/usage/removed-flag.md +20 -0
  32. package/docs/errors/validation/menu-unavailable.md +22 -0
  33. package/docs/errors/validation/not-followable.md +23 -0
  34. package/docs/errors/validation/package-missing.md +29 -0
  35. package/docs/errors/validation/unknown-path.md +16 -4
  36. package/docs/errors/wait/deadline-exceeded.md +14 -0
  37. package/docs/errors/wait/interrupted.md +10 -0
  38. package/package.json +14 -11
  39. package/src/absent-menu.ts +280 -0
  40. package/src/api-fanout.ts +22 -12
  41. package/src/api.ts +511 -186
  42. package/src/btest.ts +19 -17
  43. package/src/cli/api.ts +126 -34
  44. package/src/cli/execute.ts +18 -5
  45. package/src/cli/retrieve.ts +223 -22
  46. package/src/cli/terminal.ts +10 -5
  47. package/src/cli/transfer.ts +23 -8
  48. package/src/core/envelope.ts +14 -0
  49. package/src/core/error-catalog.ts +56 -1
  50. package/src/core/inspect.ts +43 -5
  51. package/src/core/query.ts +508 -0
  52. package/src/core/routeros-errors.ts +1 -1
  53. package/src/devices.ts +6 -9
  54. package/src/discover.ts +7 -8
  55. package/src/errors.ts +116 -7
  56. package/src/execute-fanout.ts +13 -8
  57. package/src/execute.ts +106 -65
  58. package/src/explain/catalog.ts +58 -10
  59. package/src/explain/menus.ts +23 -2
  60. package/src/explain.ts +6 -6
  61. package/src/index.ts +29 -1
  62. package/src/mcp/safety.ts +43 -18
  63. package/src/mcp/tools.ts +14 -17
  64. package/src/protocols/adapter.ts +224 -118
  65. package/src/protocols/btest-session.ts +3 -2
  66. package/src/protocols/endpoint.ts +10 -0
  67. package/src/protocols/mac-telnet-console.ts +6 -3
  68. package/src/protocols/native-api.ts +223 -70
  69. package/src/protocols/sftp.ts +44 -14
  70. package/src/protocols/ssh.ts +42 -14
  71. package/src/resolver/cdb.ts +28 -7
  72. package/src/resolver/index.ts +1 -0
  73. package/src/resolver/target.ts +53 -7
  74. package/src/retrieve-fanout.ts +51 -12
  75. package/src/retrieve-follow.ts +862 -0
  76. package/src/retrieve-sample.ts +244 -0
  77. package/src/retrieve-stream.ts +115 -0
  78. package/src/retrieve-wait.ts +286 -0
  79. package/src/retrieve.ts +732 -108
  80. package/src/settings.ts +15 -15
  81. package/src/transfer-fanout.ts +40 -15
  82. package/src/transfer.ts +234 -32
package/src/retrieve.ts CHANGED
@@ -5,19 +5,31 @@ import type {
5
5
  CommonSettingsMeta,
6
6
  SettingSource as CoreSettingSource,
7
7
  EnvelopeValidationMeta,
8
+ ValidationTrace,
8
9
  } from "./core/envelope.ts";
10
+ import { notReachedReason } from "./core/envelope.ts";
9
11
  import {
10
12
  extractCompletionNames,
11
13
  inspectArgumentNames,
12
14
  inspectChildren,
13
15
  inspectChildrenOrEmpty,
14
16
  inspectCompletions,
17
+ inspectWhereBooleans,
15
18
  isArgumentNode,
16
19
  isCommandNode,
17
20
  pathTokens,
18
21
  } from "./core/inspect.ts";
22
+ import {
23
+ compileQueryWords,
24
+ type ParsedQuery,
25
+ parseQueries,
26
+ } from "./core/query.ts";
19
27
  import { toYaml } from "./core/yaml.ts";
20
- import { CentrsError, serializeCentrsError } from "./errors.ts";
28
+ import {
29
+ CentrsError,
30
+ formatCentrsErrorText,
31
+ serializeCentrsError,
32
+ } from "./errors.ts";
21
33
  import {
22
34
  createProtocolAdapter,
23
35
  type ProtocolAdapter,
@@ -50,7 +62,12 @@ import {
50
62
  toCoreSource,
51
63
  } from "./resolver/index.ts";
52
64
 
53
- export const retrieveOutputFormats = ["text", "json", "yaml"] as const;
65
+ export const retrieveOutputFormats = [
66
+ "text",
67
+ "json",
68
+ "yaml",
69
+ "ndjson",
70
+ ] as const;
54
71
  export type RetrieveOutputFormat = (typeof retrieveOutputFormats)[number];
55
72
 
56
73
  export interface RetrieveRequest {
@@ -75,8 +92,10 @@ export interface RetrieveRequest {
75
92
  attributes?: string | readonly string[];
76
93
  allAttributes?: boolean;
77
94
  listAttributes?: boolean;
78
- filter?: string;
79
- query?: string;
95
+ /** Alias of `query`; both lists are AND-ed together. */
96
+ filter?: string | readonly string[];
97
+ /** Row filters written as after `print where` (`disabled`, `mtu>=1500 and !dynamic`); several are AND-ed. */
98
+ query?: string | readonly string[];
80
99
  maxResultsBytes?: number;
81
100
  cdbFile?: string;
82
101
  cdbPassword?: string;
@@ -86,6 +105,22 @@ export interface RetrieveRequest {
86
105
  group?: string;
87
106
  /** Bounded worker-pool size for group fanout (defaults are transport-aware). */
88
107
  concurrency?: number;
108
+ /** Keep the menu's rows current over native-api `listen` (`retrieveFollow`). */
109
+ follow?: boolean;
110
+ /** `--follow` only: `.id` sweep period (`10s` default; `0` turns it off). */
111
+ sweep?: string | number;
112
+ /** Read the menu again every interval (`retrieveSample`), e.g. `5s`. */
113
+ sample?: string | number;
114
+ /** `--follow`: stop after this many `live` frames; `--sample`: after this many samples. */
115
+ count?: number;
116
+ /** `--follow`/`--sample`: wall-clock bound counted from the end of validation (e.g. `30s`). */
117
+ duration?: string | number;
118
+ /** Overall readiness/condition deadline; consumed by `retrieveWait()`. */
119
+ wait?: string | number;
120
+ /** Same grammar as `--query`: succeed when any selected row matches. */
121
+ until?: string | readonly string[];
122
+ /** Succeed on a completed read with zero selected rows. */
123
+ untilEmpty?: boolean;
89
124
  }
90
125
 
91
126
  export interface RetrieveWarning {
@@ -104,8 +139,116 @@ export interface RetrieveRequestSummary {
104
139
  timeoutMs: number;
105
140
  format: RetrieveOutputFormat;
106
141
  maxResultsBytes?: number;
142
+ /** The `--query` expressions, as given. */
143
+ query?: readonly string[];
144
+ /** Present when the request is a `--follow`. */
145
+ follow?: RetrieveFollowSettings;
146
+ /** Present when the request is a `--sample`. */
147
+ sample?: RetrieveSampleSettings;
148
+ wait?: RetrieveWaitSettings;
149
+ }
150
+
151
+ export interface RetrieveWaitSettings {
152
+ deadlineMs: number;
153
+ intervalMs: number;
154
+ until: readonly string[];
155
+ untilEmpty: boolean;
156
+ }
157
+
158
+ export interface RetrieveWaitMeta {
159
+ stopReason:
160
+ | "ready"
161
+ | "condition-met"
162
+ | "deadline-elapsed"
163
+ | "interrupted"
164
+ | "failed";
165
+ elapsedMs: number;
166
+ attempts: number;
167
+ observations: number;
168
+ }
169
+
170
+ /** The resolved cadence and bounds of a `--sample`. */
171
+ export interface RetrieveSampleSettings {
172
+ /** Time between the starts of two reads; a slow read delays the next one. */
173
+ intervalMs: number;
174
+ count?: number;
175
+ durationMs?: number;
176
+ }
177
+
178
+ /** The resolved bounds of a `--follow`. */
179
+ export interface RetrieveFollowSettings {
180
+ /** `.id` sweep period; `0` = off. */
181
+ sweepMs: number;
182
+ count?: number;
183
+ durationMs?: number;
184
+ }
185
+
186
+ /** Why a `--follow` ended. */
187
+ export type RetrieveFollowStopReason =
188
+ | "completed"
189
+ | "routeros-error"
190
+ | "count-reached"
191
+ | "duration-elapsed"
192
+ | "interrupted"
193
+ | "transport-error";
194
+
195
+ /** Counts carried by a `--follow` summary (also its `data`). */
196
+ export interface RetrieveFollowCounts {
197
+ /** Every frame line, snapshot and live. */
198
+ frames: number;
199
+ /** Frames before `synced`. */
200
+ snapshot: number;
201
+ /** `live` frames after `synced`; `--count` counts these. */
202
+ changes: number;
203
+ /** Completed `.id` sweeps. */
204
+ sweeps: number;
205
+ /** Whether the bootstrap finished before the follow ended. */
206
+ synced: boolean;
207
+ }
208
+
209
+ export interface RetrieveFollowSummary extends RetrieveFollowCounts {
210
+ stopReason: RetrieveFollowStopReason;
211
+ durationMs: number;
212
+ }
213
+
214
+ /** Why a `--sample` ended: it never completes on its own. */
215
+ export type RetrieveSampleStopReason = Exclude<
216
+ RetrieveFollowStopReason,
217
+ "completed"
218
+ >;
219
+
220
+ /** A `--sample` summary (also its `data`). */
221
+ export interface RetrieveSampleSummary {
222
+ stopReason: RetrieveSampleStopReason;
223
+ /** Sample lines emitted; a read still in flight at the stop is not one. */
224
+ samples: number;
225
+ durationMs: number;
107
226
  }
108
227
 
228
+ /**
229
+ * Per-line marker on `--follow` and `--sample` output
230
+ * (`commands/retrieve/README.md` → Follow, Sample). A `frame` is one row
231
+ * change: `upsert` carries the latest full row, `removed` carries
232
+ * `data: null`; `id` is the row identity even when the projection leaves `.id`
233
+ * out of `data`. A `sample` is one complete read; `at` is when it was sent
234
+ * (host clock) and `readMs` how long it took. The two summaries are told apart
235
+ * by their counts (`frames` vs `samples`).
236
+ */
237
+ export type RetrieveStreamMeta =
238
+ | { kind: "notice" }
239
+ | {
240
+ kind: "frame";
241
+ index: number;
242
+ phase: "snapshot" | "live";
243
+ change: "upsert" | "removed";
244
+ id: string;
245
+ source: "print" | "listen" | "sweep" | "membership";
246
+ }
247
+ | { kind: "synced"; rows: number }
248
+ | { kind: "sample"; index: number; at: string; readMs: number }
249
+ | ({ kind: "summary" } & RetrieveFollowSummary)
250
+ | ({ kind: "summary" } & RetrieveSampleSummary);
251
+
109
252
  export interface RetrieveOperationMeta {
110
253
  kind: "attributes" | "data";
111
254
  objectCount: number;
@@ -114,6 +257,10 @@ export interface RetrieveOperationMeta {
114
257
  username?: string;
115
258
  passwordProvided: boolean;
116
259
  };
260
+ /** Present only on `--follow` and `--sample` output. */
261
+ stream?: RetrieveStreamMeta;
262
+ /** The bounded waiter outcome; a watch duration never satisfies a condition. */
263
+ wait?: RetrieveWaitMeta;
117
264
  }
118
265
 
119
266
  export type RetrieveEnvelope = CentrsEnvelope<unknown, RetrieveOperationMeta>;
@@ -123,7 +270,7 @@ export type RetrieveSuccessEnvelope = CentrsSuccessEnvelope<
123
270
  >;
124
271
  export type RetrieveErrorEnvelope = CentrsErrorEnvelope<RetrieveOperationMeta>;
125
272
 
126
- interface RetrieveInspection {
273
+ export interface RetrieveInspection {
127
274
  command: "get" | "print";
128
275
  singleton: boolean;
129
276
  }
@@ -149,15 +296,108 @@ export interface ResolvedRetrieveRequest {
149
296
  allAttributes: boolean;
150
297
  listAttributes: boolean;
151
298
  verbose: boolean;
299
+ /** `--query`, parsed offline; compiled to words once the device says which bare names are booleans. */
300
+ query?: ResolvedRetrieveQuery;
301
+ follow?: RetrieveFollowSettings;
302
+ sample?: RetrieveSampleSettings;
303
+ wait?: RetrieveWaitSettings;
152
304
  warnings: readonly RetrieveWarning[];
153
305
  }
154
306
 
307
+ export interface ResolvedRetrieveQuery {
308
+ expressions: readonly string[];
309
+ parsed: ParsedQuery;
310
+ }
311
+
312
+ /**
313
+ * What a data read needs after validation: the path's shape (undefined under
314
+ * `--validate=false`) and the compiled `--query` words.
315
+ */
316
+ export interface RetrieveReadPlan {
317
+ inspection: RetrieveInspection | undefined;
318
+ query?: readonly string[];
319
+ }
320
+
321
+ export interface RetrieveOptions {
322
+ /**
323
+ * Called once resolution succeeds, before any connection. A caller that
324
+ * catches the later throw uses it to keep the resolved target, via and
325
+ * setting sources in its error envelope.
326
+ */
327
+ onResolved?: (resolved: ResolvedRetrieveRequest) => void;
328
+ /**
329
+ * Receives the gate's result the moment validation settles, so an error
330
+ * envelope built after a later throw reports the validation that ran.
331
+ */
332
+ trace?: ValidationTrace;
333
+ }
334
+
155
335
  export async function retrieve(
156
336
  request: RetrieveRequest,
157
337
  env: Record<string, string | undefined> = Bun.env,
338
+ options: RetrieveOptions = {},
158
339
  ): Promise<RetrieveSuccessEnvelope> {
340
+ if (request.wait !== undefined) {
341
+ throw new CentrsError({
342
+ code: "input/invalid-command",
343
+ summary:
344
+ "Use `retrieveWait()` for a bounded readiness or condition wait.",
345
+ remediation:
346
+ "Call `retrieveWait(request)` (library) or `centrs retrieve … --wait 30s` (CLI); inspect its success or failure envelope.",
347
+ });
348
+ }
349
+ if (request.follow) {
350
+ throw new CentrsError({
351
+ code: "input/invalid-command",
352
+ summary:
353
+ "`follow` yields a stream of envelopes instead of one `retrieve` result.",
354
+ remediation:
355
+ "Consume it with `retrieveFollow()` (library) or `centrs retrieve … --follow` (CLI): snapshot frames, `synced`, live frames, then a summary.",
356
+ context: { capability: "follow" },
357
+ });
358
+ }
359
+ if (request.sample !== undefined) {
360
+ throw new CentrsError({
361
+ code: "input/invalid-command",
362
+ summary:
363
+ "`sample` yields a stream of envelopes instead of one `retrieve` result.",
364
+ remediation:
365
+ "Consume it with `retrieveSample()` (library) or `centrs retrieve … --sample <interval>` (CLI): one line per complete read, then a summary.",
366
+ context: { capability: "sample" },
367
+ });
368
+ }
159
369
  const resolved = await resolveRetrieveRequest(request, env);
160
- return runResolvedRetrieve(resolved);
370
+ options.onResolved?.(resolved);
371
+ return runResolvedRetrieve(resolved, options.trace);
372
+ }
373
+
374
+ /**
375
+ * {@link retrieve} as an envelope that never throws. A failure after resolution
376
+ * carries the resolved target, via and setting sources
377
+ * ({@link buildRetrieveErrorEnvelopeFromResolved}); a failure before it keeps
378
+ * only the caller's input ({@link buildRetrieveErrorEnvelope}).
379
+ */
380
+ export async function retrieveEnvelope(
381
+ request: RetrieveRequest,
382
+ env: Record<string, string | undefined> = Bun.env,
383
+ options: RetrieveOptions = {},
384
+ ): Promise<RetrieveEnvelope> {
385
+ let resolved: ResolvedRetrieveRequest | undefined;
386
+ const trace = options.trace ?? {};
387
+ try {
388
+ return await retrieve(request, env, {
389
+ ...options,
390
+ trace,
391
+ onResolved: (value) => {
392
+ resolved = value;
393
+ options.onResolved?.(value);
394
+ },
395
+ });
396
+ } catch (error) {
397
+ return resolved
398
+ ? buildRetrieveErrorEnvelopeFromResolved(resolved, error, trace)
399
+ : buildRetrieveErrorEnvelope(request, error);
400
+ }
161
401
  }
162
402
 
163
403
  /**
@@ -169,10 +409,9 @@ export async function retrieve(
169
409
  */
170
410
  export async function runResolvedRetrieve(
171
411
  resolved: ResolvedRetrieveRequest,
412
+ trace: ValidationTrace = {},
172
413
  ): Promise<RetrieveSuccessEnvelope> {
173
414
  const warnings: RetrieveWarning[] = [...resolved.warnings];
174
- let availableAttributes: string[] | undefined;
175
- let inspection: RetrieveInspection | undefined;
176
415
  const backend = createProtocolAdapter({
177
416
  protocol: resolved.via.value,
178
417
  host: resolved.target.host,
@@ -186,78 +425,46 @@ export async function runResolvedRetrieve(
186
425
  });
187
426
 
188
427
  try {
189
- if (resolved.listAttributes || resolved.validate.value) {
190
- inspection = await inspectRetrievePath(resolved, backend);
191
- }
192
-
193
- if (
194
- resolved.listAttributes ||
195
- (resolved.validate.value && resolved.attributes.length > 0)
196
- ) {
197
- availableAttributes = await inspectAttributes(
428
+ if (resolved.listAttributes) {
429
+ const availableAttributes = await inspectAttributes(
198
430
  resolved,
199
- inspection ?? (await inspectRetrievePath(resolved, backend)),
431
+ await inspectRetrievePath(resolved, backend),
200
432
  backend,
201
433
  );
202
- }
203
-
204
- if (resolved.listAttributes) {
434
+ const validation: EnvelopeValidationMeta = {
435
+ enabled: true,
436
+ source: "live /console/inspect request=child+completion",
437
+ result: "passed",
438
+ availableAttributes,
439
+ };
440
+ trace.validation = validation;
205
441
  const envelope = buildSuccessEnvelope(
206
442
  resolved,
207
443
  {
208
444
  kind: "attributes",
209
- data: availableAttributes ?? [],
210
- },
211
- {
212
- enabled: true,
213
- source: "live /console/inspect request=child+completion",
214
- availableAttributes: availableAttributes ?? [],
445
+ data: availableAttributes,
215
446
  },
447
+ validation,
216
448
  warnings,
217
449
  );
218
450
  return applyMaxResultsBudget(envelope);
219
451
  }
220
452
 
221
- if (
222
- resolved.validate.value &&
223
- availableAttributes &&
224
- resolved.attributes.length > 0
225
- ) {
226
- const missing = resolved.attributes.filter(
227
- (attribute) => !availableAttributes?.includes(attribute),
228
- );
229
- if (missing.length > 0) {
230
- throw new CentrsError({
231
- code: "validation/unknown-attribute",
232
- summary: `Unknown RouterOS attribute ${missing.join(", ")} for ${resolved.path}.`,
233
- remediation:
234
- "Check the attribute name, or use `--list-attributes` to inspect the available properties first.",
235
- context: {
236
- path: resolved.path,
237
- parameter: missing[0],
238
- requestedAttributes: resolved.attributes,
239
- availableAttributes,
240
- },
241
- });
242
- }
243
- }
244
-
245
- const data = await executeRetrieve(resolved, backend, inspection);
453
+ const { validation, ...plan } = await validateRetrieveRead(
454
+ resolved,
455
+ backend,
456
+ );
457
+ // Recorded BEFORE the read, so a RouterOS fault or transport drop below
458
+ // reports the gate that passed rather than a guess (GH#435).
459
+ trace.validation = validation;
460
+ const data = await executeRetrieve(resolved, backend, plan);
246
461
  const envelope = buildSuccessEnvelope(
247
462
  resolved,
248
463
  {
249
464
  kind: "data",
250
465
  data,
251
466
  },
252
- {
253
- enabled: resolved.validate.value,
254
- source: resolved.validate.value
255
- ? availableAttributes
256
- ? "live /console/inspect request=child+completion"
257
- : "live /console/inspect request=child"
258
- : "disabled",
259
- availableAttributes,
260
- },
467
+ validation,
261
468
  warnings,
262
469
  );
263
470
  return applyMaxResultsBudget(envelope);
@@ -266,6 +473,53 @@ export async function runResolvedRetrieve(
266
473
  }
267
474
  }
268
475
 
476
+ /**
477
+ * The inspect half of a data read: the path's shape (singleton or list) and
478
+ * the requested attributes, checked once. `--validate=false` skips both.
479
+ * Shared by one-shot retrieve and `--sample`, which reads many times after it.
480
+ */
481
+ export async function validateRetrieveRead(
482
+ resolved: ResolvedRetrieveRequest,
483
+ backend: ProtocolAdapter,
484
+ ): Promise<RetrieveReadPlan & { validation: EnvelopeValidationMeta }> {
485
+ if (!resolved.validate.value) {
486
+ return {
487
+ inspection: undefined,
488
+ query: compileQueryWithoutValidation(resolved),
489
+ validation: { enabled: false, source: "disabled" },
490
+ };
491
+ }
492
+ const inspection = await inspectRetrievePath(resolved, backend);
493
+ if (resolved.query && inspection.singleton) {
494
+ throw singletonQueryError(resolved, resolved.query);
495
+ }
496
+ const availableAttributes =
497
+ resolved.attributes.length > 0 || resolved.query !== undefined
498
+ ? await assertKnownAttributes(resolved, inspection, backend)
499
+ : undefined;
500
+ const query =
501
+ resolved.query && availableAttributes
502
+ ? await compileValidatedQuery(
503
+ resolved,
504
+ resolved.query,
505
+ availableAttributes,
506
+ backend,
507
+ )
508
+ : undefined;
509
+ return {
510
+ inspection,
511
+ query,
512
+ validation: {
513
+ enabled: true,
514
+ source: availableAttributes
515
+ ? "live /console/inspect request=child+completion"
516
+ : "live /console/inspect request=child",
517
+ result: "passed",
518
+ availableAttributes,
519
+ },
520
+ };
521
+ }
522
+
269
523
  export function buildRetrieveErrorEnvelope(
270
524
  request: RetrieveRequest,
271
525
  error: unknown,
@@ -310,6 +564,7 @@ export function buildRetrieveErrorEnvelope(
310
564
  export function buildRetrieveErrorEnvelopeFromResolved(
311
565
  resolved: ResolvedRetrieveRequest,
312
566
  error: unknown,
567
+ trace?: ValidationTrace,
313
568
  ): RetrieveErrorEnvelope {
314
569
  const centrsError =
315
570
  error instanceof CentrsError
@@ -322,11 +577,10 @@ export function buildRetrieveErrorEnvelopeFromResolved(
322
577
  cause: error,
323
578
  });
324
579
 
325
- const meta = metaFromResolved(resolved, {
326
- enabled: resolved.validate.value,
327
- source: resolved.validate.value ? "live /console/inspect" : "disabled",
328
- result: "failed",
329
- });
580
+ const meta = metaFromResolved(
581
+ resolved,
582
+ trace?.validation ?? unsettledValidationMeta(resolved, centrsError),
583
+ );
330
584
 
331
585
  return {
332
586
  ok: false,
@@ -337,6 +591,53 @@ export function buildRetrieveErrorEnvelopeFromResolved(
337
591
  };
338
592
  }
339
593
 
594
+ const RETRIEVE_DEVICE_STAGE_SOURCE = "live /console/inspect";
595
+
596
+ /**
597
+ * `meta.validation` for a failure with no settled gate result: the gate
598
+ * refused, or something stopped it before it answered (GH#435).
599
+ *
600
+ * Every retrieve gate refusal is a `CentrsError` the gate itself raised from
601
+ * an inspect answer: `validation/*`, and the singleton `--query` refusal
602
+ * (`usage/conflicting-flags`). A `transport/*` or `auth/*` failure means the
603
+ * device never answered; a `routeros/*` error is a device answer that is not
604
+ * a verdict; `internal/*` is a centrs defect. Those four report no `result`.
605
+ */
606
+ function unsettledValidationMeta(
607
+ resolved: ResolvedRetrieveRequest,
608
+ error: CentrsError,
609
+ ): EnvelopeValidationMeta {
610
+ if (!resolved.validate.value) {
611
+ return { enabled: false, source: "disabled" };
612
+ }
613
+ if (/^(transport|auth|routeros|internal)\//.test(error.code)) {
614
+ return {
615
+ enabled: true,
616
+ source: RETRIEVE_DEVICE_STAGE_SOURCE,
617
+ stages: [
618
+ {
619
+ stage: "device",
620
+ source: RETRIEVE_DEVICE_STAGE_SOURCE,
621
+ result: "skipped",
622
+ reason: notReachedReason(error.code),
623
+ },
624
+ ],
625
+ };
626
+ }
627
+ return {
628
+ enabled: true,
629
+ source: RETRIEVE_DEVICE_STAGE_SOURCE,
630
+ result: "failed",
631
+ stages: [
632
+ {
633
+ stage: "device",
634
+ source: RETRIEVE_DEVICE_STAGE_SOURCE,
635
+ result: "failed",
636
+ },
637
+ ],
638
+ };
639
+ }
640
+
340
641
  export function renderRetrieveEnvelope(
341
642
  envelope: RetrieveEnvelope,
342
643
  format: RetrieveOutputFormat,
@@ -347,10 +648,17 @@ export function renderRetrieveEnvelope(
347
648
  return JSON.stringify(envelope, null, 2);
348
649
  case "yaml":
349
650
  return toYaml(envelope);
651
+ case "ndjson":
652
+ return JSON.stringify(envelope);
350
653
  case "text":
351
- return envelope.ok
352
- ? renderRetrieveSuccessText(envelope, options)
353
- : renderRetrieveErrorText(envelope, options);
654
+ return (
655
+ (envelope.ok
656
+ ? renderRetrieveSuccessText(envelope, options)
657
+ : formatCentrsErrorText(envelope.error, options)) +
658
+ (envelope.meta.operation?.wait
659
+ ? `\nwait: ${envelope.meta.operation.wait.stopReason}; ${envelope.meta.operation.wait.elapsedMs}ms; ${envelope.meta.operation.wait.attempts} attempt(s), ${envelope.meta.operation.wait.observations} observation(s)`
660
+ : "")
661
+ );
354
662
  default:
355
663
  return exhaustiveOutputFormat(format);
356
664
  }
@@ -416,26 +724,7 @@ function renderRetrieveSuccessText(
416
724
  return lines.join("\n");
417
725
  }
418
726
 
419
- function renderRetrieveErrorText(
420
- envelope: RetrieveErrorEnvelope,
421
- options: { verbose?: boolean },
422
- ): string {
423
- const { error } = envelope;
424
- const lines = [`[${error.code}] ${error.summary}`];
425
-
426
- if (error.remediation) {
427
- lines.push(`Fix: ${error.remediation}`);
428
- }
429
-
430
- if (options.verbose && error.context) {
431
- lines.push("");
432
- lines.push(JSON.stringify(error.context, null, 2));
433
- }
434
-
435
- return lines.join("\n");
436
- }
437
-
438
- function retrieveRequestSummary(
727
+ export function retrieveRequestSummary(
439
728
  resolved: ResolvedRetrieveRequest,
440
729
  ): RetrieveRequestSummary {
441
730
  return {
@@ -448,6 +737,10 @@ function retrieveRequestSummary(
448
737
  timeoutMs: resolved.timeoutMs.value,
449
738
  format: resolved.format.value,
450
739
  maxResultsBytes: resolved.maxResultsBytes?.value,
740
+ ...(resolved.query ? { query: resolved.query.expressions } : {}),
741
+ ...(resolved.follow ? { follow: resolved.follow } : {}),
742
+ ...(resolved.sample ? { sample: resolved.sample } : {}),
743
+ ...(resolved.wait ? { wait: resolved.wait } : {}),
451
744
  };
452
745
  }
453
746
 
@@ -455,7 +748,7 @@ function retrieveRequestSummary(
455
748
  * Common `target` / `via` / `settings` meta for a resolved target. Shared by
456
749
  * success and per-target error envelopes so both carry identical provenance.
457
750
  */
458
- function metaFromResolved(
751
+ export function metaFromResolved(
459
752
  resolved: ResolvedRetrieveRequest,
460
753
  validation: EnvelopeValidationMeta,
461
754
  operation?: RetrieveOperationMeta,
@@ -513,7 +806,7 @@ function metaFromResolved(
513
806
  };
514
807
  }
515
808
 
516
- function buildSuccessEnvelope(
809
+ export function buildSuccessEnvelope(
517
810
  resolved: ResolvedRetrieveRequest,
518
811
  result: { kind: "attributes" | "data"; data: unknown },
519
812
  validation: EnvelopeValidationMeta,
@@ -538,7 +831,7 @@ function buildSuccessEnvelope(
538
831
  };
539
832
  }
540
833
 
541
- function applyMaxResultsBudget(
834
+ export function applyMaxResultsBudget(
542
835
  envelope: RetrieveSuccessEnvelope,
543
836
  ): RetrieveSuccessEnvelope {
544
837
  const operation = envelope.meta.operation;
@@ -581,6 +874,9 @@ function applyMaxResultsBudget(
581
874
  export function validateRetrieveRequestShape(
582
875
  request: RetrieveRequest,
583
876
  ): readonly string[] {
877
+ // Flag values before the path: a value-less flag shifts the positionals,
878
+ // so the path check would blame the wrong token (#445).
879
+ resolveWaitSettings(request);
584
880
  if (!request.path.startsWith("/")) {
585
881
  throw new CentrsError({
586
882
  code: "input/invalid-routeros-path",
@@ -591,20 +887,19 @@ export function validateRetrieveRequestShape(
591
887
  });
592
888
  }
593
889
 
594
- if (request.filter !== undefined || request.query !== undefined) {
890
+ const query = resolveRetrieveQuery(request);
891
+ if (query && request.listAttributes) {
595
892
  throw new CentrsError({
596
- code: "validation/not-implemented",
597
- summary:
598
- "`--filter` and `--query` are not implemented yet for `retrieve`.",
893
+ code: "usage/conflicting-flags",
894
+ summary: "`--list-attributes` cannot be combined with `--query`.",
599
895
  remediation:
600
- "Remove the flag for now, or narrow the request with `--attribute` and `--max-results` instead.",
601
- context: {
602
- filter: request.filter,
603
- query: request.query,
604
- },
896
+ "Run `--list-attributes` by itself to see the property names, then filter with `--query`.",
897
+ context: { flag: "--query" },
605
898
  });
606
899
  }
607
900
 
901
+ assertStreamShape(request);
902
+
608
903
  const attributeSelections = normalizeAttributeSelection(request);
609
904
  if (request.allAttributes && attributeSelections.length > 0) {
610
905
  throw new CentrsError({
@@ -632,6 +927,198 @@ export function validateRetrieveRequestShape(
632
927
  return attributeSelections;
633
928
  }
634
929
 
930
+ /**
931
+ * `--follow`/`--sample` flag pairings, checked before any network work: the
932
+ * two are exclusive, their bounds need one of them, and a byte budget or an
933
+ * attribute listing has no meaning on a stream.
934
+ */
935
+ function assertStreamShape(request: RetrieveRequest): void {
936
+ const sample = request.sample !== undefined && request.wait === undefined;
937
+ if (request.follow && sample) {
938
+ throw new CentrsError({
939
+ code: "usage/conflicting-flags",
940
+ summary: "`--follow` cannot be combined with `--sample`.",
941
+ remediation:
942
+ "Use `--follow` for object changes RouterOS announces, or `--sample <interval>` to read the menu on a timer (counters, state `listen` never reports).",
943
+ context: { flag: "--sample" },
944
+ });
945
+ }
946
+ if (!request.follow) {
947
+ const orphan = (
948
+ [
949
+ ["--sweep", request.sweep],
950
+ ["--count", sample ? undefined : request.count],
951
+ ["--duration", sample ? undefined : request.duration],
952
+ ] as const
953
+ ).find(([, value]) => value !== undefined);
954
+ if (orphan) {
955
+ const followOnly = orphan[0] === "--sweep";
956
+ throw new CentrsError({
957
+ code: "usage/conflicting-flags",
958
+ summary: `\`${orphan[0]}\` only applies to ${followOnly ? "`--follow`" : "`--follow` or `--sample`"}.`,
959
+ remediation: followOnly
960
+ ? "Add `--follow` to follow the menu, or drop `--sweep`."
961
+ : `Add \`--follow\` or \`--sample <interval>\`, or drop \`${orphan[0]}\` for a one-shot read.`,
962
+ context: { flag: orphan[0] },
963
+ });
964
+ }
965
+ if (!sample) return;
966
+ }
967
+ const mode = request.follow ? "--follow" : "--sample";
968
+ const conflict = request.listAttributes
969
+ ? "--list-attributes"
970
+ : request.maxResultsBytes !== undefined
971
+ ? "--max-results"
972
+ : undefined;
973
+ if (conflict) {
974
+ throw new CentrsError({
975
+ code: "usage/conflicting-flags",
976
+ summary: `\`${mode}\` cannot be combined with \`${conflict}\`.`,
977
+ remediation: `Run \`${conflict}\` as a one-shot read, then use \`${mode}\` without it.`,
978
+ context: { flag: conflict },
979
+ });
980
+ }
981
+ if (
982
+ request.count !== undefined &&
983
+ (!Number.isInteger(request.count) || request.count < 1)
984
+ ) {
985
+ throw new CentrsError({
986
+ code: "settings/invalid-integer",
987
+ summary: `\`--count\` must be a positive integer. Received: ${request.count}`,
988
+ remediation: request.follow
989
+ ? "Pass how many live changes to wait for, e.g. `--count 1` for the next change."
990
+ : "Pass how many samples to take, e.g. `--count 3`.",
991
+ context: { flag: "--count", count: request.count },
992
+ });
993
+ }
994
+ }
995
+
996
+ function resolveWaitSettings(
997
+ request: RetrieveRequest,
998
+ ): RetrieveWaitSettings | undefined {
999
+ const until = request.until === undefined ? [] : [request.until].flat();
1000
+ if (until.length > 0) parseQueries(until, "--until");
1001
+ if (request.wait === undefined) {
1002
+ if (until.length > 0 || request.untilEmpty)
1003
+ throw new CentrsError({
1004
+ code: "usage/conflicting-flags",
1005
+ summary: "`--until` and `--until-empty` need a `--wait` deadline.",
1006
+ remediation:
1007
+ "Add an overall bound, e.g. `--wait 2m`, so the condition cannot wait forever.",
1008
+ });
1009
+ return undefined;
1010
+ }
1011
+ const conflict = request.follow
1012
+ ? "--follow"
1013
+ : request.count !== undefined
1014
+ ? "--count"
1015
+ : request.duration !== undefined
1016
+ ? "--duration"
1017
+ : request.listAttributes
1018
+ ? "--list-attributes"
1019
+ : until.length > 0 && request.untilEmpty
1020
+ ? "--until-empty"
1021
+ : undefined;
1022
+ if (conflict)
1023
+ throw new CentrsError({
1024
+ code: "usage/conflicting-flags",
1025
+ summary: `\`--wait\` cannot be combined with \`${conflict}\`.`,
1026
+ remediation:
1027
+ "Use `--wait` for a readiness/condition deadline, `--sample` for its polling cadence, and either `--until` or `--until-empty` for a condition.",
1028
+ });
1029
+ // A unit is required for the same reason as `--sample`: a bare `30` would
1030
+ // be a 30 ms deadline, which expires before validation completes.
1031
+ const text = String(request.wait).trim();
1032
+ const deadlineMs = /^\d+(ms|s|m)$/i.test(text) ? parseDuration(text) : -1;
1033
+ if (deadlineMs <= 0)
1034
+ throw new CentrsError({
1035
+ code: "settings/invalid-timeout",
1036
+ // `--wait --until …` reads `--until` as the deadline (#445).
1037
+ summary: text.startsWith("-")
1038
+ ? `\`--wait\` needs a deadline value before the next flag. Received the flag: ${text}`
1039
+ : `\`--wait\` needs a positive deadline with a unit. Received: ${text}`,
1040
+ remediation:
1041
+ "Pass an overall duration such as `--wait 30s` or `--wait 5m`, e.g. `--wait 20s --until 'running'`.",
1042
+ context: { flag: "--wait", value: text },
1043
+ });
1044
+ return {
1045
+ deadlineMs,
1046
+ intervalMs:
1047
+ request.sample === undefined
1048
+ ? 1_000
1049
+ : parseStreamInterval("--sample", request.sample),
1050
+ until,
1051
+ untilEmpty: request.untilEmpty ?? false,
1052
+ };
1053
+ }
1054
+
1055
+ function resolveFollowSettings(
1056
+ request: RetrieveRequest,
1057
+ ): RetrieveFollowSettings | undefined {
1058
+ if (!request.follow) return undefined;
1059
+ const durationMs =
1060
+ request.duration === undefined
1061
+ ? undefined
1062
+ : parseDuration(String(request.duration));
1063
+ return {
1064
+ sweepMs:
1065
+ request.sweep === undefined
1066
+ ? FOLLOW_SWEEP_DEFAULT_MS
1067
+ : parseStreamInterval("--sweep", request.sweep),
1068
+ ...(request.count !== undefined ? { count: request.count } : {}),
1069
+ ...(durationMs !== undefined ? { durationMs } : {}),
1070
+ };
1071
+ }
1072
+
1073
+ /** `--sweep` default (#396 slice decisions, 2026-10-08). */
1074
+ export const FOLLOW_SWEEP_DEFAULT_MS = 10_000;
1075
+
1076
+ function resolveSampleSettings(
1077
+ request: RetrieveRequest,
1078
+ ): RetrieveSampleSettings | undefined {
1079
+ if (request.sample === undefined || request.wait !== undefined)
1080
+ return undefined;
1081
+ const durationMs =
1082
+ request.duration === undefined
1083
+ ? undefined
1084
+ : parseDuration(String(request.duration));
1085
+ return {
1086
+ intervalMs: parseStreamInterval("--sample", request.sample),
1087
+ ...(request.count !== undefined ? { count: request.count } : {}),
1088
+ ...(durationMs !== undefined ? { durationMs } : {}),
1089
+ };
1090
+ }
1091
+
1092
+ /**
1093
+ * A `--sample` or `--sweep` period needs a unit: RouterOS reads a bare
1094
+ * `interval=5` as seconds while centrs durations read a bare number as
1095
+ * milliseconds, so `--sample 5` is refused rather than guessed. `--sweep 0`
1096
+ * (off) is the one bare number allowed; `--sample` must be above zero.
1097
+ */
1098
+ function parseStreamInterval(
1099
+ flag: "--sample" | "--sweep",
1100
+ value: string | number,
1101
+ ): number {
1102
+ const text = String(value).trim();
1103
+ const off = flag === "--sweep" && text === "0";
1104
+ const intervalMs = off ? 0 : /^\d+$/.test(text) ? -1 : parseDuration(text);
1105
+ if (intervalMs < 0 || (flag === "--sample" && intervalMs === 0)) {
1106
+ throw new CentrsError({
1107
+ code: "settings/invalid-timeout",
1108
+ summary:
1109
+ flag === "--sample"
1110
+ ? `\`--sample\` needs a positive interval with a unit. Received: ${text}`
1111
+ : `\`--sweep\` needs a unit (or \`0\` to turn it off). Received: ${text}`,
1112
+ remediation:
1113
+ flag === "--sample"
1114
+ ? "Pass the time between reads with its unit, e.g. `--sample 5s` or `--sample 500ms`."
1115
+ : "Pass the sweep period with its unit, e.g. `--sweep 10s` or `--sweep 500ms`, or `--sweep 0` to turn it off.",
1116
+ context: { flag, value: text },
1117
+ });
1118
+ }
1119
+ return intervalMs;
1120
+ }
1121
+
635
1122
  /**
636
1123
  * Build a {@link ResolvedRetrieveRequest} from the static request plus an
637
1124
  * already-resolved CDB record (or `undefined` for a literal target). Fanout
@@ -725,6 +1212,10 @@ export function buildResolvedRetrieve(
725
1212
  allAttributes: request.allAttributes ?? false,
726
1213
  listAttributes: request.listAttributes ?? false,
727
1214
  verbose: request.verbose ?? false,
1215
+ query: resolveRetrieveQuery(request),
1216
+ follow: resolveFollowSettings(request),
1217
+ sample: resolveSampleSettings(request),
1218
+ wait: resolveWaitSettings(request),
728
1219
  warnings: connection
729
1220
  ? [...connection.warnings]
730
1221
  : (cdbResolution?.warnings ?? []),
@@ -788,6 +1279,9 @@ export function resolveRetrieveGlobalContext(
788
1279
  timeoutMs: timeoutMs.value,
789
1280
  format: format.value,
790
1281
  maxResultsBytes: maxResultsBytes?.value,
1282
+ ...(queryExpressions(request).length > 0
1283
+ ? { query: queryExpressions(request) }
1284
+ : {}),
791
1285
  },
792
1286
  settings: {
793
1287
  via: toCoreSource(via.source),
@@ -885,11 +1379,13 @@ function resolveProtocol(
885
1379
  cdb?: CdbResolution,
886
1380
  config: Record<string, string | undefined> = {},
887
1381
  ): ResolvedSetting<RouterOsProtocol> {
1382
+ // A follow infers native-api when `via` is unset (REST cannot follow); a
1383
+ // `via` from any source still wins, and `retrieveFollow` rejects non-native.
888
1384
  const via = resolveStringSetting(
889
1385
  request.via,
890
1386
  env,
891
1387
  "CENTRS_VIA",
892
- "rest-api",
1388
+ request.follow ? "native-api" : "rest-api",
893
1389
  "via",
894
1390
  undefined,
895
1391
  cdb?.overrides.via,
@@ -974,7 +1470,7 @@ function resolveFormat(
974
1470
  * trap. Attribute/completion discovery does NOT use the swallowing probe — traps
975
1471
  * there surface as-is.
976
1472
  */
977
- async function inspectRetrievePath(
1473
+ export async function inspectRetrievePath(
978
1474
  resolved: ResolvedRetrieveRequest,
979
1475
  backend: ProtocolAdapter,
980
1476
  ): Promise<RetrieveInspection> {
@@ -1016,6 +1512,130 @@ async function inspectRetrievePath(
1016
1512
  };
1017
1513
  }
1018
1514
 
1515
+ /**
1516
+ * Inspect the menu's attribute names and reject requested ones it lacks with
1517
+ * `validation/unknown-attribute`. Shared by one-shot retrieve and `--follow`.
1518
+ */
1519
+ export async function assertKnownAttributes(
1520
+ resolved: ResolvedRetrieveRequest,
1521
+ inspection: RetrieveInspection,
1522
+ backend: ProtocolAdapter,
1523
+ ): Promise<string[]> {
1524
+ const availableAttributes = await inspectAttributes(
1525
+ resolved,
1526
+ inspection,
1527
+ backend,
1528
+ );
1529
+ const missing = resolved.attributes.filter(
1530
+ (attribute) => !availableAttributes.includes(attribute),
1531
+ );
1532
+ if (missing.length > 0) {
1533
+ throw new CentrsError({
1534
+ code: "validation/unknown-attribute",
1535
+ summary: `Unknown RouterOS attribute ${missing.join(", ")} for ${resolved.path}.`,
1536
+ remediation:
1537
+ "Check the attribute name, or use `--list-attributes` to inspect the available properties first.",
1538
+ context: {
1539
+ path: resolved.path,
1540
+ parameter: missing[0],
1541
+ requestedAttributes: resolved.attributes,
1542
+ availableAttributes,
1543
+ },
1544
+ });
1545
+ }
1546
+ return availableAttributes;
1547
+ }
1548
+
1549
+ function queryExpressions(request: RetrieveRequest): string[] {
1550
+ return [request.filter, request.query].flatMap((value) =>
1551
+ value === undefined ? [] : [value].flat(),
1552
+ );
1553
+ }
1554
+
1555
+ /** Parse `--query`/`--filter` offline; a bad expression fails before any network work. */
1556
+ function resolveRetrieveQuery(
1557
+ request: RetrieveRequest,
1558
+ ): ResolvedRetrieveQuery | undefined {
1559
+ const expressions = queryExpressions(request);
1560
+ if (expressions.length === 0) return undefined;
1561
+ return { expressions, parsed: parseQueries(expressions) };
1562
+ }
1563
+
1564
+ /**
1565
+ * Validate the predicate's property names against the menu's attributes (the
1566
+ * device fails an unknown one silently: no rows, no trap) and ask it which bare
1567
+ * names are booleans, then compile.
1568
+ */
1569
+ async function compileValidatedQuery(
1570
+ resolved: ResolvedRetrieveRequest,
1571
+ query: ResolvedRetrieveQuery,
1572
+ availableAttributes: readonly string[],
1573
+ backend: ProtocolAdapter,
1574
+ ): Promise<string[]> {
1575
+ const known = new Set([...availableAttributes, ".id"]);
1576
+ const unknown = query.parsed.names.filter((name) => !known.has(name));
1577
+ if (unknown.length > 0) {
1578
+ throw new CentrsError({
1579
+ code: "validation/unknown-attribute",
1580
+ summary: `Unknown RouterOS attribute ${unknown.join(", ")} in --query for ${resolved.path}.`,
1581
+ remediation:
1582
+ "RouterOS answers a query on a misspelled property with no rows, not an error. Check the name with `--list-attributes`.",
1583
+ context: {
1584
+ path: resolved.path,
1585
+ parameter: unknown[0],
1586
+ flag: "--query",
1587
+ query: query.expressions,
1588
+ availableAttributes: [...known].sort(),
1589
+ },
1590
+ });
1591
+ }
1592
+ const booleans = await inspectWhereBooleans(
1593
+ backend,
1594
+ resolved.path,
1595
+ query.parsed.bareNames,
1596
+ );
1597
+ return compileQueryWords(query.parsed, booleans);
1598
+ }
1599
+
1600
+ /**
1601
+ * `--validate=false` skips inspect, so nothing can say whether a bare name is a
1602
+ * boolean (`disabled=yes`) or "is set"; refuse rather than guess.
1603
+ */
1604
+ function compileQueryWithoutValidation(
1605
+ resolved: ResolvedRetrieveRequest,
1606
+ ): string[] | undefined {
1607
+ const query = resolved.query;
1608
+ if (!query) return undefined;
1609
+ if (isKnownSingletonPath(resolved.path)) {
1610
+ throw singletonQueryError(resolved, query);
1611
+ }
1612
+ if (query.parsed.bareNames.length > 0) {
1613
+ throw new CentrsError({
1614
+ code: "input/invalid-query",
1615
+ summary: `A bare name in --query (${query.parsed.bareNames.join(", ")}) needs validation to learn whether it is a boolean.`,
1616
+ remediation:
1617
+ "Drop `--validate=false`, or write a boolean out as `disabled=yes`.",
1618
+ context: {
1619
+ query: query.expressions,
1620
+ bareNames: query.parsed.bareNames,
1621
+ },
1622
+ });
1623
+ }
1624
+ return compileQueryWords(query.parsed, new Set());
1625
+ }
1626
+
1627
+ function singletonQueryError(
1628
+ resolved: ResolvedRetrieveRequest,
1629
+ query: ResolvedRetrieveQuery,
1630
+ ): CentrsError {
1631
+ return new CentrsError({
1632
+ code: "usage/conflicting-flags",
1633
+ summary: `${resolved.path} is a single record, so --query has no rows to filter.`,
1634
+ remediation: `Read it with \`centrs retrieve <router> ${resolved.path}\`, using \`--attribute <name>\` for one value.`,
1635
+ context: { path: resolved.path, flag: "--query", query: query.expressions },
1636
+ });
1637
+ }
1638
+
1019
1639
  async function inspectAttributes(
1020
1640
  resolved: ResolvedRetrieveRequest,
1021
1641
  inspection: RetrieveInspection,
@@ -1048,13 +1668,15 @@ async function inspectAttributes(
1048
1668
  .sort();
1049
1669
  }
1050
1670
 
1051
- async function executeRetrieve(
1671
+ /** The data half of a read: one `get` (singleton) or `print` (list), projected. */
1672
+ export async function executeRetrieve(
1052
1673
  resolved: ResolvedRetrieveRequest,
1053
1674
  backend: ProtocolAdapter,
1054
- inspection: RetrieveInspection | undefined,
1675
+ plan: RetrieveReadPlan,
1676
+ signal?: AbortSignal,
1055
1677
  ): Promise<unknown> {
1056
- if (inspection?.singleton ?? isKnownSingletonPath(resolved.path)) {
1057
- const data = await backend.getSingleton(resolved.path);
1678
+ if (plan.inspection?.singleton ?? isKnownSingletonPath(resolved.path)) {
1679
+ const data = await backend.getSingleton(resolved.path, { signal });
1058
1680
  if (resolved.attributes.length > 0) {
1059
1681
  return projectSingletonAttributes(data, resolved.attributes);
1060
1682
  }
@@ -1064,11 +1686,13 @@ async function executeRetrieve(
1064
1686
  return backend.list(resolved.path, {
1065
1687
  proplist: resolved.attributes.length > 0 ? resolved.attributes : undefined,
1066
1688
  detail: resolved.allAttributes,
1689
+ query: plan.query,
1690
+ signal,
1067
1691
  });
1068
1692
  }
1069
1693
 
1070
1694
  /** Fallback for `--validate=false`, which skips the inspect that detects singletons. */
1071
- function isKnownSingletonPath(path: string): boolean {
1695
+ export function isKnownSingletonPath(path: string): boolean {
1072
1696
  return ["/system/resource", "/system/identity"].includes(
1073
1697
  path.replace(/\/$/, ""),
1074
1698
  );