@tikoci/centrs 0.1.3 → 0.1.6

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 (107) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/GLOSSARY.txt +252 -0
  3. package/LICENSE +21 -0
  4. package/README.md +44 -12
  5. package/commands/AGENTS.md +14 -5
  6. package/commands/api/AGENTS.md +13 -1
  7. package/commands/api/README.md +56 -26
  8. package/commands/api/examples.md +46 -1
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +6 -1
  11. package/commands/discover/README.md +5 -11
  12. package/commands/execute/README.md +77 -36
  13. package/commands/execute/examples.md +120 -10
  14. package/commands/explain/README.md +2756 -0
  15. package/commands/explain/examples.md +702 -0
  16. package/commands/mcp/README.md +7 -4
  17. package/commands/mcp/examples.md +2 -2
  18. package/commands/retrieve/README.md +25 -27
  19. package/commands/retrieve/examples.md +16 -0
  20. package/commands/terminal/examples.md +7 -1
  21. package/commands/transfer/README.md +28 -20
  22. package/commands/transfer/examples.md +29 -0
  23. package/docs/CLI.md +353 -0
  24. package/docs/CONSTITUTION.md +138 -30
  25. package/docs/MATRIX.md +92 -18
  26. package/docs/errors/README.md +3 -0
  27. package/docs/errors/quickchr/machine-not-found.md +22 -0
  28. package/docs/errors/quickchr/machine-stopped.md +20 -0
  29. package/docs/errors/quickchr/package-unavailable.md +27 -0
  30. package/docs/errors/quickchr/unsupported-via.md +35 -0
  31. package/docs/errors/quickchr/unsupported.md +35 -0
  32. package/docs/errors/routeros/version-unsupported.md +26 -0
  33. package/docs/errors/transport/provider-trust.md +29 -0
  34. package/docs/errors/usage/stdin-ignored.md +28 -0
  35. package/docs/errors/validation/syntax.md +29 -4
  36. package/docs/errors/validation/unknown-attribute.md +37 -4
  37. package/package.json +56 -10
  38. package/src/api-fanout.ts +46 -18
  39. package/src/api.ts +351 -121
  40. package/src/cli/api.ts +26 -7
  41. package/src/cli/btest.ts +19 -3
  42. package/src/cli/execute.ts +25 -6
  43. package/src/cli/explain.ts +503 -0
  44. package/src/cli/retrieve.ts +33 -1
  45. package/src/cli/selection.ts +71 -11
  46. package/src/cli/settings.ts +2 -2
  47. package/src/cli/terminal.ts +1 -1
  48. package/src/cli/transfer.ts +23 -6
  49. package/src/cli.ts +15 -2
  50. package/src/core/envelope.ts +58 -2
  51. package/src/core/error-catalog.ts +42 -1
  52. package/src/core/execute-command.ts +148 -0
  53. package/src/core/inspect.ts +36 -4
  54. package/src/core/preflight.ts +306 -0
  55. package/src/core/routeros-string.ts +24 -0
  56. package/src/core/version.ts +4 -0
  57. package/src/core/yaml.ts +86 -0
  58. package/src/errors.ts +1 -0
  59. package/src/execute-fanout.ts +47 -18
  60. package/src/execute.ts +339 -307
  61. package/src/explain/arg-tokens.ts +151 -0
  62. package/src/explain/args.ts +1749 -0
  63. package/src/explain/blocks.ts +175 -0
  64. package/src/explain/brace-slots.ts +143 -0
  65. package/src/explain/brace-tokens.ts +39 -0
  66. package/src/explain/catalog.ts +1343 -0
  67. package/src/explain/coordinates.ts +488 -0
  68. package/src/explain/defects.ts +218 -0
  69. package/src/explain/escape-tokens.ts +77 -0
  70. package/src/explain/highlight-projection.ts +308 -0
  71. package/src/explain/is-known-menu.ts +17 -0
  72. package/src/explain/menus.ts +668 -0
  73. package/src/explain/operator-tokens.ts +316 -0
  74. package/src/explain/operators.ts +465 -0
  75. package/src/explain/path-tokens.ts +150 -0
  76. package/src/explain/pathresolve.ts +1569 -0
  77. package/src/explain/quoted-string.ts +331 -0
  78. package/src/explain/scope-brace.ts +461 -0
  79. package/src/explain/segment.ts +1165 -0
  80. package/src/explain/separator.ts +187 -0
  81. package/src/explain/split-owner.ts +194 -0
  82. package/src/explain/string-tokens.ts +77 -0
  83. package/src/explain/symbol-values.ts +556 -0
  84. package/src/explain/symbols.ts +1617 -0
  85. package/src/explain/token-ranges.ts +72 -0
  86. package/src/explain/transport.ts +514 -0
  87. package/src/explain/value-tokens.ts +83 -0
  88. package/src/explain/values.ts +357 -0
  89. package/src/explain/verbs.ts +64 -0
  90. package/src/explain/verbsplit.ts +955 -0
  91. package/src/explain/write.ts +856 -0
  92. package/src/explain.ts +2602 -0
  93. package/src/index.ts +225 -6
  94. package/src/mcp/server.ts +2 -1
  95. package/src/offline-gate.ts +148 -0
  96. package/src/protocols/mac-telnet-console.ts +61 -17
  97. package/src/resolver/facts.ts +1 -1
  98. package/src/resolver/index.ts +23 -4
  99. package/src/resolver/quickchr-provider.ts +597 -0
  100. package/src/resolver/selection.ts +99 -12
  101. package/src/resolver/service-endpoint.ts +155 -0
  102. package/src/resolver/target.ts +1 -1
  103. package/src/retrieve-fanout.ts +27 -13
  104. package/src/retrieve.ts +82 -77
  105. package/src/settings.ts +2 -1
  106. package/src/transfer-fanout.ts +43 -26
  107. package/src/transfer.ts +88 -45
@@ -0,0 +1,503 @@
1
+ /**
2
+ * `centrs explain` CLI surface: command metadata, arg parsing, dispatch, and
3
+ * envelope rendering for the phase-1 OFFLINE analysis (#202b, #202c-2).
4
+ *
5
+ * ## The positional grammar is conditional, and that is deliberate
6
+ *
7
+ * `explain '<input>'` is offline; `explain <router> '<input>'` is live. One
8
+ * positional means the input, two mean router-then-input — target-first like
9
+ * every other router-taking command, so adding a router never reinterprets a
10
+ * previously valid offline invocation (`commands/explain/README.md` → Surface).
11
+ * No other centrs command has an optional target where arity changes meaning,
12
+ * which is why the split is done here rather than in a shared helper.
13
+ *
14
+ * `--file` REPLACES the input positional, so with it every positional is a
15
+ * target. **Ambient stdin does not**: it is read only when no positional could
16
+ * be the input, a positional always wins, and a redirected fd 0 that went
17
+ * unread is reported as a `usage/stdin-ignored` warning rather than dropped
18
+ * quietly. `--file -` names stdin explicitly and works in both forms. The
19
+ * reasoning — and the measurement behind it — is on {@link readInput}.
20
+ *
21
+ * ## What this surface refuses to do
22
+ *
23
+ * - **The live form is rejected, not degraded.** Phase 2 owns the probes; a
24
+ * phase-1 run that silently analyzed offline while a router was named would
25
+ * report `mode: "offline"` about an invocation that asked for more.
26
+ * - **Fan-out is rejected** (`usage/fanout-not-supported`, the `terminal`
27
+ * pattern): `explain` takes at most one router.
28
+ * - **`--complete` / `--schema` enumerate nothing offline.** They are accepted,
29
+ * they emit the live-target tip, and they add no fabricated `data.schema` —
30
+ * offline consults no schema snapshot, by ratified decision.
31
+ */
32
+
33
+ import { fstatSync } from "node:fs";
34
+ import type { Warning } from "../core/envelope.ts";
35
+ import { CentrsError, formatCentrsErrorText } from "../errors.ts";
36
+ import {
37
+ buildExplainErrorEnvelope,
38
+ buildExplainFacetTip,
39
+ type ExplainFailOn,
40
+ type ExplainOutputFormat,
41
+ explainEnvelope,
42
+ explainExitCode,
43
+ explainFailOnLevels,
44
+ explainOutputFormats,
45
+ renderExplainEnvelope,
46
+ resolveExplainFormat,
47
+ } from "../explain.ts";
48
+ import { describeCentrs } from "../index.ts";
49
+ import { loadEnvFileDefaults } from "../resolver/config-file.ts";
50
+ import type { ResolvedSetting } from "../resolver/settings.ts";
51
+ import {
52
+ type CliCommandMetadata,
53
+ expectValue,
54
+ renderCommandHelp,
55
+ unknownFlagError,
56
+ } from "./common.ts";
57
+ import { selectionFlagTokens } from "./selection.ts";
58
+
59
+ export const explainCliCommand: CliCommandMetadata = {
60
+ name: "explain",
61
+ usage: "centrs explain '<input>' [flags]",
62
+ summary:
63
+ "Analyze a RouterOS command offline: canonical form, structure, and syntax diagnostics.",
64
+ options: [
65
+ {
66
+ flag: "--file",
67
+ valueName: "<path>",
68
+ description:
69
+ "Read the input from a file instead of the positional (`-` reads stdin). Piped stdin is used automatically when no input positional is given.",
70
+ },
71
+ {
72
+ flag: "--fail-on",
73
+ valueName: `<${explainFailOnLevels.join("|")}>`,
74
+ description:
75
+ "Exit 2 when the verdict reaches this severity. Default `error`; an ambiguous or unknown statement is a warning, never an error.",
76
+ },
77
+ {
78
+ flag: "--complete",
79
+ description:
80
+ "Continuation candidates at the cursor. Live evidence — offline emits a tip and enumerates nothing.",
81
+ },
82
+ {
83
+ flag: "--schema",
84
+ description:
85
+ "Path enumeration (verbs, args, types, enums). Live evidence — offline emits a tip and enumerates nothing.",
86
+ },
87
+ {
88
+ flag: "--curl",
89
+ description:
90
+ "Render a ready-to-edit REST curl for statements covered by a runtime-tested mapping rule.",
91
+ },
92
+ {
93
+ flag: "--tokens",
94
+ description:
95
+ "Emit the total, gapless token partition behind `data.tokens[]` (every byte not claimed by an analyzer is `unclassified`).",
96
+ },
97
+ {
98
+ flag: "--format",
99
+ valueName: `<${explainOutputFormats.join("|")}>`,
100
+ description: "Output format for the CLI response.",
101
+ },
102
+ { flag: "--json", description: "Shortcut for `--format json`." },
103
+ { flag: "--yaml", description: "Shortcut for `--format yaml`." },
104
+ { flag: "--verbose", description: "Verbose error output." },
105
+ ],
106
+ };
107
+
108
+ export interface ExplainCliArgs {
109
+ help?: boolean;
110
+ positionals: string[];
111
+ filePath?: string;
112
+ failOn: ExplainFailOn;
113
+ facets: string[];
114
+ curl?: boolean;
115
+ tokens?: boolean;
116
+ format?: ExplainOutputFormat;
117
+ verbose?: boolean;
118
+ }
119
+
120
+ export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
121
+ const parsed: ExplainCliArgs = {
122
+ positionals: [],
123
+ failOn: "error",
124
+ facets: [],
125
+ };
126
+ let literal = false;
127
+ for (let index = 0; index < args.length; index += 1) {
128
+ const arg = args[index];
129
+ if (arg === undefined) continue;
130
+ if (literal) {
131
+ parsed.positionals.push(arg);
132
+ continue;
133
+ }
134
+ switch (arg) {
135
+ case "--":
136
+ // Everything after `--` is input, however command-shaped it looks:
137
+ // RouterOS input legitimately starts with `-` (`--` is also a comment
138
+ // in some spellings) and must never be read as a centrs flag.
139
+ literal = true;
140
+ break;
141
+ case "--help":
142
+ case "-h":
143
+ parsed.help = true;
144
+ break;
145
+ case "--file":
146
+ parsed.filePath = expectValue(args, ++index, arg);
147
+ break;
148
+ case "--fail-on": {
149
+ const value = expectValue(args, ++index, arg);
150
+ if (!(explainFailOnLevels as readonly string[]).includes(value)) {
151
+ throw new CentrsError({
152
+ code: "input/invalid-command",
153
+ summary: `--fail-on must be one of ${explainFailOnLevels.join(", ")}; got ${value}.`,
154
+ remediation:
155
+ "Pass `--fail-on error` (default), `--fail-on warning`, or `--fail-on never`.",
156
+ context: { failOn: value },
157
+ });
158
+ }
159
+ parsed.failOn = value as ExplainFailOn;
160
+ break;
161
+ }
162
+ case "--complete":
163
+ parsed.facets.push("--complete");
164
+ break;
165
+ case "--schema":
166
+ parsed.facets.push("--schema");
167
+ break;
168
+ case "--curl":
169
+ parsed.curl = true;
170
+ break;
171
+ case "--tokens":
172
+ parsed.tokens = true;
173
+ break;
174
+ case "--format": {
175
+ const value = expectValue(args, ++index, arg);
176
+ if (!(explainOutputFormats as readonly string[]).includes(value)) {
177
+ throw new CentrsError({
178
+ code: "settings/invalid-format",
179
+ summary: `--format must be one of ${explainOutputFormats.join(", ")}; got ${value}.`,
180
+ remediation: `Choose one of ${explainOutputFormats.join(", ")}.`,
181
+ context: { format: value },
182
+ });
183
+ }
184
+ parsed.format = value as ExplainOutputFormat;
185
+ break;
186
+ }
187
+ case "--json":
188
+ parsed.format = "json";
189
+ break;
190
+ case "--yaml":
191
+ parsed.format = "yaml";
192
+ break;
193
+ case "--verbose":
194
+ parsed.verbose = true;
195
+ break;
196
+ default:
197
+ // `explain` analyzes one input against at most one router, so a
198
+ // multi-target selection has nothing to mean here (the `terminal`
199
+ // pattern, shared catalog code).
200
+ if ((selectionFlagTokens as readonly string[]).includes(arg)) {
201
+ throw fanoutNotSupportedError({ flag: arg });
202
+ }
203
+ if (arg.startsWith("-")) {
204
+ throw unknownFlagError("explain", arg, explainCliCommand.options);
205
+ }
206
+ parsed.positionals.push(arg);
207
+ break;
208
+ }
209
+ }
210
+ return parsed;
211
+ }
212
+
213
+ /**
214
+ * The one fan-out refusal, for both ways of asking for one.
215
+ *
216
+ * A selection flag and more than one positional target are the SAME fact under
217
+ * the constitution (`isFanoutMode` keys on either), so they get the same code.
218
+ * Giving the positional form its own `input/invalid-command` would have made a
219
+ * documented contract — the module header and the command README both promise
220
+ * `usage/fanout-not-supported` — false for half its cases.
221
+ */
222
+ function fanoutNotSupportedError(context: {
223
+ flag?: string;
224
+ targets?: readonly string[];
225
+ }): CentrsError {
226
+ return new CentrsError({
227
+ code: "usage/fanout-not-supported",
228
+ summary:
229
+ "`centrs explain` analyzes one input against at most one router and cannot fan out across a multi-target selection.",
230
+ remediation:
231
+ "Pass one input and at most one router, quoting the input so the shell keeps it as one argument (`centrs explain '/ip/route print'`); use `execute` / `api` for multi-target commands.",
232
+ context: {
233
+ capability: "explain",
234
+ ...(context.flag === undefined ? {} : { flag: context.flag }),
235
+ ...(context.targets === undefined
236
+ ? {}
237
+ : { targets: [...context.targets] }),
238
+ },
239
+ });
240
+ }
241
+
242
+ /**
243
+ * The one live-form error.
244
+ *
245
+ * Named separately because it is a PHASE boundary, not a usage mistake: the
246
+ * invocation is the ratified grammar and will work unchanged when phase 2
247
+ * lands, so the remediation points at the offline form rather than at `--help`.
248
+ */
249
+ function liveNotImplementedError(router: string): CentrsError {
250
+ // A "router" that starts `/` or `:` is almost always an unquoted input the
251
+ // shell split, so name that reading first rather than reporting a router
252
+ // nobody meant to give.
253
+ const looksLikeInput = /^[/:]/.test(router);
254
+ return new CentrsError({
255
+ code: "usage/not-implemented",
256
+ summary: looksLikeInput
257
+ ? `Read \`${router}\` as a router, because two positionals mean \`<router> '<input>'\` — the input was probably not quoted.`
258
+ : `\`centrs explain <router> '<input>'\` needs live \`/console/inspect\` evidence, which is phase 2; the router \`${router}\` was not contacted.`,
259
+ remediation: looksLikeInput
260
+ ? "Quote the whole input as one argument: `centrs explain '/ip/route print'`."
261
+ : "Drop the router for the offline analysis (`centrs explain '<input>'`), or use `rosetta` for documented RouterOS schema facts.",
262
+ context: { router, phase: "2" },
263
+ });
264
+ }
265
+
266
+ /**
267
+ * Is fd 0 attached to something that could be carrying input?
268
+ *
269
+ * A **stat**, never a read. Stated as an exclusion, because the carriers are
270
+ * open-ended while the non-carriers are two: a TTY (a human who would be left
271
+ * waiting on EOF) and the ambient `/dev/null` that CI runners and `< /dev/null`
272
+ * scripts attach. Both are character devices, and so is `/dev/zero`, the one
273
+ * shape whose read never ends. Everything else — a shell pipe, a redirected
274
+ * file, the socket `Bun.spawn` hands a child — may be handing us bytes.
275
+ *
276
+ * An `isTTY`-only check is not enough: it reads `< /dev/null` as "the user
277
+ * piped input", which would break every non-interactive invocation.
278
+ */
279
+ function stdinMayCarryInput(): boolean {
280
+ try {
281
+ const stat = fstatSync(0);
282
+ return !stat.isCharacterDevice() && !stat.isDirectory();
283
+ } catch {
284
+ return false;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Split the positionals into targets and the one that may be the input.
290
+ *
291
+ * Pure and cheap **on purpose**: the caller must be able to refuse the live and
292
+ * fan-out forms BEFORE any input source is touched. Reading first meant
293
+ * `centrs explain router --file -` blocked on EOF before returning
294
+ * `usage/not-implemented`, and `… --file missing` reported a file error instead
295
+ * of the live refusal — the wrong answer, arrived at slowly.
296
+ *
297
+ * `--file` replaces the input positional, so with it every positional is a
298
+ * target. Otherwise the LAST positional is the input and everything before it is
299
+ * a target; splitting from the end is what keeps the two forms unambiguous.
300
+ *
301
+ * PRESENCE, never truthiness: `centrs explain ""` gave an empty positional that
302
+ * a `&&` spread dropped, so the same argv fell through to ambient stdin and
303
+ * analyzed whatever was piped in. An explicit empty argument is a mistake the
304
+ * user made, not an absent argument, and "a positional always wins" has to hold
305
+ * for it too — otherwise identical argv means different things depending on the
306
+ * shape of fd 0.
307
+ */
308
+ function splitPositionals(parsed: ExplainCliArgs): {
309
+ targets: readonly string[];
310
+ inputPositional?: string;
311
+ } {
312
+ if (parsed.filePath !== undefined) return { targets: parsed.positionals };
313
+ const positionals = [...parsed.positionals];
314
+ const inputPositional = positionals.pop();
315
+ return inputPositional === undefined
316
+ ? { targets: positionals }
317
+ : { targets: positionals, inputPositional };
318
+ }
319
+
320
+ /**
321
+ * Read the input text, once the invocation is known to be offline.
322
+ *
323
+ * ## Ambient stdin is read ONLY when nothing else could be the input
324
+ *
325
+ * Measured, not assumed: consuming fd 0 whenever it looked like a carrier made
326
+ * `bun test` read the invoking shell's stdin, feed those bytes to the analyzer,
327
+ * and fail 21 unrelated tests — once, because the read DRAINED the fd and the
328
+ * next run passed. `runCli` is called in-process by tests, so any ambient read
329
+ * on a path a test can reach is a landmine that fires on whoever's terminal has
330
+ * bytes queued. Zero positionals is the one shape no in-process caller uses.
331
+ *
332
+ * ## The collision is reported, not resolved
333
+ *
334
+ * `… | centrs explain '<input>'` cannot silently drop the pipe — but proving the
335
+ * pipe has bytes would mean reading it, which is the landmine above. So the
336
+ * stat-only signal becomes a WARNING on a result that is otherwise exactly what
337
+ * the arguments asked for, naming `--file -` as the way to mean the other thing.
338
+ * A warning is a fact about the invocation; it changes no analysis, so it cannot
339
+ * make a test's subject non-deterministic.
340
+ */
341
+ async function readInput(
342
+ parsed: ExplainCliArgs,
343
+ inputPositional: string | undefined,
344
+ ): Promise<{ input: string; warnings: readonly Warning[] }> {
345
+ if (parsed.filePath !== undefined) {
346
+ const input =
347
+ parsed.filePath === "-"
348
+ ? await Bun.stdin.text()
349
+ : await readFileInput(parsed.filePath);
350
+ return { input, warnings: [] };
351
+ }
352
+ if (inputPositional === undefined) {
353
+ const piped = stdinMayCarryInput() ? await Bun.stdin.text() : "";
354
+ if (piped.trim() === "") throw missingInputError();
355
+ return { input: piped, warnings: [] };
356
+ }
357
+ // An empty positional is rejected HERE, before fd 0 is even stat'd: it is a
358
+ // present-but-empty argument, so falling through to stdin would make the same
359
+ // argv mean two different things.
360
+ if (inputPositional.trim() === "") throw missingInputError();
361
+ return {
362
+ input: inputPositional,
363
+ warnings: stdinMayCarryInput() ? [STDIN_IGNORED_WARNING] : [],
364
+ };
365
+ }
366
+
367
+ const STDIN_IGNORED_WARNING: Warning = {
368
+ code: "usage/stdin-ignored",
369
+ message:
370
+ "stdin is redirected, and the input came from the positional argument instead — anything piped in was NOT analyzed. Pass `--file -` to analyze stdin, or drop the positional.",
371
+ };
372
+
373
+ function missingInputError(): CentrsError {
374
+ return new CentrsError({
375
+ code: "input/invalid-command",
376
+ summary: "`centrs explain` needs a RouterOS input to analyze.",
377
+ remediation:
378
+ "Pass it as a quoted positional (`centrs explain '/ip/route print'`), with `--file <path>`, or on stdin.",
379
+ });
380
+ }
381
+
382
+ async function readFileInput(path: string): Promise<string> {
383
+ const file = Bun.file(path);
384
+ if (!(await file.exists())) {
385
+ throw new CentrsError({
386
+ code: "input/local-file-not-found",
387
+ summary: `--file path not found: ${path}`,
388
+ remediation:
389
+ "Check the path, or pass the input as a quoted positional instead.",
390
+ context: { path },
391
+ });
392
+ }
393
+ return await file.text();
394
+ }
395
+
396
+ /** Flags whose NEXT token is a value, so a scanner must skip it. */
397
+ const VALUE_FLAGS = new Set(["--file", "--fail-on", "--format"]);
398
+
399
+ /**
400
+ * The render options, recovered when the parse loop THREW.
401
+ *
402
+ * ONE scan for both, walking the same grammar the parser does — left to right,
403
+ * stopping at `--`, skipping each value-flag's value, last format winning. Two
404
+ * `includes()` scans is how the bug appeared twice: `centrs explain -- --json`
405
+ * read a format request out of RouterOS input, and `-- --verbose` turned on the
406
+ * private error context for a router literally named `--verbose`. A second
407
+ * option parser that disagrees with the first is the defect, so there is one.
408
+ */
409
+ function recoverRenderOptions(args: readonly string[]): {
410
+ format?: string;
411
+ verbose: boolean;
412
+ } {
413
+ let format: string | undefined;
414
+ let verbose = false;
415
+ for (let index = 0; index < args.length; index += 1) {
416
+ const arg = args[index];
417
+ if (arg === undefined || arg === "--") break;
418
+ if (arg === "--json") format = "json";
419
+ else if (arg === "--yaml") format = "yaml";
420
+ else if (arg === "--verbose") verbose = true;
421
+ else if (arg === "--format") format = args[++index];
422
+ else if (VALUE_FLAGS.has(arg)) index += 1;
423
+ }
424
+ return { format, verbose };
425
+ }
426
+
427
+ /**
428
+ * The same ladder as the success path, but it must never throw: a bad
429
+ * `CENTRS_FORMAT` (or a bad `--format`, which is often WHY we are here) would
430
+ * otherwise replace the user's actual error with a complaint about rendering.
431
+ */
432
+ function recoverFormat(
433
+ args: readonly string[],
434
+ env: Record<string, string | undefined>,
435
+ config: Record<string, string | undefined>,
436
+ ): ResolvedSetting<ExplainOutputFormat> {
437
+ const { format } = recoverRenderOptions(args);
438
+ try {
439
+ return resolveExplainFormat(format, env, config);
440
+ } catch {
441
+ try {
442
+ return resolveExplainFormat(undefined, env, config);
443
+ } catch {
444
+ return { value: "text", source: { kind: "default", key: "format" } };
445
+ }
446
+ }
447
+ }
448
+
449
+ export async function runExplainCli(args: readonly string[]): Promise<number> {
450
+ const env = Bun.env;
451
+ let config: Record<string, string | undefined> = {};
452
+ try {
453
+ // Inside the try: a settings file that cannot be read or parsed is a real
454
+ // failure with a real remedy, and swallowing it would silently bypass the
455
+ // configured format and the rest of the precedence ladder.
456
+ config = await loadEnvFileDefaults(env);
457
+ const parsed = parseExplainCliArgs(args);
458
+ if (parsed.help) {
459
+ console.log(renderCommandHelp(describeCentrs(), explainCliCommand));
460
+ return 0;
461
+ }
462
+ // Resolved ONCE across config < env < CLI, so `meta.settings.format` can
463
+ // name the tier that won (`docs/CONSTITUTION.md` → Settings precedence).
464
+ const format = resolveExplainFormat(parsed.format, env, config);
465
+
466
+ // Targets first, from the positionals alone — no file opened, no fd read.
467
+ // More than one positional target IS fan-out mode under the constitution
468
+ // (`isFanoutMode`), so it gets the same refusal as `--group` rather than a
469
+ // second code for the same fact.
470
+ const { targets, inputPositional } = splitPositionals(parsed);
471
+ if (targets.length > 1) throw fanoutNotSupportedError({ targets });
472
+ const router = targets[0];
473
+ if (router !== undefined) throw liveNotImplementedError(router);
474
+
475
+ const { input, warnings } = await readInput(parsed, inputPositional);
476
+
477
+ const envelope = explainEnvelope(input, {
478
+ format,
479
+ curl: parsed.curl,
480
+ tokens: parsed.tokens,
481
+ warnings,
482
+ tips:
483
+ parsed.facets.length > 0
484
+ ? [buildExplainFacetTip(parsed.facets)]
485
+ : undefined,
486
+ });
487
+ console.log(renderExplainEnvelope(envelope, format.value));
488
+ return explainExitCode(envelope.data.verdict, parsed.failOn);
489
+ } catch (error) {
490
+ // Only here is the parser's answer unavailable — it threw. `config` is
491
+ // whatever loaded before the throw, which is `{}` when the loader itself is
492
+ // what failed: the tier that broke cannot also be the one that renders it.
493
+ const format = recoverFormat(args, env, config);
494
+ const { verbose } = recoverRenderOptions(args);
495
+ const envelope = buildExplainErrorEnvelope(error, [], format);
496
+ console.error(
497
+ format.value === "text"
498
+ ? formatCentrsErrorText(envelope.error, { verbose })
499
+ : renderExplainEnvelope(envelope, format.value),
500
+ );
501
+ return 1;
502
+ }
503
+ }
@@ -22,6 +22,7 @@ import {
22
22
  retrieveFanout,
23
23
  retrieveOutputFormats,
24
24
  } from "../index.ts";
25
+ import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
25
26
  import {
26
27
  type CliCommandMetadata,
27
28
  expectValue,
@@ -37,6 +38,7 @@ import {
37
38
  withTips,
38
39
  } from "./missing-target.ts";
39
40
  import {
41
+ assertQuickchrExclusive,
40
42
  buildTargetSelection,
41
43
  consumeSelectionFlag,
42
44
  emptySelectionFlags,
@@ -101,10 +103,16 @@ export const retrieveCommand: CliCommandMetadata = {
101
103
  description: "Request the RouterOS detail/all-attributes shape.",
102
104
  },
103
105
  {
104
- flag: "--list-attributes",
106
+ flag: "--list-attributes / --list",
105
107
  description:
106
108
  "List inspect-derived attributes without running the data call.",
107
109
  },
110
+ {
111
+ flag: "--query / --filter",
112
+ valueName: "<expr>",
113
+ description:
114
+ "RouterOS-side row filter (maps to `.query`). Not implemented yet — returns `validation/not-implemented`.",
115
+ },
108
116
  {
109
117
  flag: "--format",
110
118
  valueName: `<${retrieveOutputFormats.join("|")}>`,
@@ -396,6 +404,13 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveCliArgs {
396
404
  const targetPositionals = positional.slice(0, -1);
397
405
  request.path = path;
398
406
  request.targetPositionals = targetPositionals;
407
+ assertQuickchrExclusive(selectionFlags, targetPositionals.length);
408
+ // Direct connection overrides conflict with `--quickchr` globally: reject at
409
+ // parse time so a repeated `--quickchr` gets one usage error (exit 1), never
410
+ // per-member failures. The resolver re-checks for library callers.
411
+ if (selectionFlags.quickchr.length > 0) {
412
+ assertNoQuickchrOverrideConflict(request, selectionFlags.quickchr[0] ?? "");
413
+ }
399
414
 
400
415
  if (isFanoutMode(selectionFlags, targetPositionals.length)) {
401
416
  if (positional.length === 0 || path.length === 0) {
@@ -411,6 +426,23 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveCliArgs {
411
426
  return request;
412
427
  }
413
428
 
429
+ // A single `--quickchr <name>` is single-target mode: the machine name is the
430
+ // target (resolved from the live descriptor inside the resolver); the lone
431
+ // positional is the routeros-path.
432
+ if (selectionFlags.quickchr.length === 1) {
433
+ if (positional.length === 0 || path.length === 0) {
434
+ throw new CentrsError({
435
+ code: "input/invalid-command",
436
+ summary: "`centrs retrieve --quickchr` requires a <routeros-path>.",
437
+ remediation:
438
+ "Add the RouterOS menu path as the final positional, e.g. `centrs retrieve --quickchr lab /system/resource`.",
439
+ context: { command: "retrieve", missingPath: true },
440
+ });
441
+ }
442
+ request.quickchr = selectionFlags.quickchr[0];
443
+ return request;
444
+ }
445
+
414
446
  if (positional.length === 0 || targetPositionals.length === 0) {
415
447
  throw missingTargetError({
416
448
  command: "retrieve",