@tikoci/centrs 0.1.1 → 0.1.2

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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,52 @@ documenting cross-cutting shifts that affect contributors and consumers.
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.1.2 — 2026-06-26
12
+
13
+ Patch release. `@tikoci/centrs@0.1.2`.
14
+
15
+ ### Added
16
+
17
+ - **`btest` TCP multi-connection fan-out is `CHR-passed`.** `centrs btest
18
+ --connection-count N` now realizes the full fan-out: the client reads the
19
+ session token from the primary's OK handshake and opens `N-1` additional TCP
20
+ data connections, each sending the grounded secondary-join
21
+ (`[token:u16 BE][0x02][0…]`). All connections drive into one shared
22
+ `BandwidthCounters` so throughput aggregates. Wire format confirmed
23
+ byte-for-byte against RouterOS 7.23.1 (the server sends a 4-byte HELLO per
24
+ connection then waits for all to join before streaming — sequential opens must
25
+ not block on a reply, or the first secondary deadlocks the server). Unauthenticated
26
+ TCP fan-out works end-to-end; authenticated (EC-SRP5) sessions stay single-stream
27
+ and warn when the realized `activeConnections` falls short of the requested count.
28
+ `BtestRunSummary` / `BtestClientData` gain an `activeConnections` field.
29
+ Grounded via unit tests (secondary-join byte format, loopback fan-out) and
30
+ integration example 11 on real CHR (4 connections open, data flows on each).
31
+
32
+ ### Fixed
33
+
34
+ - **`btest` UDP client receive reported 0 bps.** The UDP socket was bound with
35
+ `connect()` (filtering by remote address/port), which dropped the
36
+ bandwidth-server's probe packets because they arrive from the server's data
37
+ port, not the control port. The socket is now unconnected; `--remote-udp-tx-size`
38
+ emits a warning when unset (needed by the server-push direction only).
39
+ UDP `receive` and `both` cycles are now CHR-gated on real RouterOS (rx ≈ 474 KB
40
+ in CI).
41
+ - **`btest --connection-count` was silently ignored.** The count was validated and
42
+ accepted but never encoded into command byte 3 of the client hello, so RouterOS
43
+ always saw `count=0` and opened a single stream. The value is now threaded through
44
+ `BtestClientSessionOptions` → `clientHandshake`. A
45
+ `routeros/btest-connection-count-single-stream` warning fires for
46
+ `connection-count > 1` sessions that fall back to one stream (e.g. EC-SRP5 auth).
47
+ - **`btest direction=both` TX was unbounded, starving RX.** In TCP `both` the client
48
+ ran no status reader, so TX had no feedback, saturated the link, and collapsed
49
+ server→client RX. The `tcpRxLoop` now demuxes embedded 12-byte status frames
50
+ in-place by their structural marker (`0x07 ?? 00 00`) and passes them through the
51
+ shared `applyStatusFeedback` / `adaptTxFromStatus` gate used by
52
+ `tcpStatusReaderLoop`. A per-frame plausibility guard (CPU byte ≤ 100; sane
53
+ sequence ceiling) filters false matches from `--random-data` bulk streams.
54
+ Grounded: a CHR `both` run fires 5 feedback events (server bytesReceived
55
+ 78840 → 643860) and paces TX; RX is sustained instead of collapsing.
56
+
11
57
  ## 0.1.1 — 2026-06-22
12
58
 
13
59
  Patch release. `@tikoci/centrs@0.1.1`.
@@ -214,8 +260,8 @@ First published pre-release (odd minor → npm `next`). `@tikoci/centrs@0.1.0`.
214
260
  result envelope, error model + URL scheme, settings precedence, identity,
215
261
  protocol selection, done = CHR-passed).
216
262
  - `docs/MATRIX.md` — command×protocol grid; the only status surface.
217
- - `commands/<name>/{README,examples}.md` — per-command design and
218
- executable example list that gates "done."
263
+ - `commands/<name>/README.md` and `commands/<name>/examples.md` —
264
+ per-command design and executable example list that gates "done."
219
265
  - Done definition codified in
220
266
  `.github/instructions/done-definition.instructions.md`: a feature is done
221
267
  when its examples are green on real CHR via `bun run test:integration`.
package/GLOSSARY.txt CHANGED
@@ -25,6 +25,11 @@
25
25
  # https://github.com/tikoci/restraml
26
26
  # %lsp-routeros-ts: RouterOS parser and language-server utilities.
27
27
  # https://github.com/tikoci/lsp-routeros-ts
28
+ # Language Server Protocol — the "lsp" in lsp-routeros-ts, referenced as a
29
+ # grounding source for /console/inspect client patterns (e.g. commands/api).
30
+ lsp
31
+ # RouterOS maximum transmission unit property (e.g. mtu, actual-mtu).
32
+ mtu
28
33
  # %donny: Dude/discovery grounding for RouterOS device inventories.
29
34
  # https://github.com/tikoci/donny
30
35
  # %m2ir: protocol IR (WinBox, nova messages) + the declarative schema-as-data
@@ -89,6 +94,8 @@ datagram
89
94
  davidanson
90
95
  DDNS
91
96
  defconf
97
+ demux
98
+ demuxes
92
99
  dogtail
93
100
  donny
94
101
  dotenv
@@ -146,6 +153,7 @@ quickchr
146
153
  RAML
147
154
  readcdb
148
155
  remotehub
156
+ retarget
149
157
  restraml
150
158
  romon
151
159
  ros
@@ -418,3 +426,11 @@ trippable
418
426
  # btest integration-test fixtures.
419
427
  btestuser
420
428
  btestpass
429
+
430
+ # RouterOS scripting builtin referenced in commands/api grounding notes
431
+ # (:toarray coerces a comma string into an array; explains the inspect path form).
432
+ toarray
433
+
434
+ # Logic shorthand ("if and only if") used in api write-gate doc comments; cspell's
435
+ # markdown dictionary knows it but the TypeScript one does not.
436
+ iff
package/README.md CHANGED
@@ -32,8 +32,8 @@ product; without them this would just be a worse `curl`.
32
32
  | Command | Purpose |
33
33
  | ---------- | ------- |
34
34
  | `retrieve` | Read RouterOS state over REST/native API and SNMP OID/MIB values. |
35
- | `stream` | Follow live RouterOS output (`print follow`/monitor/sniffer) as an NDJSON stream of envelopes. |
36
35
  | `execute` | Run RouterOS CLI-shaped read/write commands (add/set/remove) over native API/REST/L2 surfaces. |
36
+ | `api` | Structured RouterOS API passthrough (gh-api style): one command per operation, structured in/out, can write, over REST or native API. Open-ended follow is `api … --stream` (native API; NDJSON). |
37
37
  | `terminal` | Open an interactive console, primarily SSH or MAC-Telnet. |
38
38
  | `check` | Probe reachability and management protocol availability. |
39
39
  | `devices` | View and maintain the CDB-backed device registry (the only writer). |
@@ -18,8 +18,9 @@ README's "Status" line must agree with it.
18
18
 
19
19
  ## Verb vocabulary
20
20
 
21
- Commands are centrs **verbs** (`retrieve`, `stream`, `execute`, `devices`,
22
- `discover`, `check`, `terminal`, `settings`), never one tool per RouterOS command. Sub-verbs and their
21
+ Commands are centrs **verbs** (`retrieve`, `execute`, `api`, `devices`,
22
+ `discover`, `check`, `terminal`, `settings`), never one tool per RouterOS command. Open-ended follow
23
+ is folded into `api --stream` (no separate `stream` verb). Sub-verbs and their
23
24
  aliases are canonical across CLI / API / MCP:
24
25
 
25
26
  | Canonical | Aliases | Meaning |
@@ -0,0 +1,212 @@
1
+ # commands/api — local constraints + grounded RouterOS facts
2
+
3
+ This file is the **durable home** for the RouterOS API-behavior facts the `api`
4
+ command depends on. It exists so the knowledge survives context resets: a fresh
5
+ agent picking up any `api` phase reads this first. Add a fact here only with a
6
+ source (a doc URL, a `tikoci/*` project, or a CHR run); follow the global
7
+ grounding discipline — one CHR result is a signal, reproduce before treating it as
8
+ fact.
9
+
10
+ Read order for any `api` work: `docs/CONSTITUTION.md` → `commands/api/README.md` →
11
+ `commands/api/examples.md` → this file.
12
+
13
+ ## Local constraints
14
+
15
+ - `api` is the structured one-command-per-operation surface (the verb trichotomy
16
+ in `README.md`). It must not grow code-block / multi-command behavior — that is
17
+ `execute`'s job.
18
+ - Reuse the shared cores, do not fork: `src/core/inspect.ts` (the
19
+ `/console/inspect` client) and `src/core/fanout.ts` (the fan-out engine).
20
+ - Honor `--via` literally; never silently downgrade transport.
21
+
22
+ ## Grounded facts (doc sources)
23
+
24
+ REST API — <https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API> ·
25
+ native API — <https://help.mikrotik.com/docs/spaces/ROS/pages/47579160/API> ·
26
+ inspect-client TS patterns — `tikoci/lsp-routeros-ts`
27
+ (`server/src/routeros.ts` `InspectRequest`, `server/src/validation.ts`).
28
+
29
+ - **HTTP method map (REST):** `GET`→print, `PUT`→add (**create**), `PATCH`→set,
30
+ `DELETE`→remove, `POST`→universal (run any console command). PUT — not POST — is
31
+ RouterOS's create.
32
+ - **Multi-frame results: bounded → array; open-ended → native `/listen` only.**
33
+ *Nuance (do not over-read the REST docs).* RouterOS REST **does** run
34
+ `duration=` / `monitor` commands — it regularizes the native `!re` frames into a
35
+ JSON **array of `.section` records** and returns the whole array in one response,
36
+ bounded by the **60 s** REST cap (a command asked to run longer terminates early
37
+ with an error). Native has **no** such cap. So a `duration=`/`monitor` command is
38
+ an **ordinary bounded `api` call** that returns a `.section` array on both
39
+ transports (native's `talk()` accumulates the `!re` until `!done`) — **not**
40
+ NDJSON. The only thing that needs the NDJSON streaming path is **open-ended
41
+ follow** (native `/listen`, which never sends `!done`); REST genuinely cannot do
42
+ open-ended follow. ⇒ `--listen` (open-ended) is native-only; `duration=` commands
43
+ are normal one-shot calls. (CONFIRMED below.)
44
+ - **`/listen` semantics.** Emits `!re` on change; a deleted/disappeared item's
45
+ `!re` carries the dead flag (docs say `=.dead=yes`; **CHR 7.23.1 sends
46
+ `.dead=true`** — see CONFIRMED below); it never self-terminates. Cancel with
47
+ `/cancel tag=<listen-tag>` (the `/cancel` carries its own `.tag`); the listener
48
+ then gets `!trap … message=interrupted` followed by `!done`.
49
+ - **Query.** REST `.query` words are **identical to native `?` words minus the
50
+ `?` prefix** (CONFIRMED below). Forms: eq `name=val`; comparison `>name=val` /
51
+ `<name=val` (operator **before** the name); has-property `name`; absence
52
+ `-name`; stack ops `#!` (NOT top), `#&` (AND), `#|` (OR). Multiple words with no
53
+ stack op are **implicitly AND-ed**. See the centrs mapping section below.
54
+ - **Proplist.** REST `.proplist` accepts `"name,type"` or `["name","type"]`.
55
+ Native: `=.proplist=name,type`.
56
+ - **Object id.** Set/remove by id: REST id-in-URL (`PATCH /rest/ip/address/*1`),
57
+ native `=.id=*1` word. **No native get-one-by-id shorthand** — use
58
+ `print ?.id=*1`. REST has the convenience `GET /rest/ip/address/*1` → a single
59
+ object; native maps it to `print ?.id=*1`.
60
+ - **`/console/inspect` request modes** (`InspectRequest` in lsp): `request ∈
61
+ {child, completion, highlight, syntax}`, with `input?`, `path?`, `.proplist?`,
62
+ `.query?`. `child` lists menus/args (path + attribute validity); `completion`
63
+ + `input=` yields allowed values (tips/warnings); `highlight` + `input=` is
64
+ token-level error detection (lsp's primary validator); `syntax` is the parse
65
+ tree.
66
+
67
+ ## Query mapping (centrs `--query` / `--raw-query`)
68
+
69
+ Two layers, both grounded above:
70
+
71
+ - **`--query` / `--filter`** — structured convenience, **AND-combined** (RouterOS
72
+ default), repeatable. Each maps to one query word:
73
+ - `name=value` → `name=value` (eq)
74
+ - `name!=value` → two words `name=value`, `#!` (eq then NOT-top)
75
+ - `name>value` / `name<value` → `>name=value` / `<name=value`
76
+ - `name` (no `=`) → `name` (has-property)
77
+ Multiple `--query` emit their words in order; RouterOS's implicit AND applies (no
78
+ `#&` injected). `--query` cannot express OR or arbitrary stacks — that is
79
+ `--raw-query`.
80
+ - **`--raw-query <word>`** — power-user escape hatch, repeatable. Each value is a
81
+ **verbatim RouterOS query word** (absence `-name`, OR `#|`, AND `#&`, NOT `#!`,
82
+ or any `>`/`<`/`=` form). Emitted as-is: a `.query` element over REST, a
83
+ `?`-prefixed word over native. centrs does **not** parse or reorder raw words —
84
+ the caller owns the stack. When `--query` and `--raw-query` are both given, the
85
+ structured words are emitted first, then the raw words, in order.
86
+
87
+ This keeps the common case (`--query type=ether`) clean while giving full
88
+ stack-language access without centrs modelling the whole query grammar.
89
+
90
+ ## CONFIRMED ON CHR (Phase 0 grounding spikes — CHR 7.23.1, 2026-06-29)
91
+
92
+ Probes: scratchpad `api-grounding-spike.ts` (listen/CRUD/execute) and
93
+ `api-grounding-spike2.ts` (duration/monitor + query forms + inspect path). Raw
94
+ native codec for the listen probe; `fetch` for REST; `connectNativeApi().talk()`
95
+ for native.
96
+
97
+ - **CONFIRMED — native `/listen` + `/cancel`.** `/ip/address/listen .tag=2`
98
+ emits an `!re tag=2` carrying the **full new record** on add; on delete it emits
99
+ a **minimal** `!re tag=2 { ".id":"*3", ".dead":"true" }`. **Correction to the
100
+ docs:** the wire flag is **`.dead=true`**, not `=.dead=yes`. `/cancel =tag=2
101
+ .tag=3` yields `!trap tag=2 {category:2, message:interrupted}`, then `!done
102
+ tag=3` (the cancel) **and** `!done tag=2` (the listen closes). Modern plaintext
103
+ `/login` returns a bare `!done` (no challenge) on 7.23.1.
104
+ - **CONFIRMED — native CRUD by id.** `add` returns the new id in `=ret=`; `set`
105
+ and `remove` via `=.id=<id>` succeed with zero `!re` (only `!done`); get-one is
106
+ `print ?.id=<id>` → exactly one record. `numbers=` is **not** needed (that is
107
+ execute's CLI idiom, not the API's).
108
+ - **CONFIRMED — query + proplist.** Native `/interface/print ?type=ether` filters
109
+ server-side; `=.proplist=address,interface` returns only those keys. REST
110
+ `POST /rest/interface/print {".query":["type=ether"],".proplist":"name,type"}`
111
+ → `200 [{"name":"ether1","type":"ether"}]` (a complete **array**, not a stream).
112
+ - **CONFIRMED — duration/monitor returns a bounded `.section` array (both
113
+ transports).** `POST /rest/interface/monitor-traffic {interface:ether1,
114
+ duration:"5s"}` → `200`, ~5.0 s, a **JSON array of 5 records** keyed
115
+ `.section:"0".."4"` (each `…-bits-per-second` etc.). The native
116
+ `/interface/monitor-traffic duration=2s` returns the same frames via `talk()` (2
117
+ records, each with `.section`) before `!done`. So duration commands are ordinary
118
+ bounded calls, **not** NDJSON. (Corrects the first spike's over-strong "REST does
119
+ not support continuous commands" reading; the `/rest/interface/monitor` 400 there
120
+ was just a wrong command path — `monitor-traffic` is the real menu.) The 60 s
121
+ unbounded cap stays doc-grounded — not worth a 60 s CI wait to reproduce.
122
+ - **CONFIRMED — `/console/inspect` `path` is array-typed; pass COMMA tokens, not a
123
+ slash command.** `request=child path=system,license` → children `license:dir,
124
+ export:cmd, generate-new-id:cmd, get:cmd, print:cmd, renew:cmd`; the **slash**
125
+ forms (`system/license`, `/system/license`) return **nothing**. (This is why the
126
+ first spike's license probe was empty — wrong path form.) **Why (rationale, not
127
+ yet re-spiked):** RouterOS's inspect `path` argument is internally an **array**
128
+ type — a comma string like `ip,address,set` is `:toarray`-split into
129
+ `["ip","address","set"]`, the menu walk inspect expects. A `/`-prefixed
130
+ command-style string is **not** an array and isn't split, so it matches no menu.
131
+ A JSON **array** body over REST (`{"path":["ip","address"]}`) should therefore
132
+ work too — **plausible but unconfirmed; do not rely on it.** The contract centrs
133
+ uses is the **comma-joined string** (confirmed; what both `retrieve` and
134
+ `execute` already build via their duplicate `join(",")` helpers — the Phase-1
135
+ `src/core/inspect.ts` is the single home for it). And **`renew` is a `cmd`
136
+ node** → `/system/license/renew` is a write/command, confirming the "streaming ≠
137
+ read-only" rule concretely.
138
+ - **CONFIRMED — `.query` forms (REST word == native `?`-word minus `?`).** On
139
+ `/interface/print` (2 interfaces: ether, loopback): eq `type=ether` → 1; ne
140
+ `["type=ether","#!"]` → loopback; OR `["type=ether","type=loopback","#|"]` → 2;
141
+ gt `[">actual-mtu=1000"]` → 2; has `["running"]` → 2; absence `["-running"]` →
142
+ 0; implicit-AND `["type=ether","running"]` → 1. Identical results native
143
+ (`?`-prefixed). REST license singleton: `GET /rest/system/license` →
144
+ `{"level":"free","system-id":"…"}`.
145
+ - **CONFIRMED — `POST /rest/execute` modes.** With `"as-string":""` it runs
146
+ **synchronously** → `{"ret":"CHR"}`. **Without** `as-string` it is **fire-and-
147
+ forget**: it schedules a job and returns the **job id** (`{"ret":"*18"}`, HTTP
148
+ 200) — a `:error` does **not** surface synchronously. ⇒ `api`'s script-POST must
149
+ send `as-string` (reuse execute's `restPost("/execute",{script,"as-string":""})`,
150
+ `adapter.ts:199`) so output/errors are synchronous; the bare async-job form is
151
+ not the default.
152
+ - **RESOLVED — `/system/license`** (was inconclusive in spike 1 due to the slash
153
+ path; see the COMMA-path finding above). Independently, `isApiMutating` keys the
154
+ write gate on the method/verb, so even an async/streamable write like
155
+ `/system/license/renew` confirms regardless.
156
+
157
+ ## CONFIRMED ON CHR (Phase 2+3 integration — CHR 7.23.1, 2026-06-29)
158
+
159
+ Validated by `test/integration/api.test.ts` (rest, examples 1–20, 98 assertions)
160
+ and `test/integration/api-native.test.ts` (native, N1–N8, 31 assertions), both
161
+ green on CHR 7.23.1.
162
+
163
+ - **CONFIRMED — native `/execute` supports `=as-string=` for synchronous output.**
164
+ Native `talk /execute =script=…` **without** `as-string` is **fire-and-forget**:
165
+ it schedules a job and returns the **job id** in `=ret=` (observed `"*31"`), not
166
+ the script output — exactly the REST behavior. Adding `=as-string=` (empty value)
167
+ makes it run synchronously and return the captured output, identical to REST
168
+ `/rest/execute {as-string}`. **This corrects `src/execute.ts`'s long-standing
169
+ assumption that native script mode is unsupported** (execute still blocks it; the
170
+ `api` path proves native `/execute =as-string=` works). `api` always sends
171
+ `as-string` for a script run on both transports.
172
+ - **CONFIRMED — native CRUD re-mapping to rest-style.** `add` returns the new id
173
+ only in the `!done` `=ret=` word → centrs re-maps it to `{".id": ret}` (REST PUT
174
+ returns the full created object with `.id`; native gives just the id). `set`/
175
+ `remove` reply with a bare `!done` (no `!re`) → no body → centrs surfaces `null`.
176
+ get-one is `print ?.id=<id>` → exactly one record → returned as a single object
177
+ (not a 1-element array). All grounded in `restStyleMutationData` /
178
+ `restStyleRunData` (`src/protocols/adapter.ts`).
179
+ - **CONFIRMED — REST `monitor-traffic` over the `api` path returns a `.section`
180
+ array.** `POST /rest/interface/monitor-traffic {interface,duration}` →
181
+ `[{".section":"0",…}, {".section":"1",…}]` (re-confirms the spike-2 finding via
182
+ the real `api` REST adapter). It is a command (terminal verb `monitor-traffic`,
183
+ not `print`/`get`), so it is a `POST` and **write-classed** → needs `--yes`.
184
+ - **CONFIRMED — the inspect gate over both transports.** Path existence
185
+ (`request=child` empty ⇒ `validation/unknown-path`) and add/set attribute
186
+ validity (`request=child`+`completion` ⇒ `validation/unknown-attribute`) fire
187
+ identically over rest-api and native-api, **before** any write — no `:put
188
+ [:parse]` involved (api input is a path, not a CLI string). A `/execute` script
189
+ is a CLI string ⇒ `meta.validation.semantic = "not-applicable"`.
190
+
191
+ ## CONFIRMED ON CHR (Phase 4 `--stream` follow — CHR 7.23.1, 2026-06-30)
192
+
193
+ Validated by `test/integration/api-listen.test.ts` (L1–L4) on CHR 7.23.1.
194
+ `--stream` is the primary flag; `--listen` is an alias; a `/listen` endpoint
195
+ infers both. Native-api only (REST's 60s cap → `transport/capability-unsupported`).
196
+
197
+ - **CONFIRMED — `NativeApiSession.listen()` + `/cancel` end-to-end.** Opening
198
+ `/ip/address/listen` (own `.tag`) and adding an address over REST yields an
199
+ `!re` frame; stopping (`--count`/`--duration`/Ctrl-C) sends `/cancel =tag=<t>
200
+ .tag=<n>` and the listen closes via the `interrupted` trap (category 2) then
201
+ `!done` — the generator ends **without** throwing (the interrupted trap is the
202
+ normal cancel, not an error). A non-interrupted trap or transport closure does
203
+ throw. Re-confirms the Phase-0 spike through the real `apiListen()` path.
204
+ - **CONFIRMED — deletion frames carry `.dead=true`.** Removing a pre-seeded
205
+ address over REST while listening emits a minimal `{ ".id", ".dead":"true" }`
206
+ `!re` (re-confirms `.dead=true`, not the docs' `=.dead=yes`).
207
+ - **Stream shape:** each `!re` → one NDJSON envelope frame
208
+ (`meta.operation.stream.kind="frame"`, string values, `.dead` preserved); the
209
+ stream ends with a summary envelope (`stream.kind="summary"`,
210
+ `data.stopReason` ∈ `count-reached`/`duration-elapsed`/`interrupted`/
211
+ `transport-error`, plus `frames`/`durationMs`). The CLI exit code keys on
212
+ whether the stream *started* cleanly, not on per-frame `ok`.
@@ -0,0 +1,159 @@
1
+ # api
2
+
3
+ A structured RouterOS **API passthrough**, modeled on `gh api`. You give it a
4
+ REST-style endpoint path and an HTTP method; centrs fills in credentials from the
5
+ CDB / env / flags, validates the request through `/console/inspect`, runs the
6
+ single REST or native-API operation, and returns the result in the standard
7
+ envelope.
8
+
9
+ Status: `CHR-passed` over `rest-api` and `native-api` (single-target),
10
+ including open-ended `--stream` follow (native-api only), per `docs/MATRIX.md`;
11
+ multi-target fan-out is a later phase. This file describes intent and flags; the
12
+ matrix holds the cell states. Load-bearing rules — envelope, errors, settings
13
+ precedence, identity, validation, protocol selection — live in
14
+ [`docs/CONSTITUTION.md`](../../docs/CONSTITUTION.md); the `(constitution: …)`
15
+ notes below point there rather than restating them.
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`.
20
+
21
+ ## Where `api` sits — the verb trichotomy
22
+
23
+ centrs has three RouterOS-touching verbs; pick by shape of work, not by habit:
24
+
25
+ - **`execute`** — the CLI-like runner. The full RouterOS console surface
26
+ (multi-command / script blocks, console-text output, `:put [:parse]` gate). Most
27
+ powerful and lowest-level, but the caller authors a RouterOS CLI string and the
28
+ output is console text.
29
+ - **`retrieve`** — read-only data extraction. Discoverable, structured-out,
30
+ always read.
31
+ - **`api`** (this command) — the structured middle. Enforces the
32
+ *one-command-per-operation* semantics of the REST / native API (no code
33
+ blocks), **can write**, and forces structured input **and** output. For an AI
34
+ agent this is usually the friendliest write path: no CLI-string authoring,
35
+ structured in/out. It does **not** replace `execute` — console-only and
36
+ multi-command work still needs `execute`.
37
+
38
+ ## Synopsis
39
+
40
+ ```text
41
+ centrs api <router> <endpoint> [flags]
42
+ ```
43
+
44
+ - `<router>` — IP, DNS, MAC, or CDB-resolved identity (constitution: identity).
45
+ - `<endpoint>` — a REST-style path, leniently normalized. All of
46
+ `ip/address`, `/ip/address`, `rest/ip/address`, `/rest/ip/address`,
47
+ `"ip address"`, `'ip address'` canonicalize to `/ip/address`. A trailing id
48
+ segment (`ip/address/*1`) addresses one row. A trailing `/listen` segment
49
+ infers `--stream` + `--via native-api`.
50
+
51
+ The HTTP method (`-X`, default `GET`) is honored **literally** against RouterOS's
52
+ REST mapping — `GET`→print/get, **`PUT`→add**, `PATCH`→set, `DELETE`→remove,
53
+ `POST`→run-any-command. Note the trap: **`PUT` is RouterOS's create**, not
54
+ `POST`. centrs never rewrites your method, but warns (`tip/rest-verb-mapping`)
55
+ when a bare-collection `POST` carries create-looking fields.
56
+
57
+ ## Flags
58
+
59
+ | Flag | Behavior |
60
+ | ---- | -------- |
61
+ | `-X` / `--method <verb>` | HTTP method, default `GET`, case-insensitive. |
62
+ | `-f <key=value>` | String field, repeatable; assembled into the JSON body. Values pass through verbatim (no type-guessing). |
63
+ | `-d` / `--data <json>` | Raw JSON request body. Collides with `-f` → `usage/conflicting-flags`. |
64
+ | `--input <file\|->` | Read the raw JSON body from a file or stdin (`-`). |
65
+ | `--query` / `--filter <expr>` | RouterOS-side row filter, AND-combined, repeatable. `name=value` (eq), `name!=value` (ne), `name>value`/`name<value` (cmp), `name` (has-property). Maps to REST `.query` words / native `?` words. |
66
+ | `--raw-query <word>` | Verbatim RouterOS query word (repeatable) for OR / absence / stack expressions — e.g. `--raw-query type=ether --raw-query type=vlan --raw-query '#\|'` (OR). Emitted as-is; the caller owns the stack. |
67
+ | `--attribute` / `--proplist <a,b>` | Property projection → `.proplist`. |
68
+ | `--raw` | Strip the envelope; emit bare RouterOS JSON. Implies `--validate=false`; does **not** imply `--yes`. |
69
+ | `--yes` | Confirm a mutating (non-read) request in non-interactive runs. |
70
+ | `--stream` (alias `--listen`) | Native-api-only open-ended follow → NDJSON stream of envelopes, ending with a summary envelope. Inferred from a `/listen` endpoint. `--via rest-api --stream` errors `transport/capability-unsupported` (REST's 60 s cap cannot follow). |
71
+ | `--duration <dur>` / `--count <n>` | Bound a `--stream`: stop after a wall-clock window / after N frames. |
72
+ | `--validate[=false]` | Default `true`. Gate is `/console/inspect` (see Validation). |
73
+ | `--via <protocol>` | `rest-api` (default) or `native-api`. No silent downgrade. |
74
+ | `--format <json\|yaml\|text>` | Output format. Defaults to `json` for `api` (machine-first); `CENTRS_FORMAT` overrides. Under `--stream`, `json`/`yaml` emit one compact envelope per line (NDJSON); `text` emits a concise row per frame. |
75
+ | target/auth | `--host`, `--port`, `--username`/`--user`/`-u`, `--password`, `--insecure`, `--timeout`, `--cdb-file`, `--cdb-password`, `--resolve <none\|arp>` — same single-target resolver as `retrieve`/`execute`. |
76
+
77
+ Multi-target fan-out (`--group` / `--where` / `--all` / `--default` /
78
+ `--concurrency`, reusing `src/core/fanout.ts`) lands in a later phase. When it
79
+ does, `--stream` is single-session: N>1 targets → `usage/fanout-not-supported`.
80
+
81
+ ## Validation
82
+
83
+ Default on. Because the input is a structured path (not a CLI string), the gate is
84
+ `/console/inspect` — **not** `:put [:parse]`:
85
+
86
+ - Path existence + per-verb attribute validity via `request=child`
87
+ (`validation/unknown-path`, `validation/unknown-attribute`).
88
+ - Value-level advice via `request=completion` (+`input=`) feeding `tips`/`warnings`.
89
+ - **Carve-out:** a script-shaped `POST /rest/execute` is a CLI string, not a path,
90
+ so the inspect gate is `not-applicable` (`meta.validation.semantic`); RouterOS
91
+ re-validates on the run.
92
+
93
+ `--validate=false` (and `--raw`, which implies it) skip the preflight; RouterOS
94
+ still re-validates writes server-side. Disabling validation to make a call pass is
95
+ forbidden (constitution: validation is the product).
96
+
97
+ ## Write confirmation
98
+
99
+ Read-only **iff** the method is `GET`, or the endpoint terminal verb is
100
+ `print`/`get`, or it is `listen`. Everything else is treated as a write and needs
101
+ confirmation (`--yes` non-interactively, or a TTY prompt) → otherwise
102
+ `usage/confirmation-required`. Two consequences:
103
+
104
+ - A `POST …/print` paged read does **not** prompt (keyed on the verb, not the
105
+ wire HTTP method).
106
+ - Streaming does **not** imply read-only: an async-but-mutating command like
107
+ `/system/license/renew --stream` still confirms.
108
+
109
+ `--raw` does not bypass this gate.
110
+
111
+ ## Output
112
+
113
+ Always rest-style JSON in the standard envelope (constitution: result envelope).
114
+ Over native-api the records are re-mapped to the same rest-style shape, with string
115
+ values (the binary API carries no JSON scalar types).
116
+
117
+ `--raw` is the bare-passthrough escape hatch (the envelope-lossless rule is waived
118
+ for it): success prints only the RouterOS body on stdout; a RouterOS error prints
119
+ the RouterOS error payload to stderr with a nonzero exit; a centrs-side failure
120
+ with no RouterOS response prints a compact `{code,message}` to stderr. Exit code is
121
+ `0` iff `ok`.
122
+
123
+ ## Multi-frame results: bounded vs open-ended
124
+
125
+ RouterOS produces multi-frame output two ways, and `api` treats them differently:
126
+
127
+ - **Bounded `duration=` / `monitor` commands are ordinary `api` calls.** Both
128
+ transports return the accumulated frames as a normal **array** (REST regularizes
129
+ the native `!re` frames into `.section`-keyed records; native's reader collects
130
+ them until the command completes). No flag needed — e.g.
131
+ `centrs api $R interface/monitor-traffic -f interface=ether1 -f duration=5s`
132
+ returns a `.section` array. REST bounds these at the **60 s** cap (a longer
133
+ `duration=` terminates early with an error); native has no cap. This is **not**
134
+ NDJSON.
135
+ - **Open-ended follow is `--stream` (alias `--listen`), native-api only.** Only
136
+ the native API `/listen` command follows indefinitely (REST cannot — the 60 s
137
+ cap). Each `!re` becomes one rest-style envelope frame (one NDJSON line; a
138
+ deletion's `.dead=true` flag is preserved); the stream ends with a summary
139
+ envelope (`data.stopReason` ∈ `count-reached`/`duration-elapsed`/`interrupted`/
140
+ `transport-error`, plus `frames` and `durationMs`). `--duration`/`--count`
141
+ bound it; Ctrl-C stops and still emits the summary. `--via rest-api --stream` →
142
+ `transport/capability-unsupported`. The exit code reflects whether the stream
143
+ *started* cleanly, not whether every frame was `ok`.
144
+
145
+ ## MCP (deferred — forward guidance)
146
+
147
+ `api` is **not** exposed over the MCP frontend yet: the MCP server is the
148
+ least-mature surface, and a raw passthrough sits awkwardly against its "scoped
149
+ verbs, never one tool per RouterOS command" model. But precisely because `api` is
150
+ structured and agent-friendly, it is the strongest *future* MCP candidate for an
151
+ agent driving RouterOS — to be revisited behind the per-device `mcp=ro|rw` +
152
+ `confirm:true` write gate (method-aware: read verbs `ro`, everything else `rw`) and
153
+ the CDB-as-allowlist authorization model (constitution: MCP surface).
154
+
155
+ ## Definition of done
156
+
157
+ `CHR-passed` only when every example in `examples.md` runs green against a real CHR
158
+ through `bun run test:integration`. See
159
+ [`docs/CONSTITUTION.md`](../../docs/CONSTITUTION.md) for the full done rule.