@tikoci/centrs 0.1.6 → 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 (89) hide show
  1. package/CHANGELOG.md +342 -0
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +10 -6
  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 +135 -42
  8. package/commands/api/examples.md +275 -19
  9. package/commands/devices/README.md +21 -4
  10. package/commands/devices/examples.md +33 -2
  11. package/commands/execute/README.md +60 -18
  12. package/commands/execute/examples.md +224 -0
  13. package/commands/explain/README.md +42 -5
  14. package/commands/mcp/README.md +2 -1
  15. package/commands/retrieve/README.md +348 -4
  16. package/commands/retrieve/examples.md +442 -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 +29 -21
  22. package/docs/CONSTITUTION.md +41 -16
  23. package/docs/MATRIX.md +38 -1
  24. package/docs/errors/README.md +3 -1
  25. package/docs/errors/cdb/create-failed.md +20 -0
  26. package/docs/errors/cdb/file-exists.md +23 -0
  27. package/docs/errors/input/invalid-query.md +36 -0
  28. package/docs/errors/input/invalid-target.md +25 -3
  29. package/docs/errors/input/local-destination.md +20 -0
  30. package/docs/errors/input/unsupported-query.md +19 -0
  31. package/docs/errors/internal/byte-string.md +13 -0
  32. package/docs/errors/routeros/invalid-value.md +3 -2
  33. package/docs/errors/settings/invalid-timeout.md +6 -0
  34. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  35. package/docs/errors/transport/stream-overflow.md +21 -0
  36. package/docs/errors/usage/removed-flag.md +20 -0
  37. package/docs/errors/validation/menu-unavailable.md +22 -0
  38. package/docs/errors/validation/not-followable.md +23 -0
  39. package/docs/errors/validation/package-missing.md +29 -0
  40. package/docs/errors/validation/unknown-path.md +16 -4
  41. package/docs/errors/wait/deadline-exceeded.md +14 -0
  42. package/docs/errors/wait/interrupted.md +10 -0
  43. package/package.json +14 -11
  44. package/src/absent-menu.ts +280 -0
  45. package/src/api-fanout.ts +22 -12
  46. package/src/api.ts +603 -194
  47. package/src/btest.ts +19 -17
  48. package/src/cli/api.ts +126 -34
  49. package/src/cli/devices.ts +16 -3
  50. package/src/cli/execute.ts +18 -5
  51. package/src/cli/retrieve.ts +216 -22
  52. package/src/cli/terminal.ts +10 -5
  53. package/src/cli/transfer.ts +23 -8
  54. package/src/core/envelope.ts +14 -0
  55. package/src/core/error-catalog.ts +70 -1
  56. package/src/core/execute-command.ts +313 -6
  57. package/src/core/inspect.ts +65 -27
  58. package/src/core/query.ts +508 -0
  59. package/src/core/routeros-errors.ts +67 -1
  60. package/src/data/winbox-cdb-write.ts +22 -8
  61. package/src/devices.ts +148 -37
  62. package/src/discover.ts +7 -8
  63. package/src/errors.ts +116 -7
  64. package/src/execute-fanout.ts +14 -9
  65. package/src/execute.ts +128 -116
  66. package/src/explain/catalog.ts +64 -16
  67. package/src/explain/menus.ts +27 -6
  68. package/src/explain.ts +6 -6
  69. package/src/index.ts +31 -1
  70. package/src/mcp/tools.ts +10 -15
  71. package/src/protocols/adapter.ts +235 -118
  72. package/src/protocols/btest-session.ts +3 -2
  73. package/src/protocols/endpoint.ts +10 -0
  74. package/src/protocols/mac-telnet-console.ts +12 -7
  75. package/src/protocols/native-api.ts +252 -73
  76. package/src/protocols/sftp.ts +44 -14
  77. package/src/protocols/ssh.ts +42 -14
  78. package/src/resolver/cdb.ts +28 -7
  79. package/src/resolver/index.ts +1 -0
  80. package/src/resolver/target.ts +84 -18
  81. package/src/retrieve-fanout.ts +51 -12
  82. package/src/retrieve-follow.ts +862 -0
  83. package/src/retrieve-sample.ts +244 -0
  84. package/src/retrieve-stream.ts +115 -0
  85. package/src/retrieve-wait.ts +286 -0
  86. package/src/retrieve.ts +741 -109
  87. package/src/settings.ts +15 -15
  88. package/src/transfer-fanout.ts +40 -15
  89. package/src/transfer.ts +234 -32
@@ -7,17 +7,19 @@ single REST or native-API operation, and returns the result in the standard
7
7
  envelope.
8
8
 
9
9
  Status: `CHR-passed` over `rest-api` and `native-api`, including multi-target
10
- fan-out and open-ended `--stream` follow (native-api only), per
10
+ fan-out and incremental `--stream` replies (native-api only), per
11
11
  `docs/MATRIX.md`. This file describes intent and flags; the matrix holds the
12
12
  cell states. Load-bearing rules — envelope, errors, settings precedence,
13
13
  identity, validation, protocol selection — live in
14
14
  [`docs/CONSTITUTION.md`](../../docs/CONSTITUTION.md); the `(constitution: …)`
15
15
  notes below point there rather than restating them.
16
16
 
17
- `api` absorbs the former `stream` command: open-ended follow is `api <router>
18
- <endpoint> --stream` (or the `/listen` endpoint form). `--listen` is an accepted
19
- alias of `--stream`. Streaming is single-session — it cannot combine with
20
- multi-target fan-out (see below).
17
+ `api` absorbs the former `stream` command. `api` sends the command as typed:
18
+ following changes is the menu's own `listen` (`api <router> ip/address/listen`),
19
+ and `--stream` delivers any command's replies incrementally. `--listen` was
20
+ removed in #402 (`usage/removed-flag`, naming the exact replacement): it also
21
+ turned a GET into a listen. Streaming is single-session — it cannot combine
22
+ with multi-target fan-out (see below).
21
23
 
22
24
  ## Where `api` sits — the verb trichotomy
23
25
 
@@ -46,8 +48,10 @@ centrs api <router> <endpoint> [flags]
46
48
  - `<endpoint>` — a REST-style path, leniently normalized. All of
47
49
  `ip/address`, `/ip/address`, `rest/ip/address`, `/rest/ip/address`,
48
50
  `"ip address"`, `'ip address'` canonicalize to `/ip/address`. A trailing id
49
- segment (`ip/address/*1`) addresses one row. A trailing `/listen` segment
50
- infers `--stream` + `--via native-api`.
51
+ segment (`ip/address/*1`) addresses one row for GET/PATCH/DELETE; PUT and
52
+ POST reject it with `input/invalid-path`. A trailing `/listen` segment is
53
+ the menu's `listen` command, which implies `--stream` + `--via native-api`
54
+ and requires GET (`ip/address/*1/listen` follows one row).
51
55
 
52
56
  The HTTP method (`-X`, default `GET`) is honored **literally** against RouterOS's
53
57
  REST mapping — `GET`→print/get, **`PUT`→add**, `PATCH`→set, `DELETE`→remove,
@@ -64,18 +68,31 @@ the generated reference cannot carry:
64
68
 
65
69
  - `-f` values pass through verbatim (no type-guessing); `-d`/`--input`
66
70
  collide with `-f` → `usage/conflicting-flags`.
67
- - `--query`/`--filter` map to REST `.query` words / native `?` words.
71
+ - `--query`/`--filter` take the same `print where` grammar as
72
+ `retrieve --query` ([retrieve README → Query](../retrieve/README.md#query---query))
73
+ and compile to REST `.query` words / native `?` words. A bare name is
74
+ `name=yes` for a boolean and "has a value" otherwise, which the router is
75
+ asked during validation; with `--validate=false` (or `--raw`) a bare name
76
+ fails `input/invalid-query`. Unlike `retrieve`, `api` does not check the
77
+ property names.
68
78
  - `--raw-query` is emitted as-is; the caller owns the stack — e.g.
69
- `--raw-query type=ether --raw-query type=vlan --raw-query '#|'` (OR).
79
+ `--raw-query type=ether --raw-query type=vlan --raw-query '#|'` (OR), or the
80
+ has-property `--raw-query comment` / absence `--raw-query -comment` words.
70
81
  - `--via rest-api --stream` errors `transport/capability-unsupported`
71
82
  (REST's 60 s cap cannot follow).
72
- - Under `--stream`, `--format json`/`yaml` emit one compact envelope per
73
- line (NDJSON); `text` emits a concise row per frame.
83
+ - `--format ndjson` prints every envelope as one compact line, one-shot or
84
+ streamed; it never starts a stream by itself. Under `--stream`,
85
+ `json`/`yaml` also emit NDJSON (unchanged for existing callers); `text`
86
+ emits a concise row per frame.
74
87
  - The target/auth flags (`--host`, `--port`, `--username`, `--password`,
75
88
  `--insecure`, `--timeout`, `--cdb-file`, `--cdb-password`, `--resolve`)
76
89
  use the same single-target resolver as `retrieve`/`execute`; `--quickchr`
77
90
  resolves host/port/auth from the live `@tikoci/quickchr` descriptor
78
91
  instead (constitution: resolution providers).
92
+ - The `--host` **flag** stands in for a missing `<router>`:
93
+ `api --host http://192.0.2.10:8080 system/resource` targets that host with
94
+ no CDB lookup. `CENTRS_HOST` never does; `api <endpoint>` with it set is
95
+ still a missing `<router>`.
79
96
 
80
97
  ## Fan-out (multi-target)
81
98
 
@@ -93,7 +110,7 @@ vs `--query` distinction.
93
110
  - **Writes fan out under `--yes`**, confirmed once up front (not per target).
94
111
  Without `--yes`, the error names the blast radius (how many routers) and that
95
112
  `--yes` is required.
96
- - **`--listen`/`--stream` is single-session** → `usage/fanout-not-supported` in
113
+ - **`--stream` (or a `/listen` endpoint) is single-session** → `usage/fanout-not-supported` in
97
114
  fan-out mode. **`--raw` strips the envelope** → `usage/conflicting-flags` in
98
115
  fan-out mode (per-target envelopes can't be bare).
99
116
 
@@ -112,15 +129,18 @@ gate is `/console/inspect` — **not** `:put [:parse]`:
112
129
  does) answers normally on the same build. See the 7.12.2 finding in
113
130
  `AGENTS.md` (GH#343).
114
131
  - **Carve-out:** a script-shaped `POST /rest/execute` is a CLI string, not a path,
115
- so the inspect gate is `not-applicable` (`meta.validation.semantic`); RouterOS
116
- re-validates on the run. Until GH#354 that carve-out meant the script was sent
117
- with *no* preflight at all. It is now gated by the offline stage (constitution:
118
- validation) — the same analyzer `explain` publishes, run over the `script`
119
- field with no connection, so a syntax fault is a byte span in
120
- `error.context.span` instead of a round trip. `meta.validation.stages[]` shows
121
- the offline stage `passed` and the device stage `skipped` with its reason. A
122
- missing or blank `script` is still `input/invalid-command`, not a syntax
123
- rejection.
132
+ so structured inspect is `not-applicable` (`meta.validation.semantic`). It
133
+ follows the CLI-string pipeline instead: offline analysis, then live
134
+ `:put [:parse ...]`, then run. An offline rejection is still a byte span with
135
+ no connection; an offline pass or abstention continues to the device stage.
136
+ `meta.validation.stages[]` reports both decisions. A missing or blank `script`
137
+ is still `input/invalid-command`, not a syntax rejection. Validation passing
138
+ is not the run succeeding: `as-string` makes the console's own text the reply
139
+ body, so a runtime rejection arrives as an HTTP-200 `ret`. `/execute` output
140
+ is normalized through the same grounded result table `execute` uses, so
141
+ `no such item` fails the envelope with a `routeros/*` code while the stages
142
+ stay `passed`. Ordinary output that merely contains a fault string is
143
+ unaffected — the result matchers are anchored, not substring.
124
144
  - Structured path requests are **not** offline-gated. The input is a path plus a
125
145
  body, not a CLI string, and an offline *path* gate collides with GH#211's
126
146
  unlisted-path decision. On the success path `meta.validation.stages[]` is
@@ -168,27 +188,100 @@ the RouterOS error payload to stderr with a nonzero exit; a centrs-side failure
168
188
  with no RouterOS response prints a compact `{code,message}` to stderr. Exit code is
169
189
  `0` iff `ok`.
170
190
 
171
- ## Multi-frame results: bounded vs open-ended
172
-
173
- RouterOS produces multi-frame output two ways, and `api` treats them differently:
174
-
175
- - **Bounded `duration=` / `monitor` commands are ordinary `api` calls.** Both
176
- transports return the accumulated frames as a normal **array** (REST regularizes
177
- the native `!re` frames into `.section`-keyed records; native's reader collects
178
- them until the command completes). No flag needed — e.g.
179
- `centrs api $R interface/monitor-traffic -f interface=ether1 -f duration=5s`
180
- returns a `.section` array. REST bounds these at the **60 s** cap (a longer
181
- `duration=` terminates early with an error); native has no cap. This is **not**
182
- NDJSON.
183
- - **Open-ended follow is `--stream` (alias `--listen`), native-api only.** Only
184
- the native API `/listen` command follows indefinitely (REST cannot — the 60 s
185
- cap). Each `!re` becomes one rest-style envelope frame (one NDJSON line; a
186
- deletion's `.dead=true` flag is preserved); the stream ends with a summary
187
- envelope (`data.stopReason` ∈ `count-reached`/`duration-elapsed`/`interrupted`/
188
- `transport-error`, plus `frames` and `durationMs`). `--duration`/`--count`
189
- bound it; Ctrl-C stops and still emits the summary. `--via rest-api --stream` →
190
- `transport/capability-unsupported`. The exit code reflects whether the stream
191
- *started* cleanly, not whether every frame was `ok`.
191
+ ## Multi-frame results and incremental replies
192
+
193
+ Without `--stream`, bounded commands return their accumulated rows as an ordinary
194
+ array. REST retains its **60 s** cap; native API collects replies until `!done`.
195
+
196
+ `--stream` emits the literal command's native replies incrementally, whether
197
+ the command is finite or open-ended (#402: one wire reply, one line).
198
+ `--via rest-api --stream` fails with `transport/capability-unsupported`;
199
+ centrs never substitutes polling.
200
+
201
+ - **Following changes is a command you ask for**: the `<menu>/listen`
202
+ endpoint (GET; implies `--stream` and native-api), or POST `<menu>/print`
203
+ with `-f follow-only=` / `-f follow=` to pass print arguments.
204
+ `ip/address/*1/listen` follows one row (`?.id=*1`).
205
+ - **GET menu + `--stream`** streams one literal print and completes; it does
206
+ not follow. Its summary carries `tip/stream-print` naming the `/listen`
207
+ form (see the notice below). A streamed GET (or a `/listen`) rejects `-f` fields rather than
208
+ silently dropping them.
209
+ - **POST command + `--stream`** runs the literal command with its attributes:
210
+ `api $R /tool/ping -X POST -f address=10.0.2.2 -f count=3 --stream --yes`.
211
+ POST `/system/resource/print -f interval=1 --stream --count 2` samples twice.
212
+ PUT/PATCH/DELETE retain their add/set/remove mapping and confirmation gates.
213
+ Query/projection flags on these mutations or `/execute` scripts fail with
214
+ `usage/conflicting-flags` instead of being ignored. `/execute` retains
215
+ synchronous `as-string` script semantics. Streaming never makes a command
216
+ read-only.
217
+ - Each wire reply emits one envelope with `meta.operation.stream.kind=frame`
218
+ and `reply`: **`re`** (a row; `data` is the row, device `.section`,
219
+ `.dead`, `.nextid` and `.about` verbatim) or **`empty`** (RouterOS `!empty`:
220
+ zero rows; `data` is `null`). RouterOS sends `!empty` for an empty finite
221
+ read, for **each zero-row tick** of `print interval=`, and for a listen
222
+ cancelled before it sent anything. A frame received after centrs stopped the
223
+ stream locally carries `afterStop: true` — that cancellation `!empty` is not
224
+ evidence of an empty table. A terminal `!done` is not a frame: its
225
+ attributes, when received, appear in successful summary `data.done`,
226
+ including a terminal result received during local cancellation. The stop
227
+ reason still reports the first local bound.
228
+ - Read outcomes from **`meta.operation.stream`** on both success and failure;
229
+ successful `data.stopReason` is a compatibility copy.
230
+ - Exactly one terminal summary follows a started stream. Its marker is
231
+ `meta.operation.stream.kind=summary`, with `frames` (every reply line),
232
+ `rows` (`!re`), `empty` (`!empty`), `durationMs`, and `stopReason`:
233
+ `completed`, `count-reached`, `duration-elapsed`, `interrupted`,
234
+ `routeros-error`, or `transport-error`. `meta.operation.objectCount` is
235
+ `rows`. A RouterOS/transport failure (`!trap`, `!fatal`, a dropped
236
+ connection) has `ok:false` and a structured `error`; summary counts/reason
237
+ are in metadata (error envelopes have no `data`). Previously emitted rows
238
+ remain partial results. A failed summary retains any
239
+ `transport/cancel-unacknowledged` warning as well. Preflight failures emit
240
+ one error envelope without starting a stream.
241
+ - Structured streams put **all** envelopes, including errors, on stdout as
242
+ NDJSON and exit nonzero for any failure, even after successful rows. Text
243
+ mode uses the same outcome/exit rules. `--raw` retains bare payloads and
244
+ stderr errors; it does not provide the structured stream contract.
245
+
246
+ `api` never edits a change feed to make it safer; it says what RouterOS will
247
+ not report as tips (CHR 7.23.7 + 7.24.5). Because that advice depends only on
248
+ the request, it arrives **first**: a leading envelope with
249
+ `meta.operation.stream.kind=notice`, `data: null` and the `tips`, before any
250
+ reply — so a filtered watch that never gets a reply still shows it. A notice is
251
+ not a reply and is not counted in `frames`/`rows`; a stream with no advice has
252
+ no notice. The same tips are repeated on the terminal summary, successful or
253
+ failed. `--raw` omits the notice.
254
+
255
+ - `tip/filtered-follow` — a `listen` (or `follow`) with a query reports
256
+ neither rows leaving the filter nor deletes. Follow unfiltered and filter
257
+ on the client, or follow one row (`<menu>/*id/listen`). Stateful filtered
258
+ follow belongs to `retrieve --follow` (#396/#397).
259
+ - `tip/follow-proplist` — a `--proplist` without `.id,.dead` makes RouterOS
260
+ strip both from a delete frame, even under `?.id=`: the delete arrives as an
261
+ `!re` with no attributes. Add `.id,.dead` to see deletes.
262
+
263
+ `--count N` stops after N **`!re` rows**; `!empty` replies never count, so an
264
+ empty tick cannot use up the count before the data arrives.
265
+ `-f count=3` and `-f duration=2s` are **device arguments**; `--count` and
266
+ `--duration` are **centrs bounds**. The duration window starts after validation,
267
+ excluding its connection/login. With validation disabled, connect/login happen
268
+ inside the window. Duration expiry or Ctrl-C sends `/cancel` and waits up to
269
+ `--timeout` for acknowledgement; otherwise centrs closes locally and reports
270
+ `transport/cancel-unacknowledged`. Count stop sends `/cancel` best-effort and
271
+ closes without waiting (#385, example L5). A local bound is not a device `!done`.
272
+
273
+ For the next wire change on a connected router:
274
+
275
+ ```bash
276
+ centrs api $A /ip/address/listen --port $API_PORT --stream --count 1 --duration 60s
277
+ ```
278
+
279
+ This counts replies rather than asserting readiness or a predicate. Use
280
+ `retrieve --wait` / `--until` / `--until-empty` instead when the router may
281
+ still be booting, when the condition may already be true, when you are on REST,
282
+ or when the state you need is one that `listen` never reports (counters,
283
+ `running`, timeouts)
284
+ ([retrieve → Wait](../retrieve/README.md#wait---wait---until---until-empty)).
192
285
 
193
286
  ## MCP (deferred — forward guidance)
194
287
 
@@ -5,7 +5,8 @@ Each numbered example is an executable spec asserted by CHR integration tests
5
5
  `test/integration/api.test.ts` (rest-api examples),
6
6
  `test/integration/api-native.test.ts` (native-api examples, N…),
7
7
  `test/integration/api-listen.test.ts` (streaming examples, L…), and
8
- `test/integration/api-fanout.test.ts` (fan-out examples, F…). If a line here is not
8
+ `test/integration/api-fanout.test.ts` (fan-out examples, F…), and
9
+ `test/integration/retrieve-query.test.ts` (query grammar examples, QA…). If a line here is not
9
10
  exercised by a test, the test file is wrong; if a line passes only with
10
11
  `--validate=false`, the implementation is wrong (see
11
12
  [`docs/CONSTITUTION.md`](../../docs/CONSTITUTION.md)).
@@ -156,7 +157,8 @@ centrs api $R execute -X POST -f script=':put [/system/identity/get name]' --use
156
157
 
157
158
  Envelope: `ok: true`, `data` is string-shaped and contains the CHR identity;
158
159
  `meta.validation.semantic=not-applicable` (script, not a path) and
159
- `meta.validation.stages` is `offline: passed` then `device: skipped`.
160
+ `meta.validation.stages` is `offline: passed` then live `device: passed` from
161
+ `:put [:parse ...]`.
160
162
 
161
163
  ### 16b. The `/execute` script is gated offline (GH#354)
162
164
 
@@ -173,10 +175,46 @@ Envelope: `ok: false`, `error.code=validation/syntax`,
173
175
  `error.context.span={"start":5,"end":6}`, and **not** a `transport/*` code.
174
176
  `meta.validation.stages` is `offline: failed` then `device: skipped`.
175
177
 
176
- ### 17. `--via rest-api --listen` is rejected
178
+ ### 16c. Offline pass continues to live `:parse`
177
179
 
178
180
  ```bash
179
- centrs api $R ip/address --listen --via rest-api --username $U --password $P
181
+ centrs api $R execute -X POST -f script='/ip/address/add no-such-arg=x' --username $U --password $P --yes
182
+ ```
183
+
184
+ Envelope: `ok: false` with a device validation code
185
+ (`validation/unknown-attribute`, or `validation/syntax` on older RouterOS), and
186
+ `meta.validation.stages` is `offline: passed` then `device: failed`. Offline
187
+ acceptance remains `runtimeAcceptance: not-proven`; it never suppresses the live
188
+ stage.
189
+
190
+ ### 16d. A runtime rejection in `/execute` output is not `ok: true`
191
+
192
+ ```bash
193
+ centrs api $R execute -X POST -f script='/ip/service/set www-ssl certificate=nope' --username $U --password $P --yes
194
+ ```
195
+
196
+ Envelope: `ok: false`, `error.code=routeros/invalid-value`. `as-string` makes
197
+ the console's own text the reply body, so RouterOS carries this rejection in an
198
+ HTTP-200 `ret`; the validation gate stays `passed` because it did pass — the
199
+ command was rejected at run, not at validation. Ordinary output that merely
200
+ mentions a fault string (`:put "status: no such item appears in help"`) stays
201
+ `ok: true`.
202
+
203
+ ### 16e. Wrong credentials: the device stage is not reached (GH#435)
204
+
205
+ ```bash
206
+ centrs api $R system/identity --username centrs-wrong --password centrs-wrong --json
207
+ ```
208
+
209
+ Envelope: `ok: false`, `error.code=transport/auth-failed`. `meta.validation`
210
+ has no `result`. Its device stage is `skipped` with
211
+ `reason: "not reached: transport/auth-failed"`, because RouterOS answered 401
212
+ before `/console/inspect` could give a verdict.
213
+
214
+ ### 17. `--via rest-api --stream` is rejected
215
+
216
+ ```bash
217
+ centrs api $R ip/address --stream --via rest-api --username $U --password $P
180
218
  ```
181
219
 
182
220
  Envelope: `ok: false`, `error.code=transport/capability-unsupported` (REST cannot
@@ -207,7 +245,7 @@ centrs api $R interface/monitor-traffic -X POST -f interface=ether1 -f duration=
207
245
  ```
208
246
 
209
247
  Envelope: `ok: true`, `data` is an **array** of `.section`-keyed records (one per
210
- ~second), `meta.via=rest-api`. This is an ordinary bounded call — no `--listen`,
248
+ ~second), `meta.via=rest-api`. This is an ordinary bounded call — no `--stream`,
211
249
  no NDJSON. `monitor-traffic` is a command (not `print`/`get`), so it is a
212
250
  `POST` and write-classed (needs `--yes`); centrs never relaxes that to read-only.
213
251
  (REST bounds it at the 60 s cap; native has no cap.)
@@ -239,6 +277,15 @@ key. Contrast example 13, where bare `--raw` skips the preflight entirely. This
239
277
  pair is how you tell a RouterOS rejection from a centrs bug while staying on the
240
278
  bare-passthrough surface.
241
279
 
280
+ ### H1. `--host` stands in for `<router>` (#428)
281
+
282
+ ```bash
283
+ centrs api --host $R system/identity --username $U --password $P --json
284
+ ```
285
+
286
+ `ok: true`, with `data.name` the router's identity. The lone positional is the
287
+ endpoint, and there is no CDB lookup.
288
+
242
289
  ## native-api (`--via native-api`)
243
290
 
244
291
  The same contract over the binary API. Validation still runs through
@@ -308,30 +355,54 @@ stdout is the bare rest-style array (string values); exit code 0.
308
355
  centrs api $A execute -X POST -f script=':put [/system/identity/get name]' --via native-api --port $API_PORT --username $U --password $P --yes
309
356
  ```
310
357
 
311
- Envelope: `ok: true`, `data` contains the CHR identity.
358
+ Envelope: `ok: true`, `data` contains the CHR identity and validation reports
359
+ offline plus live `:parse` stages passed.
360
+
361
+ ### N8b. Native `/execute` also rejects at live `:parse`
362
+
363
+ ```bash
364
+ centrs api $A execute -X POST -f script='/ip/address/add no-such-arg=x' --via native-api --port $API_PORT --username $U --password $P --yes
365
+ ```
366
+
367
+ Envelope: `ok: false`; the accepted version-specific validation code is
368
+ `validation/unknown-attribute` or `validation/syntax`, with stages
369
+ `offline: passed`, `device: failed`.
370
+
371
+ ### N8c. Native `/execute` runtime rejection fails the envelope
372
+
373
+ ```bash
374
+ centrs api $A execute -X POST -f script='/ip/service/set www-ssl certificate=nope' --via native-api --port $API_PORT --username $U --password $P --yes
375
+ ```
376
+
377
+ Envelope: `ok: false`, `error.code=routeros/invalid-value`, with the validation
378
+ stages still `passed` — the same normalization as REST, since both adapters
379
+ surface the `as-string` console text as `data`.
312
380
 
313
381
  ## listen / `--stream` (native-api only)
314
382
 
315
- Open-ended follow. `--stream` is the primary flag; `--listen` is an accepted
316
- alias and a trailing `/listen` endpoint segment infers it. Output is NDJSON: one
317
- envelope per change frame, then a terminating summary envelope.
383
+ `api` sends the command as typed (#402). Following changes is the menu's
384
+ `listen`, requested as a `<menu>/listen` endpoint, which implies `--stream` and
385
+ native-api. `--stream` delivers any command's replies incrementally: one NDJSON
386
+ envelope per wire reply (`!re` row or `!empty`), then a terminating summary.
387
+ `--listen` is removed (`usage/removed-flag`, unit- and smoke-tested with its
388
+ exact replacement).
318
389
 
319
- ### L1. `--stream` streams a change as an NDJSON frame, then a summary
390
+ ### L1. A `/listen` stream emits a change as an NDJSON frame, then a summary
320
391
 
321
392
  ```bash
322
- centrs api $A ip/address --stream --count 1 --via native-api --port $API_PORT --username $U --password $P
393
+ centrs api $A ip/address/listen --count 1 --via native-api --port $API_PORT --username $U --password $P
323
394
  ```
324
395
 
325
396
  While listening, the harness adds an address over REST. stdout is NDJSON: at least
326
- one envelope frame for the new row (`meta.operation.stream.kind=frame`), then a
327
- final summary envelope (`meta.operation.stream.kind=summary`,
328
- `data.stopReason=count-reached`, `data.frames>=1`). Exit code 0 (stream started
329
- cleanly).
397
+ one envelope frame for the new row (`meta.operation.stream.kind=frame`,
398
+ `reply=re`), then a final summary envelope (`meta.operation.stream.kind=summary`,
399
+ `meta.operation.stream.stopReason=count-reached`,
400
+ `meta.operation.stream.rows>=1`). Exit code 0 (successful local count stop).
330
401
 
331
402
  ### L2. A deletion frame carries `.dead`
332
403
 
333
404
  ```bash
334
- centrs api $A ip/address --stream --duration 3s --via native-api --port $API_PORT --username $U --password $P
405
+ centrs api $A ip/address/listen --duration 3s --via native-api --port $API_PORT --username $U --password $P
335
406
  ```
336
407
 
337
408
  The harness removes a pre-seeded address; an emitted frame's record carries
@@ -350,11 +421,174 @@ No `--stream` / `--via` given; the `/listen` endpoint infers `--stream` and
350
421
  ### L4. Bounded `--duration` reports its stop reason
351
422
 
352
423
  ```bash
353
- centrs api $A ip/address --stream --duration 2s --via native-api --port $API_PORT --username $U --password $P
424
+ centrs api $A ip/address/listen --duration 2s --via native-api --port $API_PORT --username $U --password $P
354
425
  ```
355
426
 
356
427
  With no change during the window, the stream ends after ~2 s with a summary
357
- envelope whose `data.stopReason=duration-elapsed`. Exit code 0.
428
+ envelope whose `meta.operation.stream.stopReason=duration-elapsed`, `rows=0`,
429
+ `empty=1`. Exit code 0. RouterOS answers the cancel of a listen that sent
430
+ nothing with interrupted, `!empty`, `!done`: that `!empty` is the one frame,
431
+ with `reply=empty`, `data=null` and `afterStop: true`.
432
+
433
+ ### L5. A router that ignores `/cancel` cannot hold the process open
434
+
435
+ ```bash
436
+ centrs api $A ip/address --stream --duration 200ms --timeout 1s --via native-api --port $API_PORT --username $U --password $P
437
+ ```
438
+
439
+ A stream stopped by `--duration` or Ctrl-C sends `/cancel` and waits up to
440
+ `--timeout` for the router's acknowledgement (the interrupted `!trap` and
441
+ `!done`). If none arrives, centrs closes the session itself. The summary still
442
+ reports its stop reason (`duration-elapsed` or `interrupted`), exit code is 0,
443
+ and `warnings` carries `transport/cancel-unacknowledged`. A router that answers
444
+ inside the window gets no warning. `--count` sends `/cancel` without waiting,
445
+ so it exits at once and never warns (#385).
446
+
447
+ RouterOS on CHR always acknowledges `/cancel`, so this example runs against a
448
+ loopback native-API peer that ignores it, as a real CLI process:
449
+ `test/integration/api-stream-cancel.test.ts` covers `--duration`, SIGINT,
450
+ `--count` and the cooperative control. The session-level cases (silent peer,
451
+ chatty peer, trap without `!done`, close after cancel) are in
452
+ `test/unit/native-api.test.ts`.
453
+
454
+ ### L6. Finite ping completes naturally
455
+
456
+ ```bash
457
+ centrs api $A tool/ping -X POST -f address=10.0.2.2 -f count=3 --stream --yes --via native-api --port $API_PORT --username $U --password $P
458
+ ```
459
+
460
+ Three reply frames, then a successful summary with `meta.operation.stream.stopReason=completed`.
461
+ The device's `count=3` completes the command; no centrs `--count` is needed.
462
+
463
+ ### L7. Monitor replies preserve `.section`
464
+
465
+ ```bash
466
+ centrs api $A interface/monitor-traffic -X POST -f interface=ether1 -f duration=2s --stream --yes --via native-api --port $API_PORT --username $U --password $P
467
+ ```
468
+
469
+ One or more rows carrying device `.section` values, then `completed`.
470
+
471
+ ### L8. Explicit print interval streams instead of listening
472
+
473
+ ```bash
474
+ centrs api $A system/resource/print -X POST -f interval=1 --stream --count 2 --via native-api --port $API_PORT --username $U --password $P
475
+ ```
476
+
477
+ Two reply frames and a `count-reached` summary. POST print stays read-only.
478
+
479
+ ### L9. Projection is sent as typed; the tip says what it hides
480
+
481
+ ```bash
482
+ centrs api $A ip/address/listen --proplist address --duration 3s --via native-api --port $API_PORT --username $U --password $P
483
+ centrs api $A ip/address/listen --proplist address,.id,.dead --duration 3s --via native-api --port $API_PORT --username $U --password $P
484
+ ```
485
+
486
+ The harness deletes a seeded address during each. With `address` alone no
487
+ frame carries `.dead` for it, and the leading notice and the summary carry
488
+ `tip/follow-proplist`.
489
+ With `address,.id,.dead` the delete frame is `{ ".id", ".dead": "true" }` and
490
+ there is no tip.
491
+
492
+ ### L10. A filtered listen is sent as typed, with a tip
493
+
494
+ ```bash
495
+ centrs api $A ip/address/listen --query interface=ether1 --duration 3s --via native-api --port $API_PORT --username $U --password $P
496
+ ```
497
+
498
+ The harness deletes a seeded `ether1` address while listening. RouterOS sends
499
+ nothing for it: the only frame is the cancellation `!empty` (`afterStop`).
500
+ The first line is a `notice` with `tip/filtered-follow`; the summary is `ok`
501
+ and repeats it. `--raw-query` is sent
502
+ the same way.
503
+
504
+ ### L11. Command attributes are validated
505
+
506
+ ```bash
507
+ centrs api $A tool/ping -X POST -f no-such-arg=x --stream --yes --via native-api --port $API_PORT --username $U --password $P
508
+ ```
509
+
510
+ `validation/unknown-attribute`; no ping stream starts.
511
+
512
+ ### L12. Streaming keeps the mutator confirmation gate
513
+
514
+ ```bash
515
+ centrs api $A system/license/renew -X POST --stream --via native-api --port $API_PORT --username $U --password $P
516
+ ```
517
+
518
+ Non-interactively, `usage/confirmation-required`; no renew operation is sent.
519
+
520
+ ### L13. Mutation streams retain method mapping and terminal `ret`
521
+
522
+ ```bash
523
+ centrs api $A ip/address -X PUT -f address=198.51.100.34/32 -f interface=ether1 --stream --yes --via native-api --port $API_PORT --username $U --password $P
524
+ ```
525
+
526
+ A successful zero-row summary with `data.done.ret` naming the created address.
527
+ The harness retrieves that ID to check its address, then streams DELETE on that
528
+ ID and requires natural completion. Neither mutation becomes a listen.
529
+
530
+ ### L14. Streamed `/execute` runtime errors fail the terminal summary
531
+
532
+ ```bash
533
+ centrs api $A execute -X POST --stream --via native-api --yes --json -f 'script=/ip/service/set www-ssl certificate=nope' --port $API_PORT --username $U --password $P
534
+ ```
535
+
536
+ With validation enabled and no `nope` certificate, syntax validation passes but
537
+ RouterOS rejects the value at runtime. The sole terminal envelope is `ok: false`,
538
+ `routeros/invalid-value`, and `meta.operation.stream.stopReason=routeros-error`; the CLI exits nonzero.
539
+ Ordinary stdout such as `:put "status: no such item appears in ordinary output"`
540
+ still completes successfully with its text in `data.done.ret`.
541
+
542
+ ### L15. An addressed listen reports its deletion; a projection hides it
543
+
544
+ ```bash
545
+ centrs api $A "ip/address/$ID/listen" --duration 3s --via native-api --port $API_PORT --username $U --password $P
546
+ centrs api $A "ip/address/$ID/listen" --duration 3s --proplist address --via native-api --port $API_PORT --username $U --password $P
547
+ ```
548
+
549
+ For each variant the harness seeds an address, starts the addressed listen
550
+ (`?.id=$ID`), and deletes it after the listening barrier. Without projection
551
+ the stream emits `.id=$ID` with `.dead=true` and no tip. With `--proplist
552
+ address` RouterOS strips both, so the delete is one `!re` with no attributes
553
+ (`data={}`) and the summary carries `tip/follow-proplist`.
554
+
555
+ ### L16. POST print preserves query/projection in either delivery mode
556
+
557
+ ```bash
558
+ centrs api $A ip/address/print -X POST --query address=198.51.100.34/32 --proplist address --via native-api --port $API_PORT --username $U --password $P
559
+ centrs api $A ip/address/print -X POST --query address=198.51.100.34/32 --proplist address --stream --via native-api --port $API_PORT --username $U --password $P
560
+ ```
561
+
562
+ The harness seeds the L13 address. Both modes return only that address and only
563
+ the requested `address` field; the streamed form then completes naturally. The
564
+ one-shot REST equivalent is also checked, using the same query/projection.
565
+
566
+ ### L17. A GET stream is one literal print
567
+
568
+ ```bash
569
+ centrs api $A ip/address --stream --via native-api --port $API_PORT --username $U --password $P
570
+ ```
571
+
572
+ The print completes on its own (`stopReason=completed`) with one `re` frame
573
+ per row (`rows` equals a one-shot GET's row count); a leading notice and the
574
+ summary carry `tip/stream-print` naming `ip/address/listen`. It never becomes a listen.
575
+
576
+ ### L18. Zero-row ticks are `!empty` frames and do not use up `--count`
577
+
578
+ ```bash
579
+ centrs api $A ip/firewall/raw/print -X POST -f interval=1 --stream --count 1 --duration 2500ms --via native-api --port $API_PORT --username $U --password $P
580
+ ```
581
+
582
+ On the CHR's empty raw table, each tick is a frame with `reply=empty` and
583
+ `data=null`; at least two arrive. They do not count toward `--count 1`, so the
584
+ stream ends on `duration-elapsed` with `rows=0`.
585
+
586
+ Loopback regressions in `test/integration/api-stream-command.test.ts` also
587
+ exercise natural/empty completion, `!empty` frames, row-only `--count`,
588
+ `afterStop`, the stream tips, `--format ndjson`, terminal `ret`,
589
+ first/midstream trap, unsolicited interruption, fatal/close, and nonzero
590
+ process exit with the failure summary on stdout. Device-dependent examples
591
+ L6–L18 run in `test/integration/api-listen.test.ts` with validation enabled.
358
592
 
359
593
  ## fanout (multi-target, F…)
360
594
 
@@ -364,7 +598,7 @@ the live CHR (comment `board=chr`), record 1 is an unreachable host (comment
364
598
  (`data = { summary, targets[] }`; outer `ok` = orchestration success; per-target
365
599
  failures are inner `ok:false`), with the granular exit code (0 all-ok / 2 partial /
366
600
  1 all-failed or orchestration error). See `test/integration/api-fanout.test.ts`.
367
- The `--raw`/`--listen` + fan-out guards are network-free and validated in
601
+ The `--raw`/`--stream` + fan-out guards are network-free and validated in
368
602
  `test/integration/cli-smoke.test.ts`.
369
603
 
370
604
  ### F1. `--group` fans a GET out; a dead target is an inner failure
@@ -457,6 +691,28 @@ target fails deterministically: inner `ok:false` with
457
691
  `error.code=target/unresolved` (never a `transport/dns` attempt on `"__default__"`).
458
692
  `data.summary = { total: 1, ok: 0, failed: 1 }`. Exit code 1 (every target failed).
459
693
 
694
+ ## `--query` grammar (#397)
695
+
696
+ `--query` takes the `print where` grammar shared with `retrieve --query`. The
697
+ test seeds `qa-397` address-list rows, one of them disabled with comment `x>y`.
698
+
699
+ ### QA1. A bare boolean is `=yes`, on either transport
700
+
701
+ ```bash
702
+ centrs api $R ip/firewall/address-list --query 'list=qa-397 and disabled' --proplist address --username $U --password $P
703
+ centrs api $A ip/firewall/address-list --via native-api --query 'list=qa-397 and disabled' --proplist address --port $API_PORT --username $U --password $P
704
+ ```
705
+
706
+ Both return only the disabled row's address.
707
+
708
+ ### QA2. A value containing an operator stays one value
709
+
710
+ ```bash
711
+ centrs api $R ip/firewall/address-list --query 'comment=x>y' --proplist address --username $U --password $P
712
+ ```
713
+
714
+ Returns the row whose comment is `x>y`; the query word sent is `comment=x>y`.
715
+
460
716
  ## quickchr targets (#134)
461
717
 
462
718
  `$NAME` is the machine name of a running quickchr-managed CHR. Host/port/auth