@tikoci/centrs 0.1.7 → 0.1.8-next.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +235 -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/retrieve/README.md +348 -4
  14. package/commands/retrieve/examples.md +422 -5
  15. package/commands/terminal/README.md +14 -4
  16. package/commands/terminal/examples.md +22 -3
  17. package/commands/transfer/README.md +55 -9
  18. package/commands/transfer/examples.md +106 -7
  19. package/docs/CLI.md +28 -20
  20. package/docs/CONSTITUTION.md +30 -10
  21. package/docs/MATRIX.md +38 -1
  22. package/docs/errors/README.md +3 -1
  23. package/docs/errors/input/invalid-query.md +36 -0
  24. package/docs/errors/input/local-destination.md +20 -0
  25. package/docs/errors/input/unsupported-query.md +19 -0
  26. package/docs/errors/internal/byte-string.md +13 -0
  27. package/docs/errors/settings/invalid-timeout.md +6 -0
  28. package/docs/errors/transport/stream-overflow.md +21 -0
  29. package/docs/errors/usage/removed-flag.md +20 -0
  30. package/docs/errors/validation/menu-unavailable.md +22 -0
  31. package/docs/errors/validation/not-followable.md +23 -0
  32. package/docs/errors/validation/package-missing.md +29 -0
  33. package/docs/errors/validation/unknown-path.md +16 -4
  34. package/docs/errors/wait/deadline-exceeded.md +14 -0
  35. package/docs/errors/wait/interrupted.md +10 -0
  36. package/package.json +14 -11
  37. package/src/absent-menu.ts +280 -0
  38. package/src/api-fanout.ts +22 -12
  39. package/src/api.ts +511 -186
  40. package/src/btest.ts +19 -17
  41. package/src/cli/api.ts +126 -34
  42. package/src/cli/execute.ts +18 -5
  43. package/src/cli/retrieve.ts +216 -22
  44. package/src/cli/terminal.ts +10 -5
  45. package/src/cli/transfer.ts +23 -8
  46. package/src/core/envelope.ts +14 -0
  47. package/src/core/error-catalog.ts +56 -1
  48. package/src/core/inspect.ts +43 -5
  49. package/src/core/query.ts +508 -0
  50. package/src/core/routeros-errors.ts +1 -1
  51. package/src/devices.ts +6 -9
  52. package/src/discover.ts +7 -8
  53. package/src/errors.ts +116 -7
  54. package/src/execute-fanout.ts +13 -8
  55. package/src/execute.ts +106 -65
  56. package/src/explain/catalog.ts +58 -10
  57. package/src/explain/menus.ts +23 -2
  58. package/src/explain.ts +6 -6
  59. package/src/index.ts +29 -1
  60. package/src/mcp/tools.ts +9 -14
  61. package/src/protocols/adapter.ts +224 -118
  62. package/src/protocols/btest-session.ts +3 -2
  63. package/src/protocols/endpoint.ts +10 -0
  64. package/src/protocols/mac-telnet-console.ts +6 -3
  65. package/src/protocols/native-api.ts +223 -70
  66. package/src/protocols/sftp.ts +44 -14
  67. package/src/protocols/ssh.ts +42 -14
  68. package/src/resolver/cdb.ts +28 -7
  69. package/src/resolver/index.ts +1 -0
  70. package/src/resolver/target.ts +53 -7
  71. package/src/retrieve-fanout.ts +51 -12
  72. package/src/retrieve-follow.ts +862 -0
  73. package/src/retrieve-sample.ts +244 -0
  74. package/src/retrieve-stream.ts +115 -0
  75. package/src/retrieve-wait.ts +286 -0
  76. package/src/retrieve.ts +727 -108
  77. package/src/settings.ts +15 -15
  78. package/src/transfer-fanout.ts +40 -15
  79. package/src/transfer.ts +234 -32
@@ -373,6 +373,23 @@ centrs execute 127.0.0.1 '/ip/address/add address=198.51.100.51/32 interface=eth
373
373
  Envelope: `ok: false`, `error.code=validation/unknown-attribute`, `meta.via=ssh`,
374
374
  and no address is added — the console `:parse` gate catches `bogus` pre-mutation.
375
375
 
376
+ ### S5. An explicit rejected key cannot fall back to an unrelated agent key
377
+
378
+ `test/integration/ssh-identity.test.ts` imports `$KEY` on a disposable CHR and
379
+ loads it into a private SSH agent. `$WRONG_KEY` is a second, untrusted key.
380
+ The test uses an empty OpenSSH config so no other `IdentityFile` is selected.
381
+
382
+ ```bash
383
+ centrs execute 127.0.0.1 ':put [/system/identity/get name]' --via ssh --port $SSH_PORT --username $U --ssh-key $WRONG_KEY --insecure --json
384
+ centrs transfer 127.0.0.1 list --via sftp --port $SSH_PORT --username $U --ssh-key $WRONG_KEY --insecure --json
385
+ printf ':put ("centrs-identity:" . [/system/identity/get name])\n/quit\n' | centrs terminal 127.0.0.1 --via ssh --port $SSH_PORT --username $U --ssh-key $WRONG_KEY --insecure
386
+ ```
387
+
388
+ Execute and transfer fail with `auth/failed`; the terminal relay preserves
389
+ OpenSSH's nonzero exit and `Permission denied` diagnostic. The test disables
390
+ interactive prompts on its terminal relay. Controls using `$KEY`, or omitting
391
+ `--ssh-key` to use the private agent, succeed for all three commands.
392
+
376
393
  ## Offline gate (stage 1)
377
394
 
378
395
  Validation runs offline before it runs on the device
@@ -444,7 +461,9 @@ centrs execute $R '/ip/address/find where=' --via rest-api --username $U --passw
444
461
  Envelope: `ok: false`, `error.code=validation/syntax`, `error.context.detail`
445
462
  is RouterOS's own `missing value for where (line 1 column 24)`,
446
463
  `error.position={"line":1,"column":24}`, and `meta.validation.stages` is
447
- `offline: passed` followed by `device: failed`.
464
+ `offline: passed` followed by `device: failed`. `meta.validation.syntax=false`,
465
+ and `semantic` is absent: `:parse` refused the command, so the name-level check
466
+ was never asked (GH#435).
448
467
 
449
468
  CHR 7.24.4 controls, via `:put [:parse "…"]`: `find where` without `=` parses
450
469
  (IL). `print where=` is rejected differently (`expected end of command`, at
@@ -453,6 +472,64 @@ newlines) returns `missing closing brace (line 3 column 1)`, while an unclosed
453
472
  `do={` returns `expected closing brace`. Offline analysis usually catches the
454
473
  unclosed-brace cases first.
455
474
 
475
+ ### V5. A menu from a package that is not installed is not a syntax error
476
+
477
+ RouterOS's `:parse` reports a menu the device does not have as a syntax fault —
478
+ `syntax error (line 1 column 10)` for `/zerotier/print`, pointing at the `/`
479
+ after the absent segment, and `bad command name container (line 1 column 2)` for
480
+ the space spelling `/container print` (CHR 7.23.7). centrs locates the segment,
481
+ looks the path up in its path catalog, and names the package (GH#361).
482
+
483
+ ```bash
484
+ centrs execute $R '/zerotier/print' --via rest-api --username $U --password $P --json
485
+ centrs execute $R '/container print' --via rest-api --username $U --password $P --json
486
+ ```
487
+
488
+ Envelope: `ok: false`, `error.code=validation/package-missing`,
489
+ `error.context.path` is `/zerotier` (then `/container`),
490
+ `error.context.packages` names the published package, the remediation points at
491
+ `/system/package/print` and never at the syntax, and `error.position` and
492
+ `error.context.detail` keep RouterOS's own column and words. V5–V7 all report
493
+ `meta.validation.syntax=false`. `semantic` is absent, or `not-applicable` for
494
+ the script-shaped `/container print` (GH#435).
495
+
496
+ ### V6. A menu no RouterOS build has is an unknown path
497
+
498
+ ```bash
499
+ centrs execute $R '/ip/nosuchmenu/print' --via rest-api --username $U --password $P --json
500
+ ```
501
+
502
+ Envelope: `ok: false`, `error.code=validation/unknown-path`,
503
+ `error.context.path=/ip/nosuchmenu`, `error.context.segment=nosuchmenu`. The
504
+ path catalog covers every 7.10–7.25 build with all extra packages, so a path it
505
+ lacks is not a missing package.
506
+
507
+ ### V7. A published menu gated on more than a package
508
+
509
+ ```bash
510
+ centrs execute $R '/interface/w60g/print' --via rest-api --username $U --password $P --json
511
+ ```
512
+
513
+ Envelope: `ok: false`, `error.code=validation/menu-unavailable`,
514
+ `error.context.path=/interface/w60g`, and `error.context.gates` carries both
515
+ published gates — package `wireless-rep` and hardware capability `60ghz` —
516
+ because a 60 GHz menu can be absent with the package installed. Nested and later
517
+ statements locate the same way (`:put [/interface/w60g/get [find] disabled]`
518
+ points at column 22). A menu-scope block (`/ip { nosuchmenu print }`) or a menu
519
+ named on an earlier line abstains and stays `validation/syntax`: the offline
520
+ analyzer does not resolve that parent, and centrs does not guess it.
521
+
522
+ ### V8. Wrong credentials: the device stage is not reached (GH#435)
523
+
524
+ ```bash
525
+ centrs execute $R '/system/identity/print' --via rest-api --username centrs-wrong --password centrs-wrong --json
526
+ ```
527
+
528
+ Envelope: `ok: false`, `error.code=transport/auth-failed`. `meta.validation`
529
+ has no `result`, `syntax` or `semantic`. Its stages are `offline: passed`, then
530
+ `device: skipped` with `reason: "not reached: transport/auth-failed"`. RouterOS
531
+ answered 401 before `:parse` could give a verdict.
532
+
456
533
  ## Write authorization and returned runtime faults
457
534
 
458
535
  These regressions are transport-independent and run in
@@ -584,6 +661,17 @@ command). With no `--cdb-file`, both are ad-hoc literals, labeled by
584
661
  `meta.target.input` with no `recordIndex`: `data.summary = { total: 2, ok: 1,
585
662
  failed: 1 }`, exit `2`.
586
663
 
664
+ ### F7. An address that misses a pinned CDB is a literal; a name is not (#427)
665
+
666
+ ```bash
667
+ centrs execute $A:$API_PORT no-such-router --cdb-file $CDB --username $U --password $P --json -- /system/resource/print
668
+ ```
669
+
670
+ Neither positional matches a record. The address is dialed as a literal host
671
+ (inner `ok: true`). The name fails closed with `cdb/not-found-target`, because
672
+ it can't be told apart from a mistyped record name. `data.summary = { total: 2,
673
+ ok: 1, failed: 1 }`, exit `2`.
674
+
587
675
  ## Protocol selection notes
588
676
 
589
677
  For an unresolved MAC target, execute auto-selection defaults to mac-telnet
@@ -599,6 +687,53 @@ Envelope: `meta.via=mac-telnet` when `$MAC` is not resolved from CDB and no
599
687
  they must opt into MAC-to-IP resolution (`--resolve arp` + `--via …`) before
600
688
  protocol selection.
601
689
 
690
+ ### P1. An `http(s)://` target selects rest-api on its own port (#426)
691
+
692
+ ```bash
693
+ centrs execute http://$A:$REST_PORT ':put [/system/identity/get name]' --username $U --password $P --json
694
+ ```
695
+
696
+ Envelope: `ok: true`, `meta.via=rest-api`, `meta.settings.via =
697
+ { kind: "default", key: "via:target-url" }`. No `--via` is pinned, and the URL's
698
+ scheme and port are what get dialed.
699
+
700
+ ### P2. A typed `host:port` keeps native-api on that port (#426)
701
+
702
+ ```bash
703
+ centrs execute $A:$API_PORT ':put [/system/identity/get name]' --username $U --password $P --json
704
+ ```
705
+
706
+ Envelope: `ok: true`, `meta.via=native-api`, dialing `$API_PORT` (no
707
+ `--port` flag), the same way ssh and REST already treat a typed port.
708
+
709
+ ### P3. A pinned CDB doesn't refuse an address it has no record for (#427)
710
+
711
+ ```bash
712
+ centrs execute $A:$API_PORT ':put [/system/identity/get name]' --cdb-file $CDB --username $U --password $P --json
713
+ centrs execute http://$A:$REST_PORT ':put [/system/identity/get name]' --cdb-file $CDB --username $U --password $P --json
714
+ centrs execute no-such-router ':put [/system/identity/get name]' --cdb-file $CDB --username $U --password $P --json
715
+ ```
716
+
717
+ `$CDB` has no record for any of the three. The first two are `ok: true` over
718
+ native-api and rest-api: an IP, `[IPv6]`, `http(s)://` URL or MAC that misses a
719
+ pinned CDB is a literal host, exactly as with no CDB. The third is `ok: false`,
720
+ `error.code = cdb/not-found-target`: a name or identity that misses still fails
721
+ closed.
722
+
723
+ ## `--host` as the target (#428)
724
+
725
+ ### H1. `--host` stands in for `<target>`; a lone positional is the command
726
+
727
+ ```bash
728
+ centrs execute --host http://$A:$REST_PORT ':put [/system/identity/get name]' --username $U --password $P --json
729
+ centrs execute --host $A:$API_PORT --username $U --password $P --json -- ':put [/system/identity/get name]'
730
+ ```
731
+
732
+ Both are `ok: true`, the first over rest-api (the URL names REST) and the second
733
+ over native-api on `$API_PORT`. `meta.target.source = { kind: "cli", key:
734
+ "host" }`, with no `recordIndex`: there is no `<target>`, so there is no CDB
735
+ lookup. `retrieve`, `api` and `transfer` have the same rule (their H1).
736
+
602
737
  ## quickchr targets (#134)
603
738
 
604
739
  `$NAME` is the machine name of a running quickchr-managed CHR. Host/port/auth
@@ -199,10 +199,11 @@ today's path/verb/args split and script-vs-structured gate:
199
199
  Discovery is the union of MikroTik's two published inventories, not the
200
200
  sitemap alone. A *branching* menu is served as a trailing-slash category URL
201
201
  with no `.md` of its own, while that menu's own entry is published at
202
- `<dir>/<basename>.md` and listed only in `llms.txt` — so sitemap-only
203
- discovery dropped 256 of 1,070 pages, silently and field-heavily. Generation
204
- asserts that every category dir still contributes its leaf, so the next
205
- inventory reshape fails loudly instead of shrinking the table (#285,
202
+ `<dir>/<basename>.md` (since 2026-10-06, `<dir>.md`) and listed only in
203
+ `llms.txt` — so sitemap-only discovery dropped 256 of 1,070 pages, silently
204
+ and field-heavily. Generation asserts that every category dir still
205
+ contributes its own page under one of those two spellings, so the next
206
+ inventory reshape fails loudly instead of shrinking the table (#285, #417,
206
207
  tikoci/rosetta#137).
207
208
 
208
209
  **Source refresh (#335, accepted 2026-09-13).** The inspect pins retain the
@@ -232,6 +233,31 @@ today's path/verb/args split and script-vs-structured gate:
232
233
  moves both from `inspect` to `both` — 961 `both`, 65 `inspect`, 94
233
234
  `published`. Zero navigation-vs-command contradictions, unchanged.
234
235
 
236
+ **Source refresh (accepted 2026-10-11, #417).** MikroTik changed the CLI
237
+ Reference layout on 2026-10-06: a branching menu's own entry moved from
238
+ `<dir>/<basename>.md` to `<dir>.md`, and pages dropped their MDX `import`
239
+ preamble, so the `# title` line is no longer fenced off by an import. Both
240
+ guards refused, correctly, and the catalog check could not report anything
241
+ for days. The parser now accepts either layout (a category dir needs one of
242
+ the two page spellings, and both at once is reported); losing the page
243
+ entirely still aborts. The inventories reconcile exactly: 846 sitemap pages
244
+ plus 253 category pages equal the 1,099 pages `llms.txt` lists. The inspect
245
+ pins add 7.26beta1 (x86 and arm64) because the manual documents the
246
+ development channel, and a path new in it with no matching tree is
247
+ published-only with no gate to explain it. Reviewed against the committed
248
+ catalog: 47 paths added, 1 removed, 3 gate strings changed, and no kind
249
+ change, no provenance loss and no navigation-vs-command contradiction. The
250
+ additions are the 32 `/cmr` paths, `/interface/wifi/dashboard`,
251
+ `/system/dashboard`, `/tool/apptraffic`, `/interface/ethernet/pon/monitor`,
252
+ `/interface/wireless/snooper/snoop` and
253
+ `/interface/ethernet/switch/rule/reset-counters-all`; the removal is the
254
+ published-only `/system/routerboard/settings/keep-frequency`, which its page
255
+ no longer lists. The CLI Reference moves from 1,055 pages / 1,062 entries to
256
+ 1,099 / 1,106; the inspect union from 617 to 636 menus; the catalog from
257
+ 1,120 to 1,166 paths: 1,005 `both`, 67 `inspect`, 94 `published`.
258
+ `/interface/wifi/dashboard` and `/system/dashboard` are `inspect` only: the
259
+ trees carry them, the manual publishes no menu entry for them.
260
+
235
261
  There are real unknowns in how far offline parsing can go (expression
236
262
  grammar, scope fidelity vs `:parse`, `[]`-nesting corner cases). Grounding
237
263
  those unknowns was **phase 0** (#185); its findings are folded in throughout
@@ -1,8 +1,10 @@
1
1
  # retrieve
2
2
 
3
3
  Status: `CHR-passed` over `rest-api` and `native-api`, including multi-target
4
- fan-out (see **Target selection**). `snmp` is `not-started`. Matches
5
- `docs/MATRIX.md`.
4
+ fan-out (see **Target selection**), `--query` row filters (see **Query**),
5
+ `--follow` over `native-api` (see **Follow**) and `--sample` over both (see
6
+ **Sample**) and bounded readiness/conditions (see **Wait**). `snmp` is
7
+ `not-started`. Matches `docs/MATRIX.md`.
6
8
 
7
9
  Read RouterOS state. RouterOS menu reads model `<path>/<verb>` where the verb
8
10
  is `print`-style (`print`, `get`, and async POST-shaped reads as they're
@@ -50,6 +52,8 @@ duplicate that table. Behavior notes the generated reference cannot carry:
50
52
  - `--format json` (shortcut `--json`): set `CENTRS_FORMAT=json` to make the
51
53
  structured envelope the default. `--format yaml` renders the same envelope.
52
54
  - `--port`: native-api defaults to 8728; `--port 8729` selects TLS `api-ssl`.
55
+ A port typed into the target (`host:port`) is honored too, below the port
56
+ settings ladder (`--port`, `CENTRS_PORT`, a CDB `port=` comment, config).
53
57
  - `--query`/`--filter` are RouterOS **row** filters and always return an
54
58
  array (see Synopsis); `--where` filters *which devices* fan out, not rows.
55
59
  - `--all-attributes` conflicts with `--attribute(s)` (see Validation).
@@ -63,7 +67,7 @@ Spec-tier flags with no implementation yet — today they fail as unknown flags:
63
67
 
64
68
  | Flag | Designed behavior |
65
69
  | ----------------------------------- | ----------------------------------------------------------------------------------------- |
66
- | `--once` | Bounded single read of a monitor-style menu (RouterOS `once`): returns **one** envelope and never follows. Open-ended follow is `api … --stream`. See constitution: protocol selection. |
70
+ | `--once` | Bounded single read of a monitor-style menu (RouterOS `once`): returns **one** envelope and never follows. Open-ended follow is `--follow` (below), or `api … <menu>/listen` for the literal wire. See constitution: protocol selection. |
67
71
  | `--max-bytes <n>` | Byte budget for the rendered payload. If the response would exceed it, centrs truncates to fit, keeps `ok: true`, and adds a warning + `meta.truncated`. Not an error. (Will replace the implemented `--max-results`, which fails instead of truncating.) |
68
72
  | `--max-rows <n>` | Maximum row count for list reads. Excess rows are clipped; `ok: true` with a warning + `meta.truncated`. |
69
73
  | `--yaml` | Shortcut for `--format yaml`. |
@@ -77,6 +81,15 @@ Default validator: `/console/inspect request=syntax path=<comma-joined>,print`
77
81
  unknown attributes with `validation/*` codes that include the inspect-suggested
78
82
  alternatives in the error envelope.
79
83
 
84
+ `meta.validation.result` follows
85
+ [the constitution](../../docs/CONSTITUTION.md#validation-is-the-product). It is
86
+ `passed` once inspect accepts the read, even if the read fails afterward. It is
87
+ `failed` when the gate refuses: `validation/*`, or `--query` on a singleton.
88
+ It is absent when a `transport/*`, `auth/*`, `routeros/*` or `internal/*` error
89
+ stopped inspect from answering; the device stage then says `not reached`
90
+ (examples 12 and 16b; 16c is the `passed` case). With `--validate=false` it
91
+ is `{enabled: false, source: "disabled"}`.
92
+
80
93
  If `--all-attributes` is combined with `--attribute(s)`, fail with
81
94
  `usage/conflicting-flags` *before* hitting the network.
82
95
 
@@ -140,6 +153,334 @@ warning entry — it is not an error. (Earlier text said a byte overflow returns
140
153
  *error* with the needed size; superseded — truncation is an `ok: true` footnote so
141
154
  partial data stays usable.)
142
155
 
156
+ A failure **after** target resolution keeps what resolution found. The error
157
+ envelope carries the resolved `meta.target` (host, port, base URL, record,
158
+ sources), `meta.via`, the setting sources in `meta.settings`, and
159
+ `meta.operation.auth` (`username`, `passwordProvided`; provenance only, never
160
+ the secret). This holds for one-shot reads, `--follow`, `--sample`, `--wait`,
161
+ each fan-out member and the MCP `centrs_retrieve` tool. A failure before
162
+ resolution (bad shape, unknown target) keeps `meta.target.input` and a valid
163
+ pinned `via`, with empty `meta.settings`. The
164
+ library exposes this as `retrieveEnvelope()`, which never throws (examples 13,
165
+ 16b).
166
+
167
+ ## Query (`--query`)
168
+
169
+ `retrieve <router> <menu> --query '<expr>'` returns only the rows that match,
170
+ filtered on the router. `<expr>` is what you would write after `print where`:
171
+
172
+ ```bash
173
+ centrs retrieve $R /interface --query 'type=ether and !disabled'
174
+ centrs retrieve $R /ip/route --query '(dst-address="0.0.0.0/0" or distance>=10) and active'
175
+ ```
176
+
177
+ `--filter` is the same flag. Repeat either, and the expressions are AND-ed. A
178
+ list read with `--query` is always an array, `[]` when nothing matches. The
179
+ result works with `--attribute(s)`, `--sample`, `--follow` and fan-out.
180
+ A singleton (`/system/identity`) has no rows to filter and
181
+ fails `usage/conflicting-flags`.
182
+
183
+ **What it compiles to.** centrs parses the expression before connecting and
184
+ sends it as the API query (native `?` words, REST `.query`; one language,
185
+ `commands/api/AGENTS.md`). The router evaluates it, so comparisons use
186
+ RouterOS's own per-property typing:
187
+
188
+ | You write | RouterOS query words |
189
+ | --------- | -------------------- |
190
+ | `a=v`, `a!=v` | `a=v`; `a=v` `#!` |
191
+ | `a<v`, `a>v` | `<a=v` `-a` `#\|`; `>a=v` |
192
+ | `a<=v`, `a>=v` | `>a=v` `#!`; `<a=v` `-a` `#\|` `#!` |
193
+ | bare `a` | `a=yes` if `a` is a boolean, otherwise `a` (has a value) |
194
+ | `!x`, `x and y`, `x or y` | x `#!`; x y `#&`; x y `#\|` |
195
+
196
+ As in `print where`, a row without the property counts as smaller than any
197
+ value: `timeout<1d` includes static address-list entries (no `timeout`), and
198
+ `timeout>1h` does not. API `<`/`>` skip such rows on their own, hence the `-a`
199
+ ("has no `a`") word. `and` (`&&`) binds tighter than `or` (`||`), as in RouterOS. Values run to the
200
+ next space or parenthesis, and start right after the operator (RouterOS
201
+ rejects `comment= x`). Quote a value with spaces or an `=` (`comment="uplink a"`,
202
+ `comment="x=yes"`), using RouterOS escapes: `\"`, `\\`, `\$`, `\?`, `\_`, `\a`
203
+ `\b` `\f` `\n` `\r` `\t` `\v`, and two uppercase hex digits (`\41`; `\ff` is `\f`
204
+ then `f`). A backslash before whitespace drops it and the whole whitespace run
205
+ after it, so `"x\<newline> y"` is `xy`. A hex
206
+ byte above `\7F` is refused: centrs sends values as UTF-8, so it could not match
207
+ the raw byte RouterOS stores.
208
+
209
+ **A bare name.** `where disabled` means `disabled=yes`, but `where comment`
210
+ means "comment is set" (RouterOS stores an empty comment as absent). Only the
211
+ router knows which kind a property is, so centrs asks it: `/console/inspect`
212
+ completion after `where <name>=` offers exactly `yes`/`no` for a boolean. With
213
+ `--validate=false` there is no one to ask, and a bare name fails
214
+ `input/invalid-query`; write `disabled=yes`.
215
+
216
+ **Validation.** Every property in the expression is checked against the
217
+ menu's attributes (as for `--attributes`, plus `.id`). RouterOS answers a query
218
+ on a misspelled property with no rows and no error, so a typo fails
219
+ `validation/unknown-attribute` instead.
220
+
221
+ **Same rows as `print where`, with these differences.** On CHR 7.23.7 and
222
+ 7.24.5, 58 expressions over three menus return exactly the rows
223
+ `find where` selects (examples QY1, QY2). The exceptions come from the CLI
224
+ typing an unquoted literal, which the query does not do:
225
+
226
+ - `where dst-port=80` reads `80` as a number and matches nothing, and
227
+ `where protocol=tcp` likewise; the query matches the `"80"` and `"tcp"` that
228
+ `retrieve` shows. Quoted (`dst-port="80"`), the CLI agrees. This difference
229
+ is version-dependent: on CHR 7.25beta3, 7.25beta5, 7.25rc1 and 7.26beta1,
230
+ both unquoted CLI expressions also match. QY2 keeps exact expectations for
231
+ both behaviors; API query results stay the same over both transports.
232
+ - Ordering (`<`, `>`, `<=`, `>=`) on a property RouterOS returns as text
233
+ (address-list `address`, `dst-port`) is text order: `192.0.2.10` sorts
234
+ before `192.0.2.9`. Numbers (`mtu`, `vlan-id`, `distance`) compare as
235
+ numbers.
236
+ - The query accepts `disabled=true` as `yes`; the CLI rejects it.
237
+
238
+ **Refused, never approximated.** `~` (regex) and `in` (prefix membership) have
239
+ no query word, and a `$variable`, `[command]` or `{array}` value needs the
240
+ script evaluator: these fail `input/unsupported-query` with a tip. Four
241
+ spellings RouterOS reads differently from how they look fail
242
+ `input/invalid-query`: `!a=v` (RouterOS reads `(!a)=v`; write `!(a=v)` or
243
+ `a!=v`), `not` (not a RouterOS operator; use `!`), conditions separated
244
+ only by a space (join them with `and` or `or`), and an unquoted `=` inside a
245
+ value (RouterOS reads `comment=x=yes` as `(comment=x)=yes`, and `comment=x!=y`
246
+ matches nothing; quote it: `comment="x=yes"`).
247
+
248
+ ## Follow (`--follow`)
249
+
250
+ `retrieve <router> <menu> --follow` keeps a menu's rows current: first the
251
+ rows that exist now, then a line for every change, as NDJSON. It is the
252
+ friendly wrapper over native-API `listen`; `api <router> <menu>/listen` is the
253
+ literal wire (`commands/api/README.md`). Decisions and CHR evidence: #396.
254
+
255
+ **What starts it.** `--follow` on a list menu whose `print` takes
256
+ `follow-only` in `/console/inspect` (checked live). A menu without it, a
257
+ singleton included, fails `validation/not-followable` before anything is
258
+ followed. Native-api only: an unset `via` infers native-api, and a `via` of
259
+ `rest-api` from any source (flag, env, CDB, settings) fails
260
+ `transport/capability-unsupported`; there is no polling fallback. Single
261
+ target only: a fan-out selector fails `usage/fanout-not-supported`. Follow is
262
+ filtered with the same `--query`/`--filter` grammar as a one-shot read (#397).
263
+
264
+ **What each line means.** Every line is one envelope; read
265
+ `meta.operation.stream`:
266
+
267
+ | `kind` | When | `data` | Fields |
268
+ | ------ | ---- | ------ | ------ |
269
+ | `frame` | One row change | the row (`upsert`), `null` (`removed`) | `index`, `phase` (`snapshot`/`live`), `change` (`upsert`/`removed`), `id`, `source` (`print`/`listen`/`sweep`/`membership`) |
270
+ | `synced` | Once, after the snapshot | `null` | `rows`: rows held at that point |
271
+ | `notice` | First, only when there is advice | `null` | — (`tips` carries it) |
272
+ | `summary` | Last, exactly once | the counts | `stopReason`, `frames`, `snapshot`, `changes`, `sweeps`, `synced`, `durationMs` |
273
+
274
+ - **Bootstrap (A1).** centrs starts the `listen`, then the `print`, on one
275
+ connection. It emits the `print` rows, then every `listen` change received
276
+ since the `listen` started, all as `phase: "snapshot"`, then `synced`.
277
+ Applying the lines in order gives the menu's state at `synced`. That order
278
+ had 0 errors over about 14.7k writes on 7.23.7 and 7.24.5; merging by arrival
279
+ order resurrects deleted rows (#396 round 3). `synced` means the bootstrap
280
+ was processed. It is not an atomic snapshot, a full event history, or
281
+ routing convergence.
282
+ - **`upsert`** carries the latest full row; replace what you hold for `id`.
283
+ **`removed`** carries `data: null`; drop `id`. The row identity is always
284
+ `meta.operation.stream.id`, even when `--attributes` leaves `.id` out of
285
+ `data`.
286
+ - **Changes are coalesced state, not an event log.** RouterOS sends a change
287
+ about 200 ms after a write and folds writes in between. A row created and
288
+ deleted inside that window may never appear, and a flap may arrive as one
289
+ line or none. A `.dead` for an `id` centrs never reported is dropped, so a
290
+ removal the sweep saw first is reported once, as `source: "sweep"`.
291
+ - **Removals RouterOS does not send.** View menus (`/interface/<type>`,
292
+ `/ip/route`, `/ipv6/route`) never send `.dead`. An `.id`-only sweep
293
+ (`print .proplist=.id`) runs every `--sweep` (default `10s`) on every followed
294
+ menu and reports an `id` that is gone as `removed` with `source: "sweep"`.
295
+ An `id` that changed since that sweep was sent is never removed by it. A
296
+ failed sweep ends the follow with an error; it never infers a removal. A
297
+ sweep only checks membership: it does not repair a missed field update or
298
+ report rows `listen` never announced. `--sweep` needs a unit (`10s`,
299
+ `500ms`); a bare number fails `settings/invalid-timeout`, as for `--sample`.
300
+ `--sweep 0` turns it off, with a notice tip saying view-menu removals will
301
+ not be reported.
302
+ - **What `listen` never reports.** Hot counters (`rx-byte`, firewall
303
+ `bytes`/`packets`) and some protocol state (BFD sessions accept `listen` but
304
+ stay silent). The follow is correct but quiet there; `--sample` (below)
305
+ is the answer for those.
306
+ - **Filtered membership.** The snapshot and sweep carry the predicate; the
307
+ listen stays unfiltered, retaining `.id,.dead`. For changed ids centrs reads
308
+ the current matching rows from RouterOS, in batches of at most 256 ids with
309
+ one membership read in flight. No client type coercion is involved, and
310
+ predicate fields need not appear in the caller's projection. These frames
311
+ have `source: "membership"`: a matching row is upserted, and a held row that
312
+ no longer matches (or no longer exists) is removed. This removal is a change
313
+ in the selected set, not a claim that RouterOS sent `.dead`. A real `.dead`
314
+ still has `source: "listen"` and only removes a held id. Delayed membership
315
+ results are discarded for ids with newer wire changes; sweeps and membership
316
+ reads are serialized. Bootstrap fixes its changed-id/version cutoff at the
317
+ snapshot's wire completion and reconciles those ids before `synced`. A
318
+ reply superseded by a newer change is discarded; that newer version stays
319
+ queued for live reconciliation. Continuous writes cannot extend bootstrap
320
+ indefinitely or postpone the first sweep and live `--count`. This may
321
+ coalesce more changes than unfiltered A1 replay.
322
+ The filtered sweep also detects silent exits from a predicate (e.g. a
323
+ decreasing address-list `timeout`). It does not discover silent entries or
324
+ repair silent field updates: use `--sample` for such state. With `--sweep 0`,
325
+ silent exits remain held until a listen notification arrives.
326
+ - **Projection.** `--attribute(s)` projects `data`; centrs still asks RouterOS
327
+ for `.id,.dead` and removes them from `data` unless you named them.
328
+ `--all-attributes` sends `detail` to both `listen` and `print`.
329
+ `--list-attributes` and `--max-results` conflict with `--follow`
330
+ (`usage/conflicting-flags`).
331
+
332
+ **What ends it.** `--count N` stops after N `live` frames; snapshot frames and
333
+ `synced` never count, so `--count 1` is "the next observed change" (coalesced,
334
+ so not a guaranteed transition). `--duration <d>` is a wall-clock bound that
335
+ includes the bootstrap. Ctrl-C (`SIGINT`) stops it. Each ends with a
336
+ successful summary whose `stopReason` is `count-reached`, `duration-elapsed`,
337
+ or `interrupted`. A RouterOS error, a lost connection, a failed sweep or a full
338
+ buffer ends it with a failed summary (`ok: false`, `stopReason`
339
+ `routeros-error` or `transport-error`), whose counts are partial.
340
+ `routeros-error` means RouterOS answered with an error; `transport-error`
341
+ covers every other failure, and `error.code` names the cause. Buffers are
342
+ bounded: if more than 50,000 changes are waiting (a consumer that stopped
343
+ reading, or a burst during a long snapshot), the follow ends with
344
+ `transport/stream-overflow`. The state you hold is stale; follow again to get a
345
+ new snapshot. `--sweep` without `--follow`, and `--count` or `--duration`
346
+ without `--follow` or `--sample`, fail `usage/conflicting-flags`.
347
+
348
+ **Format.** Under `--follow`, `json`, `yaml` and `ndjson` all print one compact
349
+ envelope per line (as `api --stream` does); `text` prints one short row per
350
+ line. Every envelope, errors included, goes to stdout, and the exit code is
351
+ nonzero for any failure, even after rows. Without `--follow`,
352
+ `--format ndjson` prints the ordinary envelope as one compact line; it never
353
+ starts a follow.
354
+
355
+ Recipes (bounded, so an agent can run them unattended):
356
+
357
+ ```bash
358
+ # Watch: hold /ip/address for 30s, then stop.
359
+ centrs retrieve $R /ip/address --follow --duration 30s
360
+ # Wait for the next change, at most 60s.
361
+ centrs retrieve $R /ip/address --follow --count 1 --duration 60s
362
+ ```
363
+
364
+ ## Sample (`--sample`)
365
+
366
+ `retrieve <router> <menu> --sample <interval>` reads the menu again every
367
+ interval: one line per complete read, as NDJSON. It sees what `--follow`
368
+ cannot (hot counters such as `rx-byte`, and state `listen` never announces),
369
+ at the cost of missing whatever changes and changes back between two reads.
370
+
371
+ **What starts it.** `--sample` with an interval that has a unit (`5s`,
372
+ `500ms`, `1m`). A bare number fails `settings/invalid-timeout`: RouterOS reads
373
+ `interval=5` as seconds and centrs reads `5` as milliseconds, so centrs does
374
+ not guess. Any readable menu works, list or singleton. Each sample is the
375
+ ordinary one-shot read (`print` or `get`) with the same validation (once, before
376
+ the first read), projection and transport as `retrieve` without `--sample`:
377
+ `rest-api` by default, `native-api` when pinned. Single target only; a fan-out
378
+ selector fails `usage/fanout-not-supported`. `--follow` with `--sample` fails
379
+ `usage/conflicting-flags`.
380
+
381
+ Why reads and not RouterOS's own `print interval=`: on CHR 7.24.5 that feed has
382
+ no end-of-tick marker, so a tick is only known complete when the next tick (or
383
+ `!empty`) arrives, one whole interval later. A tick heavier than its interval
384
+ also stalls the connection. A one-shot read ends with its own `!done` or HTTP
385
+ response, so every sample is known complete when it arrives. The raw tick feed
386
+ stays available as `api <router> <menu>/print -X POST -f interval=1s --stream`
387
+ (`commands/api/README.md`).
388
+
389
+ **What each line means.** Every line is one envelope; read
390
+ `meta.operation.stream`:
391
+
392
+ | `kind` | When | `data` | Fields |
393
+ | ------ | ---- | ------ | ------ |
394
+ | `sample` | One complete read | the whole read: a row array (list), the record (singleton), or the bare value (one `--attribute` on a singleton) | `index` (1-based), `at` (when the read was sent, host clock, ISO 8601), `readMs` |
395
+ | `summary` | Last, exactly once | the counts | `stopReason`, `samples`, `durationMs` |
396
+
397
+ - **A sample is the whole state at that read.** A row missing from a sample
398
+ was gone when it was read; there is no separate removal line. A list read
399
+ with no rows is `data: []`, still a complete sample.
400
+ - **Cadence.** Reads start one interval apart, measured start to start. A
401
+ read slower than the interval delays the next read rather than overlapping
402
+ it, and missed slots are not made up in a burst. `at` gives the real spacing,
403
+ which is what a counter rate needs.
404
+ - **Not an event log.** A change shorter than the interval can fall between
405
+ two reads. Use `--follow` for object changes RouterOS announces.
406
+
407
+ **What ends it.** `--count N` stops after N samples. `--duration <d>` is a
408
+ wall-clock bound counted from the end of validation. Ctrl-C (`SIGINT`) stops
409
+ it. Each ends with a successful summary whose `stopReason` is `count-reached`,
410
+ `duration-elapsed` or `interrupted`. A read still in flight when the run stops
411
+ is not reported, because it is not complete. A failed read (a RouterOS error,
412
+ a lost connection, a timeout) ends the run with a failed summary (`ok: false`,
413
+ `stopReason` `routeros-error` or `transport-error`, as for `--follow`)
414
+ carrying the partial `samples` count; there is no retry. `--list-attributes`, `--max-results` and
415
+ `--sweep` conflict with `--sample` (`usage/conflicting-flags`).
416
+
417
+ **Format.** As for `--follow`: `json`, `yaml` and `ndjson` print one compact
418
+ envelope per line, and `text` prints `index`, `at` and the data on one line.
419
+ Every envelope, errors included, goes to stdout; the exit code is nonzero for
420
+ any failure, even after samples.
421
+
422
+ Recipes (bounded, so an agent can run them unattended):
423
+
424
+ ```bash
425
+ # Interface counters every 5s, three samples.
426
+ centrs retrieve $R /interface --attributes name,rx-byte,tx-byte --sample 5s --count 3
427
+ # CPU load once a second for a minute.
428
+ centrs retrieve $R /system/resource --attribute cpu-load --sample 1s --duration 1m
429
+ ```
430
+
431
+ ## Wait (`--wait`, `--until`, `--until-empty`)
432
+
433
+ `retrieve <router> <menu> --wait <deadline>` waits for a successful read.
434
+ It retries refused, closed, reset and timed-out connections, and a host that is
435
+ down or unreachable, so it can start while a router or CHR is rebooting or its
436
+ services are coming up. Authentication,
437
+ TLS, DNS, RouterOS and validation errors fail immediately. Identity/CDB/quickchr
438
+ resolution errors also fail immediately; `--quickchr` must name a running VM.
439
+ The caller's pinned protocol is preserved.
440
+
441
+ ```bash
442
+ centrs retrieve --quickchr sun /system/resource --wait 5m --json
443
+ centrs retrieve $R /routing/ospf/neighbor --wait 2m --until state=Full --json
444
+ centrs retrieve $R /routing/ospf/neighbor --wait 2m --until-empty --json
445
+ ```
446
+
447
+ - **Deadline and cadence.** `--wait` is the overall deadline, including
448
+ validation and retries, and needs a unit (`30s`, `5m`): a bare number is
449
+ refused, as for `--sample`. `--timeout` bounds each request. Reads begin one
450
+ second apart by default; `--sample 500ms` selects another cadence, measured
451
+ start to start without overlapping reads. With `--wait`, `--sample` yields
452
+ one final result rather than a stream. Slow reads delay the next one and
453
+ unfinished reads at the deadline are abandoned.
454
+ - **Any matching row.** `--until '<expr>'` uses the same grammar, property
455
+ validation and device typing as `--query`. Repeat it to AND conditions.
456
+ `--query` selects the rows to consider; `--until` terminates the wait when
457
+ any of those rows match. The final `data` contains those matching rows,
458
+ projected as requested; predicate properties need not appear in `data`.
459
+ Already true succeeds on the initial read. Zero rows never satisfy it.
460
+ Predicates apply to list menus; singleton readiness uses `--wait` alone.
461
+ - **Zero rows.** `--until-empty` succeeds only on a completed read with zero
462
+ rows selected by `--query`. Silence, transport failure, unfinished reads and
463
+ cancellation do not prove emptiness. It needs a list menu and conflicts
464
+ with `--until`.
465
+ - **One result.** After request resolution, every terminal envelope carries `meta.operation.wait` with
466
+ `stopReason`, `elapsedMs`, `attempts` and `observations`. Attempts include
467
+ failed validation/connection attempts; observations count completed data
468
+ reads. `ready` and `condition-met` are successful. On deadline expiry,
469
+ `deadline-elapsed` is failed: it preserves the last transient transport error,
470
+ or reports `wait/deadline-exceeded` when the last completed observation did
471
+ not satisfy the condition. Ctrl-C or an aborted library signal fails with
472
+ `wait/interrupted`, `stopReason: "interrupted"`. Other errors have
473
+ `stopReason: "failed"`. CLI exit is 0 on success and 1 on any failure;
474
+ the one final envelope, including failures, is written to stdout.
475
+
476
+ Single target only. `--until` and `--until-empty` require `--wait`.
477
+ `--follow`, `--count`, `--duration` and `--list-attributes` conflict with it.
478
+ This waiter uses completed reads over REST or native-api, including counters
479
+ and protocol state that listen never reports. For open-ended change feeds use
480
+ `--follow`; `--duration` finishing a watch does not establish a wait condition.
481
+ The library entry is `retrieveWait(request, env?, { signal? })`, returning a
482
+ success or failure envelope; `retrieve()` remains the one-shot entry.
483
+
143
484
  ## Target selection
144
485
 
145
486
  retrieve fans out over the **shared target-selection grammar** — multiple
@@ -155,7 +496,10 @@ stays the single-target envelope; a selector flag or a second positional target
155
496
  switches to fan-out. `--quickchr <name>` (the named-live-provider) behaves like a
156
497
  lone positional: one flag stays single-target, repeating it fans out, and mixing
157
498
  it with positionals or CDB selectors is `usage/conflicting-flags` (constitution:
158
- resolution providers).
499
+ resolution providers). The `--host` **flag** with no `<router>` positional
500
+ (`retrieve --host http://192.0.2.10:8080 /system/resource`) is a single target
501
+ too: no CDB lookup, so auth comes from flags, env or config. `CENTRS_HOST` never
502
+ stands in for `<router>`.
159
503
 
160
504
  retrieve-specific behavior on top of the shared engine:
161
505