@tikoci/centrs 0.1.9-next.24 → 0.1.9

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 (71) hide show
  1. package/CHANGELOG.md +121 -6
  2. package/GLOSSARY.txt +9 -1
  3. package/README.md +43 -29
  4. package/commands/api/AGENTS.md +3 -2
  5. package/commands/api/README.md +8 -1
  6. package/commands/btest/README.md +9 -0
  7. package/commands/btest/examples.md +7 -0
  8. package/commands/devices/README.md +160 -30
  9. package/commands/devices/examples.md +168 -2
  10. package/commands/execute/README.md +13 -2
  11. package/commands/explain/README.md +223 -3
  12. package/commands/explain/examples.md +8 -1
  13. package/commands/retrieve/README.md +24 -3
  14. package/commands/retrieve/examples.md +84 -0
  15. package/commands/settings/README.md +6 -0
  16. package/commands/settings/examples.md +17 -0
  17. package/commands/transfer/README.md +25 -0
  18. package/commands/transfer/examples.md +46 -0
  19. package/docs/CLI.md +53 -11
  20. package/docs/MATRIX.md +5 -3
  21. package/docs/errors/cdb/encrypt-failed.md +10 -0
  22. package/docs/errors/cdb/password-required.md +5 -4
  23. package/docs/errors/cdb/plaintext-backups.md +11 -0
  24. package/docs/errors/input/empty-stdin.md +27 -0
  25. package/docs/errors/input/invalid-routeros-path.md +25 -3
  26. package/docs/errors/input/invalid-stdin.md +28 -0
  27. package/docs/errors/usage/stdin-is-tty.md +21 -0
  28. package/package.json +11 -7
  29. package/src/api-fanout.ts +2 -0
  30. package/src/api.ts +12 -0
  31. package/src/btest.ts +7 -0
  32. package/src/cli/api.ts +19 -2
  33. package/src/cli/btest.ts +9 -0
  34. package/src/cli/common.ts +1 -0
  35. package/src/cli/devices.ts +248 -15
  36. package/src/cli/execute.ts +12 -3
  37. package/src/cli/retrieve.ts +9 -0
  38. package/src/cli/settings.ts +28 -6
  39. package/src/cli/terminal.ts +9 -0
  40. package/src/cli/transfer.ts +10 -1
  41. package/src/core/error-catalog.ts +28 -2
  42. package/src/core/menu-path.ts +89 -0
  43. package/src/devices.ts +378 -33
  44. package/src/execute-fanout.ts +2 -0
  45. package/src/execute.ts +7 -0
  46. package/src/explain/blocks.ts +2 -2
  47. package/src/explain/catalog.ts +22 -12
  48. package/src/explain/pasted-prompt.ts +198 -0
  49. package/src/explain/pathresolve.ts +2 -2
  50. package/src/explain/segment.ts +2 -2
  51. package/src/explain/symbols.ts +2 -2
  52. package/src/explain/verbsplit.ts +2 -2
  53. package/src/explain/write.ts +2 -2
  54. package/src/explain.ts +2 -0
  55. package/src/index.ts +5 -0
  56. package/src/mcp/safety.ts +7 -6
  57. package/src/mcp/tools.ts +30 -68
  58. package/src/protocols/adapter.ts +2 -0
  59. package/src/protocols/btest-session.ts +10 -30
  60. package/src/protocols/ec-srp5.ts +19 -10
  61. package/src/protocols/mac-telnet-console.ts +2 -2
  62. package/src/resolver/cdb.ts +66 -9
  63. package/src/resolver/index.ts +1 -0
  64. package/src/resolver/quickchr-provider.ts +5 -0
  65. package/src/resolver/selection.ts +15 -4
  66. package/src/retrieve-fanout.ts +2 -0
  67. package/src/retrieve.ts +11 -0
  68. package/src/settings.ts +24 -8
  69. package/src/terminal.ts +7 -0
  70. package/src/transfer-fanout.ts +2 -0
  71. package/src/transfer.ts +79 -14
@@ -257,6 +257,22 @@ today's path/verb/args split and script-vs-structured gate:
257
257
  `/interface/wifi/dashboard` and `/system/dashboard` are `inspect` only: the
258
258
  trees carry them, the manual publishes no menu entry for them.
259
259
 
260
+ **Source refresh (accepted 2026-10-30).** The CLI Reference added
261
+ `compare-versions`, a version-comparison widget page with no `**Type:**`
262
+ entry. `llms.txt` lists it as a page and the sitemap serves it as a category
263
+ URL, so it aborted the parser and would have aborted the orphan-dir check.
264
+ It joins `index` as a hand-audited non-command slug, excluded from both
265
+ inventories. The CLI Reference moves from 1,099 pages / 1,106 entries to
266
+ 1,109 / 1,116; the inspect pins and the 636-menu union are unchanged. The
267
+ catalog grows from 1,166 to 1,176 paths: 1,005 `both`, 67 `inspect`, 104
268
+ `published`. It adds 10 paths and removes none, with no kind change and no
269
+ provenance loss. The additions are `/app/settings/remove-certificate`, three
270
+ `/system/resource/hardware` commands (`power-cycle`, `power-reset`,
271
+ `reset-overcurrent`), and `/interface/wifi/easymesh` with five children. The
272
+ three `/cmr/client` gate strings drop `!powerpc`. easymesh is published with
273
+ no gate and is in no CHR tree through 7.26beta3, so the unexplained residue
274
+ grows from `/root/terminal` alone to seven paths.
275
+
260
276
  There are real unknowns in how far offline parsing can go (expression
261
277
  grammar, scope fidelity vs `:parse`, `[]`-nesting corner cases). Grounding
262
278
  those unknowns was **phase 0** (#185); its findings are folded in throughout
@@ -3007,6 +3023,202 @@ a process boundary. A B5 rename is therefore a visible break here rather than a
3007
3023
  silent one — which is the point of settling the vocabulary before an external
3008
3024
  consumer commits to it.
3009
3025
 
3026
+ ## Topic checks and related live reads (#493, design — no implementation)
3027
+
3028
+ This section is the bounded design #493 asks for. It proposes shapes, ownership,
3029
+ and acceptance gates; **no topic check, special-command rule, or contextual
3030
+ read ships with it**. Each check needs its own accepted/rejected CHR controls
3031
+ and committed evidence before an implementation slice lands, and every new
3032
+ RouterOS semantic claim is grounded then — not here. The three examples below
3033
+ are **SYNTHETIC shape illustrations, not device claims**: they show the envelope
3034
+ a check would produce, with invented content, so reviewers can judge the
3035
+ contract without mistaking illustration for evidence.
3036
+
3037
+ **Ownership: one rule table, three consumers.** Fact and check definitions live
3038
+ in one module beside the analyzer (sibling of `src/explain/write.ts`, which is
3039
+ the precedent: an explain-only inference over the shipped structure). The
3040
+ CLI renders it, the library returns it, and the MCP adapter reshapes the same
3041
+ object — no copies of rule logic per surface. Checks evaluate against the
3042
+ **statement tree** (`structure.statements[]` ids plus `parent`/`owner` links),
3043
+ never against byte containment: a finding anchors to a statement id and its
3044
+ span, so a reordered document moves the finding with its statement.
3045
+
3046
+ **Findings reuse the shipped envelope — no new finding object.** A check emits
3047
+ what explain already emits: an `ExplainDiagnostic` (`code`, `severity`,
3048
+ `message`, `span`, `ev`) for the finding, the `ExplainEvidence` entry it cites
3049
+ (`id`, `source`, `probe`, `basis`, `outcome`, `statementIds`) for how it was
3050
+ decided and which statement it anchors to, and a `tip` (`code`, `message`,
3051
+ `fix`) for the next step. Codes follow `explain/<source>/<slug>`, with the
3052
+ check family as the slug prefix (`topic-…`, `special-command-…`). Topic
3053
+ findings are `info`: like the pasted-prompt tip, advice never moves
3054
+ `data.verdict`, which stays the validity answer. A check that should raise the
3055
+ verdict is a separate, explicitly decided change, not a default. Certainty is
3056
+ the cited evidence's `basis` — `heuristic` for a ratified rule over the
3057
+ offline structure (the `containsWrite` precedent), `derived` once it rests on
3058
+ live responses — so how sure a finding is reads from the same field as every
3059
+ other explain fact.
3060
+
3061
+ **What is decided where.** Source-only: structural facts the canonicalizer
3062
+ already proves (unresolved statements, write-shape, symbol bindings). Live
3063
+ schema: what a menu accepts (argument names, enums) — only `--schema` /
3064
+ `--complete` against a named router, never a bundled snapshot (decision 3).
3065
+ Device observations: current state (`print` rows) — only through an explicitly
3066
+ requested contextual read (below). Partial script input is never treated as
3067
+ final device state, and a finding never grants execution permission: advice
3068
+ and the execution gate are separate answers, and a lost read response is
3069
+ reported as a failed read, never as a successful execution.
3070
+
3071
+ **Contextual reads are opt-in and bounded.** The default offline entry stays
3072
+ network-free; naming a router does not authorize reads. A contextual read
3073
+ fires only under an explicit flag (name TBD, e.g. `--with-print`), one bounded
3074
+ `print` per confidently resolved menu, projected and truncated, each reported
3075
+ independently with its read command, time, projection, and truncation — so an
3076
+ agent can distinguish current observed state from the proposed script. The
3077
+ target is the envelope's `meta.target`, not a per-read field. Deduplication is by (statement id, resolved path): two
3078
+ statements in one menu share one read, and a failed read fails alone. The
3079
+ read is a validated centrs `retrieve` on the resolved path — never the user's
3080
+ `[find]`, expression, or analyzed script executed to get context.
3081
+
3082
+ **Secrets follow the existing redaction contract.** Related reads ride the
3083
+ redaction paths from #359: secret-bearing menus and verbose file output are
3084
+ high-risk surfaces, so a contextual read defaults to a non-sensitive
3085
+ projection, names its projection explicitly, and never exports key material
3086
+ into advice artifacts or logs. A check whose finding would need a sensitive
3087
+ field states that need in its definition and is refused without an explicit
3088
+ sensitivity opt-in — no automatic sensitive exports.
3089
+
3090
+ **Shape 1 — a grounded topic finding (synthetic illustration).**
3091
+ Input `SYNTHETIC-1` (illustrative only):
3092
+ `/ip service set telnet disabled=no` — a statement the analyzer resolves with
3093
+ id `s0`, span 0–34. A management-plane topic check (definition owned beside
3094
+ the analyzer, evaluated against `s0`) adds one diagnostic, the evidence entry
3095
+ it cites, and a tip; the ids and the check's `probe` name are invented. A
3096
+ partial envelope — only the fields the finding adds or reads are shown:
3097
+
3098
+ ```json
3099
+ {
3100
+ "data": {
3101
+ "verdict": "pass",
3102
+ "diagnostics": [
3103
+ {
3104
+ "code": "explain/canonicalizer/topic-plaintext-management-service",
3105
+ "severity": "info",
3106
+ "message": "This enables telnet, a plaintext management service.",
3107
+ "span": { "start": 0, "end": 34 },
3108
+ "ev": "e10"
3109
+ }
3110
+ ],
3111
+ "evidence": [
3112
+ {
3113
+ "id": "e10",
3114
+ "source": "canonicalizer",
3115
+ "probe": "topicPlaintextManagementService",
3116
+ "basis": "heuristic",
3117
+ "outcome": "ok",
3118
+ "statementIds": ["s0"]
3119
+ }
3120
+ ]
3121
+ },
3122
+ "tips": [
3123
+ {
3124
+ "code": "tip/explain-topic-plaintext-management-service",
3125
+ "message": "Telnet sends credentials in clear text.",
3126
+ "fix": "Disable the plaintext service or restrict it to a management address list."
3127
+ }
3128
+ ]
3129
+ }
3130
+ ```
3131
+
3132
+ The verdict stays `pass`; probe outcome and `runtimeAcceptance` are untouched —
3133
+ the finding does not claim the router accepted anything.
3134
+
3135
+ **Shape 2 — a special-command fact (synthetic illustration).** #363 records
3136
+ the real cases (deferred there, not duplicated here); the shape is Shape 1's,
3137
+ with the `special-command-` slug family and a consequence, not a
3138
+ configuration judgement. Illustrative only:
3139
+
3140
+ ```json
3141
+ {
3142
+ "code": "explain/canonicalizer/special-command-connection-loss",
3143
+ "severity": "info",
3144
+ "message": "This command can drop the session that runs it.",
3145
+ "span": { "start": 64, "end": 79 },
3146
+ "ev": "e11"
3147
+ }
3148
+ ```
3149
+
3150
+ `e11` cites `statementIds: ["s2"]`, and the paired tip's `fix` says to run it
3151
+ from a path that survives the change.
3152
+
3153
+ No isolated device-mode table ships first (the #363 deferral stands); a
3154
+ special-command fact names its consequence and its evidence, nothing more.
3155
+
3156
+ **Shape 3 — an explicitly requested contextual read (synthetic illustration).**
3157
+ Input (illustrative): `/ip address add address=198.51.100.1/32 interface=ether1`
3158
+ with an explicit `--with-print`-style opt-in. The read follows the live
3159
+ argument-name precedent (`data.live.arguments[]` cites a `live-inspect`
3160
+ evidence entry): one evidence entry per read, and the rows in `data.live`
3161
+ citing it, independent of the analysis verdict. A partial `data` object —
3162
+ `evidence` is `data.evidence`, `live` is `data.live`:
3163
+
3164
+ ```json
3165
+ {
3166
+ "evidence": [
3167
+ {
3168
+ "id": "e12",
3169
+ "source": "live-read",
3170
+ "probe": "print",
3171
+ "basis": "direct",
3172
+ "outcome": "ok",
3173
+ "version": "SYNTHETIC-version",
3174
+ "observedAt": "SYNTHETIC-timestamp",
3175
+ "statementIds": ["s0"],
3176
+ "path": "/ip/address",
3177
+ "projection": ["address", "interface", "disabled"],
3178
+ "truncation": { "returned": 3, "total": 12 }
3179
+ }
3180
+ ],
3181
+ "live": {
3182
+ "reads": [
3183
+ {
3184
+ "ev": "e12",
3185
+ "path": "/ip/address",
3186
+ "statementIds": ["s0"],
3187
+ "rows": [
3188
+ { "address": "192.0.2.1/24", "interface": "ether1", "disabled": "false" },
3189
+ { "address": "198.51.100.2/24", "interface": "ether2", "disabled": "false" },
3190
+ { "address": "203.0.113.1/24", "interface": "bridge", "disabled": "true" }
3191
+ ]
3192
+ }
3193
+ ]
3194
+ }
3195
+ }
3196
+ ```
3197
+
3198
+ The target is the envelope's existing `meta.target`, not a per-read field.
3199
+ Three additions are proposed, not shipped: the `live-read` evidence source,
3200
+ the `projection` and `truncation` evidence fields, and `data.live.reads[]`.
3201
+ Originating statement and path, time, projection, and truncation are all
3202
+ present, so observed state cannot be mistaken for the proposed script. A
3203
+ failed read reports its `outcome` with `reason`/`errorCode` and fails alone.
3204
+
3205
+ **Acceptance controls and dependencies before implementation slices.**
3206
+
3207
+ - Each check ships with accepted/rejected CHR controls and committed evidence;
3208
+ synthetic shapes above are replaced by measured rows, never promoted as-is.
3209
+ - Controls assert statement-id anchoring (finding moves with its statement),
3210
+ verdict independence (a finding leaves `data.verdict` unchanged), the cited
3211
+ evidence's `basis`, and read independence (one failed read does not fail
3212
+ the analysis or sibling reads).
3213
+ - A check that needs a sensitive field is refused without the sensitivity
3214
+ opt-in; the redaction contract is asserted, not assumed.
3215
+ - Dependencies: published offline readings stay honest (#211, #350, #351,
3216
+ #358, #371); live path/argument/probe facts come from #236; live value
3217
+ types are distinct evidence (#225); the MCP result migration is separate
3218
+ (#223); text stdout/stderr routing is unchanged (#477); device-mode
3219
+ provisioning stays in #95. No new plugin DSL or framework is preselected —
3220
+ existing `warnings`/`tips` and explain diagnostics are reused where they fit.
3221
+
3010
3222
  ## Non-goals
3011
3223
 
3012
3224
  - No LSP server in centrs — the LSP is a consumer the library must support,
@@ -3272,14 +3484,22 @@ a missing mandatory value, and a name collision. That was verified rather than
3272
3484
  assumed — each blocker statement was replayed through `:put [:parse …]` and
3273
3485
  `/console/inspect request=highlight` on both versions and **accepted every
3274
3486
  time**, with controls (`no-such-attr=`, an unterminated string, a nonexistent
3275
- menu) correctly rejected. So these results carry **no** implication about
3487
+ menu) correctly rejected by `:parse`. `highlight` is the weaker of the two: it
3488
+ marks only the nonexistent menu with an `error` class, reading the bad
3489
+ attribute as `obj-inactive` and the unterminated string as ordinary tokens, so
3490
+ the controls' rejection rests on `:parse`. So these results carry **no** implication about
3276
3491
  whether `explain` can parse the forms, and they do not license claims about the
3277
3492
  export styles in general: the captures come from one synthetic configuration on
3278
3493
  CHR, which has a single NIC and no switch or wireless hardware, so its defconf
3279
3494
  and its available menus differ from any real device's. Treat the provenance
3280
3495
  label as **L1-emitted, L2-refuted for these captures on CHR**. Harness and
3281
- per-document results: `.scratch/explain-lab-q203-import-verify.ts`;
3282
- validity check: `.scratch/q203-blocker-validity.ts`.
3496
+ per-document results: the q203 import-verify lab (history — see #203; its
3497
+ scripts and corpora were never promoted, so the counts above rest on those
3498
+ lab captures). Validity check: `bun run explain:probe:import-blocker-validity`
3499
+ (promoted from the q203 lab for #272; replays the four blockers plus the
3500
+ three rejecting controls through `:parse` and `highlight` on a disposable
3501
+ CHR per version; re-run on 7.23.2 and 7.24rc2 for #568, 6/6 and 7/7 rows
3502
+ agree).
3283
3503
 
3284
3504
  **Non-blocking / deferred (not ratification-gating):** Q5 expression depth, Q7
3285
3505
  tokenizer corners, Q9 potential-command taxonomy, Q12 span-vocabulary draft —
@@ -888,7 +888,14 @@ centrs explain '[admin@MikroTik] > /ip address print' --json
888
888
  `data.verdict` is `fail` with `explain/canonicalizer/invalid-at-sign` at the
889
889
  `@`. The device answers `syntax error (line 1 column 7)`, the same byte. This
890
890
  is a pasted console prompt. `:put a@b` and `comment=a@b` pass, because a `@` is
891
- a value byte in an argument.
891
+ a value byte in an argument. The envelope also carries
892
+ `tip/explain-pasted-prompt` offering the stripped `/ip address print` as
893
+ repair text (never applied). A `/menu>` prompt such as
894
+ `/interface/lte> monitor lte1` keeps a `warn` abstention with the same tip
895
+ shape, offering `monitor lte1` typed at `/interface/lte`. The manual's
896
+ in-menu prompt `[admin@MikroTik] /ip/address> add …` fails the same way and
897
+ offers `add …` typed at `/ip/address`; a HotLock `>>` is stripped with the
898
+ prompt.
892
899
 
893
900
  ### 41. A named comparison brace inside a `[find where …]` is rejected
894
901
 
@@ -96,6 +96,23 @@ error code, so its reason is `not reached: interrupted` (#537). With
96
96
  If `--all-attributes` is combined with `--attribute(s)`, fail with
97
97
  `usage/conflicting-flags` *before* hitting the network.
98
98
 
99
+ A read only ever dispatches a `print`/`get`, on every transport and even with
100
+ `--validate=false`: that flag skips command validation, not the read/write
101
+ split (#586). Each path segment must be a RouterOS menu name — letters,
102
+ digits and hyphens (case folds), the grammar of every dir and command in the
103
+ 7.9–7.26 tree — or the read fails with `input/invalid-routeros-path` before
104
+ resolution. Before this check, a `#` or `?` cut the appended `/print` off the
105
+ REST URL, so `retrieve '/system/reboot#' --no-validate --attribute uptime`
106
+ posted `/rest/system/reboot` and rebooted CHR 7.24.5. A well-formed command
107
+ path is safe: RouterOS answers `POST /rest/system/reboot/print` (and a `GET` of
108
+ a command) with "no such command", and native API answers
109
+ `/system/reboot/print` the same way (CHR 7.24.5). Under the REST adapter, a
110
+ second guard refuses any request path containing `?`, `#`, `\`, a control
111
+ character, or a `.`/`..` segment (including the percent-encoded `%2e%2e` and
112
+ `.%2E` forms, which the URL parser also collapses) for every command. RouterOS
113
+ does not decode percent-escapes in REST paths, so nothing else changes the
114
+ target.
115
+
99
116
  SNMP validation is separate from RouterOS `/console/inspect`: OIDs and MIB
100
117
  names must resolve through the MIB cache before any SNMP request is sent.
101
118
 
@@ -524,7 +541,11 @@ stays the single-target envelope; a selector flag or a second positional target
524
541
  switches to fan-out. `--quickchr <name>` (the named-live-provider) behaves like a
525
542
  lone positional: one flag stays single-target, repeating it fans out, and mixing
526
543
  it with positionals or CDB selectors is `usage/conflicting-flags` (constitution:
527
- resolution providers). The `--host` **flag** with no `<router>` positional
544
+ resolution providers). When several CDB records share one `<router>` (the
545
+ same address saved under two users), `--match user=<name>` (or
546
+ `target=<addr>`, or a record type) picks one, as on `devices show`; it is
547
+ single-target only (see
548
+ [devices → Ambiguity](../devices/README.md#ambiguity), #583). The `--host` **flag** with no `<router>` positional
528
549
  (`retrieve --host http://192.0.2.10:8080 /system/resource`) is a single target
529
550
  too: no CDB lookup, so auth comes from flags, env or config. `CENTRS_HOST` never
530
551
  stands in for `<router>`.
@@ -562,5 +583,5 @@ reach green is forbidden. See `docs/CONSTITUTION.md` for the full done rule.
562
583
  - **ssh / mac-telnet / romon / winbox-terminal** — execute surfaces, not
563
584
  retrieve surfaces.
564
585
  - **tips** — when a `<router>` fails to resolve against an empty CDB, retrieve
565
- emits the same `tip/no-devices` advice as `devices list`, steering toward
566
- `centrs devices discover` and `centrs settings`.
586
+ emits `tip/no-devices`, steering toward `centrs discover --save` and
587
+ `centrs devices add`.
@@ -146,6 +146,43 @@ centrs retrieve $R /system/license --username $U --password $P --json
146
146
  centrs retrieve $R /system/resource --attribute bogus --username $U --password $P --json
147
147
  ```
148
148
 
149
+ ## Read paths dispatch only reads (#586)
150
+
151
+ A read sends only a `print`/`get`, even with `--validate=false`. Covered by
152
+ the CHR test `test/integration/read-path-guard.test.ts` (RP1–RP2, over REST
153
+ and native-api, for `retrieve` and `api`; the router's uptime must never go
154
+ backwards) and the offline grammar and REST-guard tests in
155
+ `test/unit/menu-path.test.ts` (RP3).
156
+
157
+ ### RP1. A `#` or `?` in the path is refused before dispatch
158
+
159
+ ```bash
160
+ centrs retrieve $R '/system/reboot#' --no-validate --attribute uptime --username $U --password $P --json
161
+ centrs api $R '/system/reboot?' --validate=false --attribute uptime --username $U --password $P --json
162
+ ```
163
+
164
+ Envelope: `ok: false`, `error.code=input/invalid-routeros-path`,
165
+ `error.context.invalidSegments` names the bad segment, and nothing is sent.
166
+ Before #586 the first line posted `/rest/system/reboot` and rebooted the CHR.
167
+
168
+ ### RP2. A well-formed command path is RouterOS's refusal, not a dispatch
169
+
170
+ ```bash
171
+ centrs retrieve $R /system/reboot --no-validate --username $U --password $P --json
172
+ centrs retrieve $R /system/reset-configuration --no-validate --attribute uptime --username $U --password $P --json
173
+ ```
174
+
175
+ Envelope: `ok: false` with a `routeros/*` code. RouterOS answers
176
+ `<command>/print` and a `GET` of a command with "no such command", so the
177
+ appended `/print` is the guarantee once the path cannot cut it off.
178
+
179
+ ### RP3. Menu-name grammar and the REST URL guard
180
+
181
+ Path segments are letters, digits and hyphens (case folds:
182
+ `/IP/Address` is accepted). Under every REST caller, a request path with
183
+ `?`, `#`, `\`, a control character or a `.`/`..` segment (including
184
+ `%2e%2e` and `.%2E`) fails `input/invalid-routeros-path` before `fetch`.
185
+
149
186
  ## Transport / error contract
150
187
 
151
188
  ### 12. Bad credentials
@@ -821,3 +858,50 @@ centrs retrieve $R /interface --until name=missing --wait 500ms --username $U --
821
858
  One envelope goes to stdout, with `stopReason: "deadline-elapsed"`; exit 1.
822
859
  No intermediate sample frames are printed. Unit anchors cover slow validation,
823
860
  unfinished reads, cancellation and narrow retry classification.
861
+
862
+ ## Record selection (`--match`, #583)
863
+
864
+ Covered by the named tests `MA1`–`MA3` in `test/integration/cdb-match.test.ts`
865
+ on disposable CHR; MA4 is the offline contract in the `MA4.` tests of
866
+ `test/unit/cdb-match.test.ts`, which drive `retrieve`/`api` through the CLI. `$CDB` holds
867
+ two `ipAdmin` records for the same target `$R`: user `$U` with a deliberately
868
+ wrong password, and a `read`-group user `reader` with its real password.
869
+ The same selector works on `execute`, `api`, `transfer`, `terminal` and
870
+ `btest client`.
871
+
872
+ ### MA1. Two records for one target are ambiguous
873
+
874
+ ```bash
875
+ centrs retrieve $R /system/identity --cdb-file $CDB --json
876
+ ```
877
+
878
+ Envelope: `ok: false`, `error.code=identity/ambiguous`, `context.matches`
879
+ lists both records, and the remediation names `--match user=<name>`.
880
+
881
+ ### MA2. `--match user=` picks the record and its credentials
882
+
883
+ ```bash
884
+ centrs retrieve $R /system/identity --cdb-file $CDB --match user=reader --json
885
+ centrs execute $R /system/identity/print --cdb-file $CDB --match user=reader --json
886
+ centrs api $R system/identity --cdb-file $CDB --match user=reader --json
887
+ ```
888
+
889
+ Each is `ok: true` with `meta.target.recordIndex=1`. With
890
+ `--match user=$U` instead, each fails `transport/auth-failed`: the decoy's
891
+ wrong password went on the wire, so the selection is real.
892
+
893
+ ### MA3. A selector that matches nothing
894
+
895
+ ```bash
896
+ centrs retrieve $R /system/identity --cdb-file $CDB --match user=nobody --json
897
+ ```
898
+
899
+ Envelope: `ok: false`, `error.code=identity/no-match`.
900
+
901
+ ### MA4. `--match` is single-target and never ignored
902
+
903
+ With a fan-out selection (`--group`, `--where`, `--all`, several
904
+ positionals) or `--quickchr`, `--match` is `usage/conflicting-flags`. When no
905
+ CDB record is in play (no CDB file, an unregistered literal host, a
906
+ `__default__`-only fallback) it is `identity/no-match`, never a silent
907
+ literal-host read.
@@ -156,6 +156,12 @@ centrs settings reset [<attr>] # clear one/all back to built-in
156
156
  elsewhere (`src/cli/api.ts`'s `exitCode`), never a bare non-zero without an
157
157
  envelope. `settings` has no fan-out surface, so the constitution's `2`
158
158
  ("partial" fan-out result) never applies here.
159
+ - **`get` output:** text mode renders the one entry exactly as `print`
160
+ renders that key — `key: value [source: kind (key)]` under the canonical
161
+ attr name, plus any `Tips:` footer (examples.md #33). `get`'s `data` is the
162
+ bare `{ source, value }` entry, so the JSON/YAML envelope names the key in
163
+ `meta.operation.attr` (canonical name, whichever spelling was asked for).
164
+ A blank `<attr>` is refused at parse time as `input/invalid-command`.
159
165
  - **Agent-facing:** every subcommand emits the standard envelope
160
166
  (`ok`/`data`/`meta`/`error`) regardless of TTY — `--format json` is
161
167
  honored on `settings` itself exactly like every other command, so an agent
@@ -406,3 +406,20 @@ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings print --cdb-file /tmp/does-not-exi
406
406
  device configured" rather than `cdb/not-found`, since reporting settings
407
407
  should not require a CDB to exist at all). `data.defaultDevice = {
408
408
  configured: false }`.
409
+
410
+ ## Text output
411
+
412
+ ### 33. Get renders one entry in text mode, like print
413
+
414
+ ```bash
415
+ XDG_CONFIG_HOME=$SETTINGS_DIR CENTRS_MAX_RESULTS=7 centrs settings get CENTRS_MAX_RESULTS
416
+ XDG_CONFIG_HOME=$SETTINGS_DIR centrs settings get ssh-key
417
+ ```
418
+
419
+ `ok: true`, exit 0. Text mode prints the single entry as one line in the
420
+ same `key: value [source: kind (key)]` format `print` uses, under the
421
+ canonical attr name whichever spelling was asked for: `max-results: 7
422
+ [source: env (CENTRS_MAX_RESULTS)]`, then `ssh-key: (unset) [source:
423
+ default (ssh-key)]` followed by its `Tips:` footer. `get`'s `data` is the bare
424
+ entry, so the JSON envelope names the key in `meta.operation.attr`. A
425
+ credential-shaped key renders `(redacted)`, never the secret.
@@ -118,6 +118,31 @@ past 60 KB) rather than quietly switching to sftp. `scp`, `fetch`, and `ftp` are
118
118
  explicit-only; `ftp` additionally requires `ALLOW_UNSAFE_PROTOCOLS=ftp` because
119
119
  it is cleartext.
120
120
 
121
+ The method resolves `--via` → `CENTRS_TRANSFER_VIA` → the CDB record's `via=`
122
+ preference → config → auto ([constitution: settings](../../docs/CONSTITUTION.md)).
123
+ A record's `via=` names a *transport* (shared with `execute`), so transfer maps
124
+ it to a method (#591):
125
+
126
+ | CDB / `--via` transport name | Transfer method |
127
+ | --- | --- |
128
+ | `ssh` | `sftp` |
129
+ | `rest-api` (also `--via rest`) | `rest` |
130
+ | `native-api` (also `--via native`) | `native` |
131
+ | `mac-telnet`, `romon`, `winbox-terminal`, `snmp`, `mndp`, `btest` | none — `settings/invalid-via` |
132
+
133
+ `--via ssh` (and `CENTRS_TRANSFER_VIA=ssh`) is accepted as an alias of
134
+ `sftp`. A record whose `via=` cannot carry files fails `settings/invalid-via`
135
+ naming the record, rather than being ignored; pass `--via sftp|rest|native`
136
+ for that transfer. A record `via=` holds a transport id: method names
137
+ (`sftp`, `rest`, `ftp`) and unknown values are rejected when the record is
138
+ read (`cdb/invalid-option` warning) and leave the method on auto. A record
139
+ preference is a setting, not an auto hop: a `via=rest-api` record uploading
140
+ more than 60 KB fails `transport/unsupported-operation` like a pinned
141
+ `--via rest` does, and the remediation names the record. The endpoint and
142
+ credentials still come from the same record; the mapping never selects a
143
+ different one. A record's `port=` travels with a `--via` pin, so if the
144
+ record's port belongs to another service, point `--port` at the pinned one.
145
+
121
146
  **File contents are bytes on `rest`/`native`.** RouterOS carries `/file`
122
147
  contents as raw bytes on both wires — REST puts bytes 0x80–0xff unescaped in the
123
148
  JSON string, so the body is not UTF-8 — and stores the UTF-8 encoding of every
@@ -569,3 +569,49 @@ list succeeds over sftp: `meta.via=sftp`, `meta.target.source.kind=provider`,
569
569
  handed over) carried as a provider-sourced `sshKey`. Otherwise the envelope is
570
570
  `ok: false` with `error.code=quickchr/unsupported-via` and exit code 1 — a
571
571
  typed gate, **never** a password prompt and never a silent fallback to REST.
572
+
573
+ ## CDB record `via=` (#591)
574
+
575
+ A record's `via=` names a transport, shared with `execute`; transfer maps it
576
+ to a method (`ssh`→`sftp`, `rest-api`→`rest`, `native-api`→`native`). `$CDB`
577
+ holds one record for `127.0.0.1` whose comment is
578
+ `via=ssh port=$SSH_PORT ssh-key=$KEY insecure=true`; the key is imported as
579
+ in the sftp section. C1–C2 are covered by
580
+ `test/integration/transfer-cdb-via.test.ts`; C3–C4 are offline error
581
+ contracts covered by `test/unit/transfer.test.ts`
582
+ (`resolveTransferRequest takes the CDB record's via=`).
583
+
584
+ ### C1. A `via=ssh` record runs execute over ssh and transfer over sftp
585
+
586
+ ```bash
587
+ centrs execute 127.0.0.1 --cdb-file $CDB /system/identity/print --json
588
+ centrs transfer 127.0.0.1 --cdb-file $CDB upload $SRC centrs-591.txt --json
589
+ ```
590
+
591
+ Both `ok: true` with `meta.via="ssh"`, and no `--via` on either. The
592
+ transfer's `meta.settings.via` is `{kind: "comment-kv", key: "record:0:via"}`.
593
+
594
+ ### C2. Download from the same record, with and without `--via sftp`
595
+
596
+ ```bash
597
+ centrs transfer 127.0.0.1 --cdb-file $CDB download centrs-591.txt $TMP/c2-auto.txt --json
598
+ centrs transfer 127.0.0.1 --cdb-file $CDB --via sftp download centrs-591.txt $TMP/c2-sftp.txt --json
599
+ ```
600
+
601
+ Both `ok: true`, `meta.via="ssh"`; each file is byte-identical to `$SRC`.
602
+ `--via ssh` is accepted as the same method as `--via sftp`.
603
+
604
+ ### C3. A record transport that cannot carry files
605
+
606
+ A record with `via=mac-telnet` (or `romon`, `snmp`, `winbox-terminal`,
607
+ `mndp`, `btest`) and no `--via`: `ok: false`, `error.code=settings/invalid-via`,
608
+ `error.context.source="record:N:via"`, and the remediation suggests
609
+ `--via sftp`, `--via rest` or `--via native`. An explicit `--via` wins over
610
+ the record and the transfer proceeds.
611
+
612
+ ### C4. A `via=rest-api` record does not auto-hop a large upload
613
+
614
+ A `via=rest-api` record uploading more than 60 KB with no `--via`:
615
+ `ok: false`, `error.code=transport/unsupported-operation`, nothing sent. The
616
+ remediation names `record:N:via` and suggests `--via sftp`, matching a
617
+ pinned `--via rest`.