@tikoci/centrs 0.1.1 → 0.1.3

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 (95) hide show
  1. package/CHANGELOG.md +107 -2
  2. package/GLOSSARY.txt +87 -0
  3. package/README.md +25 -9
  4. package/SECURITY.md +3 -3
  5. package/commands/AGENTS.md +3 -2
  6. package/commands/api/AGENTS.md +212 -0
  7. package/commands/api/README.md +177 -0
  8. package/commands/api/examples.md +428 -0
  9. package/commands/btest/README.md +56 -31
  10. package/commands/btest/examples.md +35 -10
  11. package/commands/check/README.md +371 -53
  12. package/commands/check/examples.md +390 -0
  13. package/commands/devices/README.md +177 -37
  14. package/commands/devices/examples.md +148 -0
  15. package/commands/execute/README.md +48 -7
  16. package/commands/execute/examples.md +90 -6
  17. package/commands/retrieve/README.md +37 -51
  18. package/commands/retrieve/examples.md +40 -9
  19. package/commands/settings/README.md +286 -22
  20. package/commands/settings/examples.md +408 -0
  21. package/commands/terminal/README.md +6 -0
  22. package/commands/transfer/README.md +32 -2
  23. package/commands/transfer/examples.md +66 -0
  24. package/docs/CONSTITUTION.md +40 -15
  25. package/docs/MATRIX.md +69 -142
  26. package/docs/errors/README.md +2 -1
  27. package/docs/errors/cdb/empty-selection.md +19 -0
  28. package/docs/errors/input/incomplete-gps.md +9 -0
  29. package/docs/errors/input/invalid-altitude.md +9 -0
  30. package/docs/errors/input/invalid-bbox.md +9 -0
  31. package/docs/errors/input/invalid-coordinate.md +9 -0
  32. package/docs/errors/input/invalid-radius.md +9 -0
  33. package/docs/errors/internal/settings-failed.md +9 -0
  34. package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
  35. package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
  36. package/docs/errors/settings/consequential-value.md +26 -0
  37. package/docs/errors/settings/invalid-boolean.md +21 -3
  38. package/docs/errors/settings/invalid-format.md +18 -3
  39. package/docs/errors/settings/invalid-integer.md +18 -3
  40. package/docs/errors/settings/invalid-timeout.md +20 -3
  41. package/docs/errors/settings/invalid-via.md +22 -3
  42. package/docs/errors/settings/reserved-key.md +29 -0
  43. package/docs/errors/settings/skip-env-file-active.md +25 -0
  44. package/docs/errors/settings/unknown-key.md +25 -0
  45. package/docs/errors/usage/fanout-not-supported.md +16 -0
  46. package/docs/errors/usage/invalid-method.md +23 -0
  47. package/package.json +9 -9
  48. package/src/api-fanout.ts +454 -0
  49. package/src/api.ts +1627 -0
  50. package/src/btest.ts +61 -1
  51. package/src/cli/api.ts +660 -0
  52. package/src/cli/btest.ts +20 -2
  53. package/src/cli/common.ts +75 -0
  54. package/src/cli/devices.ts +235 -6
  55. package/src/cli/discover.ts +3 -1
  56. package/src/cli/execute.ts +107 -6
  57. package/src/cli/mcp.ts +8 -1
  58. package/src/cli/retrieve.ts +135 -90
  59. package/src/cli/selection.ts +200 -0
  60. package/src/cli/settings.ts +286 -0
  61. package/src/cli/terminal.ts +22 -7
  62. package/src/cli/transfer.ts +171 -47
  63. package/src/cli.ts +29 -0
  64. package/src/core/error-catalog.ts +69 -0
  65. package/src/core/fanout.ts +301 -0
  66. package/src/core/inspect.ts +170 -0
  67. package/src/devices.ts +212 -20
  68. package/src/discover.ts +7 -2
  69. package/src/execute-fanout.ts +438 -0
  70. package/src/execute.ts +134 -70
  71. package/src/index.ts +166 -9
  72. package/src/mcp/config.ts +12 -2
  73. package/src/mcp/tools.ts +137 -2
  74. package/src/protocols/adapter.ts +357 -9
  75. package/src/protocols/btest-session.ts +279 -46
  76. package/src/protocols/btest.ts +6 -2
  77. package/src/protocols/index.ts +11 -5
  78. package/src/protocols/native-api.ts +223 -30
  79. package/src/resolver/cdb.ts +10 -2
  80. package/src/resolver/comment-kv.ts +58 -4
  81. package/src/resolver/config-file.ts +136 -0
  82. package/src/resolver/facts.ts +78 -0
  83. package/src/resolver/geo.ts +453 -0
  84. package/src/resolver/index.ts +46 -0
  85. package/src/resolver/mac.ts +12 -3
  86. package/src/resolver/selection.ts +304 -0
  87. package/src/resolver/settings.ts +54 -5
  88. package/src/resolver/target.ts +20 -2
  89. package/src/retrieve-fanout.ts +232 -268
  90. package/src/retrieve.ts +69 -94
  91. package/src/settings.ts +1078 -0
  92. package/src/terminal.ts +11 -1
  93. package/src/transfer-fanout.ts +527 -0
  94. package/src/transfer.ts +61 -11
  95. package/commands/stream/README.md +0 -70
@@ -0,0 +1,177 @@
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`, including multi-target
10
+ fan-out and open-ended `--stream` follow (native-api only), per
11
+ `docs/MATRIX.md`. This file describes intent and flags; the matrix holds the
12
+ cell states. Load-bearing rules — envelope, errors, settings precedence,
13
+ 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`. Streaming is single-session — it cannot combine with
20
+ multi-target fan-out (see below).
21
+
22
+ ## Where `api` sits — the verb trichotomy
23
+
24
+ centrs has three RouterOS-touching verbs; pick by shape of work, not by habit:
25
+
26
+ - **`execute`** — the CLI-like runner. The full RouterOS console surface
27
+ (multi-command / script blocks, console-text output, `:put [:parse]` gate). Most
28
+ powerful and lowest-level, but the caller authors a RouterOS CLI string and the
29
+ output is console text.
30
+ - **`retrieve`** — read-only data extraction. Discoverable, structured-out,
31
+ always read.
32
+ - **`api`** (this command) — the structured middle. Enforces the
33
+ *one-command-per-operation* semantics of the REST / native API (no code
34
+ blocks), **can write**, and forces structured input **and** output. For an AI
35
+ agent this is usually the friendliest write path: no CLI-string authoring,
36
+ structured in/out. It does **not** replace `execute` — console-only and
37
+ multi-command work still needs `execute`.
38
+
39
+ ## Synopsis
40
+
41
+ ```text
42
+ centrs api <router> <endpoint> [flags]
43
+ ```
44
+
45
+ - `<router>` — IP, DNS, MAC, or CDB-resolved identity (constitution: identity).
46
+ - `<endpoint>` — a REST-style path, leniently normalized. All of
47
+ `ip/address`, `/ip/address`, `rest/ip/address`, `/rest/ip/address`,
48
+ `"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
+
52
+ The HTTP method (`-X`, default `GET`) is honored **literally** against RouterOS's
53
+ REST mapping — `GET`→print/get, **`PUT`→add**, `PATCH`→set, `DELETE`→remove,
54
+ `POST`→run-any-command. Note the trap: **`PUT` is RouterOS's create**, not
55
+ `POST`. centrs never rewrites your method, but warns (`tip/rest-verb-mapping`)
56
+ when a bare-collection `POST` carries create-looking fields.
57
+
58
+ ## Flags
59
+
60
+ | Flag | Behavior |
61
+ | ---- | -------- |
62
+ | `-X` / `--method <verb>` | HTTP method, default `GET`, case-insensitive. |
63
+ | `-f <key=value>` | String field, repeatable; assembled into the JSON body. Values pass through verbatim (no type-guessing). |
64
+ | `-d` / `--data <json>` | Raw JSON request body. Collides with `-f` → `usage/conflicting-flags`. |
65
+ | `--input <file\|->` | Read the raw JSON body from a file or stdin (`-`). |
66
+ | `--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. |
67
+ | `--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. |
68
+ | `--attribute` / `--proplist <a,b>` | Property projection → `.proplist`. |
69
+ | `--raw` | Strip the envelope; emit bare RouterOS JSON. Implies `--validate=false`; does **not** imply `--yes`. |
70
+ | `--yes` | Confirm a mutating (non-read) request in non-interactive runs. |
71
+ | `--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). |
72
+ | `--duration <dur>` / `--count <n>` | Bound a `--stream`: stop after a wall-clock window / after N frames. |
73
+ | `--validate[=false]` | Default `true`. Gate is `/console/inspect` (see Validation). |
74
+ | `--via <protocol>` | `rest-api` (default) or `native-api`. No silent downgrade. |
75
+ | `--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. |
76
+ | `--group <name>` / `--where <attr>=<value>` / `--near <lat>,<lon>,<radius>` / `--bbox <south>,<west>,<north>,<east>` / `--all` / `--default` / `--concurrency <n>` | Multi-target fan-out (see below). Repeatable `--group`/`--where`; `--near`/`--bbox` select by device GPS (lat-first; see constitution: target selection). The union de-dupes by CDB record index. |
77
+ | target/auth | `--host`, `--port`, `--username`/`--user`/`-u`, `--password`, `--insecure`, `--timeout`, `--cdb-file`, `--cdb-password`, `--resolve <none\|arp>` — same single-target resolver as `retrieve`/`execute`. |
78
+
79
+ ## Fan-out (multi-target)
80
+
81
+ Selecting more than one router — any selector flag (`--group` / `--where` /
82
+ `--near` / `--bbox` / `--all` / `--default`) or more than one positional target — switches `api` into
83
+ **fan-out mode** (`src/api-fanout.ts`, on the shared `src/core/fanout.ts` engine
84
+ and the `src/resolver/selection.ts` grammar). A plain single-positional call
85
+ stays the single-target envelope. Output is the locked `FanoutData` envelope
86
+ (`data = { summary, targets[] }`; outer `ok` = orchestration success; per-target
87
+ failures are inner `ok:false`), and the process exit code is granular: `0`
88
+ all-ok, `2` partial, `1` orchestration error or every target failed. See
89
+ `docs/CONSTITUTION.md` (Target selection) for the shared grammar and `--where`
90
+ vs `--query` distinction.
91
+
92
+ - **Writes fan out under `--yes`**, confirmed once up front (not per target).
93
+ Without `--yes`, the error names the blast radius (how many routers) and that
94
+ `--yes` is required.
95
+ - **`--listen`/`--stream` is single-session** → `usage/fanout-not-supported` in
96
+ fan-out mode. **`--raw` strips the envelope** → `usage/conflicting-flags` in
97
+ fan-out mode (per-target envelopes can't be bare).
98
+
99
+ ## Validation
100
+
101
+ Default on. Because the input is a structured path (not a CLI string), the gate is
102
+ `/console/inspect` — **not** `:put [:parse]`:
103
+
104
+ - Path existence + per-verb attribute validity via `request=child`
105
+ (`validation/unknown-path`, `validation/unknown-attribute`).
106
+ - Value-level advice via `request=completion` (+`input=`) feeding `tips`/`warnings`.
107
+ - **Carve-out:** a script-shaped `POST /rest/execute` is a CLI string, not a path,
108
+ so the inspect gate is `not-applicable` (`meta.validation.semantic`); RouterOS
109
+ re-validates on the run.
110
+
111
+ `--validate=false` (and `--raw`, which implies it) skip the preflight; RouterOS
112
+ still re-validates writes server-side. Disabling validation to make a call pass is
113
+ forbidden (constitution: validation is the product).
114
+
115
+ ## Write confirmation
116
+
117
+ Read-only **iff** the method is `GET`, or the endpoint terminal verb is
118
+ `print`/`get`, or it is `listen`. Everything else is treated as a write and needs
119
+ confirmation (`--yes` non-interactively, or a TTY prompt) → otherwise
120
+ `usage/confirmation-required`. Two consequences:
121
+
122
+ - A `POST …/print` paged read does **not** prompt (keyed on the verb, not the
123
+ wire HTTP method).
124
+ - Streaming does **not** imply read-only: an async-but-mutating command like
125
+ `/system/license/renew --stream` still confirms.
126
+
127
+ `--raw` does not bypass this gate.
128
+
129
+ ## Output
130
+
131
+ Always rest-style JSON in the standard envelope (constitution: result envelope).
132
+ Over native-api the records are re-mapped to the same rest-style shape, with string
133
+ values (the binary API carries no JSON scalar types).
134
+
135
+ `--raw` is the bare-passthrough escape hatch (the envelope-lossless rule is waived
136
+ for it): success prints only the RouterOS body on stdout; a RouterOS error prints
137
+ the RouterOS error payload to stderr with a nonzero exit; a centrs-side failure
138
+ with no RouterOS response prints a compact `{code,message}` to stderr. Exit code is
139
+ `0` iff `ok`.
140
+
141
+ ## Multi-frame results: bounded vs open-ended
142
+
143
+ RouterOS produces multi-frame output two ways, and `api` treats them differently:
144
+
145
+ - **Bounded `duration=` / `monitor` commands are ordinary `api` calls.** Both
146
+ transports return the accumulated frames as a normal **array** (REST regularizes
147
+ the native `!re` frames into `.section`-keyed records; native's reader collects
148
+ them until the command completes). No flag needed — e.g.
149
+ `centrs api $R interface/monitor-traffic -f interface=ether1 -f duration=5s`
150
+ returns a `.section` array. REST bounds these at the **60 s** cap (a longer
151
+ `duration=` terminates early with an error); native has no cap. This is **not**
152
+ NDJSON.
153
+ - **Open-ended follow is `--stream` (alias `--listen`), native-api only.** Only
154
+ the native API `/listen` command follows indefinitely (REST cannot — the 60 s
155
+ cap). Each `!re` becomes one rest-style envelope frame (one NDJSON line; a
156
+ deletion's `.dead=true` flag is preserved); the stream ends with a summary
157
+ envelope (`data.stopReason` ∈ `count-reached`/`duration-elapsed`/`interrupted`/
158
+ `transport-error`, plus `frames` and `durationMs`). `--duration`/`--count`
159
+ bound it; Ctrl-C stops and still emits the summary. `--via rest-api --stream` →
160
+ `transport/capability-unsupported`. The exit code reflects whether the stream
161
+ *started* cleanly, not whether every frame was `ok`.
162
+
163
+ ## MCP (deferred — forward guidance)
164
+
165
+ `api` is **not** exposed over the MCP frontend yet: the MCP server is the
166
+ least-mature surface, and a raw passthrough sits awkwardly against its "scoped
167
+ verbs, never one tool per RouterOS command" model. But precisely because `api` is
168
+ structured and agent-friendly, it is the strongest *future* MCP candidate for an
169
+ agent driving RouterOS — to be revisited behind the per-device `mcp=ro|rw` +
170
+ `confirm:true` write gate (method-aware: read verbs `ro`, everything else `rw`) and
171
+ the CDB-as-allowlist authorization model (constitution: MCP surface).
172
+
173
+ ## Definition of done
174
+
175
+ `CHR-passed` only when every example in `examples.md` runs green against a real CHR
176
+ through `bun run test:integration`. See
177
+ [`docs/CONSTITUTION.md`](../../docs/CONSTITUTION.md) for the full done rule.
@@ -0,0 +1,428 @@
1
+ # api — examples
2
+
3
+ Each numbered example is an executable spec asserted by CHR integration tests
4
+ (booted by `@tikoci/quickchr`), example N ↔ assertion N:
5
+ `test/integration/api.test.ts` (rest-api examples),
6
+ `test/integration/api-native.test.ts` (native-api examples, N…),
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
9
+ exercised by a test, the test file is wrong; if a line passes only with
10
+ `--validate=false`, the implementation is wrong (see
11
+ [`docs/CONSTITUTION.md`](../../docs/CONSTITUTION.md)).
12
+
13
+ `$R` is `<host>:<rest-port>` resolved by quickchr. `$A` is `<host>` and
14
+ `$API_PORT` is the native API port (`chr.ports.api`). `$U` / `$P` are CHR
15
+ credentials provided by the harness. `$ID` is the `.id` returned by the preceding
16
+ add in the same transport section. All write-shaped examples pass `--yes`.
17
+
18
+ ## rest-api (`--via rest-api`, the default)
19
+
20
+ ### 1. GET a list
21
+
22
+ ```bash
23
+ centrs api $R ip/address --username $U --password $P
24
+ ```
25
+
26
+ Envelope: `ok: true`, `data` is an array of rest-style records,
27
+ `meta.via=rest-api`, `meta.validation.source` includes `/console/inspect request=child`.
28
+
29
+ ### 2. Endpoint normalization variants resolve to one path
30
+
31
+ ```bash
32
+ centrs api $R "/rest/ip/address" --username $U --password $P
33
+ centrs api $R "rest/ip/address" --username $U --password $P
34
+ centrs api $R "ip address" --username $U --password $P
35
+ ```
36
+
37
+ Each resolves to the same canonical path; `meta.request.path == "/ip/address"`
38
+ and the `data` array matches example 1.
39
+
40
+ ### 3. GET a singleton
41
+
42
+ ```bash
43
+ centrs api $R system/resource --username $U --password $P
44
+ ```
45
+
46
+ Envelope: `ok: true`, `data` is a single object (not an array) with `uptime`,
47
+ `version`, etc.
48
+
49
+ ### 4. PUT add (RouterOS create; documents the PUT≠POST trap)
50
+
51
+ ```bash
52
+ centrs api $R ip/address -X PUT -f address=198.51.100.10/32 -f interface=ether1 -f comment=centrs-api-rest --username $U --password $P --yes
53
+ ```
54
+
55
+ Envelope: `ok: true`, `data[".id"]` matches `/^\*[0-9A-F]+$/`, `meta.via=rest-api`.
56
+ A subsequent GET of `/ip/address` contains the new row.
57
+
58
+ ### 5. PATCH set by id-in-path
59
+
60
+ ```bash
61
+ centrs api $R ip/address/$ID -X PATCH -f comment=centrs-api-rest-set --username $U --password $P --yes
62
+ ```
63
+
64
+ Envelope: `ok: true`; re-reading `ip/address/$ID` shows the new comment.
65
+
66
+ ### 6. DELETE remove by id-in-path
67
+
68
+ ```bash
69
+ centrs api $R ip/address/$ID -X DELETE --username $U --password $P --yes
70
+ ```
71
+
72
+ Envelope: `ok: true`; a subsequent GET of `/ip/address` does not contain `$ID`.
73
+
74
+ ### 7. Raw JSON body with `-d`
75
+
76
+ ```bash
77
+ centrs api $R ip/address -X PUT -d '{"address":"198.51.100.11/32","interface":"ether1"}' --username $U --password $P --yes
78
+ ```
79
+
80
+ Envelope: `ok: true`, `data[".id"]` present.
81
+
82
+ ### 8. Body from stdin with `--input -`
83
+
84
+ ```bash
85
+ echo '{"address":"198.51.100.12/32","interface":"ether1"}' | centrs api $R ip/address -X PUT --input - --username $U --password $P --yes
86
+ ```
87
+
88
+ Envelope: `ok: true`, `data[".id"]` present.
89
+
90
+ ### 9. Server-side filter with `--query`
91
+
92
+ ```bash
93
+ centrs api $R interface --query type=ether --username $U --password $P
94
+ ```
95
+
96
+ Envelope: `ok: true`, every returned record has `type=ether`; the request carried
97
+ a REST `.query` (not client-side filtering).
98
+
99
+ ### 10. Projection with `--proplist`
100
+
101
+ ```bash
102
+ centrs api $R ip/address --proplist address,interface --username $U --password $P
103
+ ```
104
+
105
+ Envelope: `ok: true`, each record has only the requested properties (plus `.id`).
106
+
107
+ ### 11. Validation rejects an unknown attribute before the write
108
+
109
+ ```bash
110
+ centrs api $R ip/address -X PUT -f address=198.51.100.13/32 -f interface=ether1 -f no-such-arg=x --username $U --password $P --yes
111
+ ```
112
+
113
+ Envelope: `ok: false`, `error.code=validation/unknown-attribute`,
114
+ `meta.validation.source=/console/inspect`, and no address is added.
115
+
116
+ ### 12. Validation rejects an unknown path
117
+
118
+ ```bash
119
+ centrs api $R ip/no-such-menu --username $U --password $P
120
+ ```
121
+
122
+ Envelope: `ok: false`, `error.code=validation/unknown-path`, no request issued.
123
+
124
+ ### 13. `--raw` success prints the bare RouterOS body
125
+
126
+ ```bash
127
+ centrs api $R ip/address --raw --username $U --password $P
128
+ ```
129
+
130
+ stdout is the bare RouterOS JSON array (no `meta`/`warnings`); validation is
131
+ skipped; exit code 0.
132
+
133
+ ### 14. `--raw` RouterOS error → stderr + nonzero exit
134
+
135
+ ```bash
136
+ centrs api $R ip/address -X PUT -f address=not-an-ip -f interface=ether1 --raw --username $U --password $P --yes
137
+ ```
138
+
139
+ stdout is empty; stderr carries the RouterOS error payload as JSON; exit code is
140
+ nonzero.
141
+
142
+ ### 15. Mutating + non-TTY + no `--yes` is refused
143
+
144
+ ```bash
145
+ centrs api $R ip/address -X PUT -f address=198.51.100.14/32 -f interface=ether1 --username $U --password $P </dev/null
146
+ ```
147
+
148
+ Envelope: `ok: false`, `error.code=usage/confirmation-required`, no request
149
+ issued, no address added. (Holds under `--raw` too.)
150
+
151
+ ### 16. POST run a console command via `/rest/execute`
152
+
153
+ ```bash
154
+ centrs api $R execute -X POST -f script=':put [/system/identity/get name]' --username $U --password $P --yes
155
+ ```
156
+
157
+ Envelope: `ok: true`, `data` is string-shaped and contains the CHR identity;
158
+ `meta.validation.semantic=not-applicable` (script, not a path).
159
+
160
+ ### 17. `--via rest-api --listen` is rejected
161
+
162
+ ```bash
163
+ centrs api $R ip/address --listen --via rest-api --username $U --password $P
164
+ ```
165
+
166
+ Envelope: `ok: false`, `error.code=transport/capability-unsupported` (REST cannot
167
+ follow); no silent open-ended poll.
168
+
169
+ ### 18. `--query` not-equal maps to a negated stack word
170
+
171
+ ```bash
172
+ centrs api $R interface --query type!=ether --username $U --password $P
173
+ ```
174
+
175
+ Envelope: `ok: true`, no returned record has `type=ether`; the request carried
176
+ REST `.query` words `["type=ether","#!"]` (eq then NOT-top).
177
+
178
+ ### 19. `--raw-query` expresses OR
179
+
180
+ ```bash
181
+ centrs api $R interface --raw-query type=ether --raw-query type=loopback --raw-query '#|' --username $U --password $P
182
+ ```
183
+
184
+ Envelope: `ok: true`, returns the union of `ether` and `loopback` interfaces; the
185
+ raw words are passed through verbatim as `.query` (REST) / `?` words (native).
186
+
187
+ ### 20. Bounded `duration=` command returns a `.section` array (not a stream)
188
+
189
+ ```bash
190
+ centrs api $R interface/monitor-traffic -X POST -f interface=ether1 -f duration=5s --username $U --password $P --yes
191
+ ```
192
+
193
+ Envelope: `ok: true`, `data` is an **array** of `.section`-keyed records (one per
194
+ ~second), `meta.via=rest-api`. This is an ordinary bounded call — no `--listen`,
195
+ no NDJSON. `monitor-traffic` is a command (not `print`/`get`), so it is a
196
+ `POST` and write-classed (needs `--yes`); centrs never relaxes that to read-only.
197
+ (REST bounds it at the 60 s cap; native has no cap.)
198
+
199
+ ### 21. GET one row by id together with `--proplist`
200
+
201
+ ```bash
202
+ centrs api $R ip/address/$ID --proplist address --username $U --password $P
203
+ ```
204
+
205
+ Envelope: `ok: true`, `data` is a **single object** (not an array) carrying only
206
+ the projected property `address` (RouterOS omits `.id` unless it is in the
207
+ proplist). Combining an id with `--query`/`--proplist` folds the id into the REST
208
+ `.query` (`.id=$ID`) and rides a `POST …/print`, then unwraps the one row —
209
+ matching the native `?.id=` read. (Plain id-only reads still use the `GET …/$ID`
210
+ URL form; see example 5's re-read.)
211
+
212
+ ## native-api (`--via native-api`)
213
+
214
+ The same contract over the binary API. Validation still runs through
215
+ `/console/inspect`, issued as native commands. Values are strings.
216
+
217
+ ### N1. GET a list
218
+
219
+ ```bash
220
+ centrs api $A interface -X GET --via native-api --port $API_PORT --username $U --password $P
221
+ ```
222
+
223
+ Envelope: `ok: true`, `data` is an array of rest-style records (string values),
224
+ `meta.via=native-api`.
225
+
226
+ ### N2. PUT add (returns `.id`)
227
+
228
+ ```bash
229
+ centrs api $A ip/address -X PUT -f address=198.51.100.20/32 -f interface=ether1 --via native-api --port $API_PORT --username $U --password $P --yes
230
+ ```
231
+
232
+ Envelope: `ok: true`, `data[".id"]` present.
233
+
234
+ ### N3. PATCH set by id (id → `=.id=` word)
235
+
236
+ ```bash
237
+ centrs api $A ip/address/$ID -X PATCH -f comment=centrs-api-native-set --via native-api --port $API_PORT --username $U --password $P --yes
238
+ ```
239
+
240
+ Envelope: `ok: true`; re-reading shows the new comment.
241
+
242
+ ### N4. DELETE remove by id (id → `=.id=` word)
243
+
244
+ ```bash
245
+ centrs api $A ip/address/$ID -X DELETE --via native-api --port $API_PORT --username $U --password $P --yes
246
+ ```
247
+
248
+ Envelope: `ok: true`; the row is gone.
249
+
250
+ ### N5. GET one by id (→ `print ?.id=`)
251
+
252
+ ```bash
253
+ centrs api $A ip/address/$ID2 -X GET --via native-api --port $API_PORT --username $U --password $P
254
+ ```
255
+
256
+ Envelope: `ok: true`, `data` is a single object whose `.id` is `$ID2`.
257
+
258
+ ### N6. Validation rejects an unknown attribute over native
259
+
260
+ ```bash
261
+ centrs api $A ip/address -X PUT -f address=198.51.100.21/32 -f interface=ether1 -f no-such-arg=x --via native-api --port $API_PORT --username $U --password $P --yes
262
+ ```
263
+
264
+ Envelope: `ok: false`, `error.code=validation/unknown-attribute`, no row added —
265
+ caught by the inspect gate, not a native `!trap`.
266
+
267
+ ### N7. `--raw` over native
268
+
269
+ ```bash
270
+ centrs api $A interface --raw --via native-api --port $API_PORT --username $U --password $P
271
+ ```
272
+
273
+ stdout is the bare rest-style array (string values); exit code 0.
274
+
275
+ ### N8. POST run a console command via native `/execute`
276
+
277
+ ```bash
278
+ centrs api $A execute -X POST -f script=':put [/system/identity/get name]' --via native-api --port $API_PORT --username $U --password $P --yes
279
+ ```
280
+
281
+ Envelope: `ok: true`, `data` contains the CHR identity.
282
+
283
+ ## listen / `--stream` (native-api only)
284
+
285
+ Open-ended follow. `--stream` is the primary flag; `--listen` is an accepted
286
+ alias and a trailing `/listen` endpoint segment infers it. Output is NDJSON: one
287
+ envelope per change frame, then a terminating summary envelope.
288
+
289
+ ### L1. `--stream` streams a change as an NDJSON frame, then a summary
290
+
291
+ ```bash
292
+ centrs api $A ip/address --stream --count 1 --via native-api --port $API_PORT --username $U --password $P
293
+ ```
294
+
295
+ While listening, the harness adds an address over REST. stdout is NDJSON: at least
296
+ one envelope frame for the new row (`meta.operation.stream.kind=frame`), then a
297
+ final summary envelope (`meta.operation.stream.kind=summary`,
298
+ `data.stopReason=count-reached`, `data.frames>=1`). Exit code 0 (stream started
299
+ cleanly).
300
+
301
+ ### L2. A deletion frame carries `.dead`
302
+
303
+ ```bash
304
+ centrs api $A ip/address --stream --duration 3s --via native-api --port $API_PORT --username $U --password $P
305
+ ```
306
+
307
+ The harness removes a pre-seeded address; an emitted frame's record carries
308
+ `.dead=true` (a minimal `{ ".id", ".dead" }` record, per the CHR-grounded
309
+ `commands/api/AGENTS.md`).
310
+
311
+ ### L3. `/listen` endpoint infers native + streaming
312
+
313
+ ```bash
314
+ centrs api $A "ip/address/listen" --count 1 --port $API_PORT --username $U --password $P
315
+ ```
316
+
317
+ No `--stream` / `--via` given; the `/listen` endpoint infers `--stream` and
318
+ `--via native-api` (`meta.via=native-api`). Same NDJSON shape as L1.
319
+
320
+ ### L4. Bounded `--duration` reports its stop reason
321
+
322
+ ```bash
323
+ centrs api $A ip/address --stream --duration 2s --via native-api --port $API_PORT --username $U --password $P
324
+ ```
325
+
326
+ With no change during the window, the stream ends after ~2 s with a summary
327
+ envelope whose `data.stopReason=duration-elapsed`. Exit code 0.
328
+
329
+ ## fanout (multi-target, F…)
330
+
331
+ These run against a CDB fixture with two records sharing group `$G`: record 0 is
332
+ the live CHR (comment `board=chr`), record 1 is an unreachable host (comment
333
+ `board=dead`). Fan-out output is the locked `FanoutData` envelope
334
+ (`data = { summary, targets[] }`; outer `ok` = orchestration success; per-target
335
+ failures are inner `ok:false`), with the granular exit code (0 all-ok / 2 partial /
336
+ 1 all-failed or orchestration error). See `test/integration/api-fanout.test.ts`.
337
+ The `--raw`/`--listen` + fan-out guards are network-free and validated in
338
+ `test/integration/cli-smoke.test.ts`.
339
+
340
+ ### F1. `--group` fans a GET out; a dead target is an inner failure
341
+
342
+ ```bash
343
+ centrs api --group $G ip/address --cdb-file $CDB --json
344
+ ```
345
+
346
+ `ok: true`, `data.summary = { total: 2, ok: 1, failed: 1 }`, `data.targets[]` in
347
+ record-index order (`[0, 1]`): target 0 is an inner success (`meta.via=rest-api`),
348
+ target 1 is inner `ok:false` with `error.code=transport/connection-refused`.
349
+ `meta.operation.kind=fanout`. Exit code 2.
350
+
351
+ ### F2. `--where` selects only the matching device
352
+
353
+ ```bash
354
+ centrs api --where board=chr ip/address --cdb-file $CDB --json
355
+ ```
356
+
357
+ The device-class selector matches only record 0, so `data.summary = { total: 1,
358
+ ok: 1, failed: 0 }`, one inner success. Exit code 0.
359
+
360
+ ### F3. An empty selection is `ok:true` with summary 0/0/0
361
+
362
+ ```bash
363
+ centrs api --group no-such-group ip/address --cdb-file $CDB --json
364
+ ```
365
+
366
+ `ok: true`, `data.summary = { total: 0, ok: 0, failed: 0 }`, `data.targets = []`,
367
+ and a `cdb/empty-group` warning. Exit code 0.
368
+
369
+ ### F4. A mutating fan-out without `--yes` is rejected, naming the blast radius
370
+
371
+ ```bash
372
+ centrs api -X PUT ip/address -f address=10.99.0.1/32 -f interface=ether1 --group $G --cdb-file $CDB --json
373
+ ```
374
+
375
+ Non-interactive and unconfirmed: outer `ok:false`,
376
+ `error.code=usage/confirmation-required`, and the message names the router count
377
+ (`2 router(s)`) and that `--yes` is required. No write is attempted. Exit code 1.
378
+
379
+ ### F5. A mutating fan-out with `--yes` writes across the selected devices
380
+
381
+ ```bash
382
+ centrs api -X PUT ip/address -f address=10.99.0.7/32 -f interface=ether1 --where board=chr --yes --cdb-file $CDB --json
383
+ ```
384
+
385
+ Confirmed once up front; the add runs on record 0 only (selected by `--where`).
386
+ `data.summary = { total: 1, ok: 1, failed: 0 }`, the inner success carries the
387
+ created `.id`. Exit code 0.
388
+
389
+ The fixture for F6–F9 adds record 2 — the reserved `__default__` credential record.
390
+
391
+ ### F6. `--all` fans across every record except `__default__`
392
+
393
+ ```bash
394
+ centrs api --all ip/address --cdb-file $CDB --json
395
+ ```
396
+
397
+ `data.summary = { total: 2, ok: 1, failed: 1 }`, `data.targets[]` record indices
398
+ `[0, 1]` (record 2 `__default__` is excluded). Exit code 2.
399
+
400
+ ### F7. A positional + `--group` union de-dupes by record index
401
+
402
+ ```bash
403
+ centrs api $R0 --group $G ip/address --cdb-file $CDB --json
404
+ ```
405
+
406
+ `$R0` is record 0's target, also a member of `$G`; the union is `{0, 1}` — record
407
+ indices `[0, 1]`, `total = 2` (not 3). Exit code 2.
408
+
409
+ ### F8. `--concurrency` bounds the worker pool
410
+
411
+ ```bash
412
+ centrs api --group $G --concurrency 1 ip/address --cdb-file $CDB --json
413
+ ```
414
+
415
+ Runs one target at a time; `meta.operation.concurrency = 1`, still
416
+ `data.summary = { total: 2, ok: 1, failed: 1 }`. (`--concurrency 2abc` / `1.5` are
417
+ rejected at parse time with `usage/invalid-concurrency`.) Exit code 2.
418
+
419
+ ### F9. `--default` selects `__default__`, which fails the connectable guard
420
+
421
+ ```bash
422
+ centrs api --default ip/address --cdb-file $CDB --json
423
+ ```
424
+
425
+ The reserved record is a credential fallback, not a connectable router, so its one
426
+ target fails deterministically: inner `ok:false` with
427
+ `error.code=target/unresolved` (never a `transport/dns` attempt on `"__default__"`).
428
+ `data.summary = { total: 1, ok: 0, failed: 1 }`. Exit code 1 (every target failed).