@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
@@ -65,6 +65,12 @@ export const selectionCommandOptions: readonly CliCommandOption[] = [
65
65
  flag: "--default",
66
66
  description: "Select the reserved `__default__` record.",
67
67
  },
68
+ {
69
+ flag: "--quickchr",
70
+ valueName: "<name>",
71
+ description:
72
+ "Target a running quickchr-managed CHR VM by name (repeatable; fans out when repeated). Exclusive of positional targets and CDB selectors; conflicts with direct connection overrides (--host/--port/--username/--password, and --ssh-key where the command has it).",
73
+ },
68
74
  {
69
75
  flag: "--concurrency",
70
76
  valueName: "<n>",
@@ -81,11 +87,13 @@ export interface SelectionFlags {
81
87
  bbox?: BboxPredicate;
82
88
  all: boolean;
83
89
  default: boolean;
90
+ /** Repeatable `--quickchr <name>` (named-live-provider; exclusive in v1). */
91
+ quickchr: string[];
84
92
  concurrency?: number;
85
93
  }
86
94
 
87
95
  export function emptySelectionFlags(): SelectionFlags {
88
- return { groups: [], where: [], all: false, default: false };
96
+ return { groups: [], where: [], all: false, default: false, quickchr: [] };
89
97
  }
90
98
 
91
99
  /** The selection flag tokens, for a command's `--help` table + unknown-flag suggestions. */
@@ -96,6 +104,7 @@ export const selectionFlagTokens = [
96
104
  "--bbox",
97
105
  "--all",
98
106
  "--default",
107
+ "--quickchr",
99
108
  "--concurrency",
100
109
  ] as const;
101
110
 
@@ -143,6 +152,9 @@ export function consumeSelectionFlag(
143
152
  case "--bbox":
144
153
  acc.bbox = parseBbox(expectValue(args, index + 1, arg));
145
154
  return index + 1;
155
+ case "--quickchr":
156
+ acc.quickchr.push(expectValue(args, index + 1, arg));
157
+ return index + 1;
146
158
  case "--concurrency": {
147
159
  const raw = expectValue(args, index + 1, arg);
148
160
  const parsed = parseStrictInteger(raw);
@@ -175,26 +187,74 @@ export function buildTargetSelection(
175
187
  where: flags.where,
176
188
  near: flags.near,
177
189
  bbox: flags.bbox,
190
+ quickchr: flags.quickchr,
178
191
  };
179
192
  }
180
193
 
194
+ /** True when any CDB selector flag is present (quickchr and positionals excluded). */
195
+ function hasCdbSelector(flags: SelectionFlags): boolean {
196
+ return (
197
+ flags.all ||
198
+ flags.default ||
199
+ flags.groups.length > 0 ||
200
+ flags.where.length > 0 ||
201
+ flags.near !== undefined ||
202
+ flags.bbox !== undefined
203
+ );
204
+ }
205
+
206
+ /**
207
+ * `--quickchr` exclusivity (v1, `docs/CONSTITUTION.md` → Resolution providers):
208
+ * a command targets quickchr machines OR CDB/literal targets, never both.
209
+ * Commands call this after splitting positionals, in both single-target and
210
+ * fan-out mode; relaxing it later is additive.
211
+ */
212
+ export function assertQuickchrExclusive(
213
+ flags: SelectionFlags,
214
+ targetPositionalCount: number,
215
+ ): void {
216
+ if (flags.quickchr.length === 0) {
217
+ return;
218
+ }
219
+ if (hasCdbSelector(flags) || targetPositionalCount > 0) {
220
+ throw new CentrsError({
221
+ code: "usage/conflicting-flags",
222
+ summary:
223
+ "`--quickchr` is exclusive: a command targets quickchr machines or CDB/literal targets, not both.",
224
+ remediation:
225
+ "Drop the positional target / CDB selector (or the `--quickchr` flag) so the selection has one target source.",
226
+ context: {
227
+ quickchr: [...flags.quickchr],
228
+ positionals: targetPositionalCount,
229
+ },
230
+ });
231
+ }
232
+ }
233
+
181
234
  /**
182
- * Fan-out mode is keyed on INTENT, not resolved count: any selector flag, or more
183
- * than one positional target. A plain single-positional call stays single-target
184
- * (never `FanoutData`); a selector that resolves to one/zero members is still
185
- * fan-out mode. See `docs/CONSTITUTION.md` (Target selection).
235
+ * True when any target-taking flag is present, so positionals are operation
236
+ * args, not targets. Superset of {@link isFanoutMode}'s flag term: a single
237
+ * `--quickchr` names the target (like a lone positional, still single-target
238
+ * mode) but already claims the positional slots.
239
+ */
240
+ export function hasTargetSelector(flags: SelectionFlags): boolean {
241
+ return hasCdbSelector(flags) || flags.quickchr.length > 0;
242
+ }
243
+
244
+ /**
245
+ * Fan-out mode is keyed on INTENT, not resolved count: any selector flag, more
246
+ * than one positional target, or a repeated `--quickchr`. A plain
247
+ * single-positional call — and a single `--quickchr <name>` — stays
248
+ * single-target (never `FanoutData`); a selector that resolves to one/zero
249
+ * members is still fan-out mode. See `docs/CONSTITUTION.md` (Target selection).
186
250
  */
187
251
  export function isFanoutMode(
188
252
  flags: SelectionFlags,
189
253
  targetPositionalCount: number,
190
254
  ): boolean {
191
255
  return (
192
- flags.all ||
193
- flags.default ||
194
- flags.groups.length > 0 ||
195
- flags.where.length > 0 ||
196
- flags.near !== undefined ||
197
- flags.bbox !== undefined ||
256
+ hasCdbSelector(flags) ||
257
+ flags.quickchr.length > 1 ||
198
258
  targetPositionalCount > 1
199
259
  );
200
260
  }
@@ -36,7 +36,7 @@ export const settingsCommand: CliCommandMetadata = {
36
36
  name: "settings",
37
37
  usage: "centrs settings [print|get|set|reset] [args] [flags]",
38
38
  summary:
39
- "Manage centrs's own global preferences (centrs.env) and inspect the __default__ CDB record.",
39
+ "Manage centrs's own global preferences (`centrs.env`) and inspect the `__default__` CDB record.",
40
40
  options: [
41
41
  {
42
42
  flag: "--all",
@@ -47,7 +47,7 @@ export const settingsCommand: CliCommandMetadata = {
47
47
  flag: "--cdb-file",
48
48
  valueName: "<path>",
49
49
  description:
50
- "`print` only — CDB to probe for the __default__ record summary (falls back to normal CDB resolution; never centrs.env).",
50
+ "`print` only — CDB to probe for the `__default__` record summary (falls back to normal CDB resolution; never centrs.env).",
51
51
  },
52
52
  {
53
53
  flag: "--cdb-password",
@@ -6,6 +6,7 @@
6
6
  * `process.*`); the core relay in `../terminal.ts` stays io-agnostic.
7
7
  */
8
8
 
9
+ import { toYaml } from "../core/yaml.ts";
9
10
  import {
10
11
  asCentrsError,
11
12
  CentrsError,
@@ -18,7 +19,6 @@ import {
18
19
  type TerminalIo,
19
20
  type TerminalRequest,
20
21
  } from "../index.ts";
21
- import { toYaml } from "../retrieve.ts";
22
22
  import {
23
23
  type CliCommandMetadata,
24
24
  expectValue,
@@ -23,6 +23,7 @@ import {
23
23
  transferOutputFormats,
24
24
  transferVerbs,
25
25
  } from "../index.ts";
26
+ import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
26
27
  import {
27
28
  type CliCommandMetadata,
28
29
  expectValue,
@@ -38,6 +39,7 @@ import {
38
39
  withTips,
39
40
  } from "./missing-target.ts";
40
41
  import {
42
+ assertQuickchrExclusive,
41
43
  buildTargetSelection,
42
44
  consumeSelectionFlag,
43
45
  emptySelectionFlags,
@@ -82,8 +84,7 @@ export const transferCommand: CliCommandMetadata = {
82
84
  "Accept a self-signed TLS cert (https/api-ssl) or a new SSH host key. Default verifies.",
83
85
  },
84
86
  {
85
- flag: "--force",
86
- valueName: "(--overwrite)",
87
+ flag: "--force / --overwrite",
87
88
  description: "Replace an existing destination. Default refuses it.",
88
89
  },
89
90
  ...selectionCommandOptions,
@@ -99,10 +100,10 @@ export const transferCommand: CliCommandMetadata = {
99
100
  "Confirm a mutating fan-out (upload/remove/mkdir/copy) across multiple routers in non-interactive runs.",
100
101
  },
101
102
  {
102
- flag: "--verify",
103
+ flag: "--verify / --no-verify",
103
104
  valueName: "<size|checksum|off>",
104
105
  description:
105
- "Post-transfer integrity check. Default size; --no-verify skips.",
106
+ "Post-transfer integrity check. Default size; `--no-verify` skips.",
106
107
  },
107
108
  {
108
109
  flag: "--type",
@@ -147,6 +148,7 @@ export const transferCommand: CliCommandMetadata = {
147
148
  "Output format. Defaults to text; --json / --yaml shortcuts.",
148
149
  },
149
150
  { flag: "--json", description: "Shortcut for --format json." },
151
+ { flag: "--yaml", description: "Shortcut for --format yaml." },
150
152
  {
151
153
  flag: "--cdb-file",
152
154
  valueName: "<path>",
@@ -479,7 +481,10 @@ function assemblePositionals(
479
481
  selectionFlags: SelectionFlags,
480
482
  fixedVerb?: TransferVerb,
481
483
  ): { request: TransferRequest; targetPositionals: readonly string[] } {
482
- const hasSelector = isFanoutMode(selectionFlags, 0);
484
+ // Any target-taking flag (CDB selector or `--quickchr`) claims the target
485
+ // slots, so every positional belongs to the verb/paths.
486
+ const hasSelector =
487
+ isFanoutMode(selectionFlags, 0) || selectionFlags.quickchr.length > 0;
483
488
  let verb: TransferVerb;
484
489
  let targetPositionals: readonly string[];
485
490
  let rest: readonly string[];
@@ -522,9 +527,21 @@ function assemblePositionals(
522
527
 
523
528
  const request: TransferRequest = { verb, ...flags };
524
529
  applyVerbPaths(request, verb, rest);
530
+ assertQuickchrExclusive(selectionFlags, targetPositionals.length);
531
+ // Direct connection overrides conflict with `--quickchr` globally: reject at
532
+ // parse time so a repeated `--quickchr` gets one usage error (exit 1), never
533
+ // per-member failures. The resolver re-checks for library callers.
534
+ if (selectionFlags.quickchr.length > 0) {
535
+ assertNoQuickchrOverrideConflict(request, selectionFlags.quickchr[0] ?? "");
536
+ }
525
537
 
526
- // Single-target (not fan-out mode) needs exactly one resolved target.
538
+ // Single-target (not fan-out mode) needs exactly one resolved target: one
539
+ // positional, or one `--quickchr <name>` (the named-live-provider).
527
540
  if (!isFanoutMode(selectionFlags, targetPositionals.length)) {
541
+ if (selectionFlags.quickchr.length === 1) {
542
+ request.quickchr = selectionFlags.quickchr[0];
543
+ return { request, targetPositionals };
544
+ }
528
545
  const targetInput = targetPositionals[0];
529
546
  if (!targetInput) {
530
547
  throw missingTargetError({
package/src/cli.ts CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  import { apiCommand, runApiCli } from "./cli/api.ts";
4
4
  import { btestCommand, runBtestCli } from "./cli/btest.ts";
5
+ import type { CliCommandMetadata } from "./cli/common.ts";
5
6
  import { devicesCommand, runDevicesCli } from "./cli/devices.ts";
6
7
  import { discoverCommand, runDiscoverCli } from "./cli/discover.ts";
7
8
  import { executeCommand, runExecuteCli } from "./cli/execute.ts";
9
+ import { explainCliCommand, runExplainCli } from "./cli/explain.ts";
8
10
  import { mcpCommand, runMcpCli } from "./cli/mcp.ts";
9
11
  import { retrieveCommand, runRetrieveCli } from "./cli/retrieve.ts";
10
12
  import { runSettingsCli, settingsCommand } from "./cli/settings.ts";
@@ -13,18 +15,26 @@ import { runTransferCli, transferCommand } from "./cli/transfer.ts";
13
15
  import { asCentrsError, formatCentrsErrorText } from "./errors.ts";
14
16
  import { describeCentrs, plannedProtocols, plannedSurfaces } from "./index.ts";
15
17
 
16
- const commandSummaries: ReadonlyArray<{ name: string; summary: string }> = [
18
+ /** Every CLI command's metadata, in help/docs order. `scripts/gen-cli-docs.ts` renders `docs/CLI.md` from this. */
19
+ export const cliCommands: ReadonlyArray<CliCommandMetadata> = [
17
20
  retrieveCommand,
18
21
  executeCommand,
19
22
  apiCommand,
20
23
  transferCommand,
21
24
  terminalCommand,
25
+ explainCliCommand,
22
26
  devicesCommand,
23
27
  discoverCommand,
24
28
  btestCommand,
25
29
  mcpCommand,
26
30
  settingsCommand,
27
- ].map((command) => ({ name: command.name, summary: command.summary }));
31
+ ];
32
+
33
+ const commandSummaries: ReadonlyArray<{ name: string; summary: string }> =
34
+ cliCommands.map((command) => ({
35
+ name: command.name,
36
+ summary: command.summary,
37
+ }));
28
38
 
29
39
  export function renderCliHelp(): string {
30
40
  return [
@@ -75,6 +85,9 @@ export async function runCli(
75
85
  if (command === "download") {
76
86
  return await runTransferCli(rest, { fixedVerb: "download" });
77
87
  }
88
+ if (command === "explain") {
89
+ return await runExplainCli(rest);
90
+ }
78
91
  if (command === "devices") {
79
92
  return await runDevicesCli(rest);
80
93
  }
@@ -27,7 +27,9 @@ import type { RouterOsProtocol } from "../protocols/index.ts";
27
27
  /**
28
28
  * Where a resolved setting/identity field came from. Precedence (low → high)
29
29
  * is config < comment-kv < env < cli; `cdb` and `arp` describe identity
30
- * resolution rather than settings precedence.
30
+ * resolution rather than settings precedence, and `provider` marks a field a
31
+ * named-live-provider supplied (e.g. `--quickchr`), which bypasses the ladder
32
+ * entirely (`docs/CONSTITUTION.md` → Resolution providers).
31
33
  */
32
34
  export type SettingSourceKind =
33
35
  | "default"
@@ -36,7 +38,8 @@ export type SettingSourceKind =
36
38
  | "env"
37
39
  | "cli"
38
40
  | "cdb"
39
- | "arp";
41
+ | "arp"
42
+ | "provider";
40
43
 
41
44
  export interface SettingSource {
42
45
  kind: SettingSourceKind;
@@ -108,6 +111,50 @@ export interface EnvelopeTargetMeta {
108
111
  sources?: Record<string, SettingSource>;
109
112
  }
110
113
 
114
+ /**
115
+ * One stage of the two-stage validation gate (GH#354).
116
+ *
117
+ * `offline` is the corpus-gated analyzer (`src/offline-gate.ts`): it opens no
118
+ * connection, so a `failed` offline stage means the device was never contacted.
119
+ * `device` is the RouterOS-side preflight — `:put [:parse …]` and/or
120
+ * `/console/inspect request=child`, depending on the command and transport.
121
+ *
122
+ * Stages appear in the order they run, and a stage that did not run is still
123
+ * listed with `result: "skipped"`. That is the point of the array: a reader must
124
+ * be able to tell "the device accepted it" from "the device was never asked".
125
+ */
126
+ export interface EnvelopeValidationStage {
127
+ stage: "offline" | "device";
128
+ /** Validator identity, e.g. `offline explain (canonicalizer)` or `:put [:parse]`. */
129
+ source: string;
130
+ result: "passed" | "failed" | "skipped";
131
+ /** Why a stage was skipped, when the reason is not simply `validate=false`. */
132
+ reason?: string;
133
+ }
134
+
135
+ /**
136
+ * Mutable sink that lets an error path report the validation that ACTUALLY ran.
137
+ *
138
+ * `runResolvedExecute` / `runResolvedApi` finish the gate and then do the real
139
+ * work, and the error envelope is built by a *different* function after the
140
+ * throw unwinds past them. Without this, a `routeros/*` fault on the run — which
141
+ * happens with both gate stages already green — got a reconstructed
142
+ * `result: "failed"` that erased the real outcome. The runner writes
143
+ * {@link ValidationTrace.validation} the moment the gate returns; the error
144
+ * builder prefers it over anything it could guess (GH#354, PR #356 review).
145
+ */
146
+ export interface ValidationTrace {
147
+ /** The completed gate result, set once validation finishes. */
148
+ validation?: EnvelopeValidationMeta;
149
+ /**
150
+ * The stage-1 verdict, recorded as soon as stage 1 returns — before stage 2
151
+ * can throw. `warn` is an abstention, and an error envelope that reported it
152
+ * as `passed` would be telling a consumer the analyzer read bytes it declined
153
+ * to judge.
154
+ */
155
+ offlineVerdict?: "pass" | "warn";
156
+ }
157
+
111
158
  export interface EnvelopeValidationMeta {
112
159
  enabled: boolean;
113
160
  /** Validator identity, e.g. `/console/inspect` or `:put [:parse]`. */
@@ -118,6 +165,15 @@ export interface EnvelopeValidationMeta {
118
165
  /** Semantic `/console/inspect` gate status, or not-applicable for script mode. */
119
166
  semantic?: boolean | "not-applicable";
120
167
  availableAttributes?: readonly string[];
168
+ /**
169
+ * Per-stage detail for the gate described in
170
+ * `docs/CONSTITUTION.md` → "Validation is the product".
171
+ *
172
+ * The flat `source` / `syntax` / `semantic` fields above stay the summary
173
+ * view and keep their meaning; this is the breakdown behind them. Absent on
174
+ * surfaces that have no RouterOS-command gate at all (`btest`).
175
+ */
176
+ stages?: readonly EnvelopeValidationStage[];
121
177
  }
122
178
 
123
179
  export interface EnvelopeTimingMeta {
@@ -234,6 +234,31 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
234
234
  summary: "A received MNDP datagram could not be decoded.",
235
235
  },
236
236
 
237
+ // quickchr/*
238
+ {
239
+ code: "quickchr/package-unavailable",
240
+ summary: "The optional `@tikoci/quickchr` package is not installed.",
241
+ },
242
+ {
243
+ code: "quickchr/unsupported",
244
+ summary:
245
+ "The installed `@tikoci/quickchr` is too old or speaks a newer descriptor version than centrs understands.",
246
+ },
247
+ {
248
+ code: "quickchr/machine-not-found",
249
+ summary: "No quickchr machine is registered under that name.",
250
+ },
251
+ {
252
+ code: "quickchr/machine-stopped",
253
+ summary:
254
+ "The quickchr machine is not running, so it has no live connection facts.",
255
+ },
256
+ {
257
+ code: "quickchr/unsupported-via",
258
+ summary:
259
+ "The chosen `--via` has no usable service on the quickchr machine (not forwarded, unavailable, or SSH without batch-capable auth).",
260
+ },
261
+
237
262
  // routeros/*
238
263
  {
239
264
  code: "routeros/api-fatal",
@@ -323,6 +348,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
323
348
  code: "routeros/unsupported-capability",
324
349
  summary: "The chosen protocol does not support this command capability.",
325
350
  },
351
+ {
352
+ code: "routeros/version-unsupported",
353
+ summary:
354
+ "The device's RouterOS version does not provide the operation this command needs.",
355
+ },
326
356
 
327
357
  // settings/*
328
358
  {
@@ -435,6 +465,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
435
465
  summary:
436
466
  "Peer verification was disabled via --insecure (TLS / SSH host-key checks skipped).",
437
467
  },
468
+ {
469
+ code: "transport/provider-trust",
470
+ summary:
471
+ "Peer verification is relaxed for a named-live-provider endpoint (a quickchr VM's self-signed TLS cert / ephemeral SSH host key), trusted by the provider's loopback-forward voucher.",
472
+ },
438
473
  {
439
474
  code: "transport/local-tool-missing",
440
475
  summary:
@@ -475,6 +510,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
475
510
  code: "usage/invalid-concurrency",
476
511
  summary: "The --concurrency value must be an integer >= 1.",
477
512
  },
513
+ {
514
+ code: "usage/stdin-ignored",
515
+ summary:
516
+ "stdin was redirected but the input came from the positional argument, so anything piped in was not analyzed.",
517
+ },
478
518
  {
479
519
  code: "usage/fanout-not-supported",
480
520
  summary:
@@ -514,7 +554,8 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
514
554
  },
515
555
  {
516
556
  code: "validation/syntax",
517
- summary: "RouterOS rejected the command syntax during the :parse gate.",
557
+ summary:
558
+ "The command syntax was rejected by the validation gate — offline analysis, or the RouterOS :parse probe.",
518
559
  },
519
560
  {
520
561
  code: "validation/unknown-attribute",
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The **script-vs-structured execution gate** and its canonical command shape.
3
+ *
4
+ * `execute.ts` owned this pair, which put every consumer of the gate — the MCP
5
+ * tool surface and `explain`'s offline analysis among them — one import away
6
+ * from the transport stack (`mac-telnet` -> `node:dgram`, `native-api` ->
7
+ * `node:crypto`). The functions themselves are pure string work, so they live in
8
+ * `src/core/` and `execute.ts` re-exports them: the public API, the locked
9
+ * contract table in `test/unit/execute-canonicalize-contract.test.ts`, and the
10
+ * gate's behavior are all unchanged by the move (#312).
11
+ *
12
+ * Nothing here reads settings, the filesystem, CDB, or a device. Changing that
13
+ * re-couples `explain`'s browser entry to the transport graph.
14
+ */
15
+
16
+ export interface CanonicalExecuteCommand {
17
+ mode: "structured" | "script";
18
+ input: string;
19
+ path: string;
20
+ verb: string;
21
+ attributes: Record<string, string>;
22
+ queries: string[];
23
+ }
24
+
25
+ /**
26
+ * The **script-vs-structured execution gate** — centrs's load-bearing
27
+ * discriminator (with {@link isWriteShaped}) for which validation runs and
28
+ * whether the write-confirmation prompt fires. centrs owns this gate; the shared,
29
+ * prose-tolerant, multi-command canonicalizer that `rosetta` / `lsp-routeros-ts`
30
+ * publish is for *canonicalization*, never the structured-mode predicate —
31
+ * widening what counts as `structured` is a product regression. Behavior is
32
+ * pinned by `test/unit/execute-canonicalize-contract.test.ts`.
33
+ *
34
+ * centrs deliberately does **not** vendor the shared parser yet. Preconditions
35
+ * for adopting it: (1) that contract stays green (no gate widening); (2) the
36
+ * vendored file is clean under centrs's strict `tsconfig` (or explicitly
37
+ * quarantined with justification); (3) `lsp-routeros-ts` also vendors/consumes
38
+ * the same parser shape, so it is genuinely shared, not a premature first copy.
39
+ */
40
+ export function canonicalizeExecuteCommand(
41
+ input: string,
42
+ ): CanonicalExecuteCommand {
43
+ const asScript = (): CanonicalExecuteCommand => ({
44
+ mode: "script",
45
+ input,
46
+ path: "",
47
+ verb: "",
48
+ attributes: {},
49
+ queries: [],
50
+ });
51
+
52
+ const trimmed = input.trim();
53
+ if (!trimmed.startsWith("/")) {
54
+ return asScript();
55
+ }
56
+
57
+ const tokens = tokenizeRouterOsCli(trimmed);
58
+ const pathToken = tokens[0] ?? "";
59
+ const pathParts = pathToken.split("/").filter(Boolean);
60
+ if (pathParts.length < 2) {
61
+ return asScript();
62
+ }
63
+
64
+ const verb = pathParts.at(-1) ?? "";
65
+ const path = `/${pathParts.slice(0, -1).join("/")}`;
66
+ const attributes: Record<string, string> = {};
67
+ const queries: string[] = [];
68
+ for (const token of tokens.slice(1)) {
69
+ // `[...]` subshell selectors (e.g. `numbers=[find ...]`) cannot be
70
+ // represented as a structured attribute map: the inner command contains
71
+ // spaces this tokenizer splits on, which would mangle a write-shaped
72
+ // command into corrupt key=value pairs. Fall back to the raw-script path
73
+ // so RouterOS evaluates the subshell with its real semantics.
74
+ if (token.includes("[") || token.includes("]")) {
75
+ return asScript();
76
+ }
77
+ if (token.startsWith("?")) {
78
+ queries.push(token);
79
+ continue;
80
+ }
81
+ const separator = token.indexOf("=");
82
+ if (separator <= 0) {
83
+ return asScript();
84
+ }
85
+ attributes[token.slice(0, separator)] = token.slice(separator + 1);
86
+ }
87
+
88
+ return {
89
+ mode: "structured",
90
+ input,
91
+ path,
92
+ verb,
93
+ attributes,
94
+ queries,
95
+ };
96
+ }
97
+
98
+ export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
99
+ return (
100
+ command.mode === "structured" &&
101
+ ["add", "set", "remove"].includes(command.verb)
102
+ );
103
+ }
104
+
105
+ function tokenizeRouterOsCli(input: string): string[] {
106
+ const tokens: string[] = [];
107
+ let current = "";
108
+ let quote: '"' | "'" | undefined;
109
+ let escaped = false;
110
+ for (const char of input) {
111
+ if (escaped) {
112
+ current += char;
113
+ escaped = false;
114
+ continue;
115
+ }
116
+ if (char === "\\") {
117
+ escaped = true;
118
+ continue;
119
+ }
120
+ if (quote) {
121
+ if (char === quote) {
122
+ quote = undefined;
123
+ } else {
124
+ current += char;
125
+ }
126
+ continue;
127
+ }
128
+ if (char === '"' || char === "'") {
129
+ quote = char;
130
+ continue;
131
+ }
132
+ if (/\s/.test(char)) {
133
+ if (current.length > 0) {
134
+ tokens.push(current);
135
+ current = "";
136
+ }
137
+ continue;
138
+ }
139
+ current += char;
140
+ }
141
+ if (escaped) {
142
+ current += "\\";
143
+ }
144
+ if (current.length > 0) {
145
+ tokens.push(current);
146
+ }
147
+ return tokens;
148
+ }
@@ -5,10 +5,16 @@
5
5
  * (path tokenizing, the comma-path join, node-type predicates, completion-name
6
6
  * extraction). This module is the single home so a third consumer — `api`, and a
7
7
  * future `explain`/`check` — reuses one grounded implementation instead of
8
- * forking a fourth. It deliberately holds only the transport-agnostic
9
- * *primitives*; each command composes its own discovery strategy on top (their
10
- * strategies differ: `retrieve` probes print/get support, `execute` unions
11
- * child-args with completions), so the strategy stays in the command.
8
+ * forking a fourth. It holds the transport-agnostic *primitives* plus the one
9
+ * discovery strategy that is shared verbatim: {@link inspectArgumentNames},
10
+ * command-level `request=child` only, used by `execute` and `api`. Strategies
11
+ * that differ stay in the command — `retrieve` probes print/get support and
12
+ * reads argument-level completion (`print,proplist` / `get,value-name`) with a
13
+ * child fallback.
14
+ *
15
+ * Command-level `request=completion` is not part of any argument-discovery
16
+ * strategy: on RouterOS 7.12.2 it hangs the REST handler and closes the
17
+ * native-API connection. See {@link inspectArgumentNames}.
12
18
  *
13
19
  * Grounding: the request modes mirror `tikoci/lsp-routeros-ts`
14
20
  * (`server/src/routeros.ts` `InspectRequest`). The behavioral facts (the array-
@@ -131,6 +137,32 @@ export async function inspectChildren(
131
137
  )) as InspectChildItem[];
132
138
  }
133
139
 
140
+ /**
141
+ * Return the argument names exposed by `request=child` at a command path.
142
+ *
143
+ * Do not supplement this with a command-level `request=completion`: RouterOS
144
+ * 7.12.2 hangs the REST handler and closes the native-API connection for that
145
+ * request shape, while `child` returns the complete argument list immediately.
146
+ * Argument-level completion (for example `print,proplist`) is a distinct probe
147
+ * and remains available through {@link inspectCompletions}.
148
+ */
149
+ export async function inspectArgumentNames(
150
+ backend: InspectBackend,
151
+ tokens: readonly string[],
152
+ ): Promise<string[]> {
153
+ const children = await inspectChildren(backend, tokens);
154
+ return [
155
+ ...new Set(
156
+ children
157
+ .filter(isArgumentNode)
158
+ .map((child) => child.name)
159
+ .filter(
160
+ (name): name is string => typeof name === "string" && name.length > 0,
161
+ ),
162
+ ),
163
+ ].sort();
164
+ }
165
+
134
166
  /** `request=completion` for a token path. */
135
167
  export async function inspectCompletions(
136
168
  backend: InspectBackend,