@tikoci/centrs 0.1.9-next.23 → 0.1.9-next.24

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 (53) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/GLOSSARY.txt +14 -0
  3. package/README.md +2 -2
  4. package/commands/api/AGENTS.md +18 -1
  5. package/commands/api/README.md +10 -6
  6. package/commands/api/examples.md +1 -1
  7. package/commands/execute/README.md +26 -6
  8. package/commands/execute/examples.md +24 -5
  9. package/commands/explain/README.md +452 -61
  10. package/commands/explain/examples.md +191 -41
  11. package/commands/retrieve/README.md +13 -2
  12. package/commands/retrieve/examples.md +30 -1
  13. package/commands/transfer/README.md +8 -4
  14. package/docs/CLI.md +19 -5
  15. package/docs/CONSTITUTION.md +14 -9
  16. package/docs/MATRIX.md +34 -21
  17. package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
  18. package/docs/errors/routeros/permission-denied.md +24 -0
  19. package/docs/errors/transport/edge-auth-failed.md +22 -0
  20. package/docs/errors/transport/write-outcome-unknown.md +3 -0
  21. package/docs/errors/usage/invalid-cursor.md +20 -0
  22. package/docs/errors/validation/unknown-path.md +9 -0
  23. package/package.json +2 -2
  24. package/src/api.ts +34 -39
  25. package/src/cli/explain.ts +206 -47
  26. package/src/core/envelope.ts +10 -3
  27. package/src/core/error-catalog.ts +15 -0
  28. package/src/core/execute-command.ts +53 -0
  29. package/src/core/fanout.ts +12 -2
  30. package/src/core/inspect.ts +4 -5
  31. package/src/core/routeros-errors.ts +25 -0
  32. package/src/execute-cmr.ts +2 -8
  33. package/src/execute.ts +48 -20
  34. package/src/explain/args.ts +115 -10
  35. package/src/explain/group-faults.ts +162 -29
  36. package/src/explain/operators.ts +2 -1
  37. package/src/explain/scope-brace.ts +226 -11
  38. package/src/explain/segment.ts +136 -1
  39. package/src/explain/symbols.ts +146 -6
  40. package/src/explain/values.ts +55 -4
  41. package/src/explain-live-facets.ts +739 -0
  42. package/src/explain-live-types.ts +490 -0
  43. package/src/explain-live.ts +593 -0
  44. package/src/explain.ts +395 -43
  45. package/src/index.ts +6 -0
  46. package/src/protocols/adapter.ts +88 -10
  47. package/src/protocols/mac-telnet.ts +38 -10
  48. package/src/protocols/native-api.ts +13 -12
  49. package/src/retrieve-follow.ts +2 -1
  50. package/src/retrieve-sample.ts +2 -1
  51. package/src/retrieve-wait.ts +10 -6
  52. package/src/retrieve.ts +77 -27
  53. package/src/transfer.ts +38 -13
@@ -22,9 +22,11 @@ re-proves it (CMR15 in `commands/execute/examples.md`):
22
22
  | `read,test,api,rest-api` | `not enough permissions (9)` | `run-script` wrote to the controller's own row; `/cmr/device/reboot` rebooted a client |
23
23
 
24
24
  MikroTik [says](https://forum.mikrotik.com/t/7-26beta-development-is-released/273582/139) this is fixed for 7.26beta2: a `read` group will not
25
- be allowed to do it. That has not been checked on a beta2 build yet. centrs
26
- still warns on any version, because the warning reads the login's policy, not
27
- the RouterOS version. On a build that refuses the write, the members fail
25
+ be allowed to do it. #527 now carries direct 7.26beta3 evidence that a
26
+ restricted caller is refused, with the first refusing release still
27
+ unbracketed. centrs still warns on any version, because the warning reads
28
+ the login's policy, not the RouterOS version. On a build that refuses the
29
+ write, the members fail
28
30
  instead and the warning only says what the login lacks.
29
31
 
30
32
  `context` names the login (`username`, `group`), the policies it lacks
@@ -0,0 +1,24 @@
1
+ # `routeros/permission-denied`
2
+
3
+ RouterOS refused the command because the user's group lacks a policy it needs.
4
+ The router answered, and a retry gives the same result. A single refused
5
+ command did not run. A multi-statement script run through `/execute` can be
6
+ partly applied: statements before the refused one already ran.
7
+
8
+ ## Typical RouterOS strings
9
+
10
+ - `not enough permissions (9)`: the command needs a policy the group does not
11
+ have, for example a `set` without `write`.
12
+ - `std failure: not allowed (9)`: on REST before RouterOS 7.26beta3, every
13
+ request fails this way (a plain GET included) when the group has `rest-api`
14
+ but not `api`. 7.26beta3 fixed it.
15
+
16
+ Over REST both arrive as HTTP 500 with the string in `detail`; over the native
17
+ API as a `!trap`. Grounded on CHR 7.23.8, 7.25, 7.26beta1 and 7.26beta3.
18
+
19
+ ## Fix
20
+
21
+ Grant the missing policy to the user's group in `/user/group`: `read` for
22
+ reads, `write` for changes. Before 7.26beta3 a REST user needs both `api` and
23
+ `rest-api`. Avoid the built-in `read` group for a least-privilege user: it also
24
+ carries `reboot`, `sensitive`, `password` and others.
@@ -0,0 +1,22 @@
1
+ # `transport/edge-auth-failed`
2
+
3
+ An HTTP layer in front of RouterOS (auth proxy, tunnel edge, WAF) refused the
4
+ request before RouterOS answered it.
5
+
6
+ ## How centrs tells
7
+
8
+ RouterOS answers every REST auth refusal with a JSON body whose `error` equals
9
+ the HTTP status (`{"error":401,"message":"Unauthorized"}`), adding
10
+ `WWW-Authenticate: Basic` only when no credentials were sent. A 401 or 403 with
11
+ any other body, or a challenge for another scheme (Cloudflare Access sends
12
+ `Bearer`), came from something in front of the router. `context` records the
13
+ challenge scheme, `server`, content type and whether a `cf-ray` header was
14
+ present. A `cf-ray` header alone does not decide it: a tunnel passes RouterOS's
15
+ own 401 through with one.
16
+
17
+ ## Fix
18
+
19
+ centrs sends only RouterOS Basic credentials, so changing the RouterOS username
20
+ or password will not help. Point `--host` at an address that reaches RouterOS
21
+ directly, or at a local proxy that authenticates to the front layer for you.
22
+ The request never reached RouterOS, so a write was not applied.
@@ -9,6 +9,9 @@ whether the write happened.
9
9
 
10
10
  - A connection closed or reset after an `execute` or `api` write was sent.
11
11
  - A REST 5xx or a timeout on a write.
12
+ - A multi-statement `execute` or `api /execute` script refused with
13
+ `routeros/permission-denied`: statements before the refused one may already
14
+ have run (#549). A single refused statement did not run and warns nothing.
12
15
 
13
16
  centrs never retries a write that it has sent, in single-target or fan-out
14
17
  mode, because replaying an `add`, a script run or a counter change would apply
@@ -0,0 +1,20 @@
1
+ # `usage/invalid-cursor`
2
+
3
+ An explain --cursor is not a byte offset inside the input being analyzed.
4
+
5
+ ## Typical trigger
6
+
7
+ `centrs explain <router> '<input>' --complete --cursor <byte>` with a value that
8
+ is not a whole number, or that is larger than the input's UTF-8 byte length.
9
+ Library callers get the same code for an original-document position
10
+ (`{ line, character }`) that addresses no character in the input.
11
+
12
+ The check runs before any connection is made, so nothing is sent to the router.
13
+
14
+ ## Fix
15
+
16
+ - Count the cursor in **UTF-8 bytes**, not characters: `é` is two bytes and
17
+ `🛰` is four. The end of the input (the default) is the input's byte length.
18
+ - A cursor inside a multi-byte character is accepted and moves back to that
19
+ character's first byte.
20
+ - Omit `--cursor` to complete or describe at the end of the input.
@@ -19,3 +19,12 @@ Two sources raise it:
19
19
 
20
20
  Check the spelling of the segment in `error.context.segment`. `centrs explain
21
21
  '<command>'` shows how the rest of the command reads offline.
22
+
23
+ One path is not a typo: `/system/routerboard` exists only on MikroTik
24
+ hardware, so `retrieve` answers its absence on CHR and x86 systems with a
25
+ targeted remediation instead of the generic one. For the device's license
26
+ ID, read `/system/license` (`system-id` on CHR, `software-id` on x86):
27
+
28
+ ```bash
29
+ centrs retrieve <router> /system/license
30
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/centrs",
3
- "version": "0.1.9-next.23",
3
+ "version": "0.1.9-next.24",
4
4
  "main": "src/index.ts",
5
5
  "module": "src/index.ts",
6
6
  "repository": {
@@ -40,7 +40,7 @@
40
40
  "bin": {
41
41
  "centrs": "src/cli.ts"
42
42
  },
43
- "description": "All-in-one TypeScript library/CLI/TUI/MCP for interacting with MikroTik RouterOS devices",
43
+ "description": "All-in-one TypeScript library/CLI/MCP for interacting with MikroTik RouterOS devices (TUI stub)",
44
44
  "engines": {
45
45
  "bun": ">=1.3.11 <2"
46
46
  },
package/src/api.ts CHANGED
@@ -62,6 +62,7 @@ import {
62
62
  promptForWriteConfirmation,
63
63
  validateRouterOsScript,
64
64
  } from "./execute.ts";
65
+ import { countExecutableStatements } from "./explain/segment.ts";
65
66
  import {
66
67
  assertOfflineSyntax,
67
68
  isOfflineGateRejection,
@@ -541,7 +542,7 @@ async function* streamResolvedApi(
541
542
  trace.validation = validation;
542
543
  resolved = await withQueryBooleans(resolved, backend, result.queryMenu);
543
544
  } else {
544
- validation = disabledValidationMeta();
545
+ validation = disabledApiValidationMeta(resolved);
545
546
  }
546
547
  } catch (error) {
547
548
  await backend.close();
@@ -824,16 +825,6 @@ function adapterForResolved(resolved: ResolvedApiRequest): ProtocolAdapter {
824
825
  });
825
826
  }
826
827
 
827
- function disabledValidationMeta(): EnvelopeValidationMeta {
828
- return {
829
- enabled: false,
830
- source: "disabled",
831
- result: "skipped",
832
- syntax: false,
833
- semantic: false,
834
- };
835
- }
836
-
837
828
  export async function resolveApiRequest(
838
829
  request: ApiRequest,
839
830
  env: Record<string, string | undefined>,
@@ -1347,7 +1338,8 @@ interface ApiValidationResult {
1347
1338
  * validator is `/console/inspect`, never `:put [:parse]`. Path existence and
1348
1339
  * add/set attribute validity both come from `request=child` — command-level
1349
1340
  * `request=completion` is unsafe on RouterOS 7.12.2 (`core/inspect.ts`). A
1350
- * `/execute` script is a CLI string → `semantic: not-applicable`.
1341
+ * `/execute` script is a CLI string, so it has no `semantic` summary; a
1342
+ * structured request runs no `:parse`, so it has no `syntax` summary (#535).
1351
1343
  */
1352
1344
  async function validateApiRequest(
1353
1345
  resolved: ResolvedApiRequest,
@@ -1383,8 +1375,7 @@ async function validateApiRequest(
1383
1375
  ? "not-applicable"
1384
1376
  : API_SCRIPT_DEVICE_STAGE_SOURCE,
1385
1377
  result: "passed",
1386
- syntax: script !== undefined,
1387
- semantic: "not-applicable",
1378
+ ...(script !== undefined ? { syntax: true } : {}),
1388
1379
  stages: [
1389
1380
  {
1390
1381
  stage: "offline",
@@ -1468,13 +1459,7 @@ async function validateApiRequest(
1468
1459
  }
1469
1460
  }
1470
1461
  return {
1471
- validation: {
1472
- enabled: true,
1473
- source: "/console/inspect request=child",
1474
- result: "passed",
1475
- syntax: false,
1476
- semantic: true,
1477
- },
1462
+ validation: structuredValidationPassed(resolved),
1478
1463
  tips,
1479
1464
  queryMenu: isCommand ? `/${parent.join("/")}` : resolved.path,
1480
1465
  };
@@ -1513,11 +1498,7 @@ async function validateApiRequest(
1513
1498
  }
1514
1499
  return {
1515
1500
  validation: {
1516
- enabled: true,
1517
- source: "/console/inspect request=child",
1518
- result: "passed",
1519
- syntax: false,
1520
- semantic: true,
1501
+ ...structuredValidationPassed(resolved),
1521
1502
  availableAttributes: available,
1522
1503
  },
1523
1504
  tips,
@@ -1526,13 +1507,7 @@ async function validateApiRequest(
1526
1507
  }
1527
1508
 
1528
1509
  return {
1529
- validation: {
1530
- enabled: true,
1531
- source: "/console/inspect request=child",
1532
- result: "passed",
1533
- syntax: false,
1534
- semantic: true,
1535
- },
1510
+ validation: structuredValidationPassed(resolved),
1536
1511
  tips,
1537
1512
  queryMenu: resolved.path,
1538
1513
  };
@@ -1857,6 +1832,12 @@ export function buildApiErrorEnvelopeFromResolved(
1857
1832
  trace?: ValidationTrace,
1858
1833
  ): ApiErrorEnvelope {
1859
1834
  const centrsError = asApiError(error);
1835
+ // A structured request is one operation; only a `/execute` script can
1836
+ // carry several statements into one dispatch (#549), at the top level or
1837
+ // inside an executed control scope.
1838
+ const script = resolved.scriptMode ? scriptOf(resolved) : undefined;
1839
+ const multiStatement =
1840
+ script !== undefined && countExecutableStatements(script) > 1;
1860
1841
  return {
1861
1842
  ok: false,
1862
1843
  error: serializeCentrsError(centrsError),
@@ -1866,6 +1847,7 @@ export function buildApiErrorEnvelopeFromResolved(
1866
1847
  isApiMutating(resolved.method, resolved.path),
1867
1848
  centrsError,
1868
1849
  trace,
1850
+ multiStatement,
1869
1851
  ),
1870
1852
  ],
1871
1853
  tips: [],
@@ -1877,6 +1859,22 @@ export function buildApiErrorEnvelopeFromResolved(
1877
1859
  };
1878
1860
  }
1879
1861
 
1862
+ /** A structured request the device stage passed; stage 1 does not apply to it. */
1863
+ function structuredValidationPassed(
1864
+ resolved: ResolvedApiRequest,
1865
+ ): EnvelopeValidationMeta {
1866
+ return {
1867
+ enabled: true,
1868
+ source: API_DEVICE_STAGE_SOURCE,
1869
+ result: "passed",
1870
+ semantic: true,
1871
+ stages: [
1872
+ offlineApiStage(resolved),
1873
+ { stage: "device", source: API_DEVICE_STAGE_SOURCE, result: "passed" },
1874
+ ],
1875
+ };
1876
+ }
1877
+
1880
1878
  /**
1881
1879
  * `meta.validation` for an api error envelope. A stage-1 rejection opened no
1882
1880
  * connection, so the meta must not report `/console/inspect` as the validator
@@ -1896,7 +1894,6 @@ function failedApiValidationMeta(
1896
1894
  source: OFFLINE_GATE_SOURCE,
1897
1895
  result: "failed",
1898
1896
  syntax: false,
1899
- semantic: "not-applicable",
1900
1897
  stages: [
1901
1898
  { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
1902
1899
  {
@@ -1911,14 +1908,14 @@ function failedApiValidationMeta(
1911
1908
  // Only a `validation/*` error identifies the device stage as the rejecter.
1912
1909
  // Everything downstream of a passed gate — a RouterOS fault on the request,
1913
1910
  // a transport drop — is served by `trace.validation` before this function is
1914
- // ever reached.
1911
+ // ever reached. A script is refused by `:parse` (syntax); a structured
1912
+ // request by `/console/inspect` (names) (#535).
1915
1913
  if (error.code.startsWith("validation/")) {
1916
1914
  return {
1917
1915
  enabled: true,
1918
1916
  source: apiDeviceStageSource(resolved),
1919
1917
  result: "failed",
1920
- syntax: false,
1921
- semantic: resolved.scriptMode ? "not-applicable" : false,
1918
+ ...(resolved.scriptMode ? { syntax: false } : { semantic: false }),
1922
1919
  stages: [offlineApiStage(resolved, trace), deviceStageFailed(resolved)],
1923
1920
  };
1924
1921
  }
@@ -2012,8 +2009,6 @@ function disabledApiValidationMeta(
2012
2009
  enabled: false,
2013
2010
  source: "disabled",
2014
2011
  result: "skipped",
2015
- syntax: false,
2016
- semantic: resolved.scriptMode ? "not-applicable" : false,
2017
2012
  stages: [
2018
2013
  { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
2019
2014
  {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `centrs explain` CLI surface: command metadata, arg parsing, dispatch, and
3
- * envelope rendering for the phase-1 OFFLINE analysis (#202b, #202c-2).
3
+ * envelope rendering for offline analysis and read-only live evidence (#491).
4
4
  *
5
5
  * ## The positional grammar is conditional, and that is deliberate
6
6
  *
@@ -20,9 +20,8 @@
20
20
  *
21
21
  * ## What this surface refuses to do
22
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.
23
+ * - **Live probes never execute the input.** The shared live module attaches
24
+ * device evidence, `--schema` and `--complete` to the offline result.
26
25
  * - **Fan-out is rejected** (`usage/fanout-not-supported`, the `terminal`
27
26
  * pattern): `explain` takes at most one router.
28
27
  * - **`--complete` / `--schema` enumerate nothing offline.** They are accepted,
@@ -45,9 +44,17 @@ import {
45
44
  renderExplainEnvelope,
46
45
  resolveExplainFormat,
47
46
  } from "../explain.ts";
47
+ import {
48
+ assertExplainTarget,
49
+ type ExplainLiveRequest,
50
+ explainLiveEnvelope,
51
+ } from "../explain-live.ts";
52
+ import { resolveExplainCursor } from "../explain-live-facets.ts";
48
53
  import { describeCentrs } from "../index.ts";
49
54
  import { loadEnvFileDefaults } from "../resolver/config-file.ts";
55
+ import { redactTargetInput } from "../resolver/index.ts";
50
56
  import type { ResolvedSetting } from "../resolver/settings.ts";
57
+ import { apiCommand } from "./api.ts";
51
58
  import {
52
59
  type CliCommandMetadata,
53
60
  expectValue,
@@ -59,10 +66,30 @@ import { selectionFlagTokens } from "./selection.ts";
59
66
 
60
67
  export const explainCliCommand: CliCommandMetadata = {
61
68
  name: "explain",
62
- usage: "centrs explain '<input>' [flags]",
69
+ usage: "centrs explain [<router>] '<input>' [flags]",
63
70
  summary:
64
- "Analyze a RouterOS command offline: canonical form, structure, and syntax diagnostics.",
71
+ "Analyze RouterOS input offline or add read-only device evidence from one router.",
65
72
  options: [
73
+ ...apiCommand.options.filter((o) =>
74
+ [
75
+ "--via",
76
+ "--host",
77
+ "--port",
78
+ "--username / --user / -u",
79
+ "--password",
80
+ "--timeout",
81
+ "--insecure",
82
+ "--cdb-file",
83
+ "--cdb-password",
84
+ "--resolve",
85
+ ].includes(o.flag),
86
+ ),
87
+ { flag: "-p", valueName: "<secret>", description: "Alias for --password." },
88
+ {
89
+ flag: "--quickchr",
90
+ valueName: "<name>",
91
+ description: "Resolve one running quickchr VM by name.",
92
+ },
66
93
  {
67
94
  flag: "--file",
68
95
  valueName: "<path>",
@@ -78,12 +105,23 @@ export const explainCliCommand: CliCommandMetadata = {
78
105
  {
79
106
  flag: "--complete",
80
107
  description:
81
- "Continuation candidates at the cursor. Live evidence — offline emits a tip and enumerates nothing.",
108
+ "Candidates at the cursor: menus and verbs, argument names, or values. Live evidence — offline emits a tip and enumerates nothing.",
82
109
  },
83
110
  {
84
111
  flag: "--schema",
85
112
  description:
86
- "Path enumeration (verbs, args, types, enums). Live evidence — offline emits a tip and enumerates nothing.",
113
+ "Describe the path at the cursor: a menu's verbs and submenus, a command's arguments, `print`'s proplist. Live evidence — offline emits a tip and enumerates nothing.",
114
+ },
115
+ {
116
+ flag: "--cursor",
117
+ valueName: "<byte>",
118
+ description:
119
+ "UTF-8 byte offset for --complete/--schema. Default: the end of the input.",
120
+ },
121
+ {
122
+ flag: "--full",
123
+ description:
124
+ "Lift --complete/--schema sizing to its safety ceiling (1024 names, 128 expansion probes).",
87
125
  },
88
126
  {
89
127
  flag: "--curl",
@@ -106,16 +144,20 @@ export const explainCliCommand: CliCommandMetadata = {
106
144
  ],
107
145
  };
108
146
 
109
- export interface ExplainCliArgs {
147
+ export interface ExplainCliArgs extends Omit<ExplainLiveRequest, "input"> {
110
148
  help?: boolean;
111
149
  positionals: string[];
112
150
  filePath?: string;
113
151
  failOn: ExplainFailOn;
114
152
  facets: string[];
153
+ cursor?: number;
154
+ full?: boolean;
115
155
  curl?: boolean;
116
156
  tokens?: boolean;
117
157
  format?: ExplainOutputFormat;
118
158
  verbose?: boolean;
159
+ /** First explicitly supplied connection flag, for the missing-target refusal. */
160
+ connectionFlag?: string;
119
161
  }
120
162
 
121
163
  export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
@@ -133,6 +175,7 @@ export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
133
175
  parsed.positionals.push(arg);
134
176
  continue;
135
177
  }
178
+ if (LIVE_CONNECTION_FLAGS.has(arg)) parsed.connectionFlag ??= arg;
136
179
  switch (arg) {
137
180
  case "--":
138
181
  // Everything after `--` is input, however command-shaped it looks:
@@ -144,6 +187,54 @@ export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
144
187
  case "-h":
145
188
  parsed.help = true;
146
189
  break;
190
+ case "--via":
191
+ parsed.via = expectValue(args, ++index, arg);
192
+ break;
193
+ case "--host":
194
+ parsed.host = expectValue(args, ++index, arg);
195
+ break;
196
+ case "--quickchr":
197
+ if (parsed.quickchr !== undefined)
198
+ throw fanoutNotSupportedError({ flag: arg });
199
+ parsed.quickchr = expectValue(args, ++index, arg);
200
+ break;
201
+ case "--timeout":
202
+ parsed.timeout = expectValue(args, ++index, arg);
203
+ break;
204
+ case "--cdb-file":
205
+ parsed.cdbFile = expectValue(args, ++index, arg);
206
+ break;
207
+ case "--cdb-password":
208
+ parsed.cdbPassword = expectValue(args, ++index, arg);
209
+ break;
210
+ case "--resolve":
211
+ parsed.resolve = expectValue(args, ++index, arg);
212
+ break;
213
+ case "--password":
214
+ parsed.password = expectValue(args, ++index, arg);
215
+ break;
216
+ case "-p":
217
+ parsed.password = expectValue(args, ++index, arg);
218
+ break;
219
+ case "-u":
220
+ case "--user":
221
+ case "--username":
222
+ parsed.username = expectValue(args, ++index, arg);
223
+ break;
224
+ case "--insecure":
225
+ parsed.insecure = true;
226
+ break;
227
+ case "--port": {
228
+ const value = expectValue(args, ++index, arg);
229
+ if (!/^\d+$/.test(value))
230
+ throw new CentrsError({
231
+ code: "input/invalid-command",
232
+ summary: "Port must be an integer.",
233
+ remediation: "Pass a TCP port between 1 and 65535.",
234
+ });
235
+ parsed.port = Number(value);
236
+ break;
237
+ }
147
238
  case "--file":
148
239
  parsed.filePath = expectValue(args, ++index, arg);
149
240
  break;
@@ -167,6 +258,22 @@ export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
167
258
  case "--schema":
168
259
  parsed.facets.push("--schema");
169
260
  break;
261
+ case "--cursor": {
262
+ const value = expectValue(args, ++index, arg);
263
+ if (!/^\d+$/.test(value))
264
+ throw new CentrsError({
265
+ code: "usage/invalid-cursor",
266
+ summary: `--cursor must be a non-negative byte offset; got ${value}.`,
267
+ remediation:
268
+ "Pass a UTF-8 byte offset into the input, or omit --cursor to complete at the end.",
269
+ context: { cursor: value },
270
+ });
271
+ parsed.cursor = Number(value);
272
+ break;
273
+ }
274
+ case "--full":
275
+ parsed.full = true;
276
+ break;
170
277
  case "--curl":
171
278
  parsed.curl = true;
172
279
  break;
@@ -236,35 +343,11 @@ function fanoutNotSupportedError(context: {
236
343
  ...(context.flag === undefined ? {} : { flag: context.flag }),
237
344
  ...(context.targets === undefined
238
345
  ? {}
239
- : { targets: [...context.targets] }),
346
+ : { targets: context.targets.map(redactTargetInput) }),
240
347
  },
241
348
  });
242
349
  }
243
350
 
244
- /**
245
- * The one live-form error.
246
- *
247
- * Named separately because it is a PHASE boundary, not a usage mistake: the
248
- * invocation is the ratified grammar and will work unchanged when phase 2
249
- * lands, so the remediation points at the offline form rather than at `--help`.
250
- */
251
- function liveNotImplementedError(router: string): CentrsError {
252
- // A "router" that starts `/` or `:` is almost always an unquoted input the
253
- // shell split, so name that reading first rather than reporting a router
254
- // nobody meant to give.
255
- const looksLikeInput = /^[/:]/.test(router);
256
- return new CentrsError({
257
- code: "usage/not-implemented",
258
- summary: looksLikeInput
259
- ? `Read \`${router}\` as a router, because two positionals mean \`<router> '<input>'\` — the input was probably not quoted.`
260
- : `\`centrs explain <router> '<input>'\` needs live \`/console/inspect\` evidence, which is phase 2; the router \`${router}\` was not contacted.`,
261
- remediation: looksLikeInput
262
- ? "Quote the whole input as one argument: `centrs explain '/ip/route print'`."
263
- : "Drop the router for the offline analysis (`centrs explain '<input>'`), or use `rosetta` for documented RouterOS schema facts.",
264
- context: { router, phase: "2" },
265
- });
266
- }
267
-
268
351
  /**
269
352
  * Is fd 0 attached to something that could be carrying input?
270
353
  *
@@ -290,11 +373,8 @@ function stdinMayCarryInput(): boolean {
290
373
  /**
291
374
  * Split the positionals into targets and the one that may be the input.
292
375
  *
293
- * Pure and cheap **on purpose**: the caller must be able to refuse the live and
294
- * fan-out forms BEFORE any input source is touched. Reading first meant
295
- * `centrs explain router --file -` blocked on EOF before returning
296
- * `usage/not-implemented`, and `… --file missing` reported a file error instead
297
- * of the live refusal — the wrong answer, arrived at slowly.
376
+ * Pure and cheap: fan-out and unsupported facets are refused before any file
377
+ * or stdin is read. Supported live calls read the input before connecting.
298
378
  *
299
379
  * `--file` replaces the input positional, so with it every positional is a
300
380
  * target. Otherwise the LAST positional is the input and everything before it is
@@ -320,7 +400,7 @@ function splitPositionals(parsed: ExplainCliArgs): {
320
400
  }
321
401
 
322
402
  /**
323
- * Read the input text, once the invocation is known to be offline.
403
+ * Read the input text after the invocation shape has been checked.
324
404
  *
325
405
  * ## Ambient stdin is read ONLY when nothing else could be the input
326
406
  *
@@ -396,7 +476,40 @@ async function readFileInput(path: string): Promise<string> {
396
476
  }
397
477
 
398
478
  /** Flags whose NEXT token is a value, so a scanner must skip it. */
399
- const VALUE_FLAGS = new Set(["--file", "--fail-on", "--format"]);
479
+ const VALUE_FLAGS = new Set([
480
+ "--file",
481
+ "--fail-on",
482
+ "--format",
483
+ "--via",
484
+ "--host",
485
+ "--port",
486
+ "--username",
487
+ "--user",
488
+ "-u",
489
+ "--password",
490
+ "-p",
491
+ "--timeout",
492
+ "--cdb-file",
493
+ "--cdb-password",
494
+ "--resolve",
495
+ "--quickchr",
496
+ "--cursor",
497
+ ]);
498
+
499
+ const LIVE_CONNECTION_FLAGS = new Set([
500
+ "--via",
501
+ "--port",
502
+ "--username",
503
+ "--user",
504
+ "-u",
505
+ "--password",
506
+ "-p",
507
+ "--timeout",
508
+ "--insecure",
509
+ "--cdb-file",
510
+ "--cdb-password",
511
+ "--resolve",
512
+ ]);
400
513
 
401
514
  /**
402
515
  * The render options, recovered when the parse loop THREW.
@@ -472,22 +585,68 @@ export async function runExplainCli(args: readonly string[]): Promise<number> {
472
585
  const { targets, inputPositional } = splitPositionals(parsed);
473
586
  if (targets.length > 1) throw fanoutNotSupportedError({ targets });
474
587
  const router = targets[0];
475
- if (router !== undefined) throw liveNotImplementedError(router);
588
+ assertExplainTarget({ targetInput: router, quickchr: parsed.quickchr });
589
+ if (router !== undefined && (/^\//.test(router) || /^:[a-z]/i.test(router)))
590
+ throw new CentrsError({
591
+ code: "input/invalid-command",
592
+ summary:
593
+ "The input was probably not quoted; two positionals mean router then input.",
594
+ remediation:
595
+ "Quote the whole input as one argument: `centrs explain '/ip/route print'`.",
596
+ });
597
+ const live =
598
+ router !== undefined ||
599
+ parsed.quickchr !== undefined ||
600
+ parsed.host !== undefined;
601
+ if (!live && parsed.connectionFlag !== undefined)
602
+ throw new CentrsError({
603
+ code: "input/invalid-command",
604
+ summary: `${parsed.connectionFlag} requires a router target for live explain.`,
605
+ remediation:
606
+ "Add <router>, --host <host>, or --quickchr <name>; remove the connection flag for offline analysis.",
607
+ context: { flag: parsed.connectionFlag },
608
+ });
609
+ if (
610
+ parsed.facets.length === 0 &&
611
+ (parsed.cursor !== undefined || parsed.full)
612
+ )
613
+ throw new CentrsError({
614
+ code: "usage/conflicting-flags",
615
+ summary: `${parsed.cursor !== undefined ? "--cursor" : "--full"} applies only with --complete or --schema.`,
616
+ remediation: "Add --complete or --schema, or remove the flag.",
617
+ });
476
618
 
477
619
  const { input, warnings } = await readInput(parsed, inputPositional);
620
+ resolveExplainCursor(input, parsed.cursor);
478
621
 
479
- const envelope = explainEnvelope(input, {
622
+ const options = {
480
623
  format,
481
624
  curl: parsed.curl,
482
625
  tokens: parsed.tokens,
483
626
  warnings,
484
627
  tips:
485
- parsed.facets.length > 0
628
+ !live && parsed.facets.length > 0
486
629
  ? [buildExplainFacetTip(parsed.facets)]
487
630
  : undefined,
488
- });
489
- console.log(renderExplainEnvelope(envelope, format.value));
490
- return explainExitCode(envelope.data.verdict, parsed.failOn);
631
+ };
632
+ const envelope = live
633
+ ? await explainLiveEnvelope(
634
+ {
635
+ ...parsed,
636
+ targetInput: router,
637
+ input,
638
+ schema: parsed.facets.includes("--schema"),
639
+ complete: parsed.facets.includes("--complete"),
640
+ },
641
+ options,
642
+ env,
643
+ )
644
+ : explainEnvelope(input, options);
645
+ if (envelope.ok) console.log(renderExplainEnvelope(envelope, format.value));
646
+ else console.error(renderExplainEnvelope(envelope, format.value));
647
+ return envelope.ok
648
+ ? explainExitCode(envelope.data.verdict, parsed.failOn)
649
+ : 1;
491
650
  } catch (error) {
492
651
  // Only here is the parser's answer unavailable — it threw. `config` is
493
652
  // whatever loaded before the throw, which is `{}` when the loader itself is