@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
@@ -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
 
@@ -171,35 +188,100 @@ the RouterOS error payload to stderr with a nonzero exit; a centrs-side failure
171
188
  with no RouterOS response prints a compact `{code,message}` to stderr. Exit code is
172
189
  `0` iff `ok`.
173
190
 
174
- ## Multi-frame results: bounded vs open-ended
175
-
176
- RouterOS produces multi-frame output two ways, and `api` treats them differently:
177
-
178
- - **Bounded `duration=` / `monitor` commands are ordinary `api` calls.** Both
179
- transports return the accumulated frames as a normal **array** (REST regularizes
180
- the native `!re` frames into `.section`-keyed records; native's reader collects
181
- them until the command completes). No flag needed — e.g.
182
- `centrs api $R interface/monitor-traffic -f interface=ether1 -f duration=5s`
183
- returns a `.section` array. REST bounds these at the **60 s** cap (a longer
184
- `duration=` terminates early with an error); native has no cap. This is **not**
185
- NDJSON.
186
- - **Open-ended follow is `--stream` (alias `--listen`), native-api only.** Only
187
- the native API `/listen` command follows indefinitely (REST cannot — the 60 s
188
- cap). Each `!re` becomes one rest-style envelope frame (one NDJSON line; a
189
- deletion's `.dead=true` flag is preserved); the stream ends with a summary
190
- envelope (`data.stopReason` ∈ `count-reached`/`duration-elapsed`/`interrupted`/
191
- `transport-error`, plus `frames` and `durationMs`). `--duration`/`--count`
192
- bound it; Ctrl-C stops and still emits the summary. The `--duration` window
193
- starts once validation is done, so it excludes validation (and the connect and
194
- login validation needs). With `--no-validate`, connect and login happen inside
195
- the window. Stopping on `--duration` or Ctrl-C sends RouterOS `/cancel`, and
196
- centrs waits up to `--timeout` for the router to acknowledge it. A router that
197
- never does gets its session closed locally, and the summary carries a
198
- `transport/cancel-unacknowledged` warning (#385). Reaching `--count` sends
199
- `/cancel` best-effort and closes the session without waiting, so it never
200
- carries that warning (examples L5). `--via rest-api --stream` →
201
- `transport/capability-unsupported`. The exit code reflects whether the stream
202
- *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)).
203
285
 
204
286
  ## MCP (deferred — forward guidance)
205
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)).
@@ -199,10 +200,21 @@ command was rejected at run, not at validation. Ordinary output that merely
199
200
  mentions a fault string (`:put "status: no such item appears in help"`) stays
200
201
  `ok: true`.
201
202
 
202
- ### 17. `--via rest-api --listen` is rejected
203
+ ### 16e. Wrong credentials: the device stage is not reached (GH#435)
203
204
 
204
205
  ```bash
205
- centrs api $R ip/address --listen --via rest-api --username $U --password $P
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
206
218
  ```
207
219
 
208
220
  Envelope: `ok: false`, `error.code=transport/capability-unsupported` (REST cannot
@@ -233,7 +245,7 @@ centrs api $R interface/monitor-traffic -X POST -f interface=ether1 -f duration=
233
245
  ```
234
246
 
235
247
  Envelope: `ok: true`, `data` is an **array** of `.section`-keyed records (one per
236
- ~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`,
237
249
  no NDJSON. `monitor-traffic` is a command (not `print`/`get`), so it is a
238
250
  `POST` and write-classed (needs `--yes`); centrs never relaxes that to read-only.
239
251
  (REST bounds it at the 60 s cap; native has no cap.)
@@ -265,6 +277,15 @@ key. Contrast example 13, where bare `--raw` skips the preflight entirely. This
265
277
  pair is how you tell a RouterOS rejection from a centrs bug while staying on the
266
278
  bare-passthrough surface.
267
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
+
268
289
  ## native-api (`--via native-api`)
269
290
 
270
291
  The same contract over the binary API. Validation still runs through
@@ -359,26 +380,29 @@ surface the `as-string` console text as `data`.
359
380
 
360
381
  ## listen / `--stream` (native-api only)
361
382
 
362
- Open-ended follow. `--stream` is the primary flag; `--listen` is an accepted
363
- alias and a trailing `/listen` endpoint segment infers it. Output is NDJSON: one
364
- 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).
365
389
 
366
- ### 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
367
391
 
368
392
  ```bash
369
- 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
370
394
  ```
371
395
 
372
396
  While listening, the harness adds an address over REST. stdout is NDJSON: at least
373
- one envelope frame for the new row (`meta.operation.stream.kind=frame`), then a
374
- final summary envelope (`meta.operation.stream.kind=summary`,
375
- `data.stopReason=count-reached`, `data.frames>=1`). Exit code 0 (stream started
376
- 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).
377
401
 
378
402
  ### L2. A deletion frame carries `.dead`
379
403
 
380
404
  ```bash
381
- 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
382
406
  ```
383
407
 
384
408
  The harness removes a pre-seeded address; an emitted frame's record carries
@@ -397,11 +421,14 @@ No `--stream` / `--via` given; the `/listen` endpoint infers `--stream` and
397
421
  ### L4. Bounded `--duration` reports its stop reason
398
422
 
399
423
  ```bash
400
- 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
401
425
  ```
402
426
 
403
427
  With no change during the window, the stream ends after ~2 s with a summary
404
- 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`.
405
432
 
406
433
  ### L5. A router that ignores `/cancel` cannot hold the process open
407
434
 
@@ -424,6 +451,145 @@ loopback native-API peer that ignores it, as a real CLI process:
424
451
  chatty peer, trap without `!done`, close after cancel) are in
425
452
  `test/unit/native-api.test.ts`.
426
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.
592
+
427
593
  ## fanout (multi-target, F…)
428
594
 
429
595
  These run against a CDB fixture with two records sharing group `$G`: record 0 is
@@ -432,7 +598,7 @@ the live CHR (comment `board=chr`), record 1 is an unreachable host (comment
432
598
  (`data = { summary, targets[] }`; outer `ok` = orchestration success; per-target
433
599
  failures are inner `ok:false`), with the granular exit code (0 all-ok / 2 partial /
434
600
  1 all-failed or orchestration error). See `test/integration/api-fanout.test.ts`.
435
- 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
436
602
  `test/integration/cli-smoke.test.ts`.
437
603
 
438
604
  ### F1. `--group` fans a GET out; a dead target is an inner failure
@@ -525,6 +691,28 @@ target fails deterministically: inner `ok:false` with
525
691
  `error.code=target/unresolved` (never a `transport/dns` attempt on `"__default__"`).
526
692
  `data.summary = { total: 1, ok: 0, failed: 1 }`. Exit code 1 (every target failed).
527
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
+
528
716
  ## quickchr targets (#134)
529
717
 
530
718
  `$NAME` is the machine name of a running quickchr-managed CHR. Host/port/auth
@@ -65,7 +65,15 @@ inert. (Earlier drafts said "the comment is not a lookup key" — superseded.)
65
65
  When `<router>` matches an entry (by `target` or a lookup key), credentials and
66
66
  other CDB-resident fields load from that entry. If nothing matches, the call
67
67
  still proceeds but `--username` / `--password` (or `CENTRS_USERNAME` /
68
- `CENTRS_PASSWORD`, or a `__default__` record) become mandatory.
68
+ `CENTRS_PASSWORD`, or a `__default__` record) become mandatory. The one
69
+ exception is a **pinned** CDB (`--cdb-file`, `CENTRS_CDB_FILE`,
70
+ `CENTRS_CDB_PASSWORD`, or a `centrs.env` CDB path) with no `__default__`
71
+ record: a DNS name or identity that matches nothing fails closed with
72
+ `cdb/not-found-target`, because it can't be told apart from a mistyped record.
73
+ An address that matches nothing still proceeds as a literal host: an IPv4 or
74
+ IPv6 literal (optionally with a port, IPv6 as `[…]:port`), an `http(s)://` URL,
75
+ or a MAC. On MCP every target must still be registered
76
+ (`cdb/target-not-registered`).
69
77
 
70
78
  ### Ambiguity
71
79
 
@@ -326,8 +334,8 @@ runs produce stable diffs.
326
334
  - A multi-target **write** is gated by `--yes`, confirmed once up front (not per
327
335
  target); without it the error/tip names the blast radius (how many routers).
328
336
  `--force` stays scoped to destructive `devices` CDB mutations.
329
- - Commands that are not safe to fan out (`terminal`, and `api --stream` /
330
- `--listen`) reject a multi-target selection with `usage/fanout-not-supported`.
337
+ - Commands that are not safe to fan out (`terminal`, and `api --stream` or a
338
+ `/listen` endpoint) reject a multi-target selection with `usage/fanout-not-supported`.
331
339
 
332
340
  ## Subcommands
333
341
 
@@ -6,7 +6,7 @@ CLI-shaped commands — there is no separate `update` command.
6
6
 
7
7
  Status: `rest-api`, `native-api`, `mac-telnet`, and `ssh` are `CHR-passed` (see
8
8
  `docs/MATRIX.md` and `commands/execute/examples.md`, examples 1–11 over REST,
9
- 12–19 over the native API, 20–22 over mac-telnet, and S1–S4 over ssh, green via
9
+ 12–19 over the native API, 20–22 over mac-telnet, and S1–S5 over ssh, green via
10
10
  `bun run test:integration`), including multi-target fan-out (see **Target
11
11
  selection**, examples F1–F5). `romon` and `winbox-terminal` remain
12
12
  `not-started`. SNMP is retrieve-only and rejects `execute`.
@@ -136,14 +136,28 @@ selection**, examples F1–F5). `romon` and `winbox-terminal` remain
136
136
  mac-telnet. Callers that want IP-level execution may opt into ARP-based
137
137
  MAC → IP resolution with `--resolve arp` (plus `--via native-api`/`rest-api`)
138
138
  before protocol selection.
139
+ - An explicit `http(s)://host[:port]` target (positional, `--host`, or
140
+ `CENTRS_HOST`) names the REST service: with no `via` pinned anywhere,
141
+ auto-selection picks `rest-api` and keeps the URL's scheme and port
142
+ (`meta.settings.via` source `default` / `via:target-url`). Otherwise
143
+ auto-selection picks native-api. On the IP transports (native-api, REST,
144
+ ssh), a port typed into the target (`host:port`, `http://host:port`,
145
+ including an explicit `:80`/`:443`) is honored. The port settings ladder
146
+ (`--port`, `CENTRS_PORT`, a CDB `port=` comment, config) wins over it, and
147
+ the transport default comes last. The port carried by a matched CDB
148
+ record's `target` is not applied to native-api. mac-telnet addresses the MAC,
149
+ so its `--host`/`--port` are only the UDP delivery endpoint.
139
150
  - When the resolved transport is mac-telnet and **no** username or password is
140
151
  resolved from any source, execute emits a `tip/mac-telnet-no-credentials`
141
152
  pointer: mac-telnet logs in with RouterOS credentials over **MTWEI** (MD5 is
142
153
  refused by current RouterOS), so an empty login is rejected at connect time.
143
154
  It is a tip, not an error — the call is still attempted.
144
155
  - Default output is human-readable `text`; pass `--json`/`--format json`
145
- (or set `CENTRS_FORMAT=json`) for the structured envelope. Errors always
146
- render as `[code] summary` + `Fix:` lines in text mode.
156
+ (or set `CENTRS_FORMAT=json`) for the structured envelope. In text mode every
157
+ command renders errors the same way (`formatCentrsErrorText`): `[code] summary`,
158
+ then `At:` (RouterOS position or offline byte span), `Device said:` (RouterOS's
159
+ own words), `Fix:` and `Details:` when present. `--verbose` adds the full
160
+ `context`.
147
161
  - CDB comment-kv metadata may provide per-target `via`, `port`, and `ssh-key`
148
162
  overrides; CLI/API arguments still win.
149
163
  - RoMON and WinBox Terminal are lower priority than mac-telnet until their
@@ -189,6 +203,10 @@ with the first SSH consumer, `transfer`/sftp; `execute / ssh` (this command) and
189
203
  `terminal / ssh` followed as separate transports over the same host-`ssh`
190
204
  plumbing (`src/protocols/ssh.ts`) — all three SSH cells are `CHR-passed`.
191
205
 
206
+ A selected key adds `IdentitiesOnly=yes`, preventing fallback to unrelated
207
+ agent keys. Explicit OpenSSH `IdentityFile` configuration still applies; see
208
+ [`terminal` → SSH key selection](../terminal/README.md#ssh-key-selection).
209
+
192
210
  Over SSH — like mac-telnet — `execute` is a **console transport**: it runs a
193
211
  CLI line and returns text (`SshExecClient`, one `ssh user@host "<command>"` per
194
212
  command; validation reuses the same `:put [:parse …]` gate), so structured
@@ -229,6 +247,16 @@ many tokens):
229
247
  the command; no `--` needed). One `--quickchr` stays single-target; repeating
230
248
  it fans out; mixing it with positionals or CDB selectors is
231
249
  `usage/conflicting-flags` (constitution: resolution providers).
250
+ - `execute --host http://192.0.2.10:8080 '/system/resource/print'` (or
251
+ `execute --host <host|url> -- <command>`) — the `--host` **flag** stands in
252
+ for a missing `<target>`: a lone positional is the command, and nothing
253
+ before `--` is fine. There is no `<target>`, so there is no CDB lookup and no
254
+ record credential can be picked up; auth comes from flags, env or config.
255
+ Two or more positionals without `--` keep the legacy split, so
256
+ `execute <mac> <command> --via mac-telnet --host <udp-host>` (the mac-telnet
257
+ delivery override) is unchanged. `CENTRS_HOST` never changes positional
258
+ parsing: `execute '<command>'` with it set still reads the lone positional as
259
+ the target.
232
260
 
233
261
  Because `execute` is the primary **write** path, a write-shaped fan-out is gated
234
262
  by **`--yes`**, confirmed **once** up front; without it