@tikoci/centrs 0.1.8 → 0.1.9-next.22

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 (80) hide show
  1. package/CHANGELOG.md +216 -31
  2. package/GLOSSARY.txt +8 -0
  3. package/README.md +16 -8
  4. package/SECURITY.md +11 -3
  5. package/commands/api/README.md +44 -15
  6. package/commands/api/examples.md +44 -6
  7. package/commands/execute/README.md +218 -7
  8. package/commands/execute/examples.md +278 -1
  9. package/commands/explain/README.md +2 -1
  10. package/commands/mcp/README.md +11 -0
  11. package/commands/mcp/examples.md +6 -0
  12. package/commands/retrieve/README.md +32 -14
  13. package/commands/retrieve/examples.md +10 -6
  14. package/commands/transfer/examples.md +9 -0
  15. package/docs/CLI.md +10 -8
  16. package/docs/CONSTITUTION.md +54 -3
  17. package/docs/MATRIX.md +20 -3
  18. package/docs/errors/cdb/empty-group.md +19 -3
  19. package/docs/errors/cdb/empty-selection.md +9 -3
  20. package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
  21. package/docs/errors/cmr/cancelled.md +15 -0
  22. package/docs/errors/cmr/client-disconnected.md +19 -0
  23. package/docs/errors/cmr/client-script-not-validated.md +22 -0
  24. package/docs/errors/cmr/console-only.md +29 -0
  25. package/docs/errors/cmr/empty-selection.md +33 -0
  26. package/docs/errors/cmr/membership-changed.md +20 -0
  27. package/docs/errors/cmr/not-a-controller.md +26 -0
  28. package/docs/errors/cmr/outcome-unknown.md +29 -0
  29. package/docs/errors/cmr/output-truncated.md +16 -0
  30. package/docs/errors/cmr/policy-denied.md +31 -0
  31. package/docs/errors/cmr/rule-selects-every-device.md +32 -0
  32. package/docs/errors/cmr/selection-includes-controller.md +27 -0
  33. package/docs/errors/cmr/selection-incomplete.md +18 -0
  34. package/docs/errors/cmr/validation-incomplete.md +23 -0
  35. package/docs/errors/routeros/version-unsupported.md +4 -0
  36. package/docs/errors/transport/write-outcome-unknown.md +24 -0
  37. package/docs/errors/usage/flag-after-separator.md +34 -0
  38. package/docs/errors/usage/missing-labels.md +19 -0
  39. package/package.json +7 -5
  40. package/src/api-fanout.ts +18 -2
  41. package/src/api.ts +230 -71
  42. package/src/btest.ts +7 -2
  43. package/src/cli/api.ts +30 -16
  44. package/src/cli/common.ts +180 -1
  45. package/src/cli/devices.ts +0 -2
  46. package/src/cli/discover.ts +0 -1
  47. package/src/cli/execute.ts +176 -21
  48. package/src/cli/explain.ts +2 -0
  49. package/src/cli/mcp.ts +0 -1
  50. package/src/cli/retrieve.ts +40 -18
  51. package/src/cli/settings.ts +0 -3
  52. package/src/cli/terminal.ts +0 -2
  53. package/src/cli/transfer.ts +10 -8
  54. package/src/cli/version.ts +87 -0
  55. package/src/cli.ts +5 -3
  56. package/src/core/cmr-fleet.ts +486 -0
  57. package/src/core/cmr.ts +182 -0
  58. package/src/core/envelope.ts +46 -1
  59. package/src/core/error-catalog.ts +95 -2
  60. package/src/core/execute-command.ts +144 -19
  61. package/src/core/fanout.ts +207 -14
  62. package/src/errors.ts +3 -1
  63. package/src/execute-cmr.ts +1442 -0
  64. package/src/execute-fanout.ts +15 -1
  65. package/src/execute.ts +173 -40
  66. package/src/index.ts +35 -1
  67. package/src/mcp/safety.ts +11 -0
  68. package/src/mcp/tools.ts +4 -1
  69. package/src/protocols/adapter.ts +54 -12
  70. package/src/resolver/cdb.ts +13 -1
  71. package/src/resolver/index.ts +4 -0
  72. package/src/resolver/selection.ts +37 -1
  73. package/src/resolver/target.ts +96 -8
  74. package/src/retrieve-fanout.ts +4 -1
  75. package/src/retrieve-follow.ts +29 -33
  76. package/src/retrieve-sample.ts +14 -8
  77. package/src/retrieve-stream.ts +38 -6
  78. package/src/retrieve.ts +73 -59
  79. package/src/transfer-fanout.ts +8 -1
  80. package/src/transfer.ts +15 -19
@@ -15,6 +15,10 @@
15
15
  * - `meta` is invariant across commands. Only `data` and `meta.operation` vary
16
16
  * per command. Do not add command-specific keys beside `target`/`via`/
17
17
  * `settings`/`validation`/`timing`/`truncated`; put them in `meta.operation`.
18
+ * - Shared meta is written once (#461, #462, #473). A fan-out target omits
19
+ * what its outer envelope already says ({@link FanoutMemberOperation}); a
20
+ * stream's data lines between `started` and `summary` carry only their
21
+ * `meta.operation` ({@link StreamLineEnvelope}).
18
22
  * - CLI render must be lossless against the JSON envelope; `--format json|yaml`
19
23
  * select serialization, not different shapes. Omit `undefined`; use `null`
20
24
  * only where absence is meaningful and must round-trip (e.g. `via: null` when
@@ -109,6 +113,17 @@ export interface EnvelopeTargetMeta {
109
113
  source?: SettingSource;
110
114
  /** Provenance per resolved field once the resolver is wired (WP-0c). */
111
115
  sources?: Record<string, SettingSource>;
116
+ /**
117
+ * `execute --cmr`: the CMR client this result belongs to, as the controller
118
+ * knows it. `id` is the controller's `/cmr/device` row; `deviceKey` is the
119
+ * device half of its `ids`. The client is never dialed directly; the
120
+ * controller is the fan-out's outer `meta.target`.
121
+ */
122
+ cmr?: {
123
+ id: string;
124
+ deviceKey?: string;
125
+ address?: string;
126
+ };
112
127
  }
113
128
 
114
129
  /**
@@ -153,6 +168,11 @@ export interface ValidationTrace {
153
168
  * to judge.
154
169
  */
155
170
  offlineVerdict?: "pass" | "warn";
171
+ /**
172
+ * Set immediately before the RouterOS request is sent. A failure after this
173
+ * point may have run on the router, so a write must not be replayed (#448).
174
+ */
175
+ dispatched?: boolean;
156
176
  }
157
177
 
158
178
  export interface EnvelopeValidationMeta {
@@ -271,9 +291,34 @@ export interface FanoutSummary {
271
291
  failed: number;
272
292
  }
273
293
 
294
+ /**
295
+ * A fan-out target's `meta.operation`. `request` and `auth` are omitted when
296
+ * they equal the outer `meta.operation`'s, and the target's `meta.settings`
297
+ * keeps only the keys whose source differs from the outer `meta.settings`, so
298
+ * a reader takes `target ?? outer` (per key for settings).
299
+ */
300
+ export type FanoutMemberOperation<Operation> = Omit<
301
+ Operation,
302
+ "request" | "auth"
303
+ > &
304
+ Partial<Pick<Operation, Extract<keyof Operation, "request" | "auth">>>;
305
+
274
306
  export interface FanoutData<Data = unknown, Operation = unknown> {
275
307
  summary: FanoutSummary;
276
- targets: readonly CentrsEnvelope<Data, Operation>[];
308
+ targets: readonly CentrsEnvelope<Data, FanoutMemberOperation<Operation>>[];
309
+ }
310
+
311
+ /**
312
+ * A stream data line that carries only its own `meta.operation`; the rest of
313
+ * its meta is on the stream's `started` and `summary` lines (and `synced` for
314
+ * `retrieve --follow`).
315
+ */
316
+ export interface StreamLineEnvelope<Data = unknown, Operation = unknown> {
317
+ ok: true;
318
+ data: Data;
319
+ warnings: readonly Warning[];
320
+ tips: readonly Tip[];
321
+ meta: { operation: Operation };
277
322
  }
278
323
 
279
324
  /** Normalize an optional warnings list to a readonly array. */
@@ -55,11 +55,13 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
55
55
  },
56
56
  {
57
57
  code: "cdb/empty-group",
58
- summary: "The named CDB group contains no records.",
58
+ summary:
59
+ "The named CDB group contains no records: a warning for a read, an error for a write-shaped fan-out.",
59
60
  },
60
61
  {
61
62
  code: "cdb/empty-selection",
62
- summary: "The target selection matched no CDB records.",
63
+ summary:
64
+ "The target selection matched no CDB records: a warning for a read, an error for a write-shaped fan-out.",
63
65
  },
64
66
  {
65
67
  code: "cdb/file-exists",
@@ -262,6 +264,83 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
262
264
  summary: "A received MNDP datagram could not be decoded.",
263
265
  },
264
266
 
267
+ // cmr/* — `execute --cmr` (#450)
268
+ {
269
+ code: "cmr/cancelled",
270
+ summary:
271
+ "The operation was cancelled before the command was sent to a CMR client.",
272
+ },
273
+ {
274
+ code: "cmr/caller-policy-not-enforced",
275
+ summary:
276
+ "Warning: the controller login lacks a policy the CMR command runs with on the clients, and RouterOS does not check it.",
277
+ },
278
+ {
279
+ code: "cmr/client-script-not-validated",
280
+ summary:
281
+ "Warning: a caller-addressed `/cmr/device/run-script` was validated as a controller request; its script was not validated on the clients.",
282
+ },
283
+ {
284
+ code: "cmr/client-disconnected",
285
+ summary:
286
+ "CMR skipped a client that is not connected to its controller; the command was not sent.",
287
+ },
288
+ {
289
+ code: "cmr/empty-selection",
290
+ summary:
291
+ "The CMR selection matched no device on the controller: for `--cmr`, a warning for a read and an error for a write; for a fleet command such as `/cmr/vlan/add`, a warning.",
292
+ },
293
+ {
294
+ code: "cmr/membership-changed",
295
+ summary:
296
+ "A CMR client left the label selection or changed its key between validation and dispatch; the command was not sent.",
297
+ },
298
+ {
299
+ code: "cmr/not-a-controller",
300
+ summary:
301
+ "The target does not answer `/cmr/device`; it is not a CMR controller.",
302
+ },
303
+ {
304
+ code: "cmr/console-only",
305
+ summary:
306
+ "The CMR command is an interactive console view that breaks an API session, so centrs refused it before running it.",
307
+ },
308
+ {
309
+ code: "cmr/outcome-unknown",
310
+ summary:
311
+ "The command was sent to a CMR client but no final status came back; it may still be running.",
312
+ },
313
+ {
314
+ code: "cmr/output-truncated",
315
+ summary:
316
+ "Warning: CMR clipped a client's command output at its ~60 KiB cap.",
317
+ },
318
+ {
319
+ code: "cmr/policy-denied",
320
+ summary:
321
+ "A CMR client refused a statement because CMR scripts run without the policy it needs; earlier statements already ran.",
322
+ },
323
+ {
324
+ code: "cmr/rule-selects-every-device",
325
+ summary:
326
+ "Warning: a CMR rule command has no device `labels` (and, for VLAN, no `port-labels`), so the rule applies to every device, the controller included.",
327
+ },
328
+ {
329
+ code: "cmr/selection-includes-controller",
330
+ summary:
331
+ "Warning: a CMR fleet command selects the controller's own row as well as its clients.",
332
+ },
333
+ {
334
+ code: "cmr/selection-incomplete",
335
+ summary:
336
+ "The controller did not answer whether every device matches the labels, so nothing was run.",
337
+ },
338
+ {
339
+ code: "cmr/validation-incomplete",
340
+ summary:
341
+ "A CMR client's validation result was incomplete or clipped, so the command was not sent to it.",
342
+ },
343
+
265
344
  // quickchr/*
266
345
  {
267
346
  code: "quickchr/package-unavailable",
@@ -533,6 +612,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
533
612
  code: "transport/unsupported-operation",
534
613
  summary: "The chosen transport does not support this operation.",
535
614
  },
615
+ {
616
+ code: "transport/write-outcome-unknown",
617
+ summary:
618
+ "Warning: a write failed after it was sent, so it may have run on the router; centrs did not retry it.",
619
+ },
536
620
 
537
621
  // usage/*
538
622
  {
@@ -544,6 +628,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
544
628
  code: "usage/conflicting-flags",
545
629
  summary: "Two or more supplied flags conflict.",
546
630
  },
631
+ {
632
+ code: "usage/flag-after-separator",
633
+ summary:
634
+ "A centrs flag was written after `--`, where every token is RouterOS input.",
635
+ },
547
636
  {
548
637
  code: "usage/invalid-concurrency",
549
638
  summary: "The --concurrency value must be an integer >= 1.",
@@ -567,6 +656,10 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
567
656
  code: "usage/invalid-method",
568
657
  summary: "The -X/--method value is not a supported HTTP method.",
569
658
  },
659
+ {
660
+ code: "usage/missing-labels",
661
+ summary: "`--cmr` was given without a `--labels` expression.",
662
+ },
570
663
  {
571
664
  code: "usage/missing-group",
572
665
  summary: "The operation requires a non-empty --group value.",
@@ -101,7 +101,7 @@ export function canonicalizeExecuteCommand(
101
101
 
102
102
  export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
103
103
  if (command.mode === "structured") {
104
- if (FILE_OUTPUT_ATTRIBUTE in command.attributes) {
104
+ if (Object.keys(command.attributes).some(isFileOutputAttribute)) {
105
105
  return true;
106
106
  }
107
107
  return !READ_ONLY_EXECUTE_VERBS.has(command.verb.toLowerCase());
@@ -110,21 +110,35 @@ export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
110
110
  }
111
111
 
112
112
  /**
113
- * The attribute that turns an otherwise read-only verb into a device write.
113
+ * The attributes that turn an otherwise read-only command into a device write.
114
114
  *
115
115
  * RouterOS routes command output to a file on the device when `file=` is
116
116
  * supplied, so a read verb creates a file: `/ip/address/export file=address`
117
117
  * writes `address.rsc`, and `/file/print file=test` writes `test.txt` (both
118
118
  * from the RouterOS manual — "Configuration Export" and "Scripting Tips and
119
119
  * Tricks", where that idiom is the documented way to create a file and needs
120
- * the script `write` policy). The verb allowlist cannot see that, so the
121
- * attribute is checked independently of it.
120
+ * the script `write` policy). `:serialize` spells it `file-name=` (its
121
+ * `/console/inspect` arguments, 7.13.5 through 7.26beta1). The verb and
122
+ * directive allowlists cannot see either, so they are checked independently.
122
123
  */
123
- const FILE_OUTPUT_ATTRIBUTE = "file";
124
+ const FILE_OUTPUT_ATTRIBUTES: readonly string[] = ["file", "file-name"];
124
125
 
125
- /** True for a `file=<name>` token — the write-shaping output redirect. */
126
+ /**
127
+ * RouterOS accepts argument prefixes (`fi=`, `file-n=`), including on
128
+ * `:serialize` (CHR 7.26beta1). An ambiguous prefix stays write-shaped too:
129
+ * only the device can disambiguate it, so the local gate fails closed.
130
+ */
131
+ function isFileOutputAttribute(name: string): boolean {
132
+ return (
133
+ name.length > 0 &&
134
+ FILE_OUTPUT_ATTRIBUTES.some((full) => full.startsWith(name))
135
+ );
136
+ }
137
+
138
+ /** True for an output-file argument, including its abbreviated spellings. */
126
139
  function isFileOutputToken(token: string): boolean {
127
- return token.startsWith(`${FILE_OUTPUT_ATTRIBUTE}=`);
140
+ const separator = token.indexOf("=");
141
+ return separator > 0 && isFileOutputAttribute(token.slice(0, separator));
128
142
  }
129
143
 
130
144
  /**
@@ -193,27 +207,59 @@ const WRITE_SHAPED_SCRIPT_DIRECTIVES: ReadonlySet<string> = new Set([
193
207
  /**
194
208
  * Script directives that are safe to run without device-write confirmation:
195
209
  * output, control flow and pure value functions, all members of `explain`'s
196
- * grounded root-cmd vocabulary (`ROOT_CMDS` in `src/explain/write.ts`). A
197
- * directive's `do={…}` or `[…]` body is its own command head and is checked on
198
- * its own. Deliberately absent: `:execute` and `:parse` (run built code),
199
- * `:global` and `:set` (outlive the script), and `:import`, `:password`,
200
- * `:undo`, `:redo` (configuration changes).
210
+ * grounded root-cmd vocabulary (`ROOT_CMDS` in `src/explain/write.ts`) and
211
+ * present on 7.26beta1. A directive's `do={…}` or `[…]` body is its own
212
+ * command head and is checked on its own. That holds because a script-typed
213
+ * argument (`:do command=`, `:if do=`, `:retry command=`, `:time`, `:onerror
214
+ * in=`) runs only a `{…}` block: RouterOS refuses a quoted string or a variable
215
+ * holding one there as an invalid value (CHR 7.24.5 and 7.26beta1, #458), so
216
+ * no body can hide in text the gate blanks. The value functions (#458) take
217
+ * only values; `:serialize` alone can write, through `file-name=` (it creates
218
+ * the file on 7.26beta1), which the file-output check catches.
219
+ *
220
+ * Deliberately absent: `:execute` and `:parse` (run built code), `:global` and
221
+ * `:set` (outlive the script), and `:import`, `:password`, `:undo`, `:redo`
222
+ * (configuration changes). A directive missing here costs `--yes`; one wrongly
223
+ * here bypasses the gate.
201
224
  */
202
225
  const READ_ONLY_SCRIPT_DIRECTIVES: ReadonlySet<string> = new Set([
203
226
  ":break",
204
227
  ":continue",
228
+ ":convert",
229
+ ":delay",
230
+ ":deserialize",
205
231
  ":do",
206
232
  ":error",
233
+ ":find",
207
234
  ":for",
208
235
  ":foreach",
209
236
  ":if",
237
+ ":jobname",
210
238
  ":len",
211
239
  ":local",
240
+ ":nothing",
212
241
  ":onerror",
213
242
  ":pick",
214
243
  ":put",
244
+ ":range",
245
+ ":retry",
215
246
  ":return",
247
+ ":rndnum",
248
+ ":rndstr",
249
+ ":serialize",
250
+ ":time",
251
+ ":timestamp",
252
+ ":toarray",
253
+ ":tobool",
254
+ ":tocrlf",
255
+ ":toid",
256
+ ":toip",
257
+ ":toip6",
258
+ ":tolf",
259
+ ":tonsec",
260
+ ":tonum",
216
261
  ":tostr",
262
+ ":totime",
217
263
  ":typeof",
218
264
  ":while",
219
265
  ]);
@@ -249,7 +295,11 @@ function scriptContainsWriteShapedCommand(input: string): boolean {
249
295
  // Every command the statement runs must be proven read-only, not just the
250
296
  // first: `:foreach i in=[/ip address find] do={/user expire-password $i}`
251
297
  // reads with `find` and writes with an unlearned mutator.
252
- if (!commandHeads(statement).every(isReadOnlyCommand)) {
298
+ if (
299
+ !commandHeads(statement).every(
300
+ (head) => !head.spacedFileOutput && isReadOnlyCommand(head.words),
301
+ )
302
+ ) {
253
303
  return true;
254
304
  }
255
305
  }
@@ -264,12 +314,23 @@ function scriptContainsWriteShapedCommand(input: string): boolean {
264
314
  * expressions, not commands, so they do not start one. Each head reads only up
265
315
  * to the next opener, which begins its own head.
266
316
  */
267
- function commandHeads(statement: string): string[][] {
268
- const heads: string[][] = [];
317
+ function commandHeads(
318
+ statement: string,
319
+ ): { words: string[]; spacedFileOutput: boolean }[] {
320
+ const heads: { words: string[]; spacedFileOutput: boolean }[] = [];
269
321
  const starts = [0];
322
+ // Match group ends once. Each command can then skip its nested bodies
323
+ // without walking them again; deeply nested reads stay linear in input size.
324
+ const closes = new Map<number, number>();
325
+ const open: number[] = [];
270
326
  for (let index = 0; index < statement.length; index += 1) {
271
- const char = statement[index];
327
+ const char = statement[index] ?? "";
272
328
  if (char === "[" || char === "{") starts.push(index + 1);
329
+ if ("[{(".includes(char)) open.push(index);
330
+ else if ("]})".includes(char)) {
331
+ const start = open.pop();
332
+ if (start !== undefined) closes.set(start, index);
333
+ }
273
334
  }
274
335
  for (const start of starts) {
275
336
  const nextOpener = statement.slice(start).search(/[[{(]/);
@@ -282,11 +343,58 @@ function commandHeads(statement: string): string[][] {
282
343
  if (head === undefined) continue;
283
344
  const words = commandWords(head, tokens.slice(1));
284
345
  // A lone closer (`}` ending a multi-line block) runs nothing.
285
- if (words.length > 0) heads.push(words);
346
+ if (words.length > 0)
347
+ heads.push({
348
+ words,
349
+ spacedFileOutput: hasSpacedFileOutput(statement, start, words, closes),
350
+ });
286
351
  }
287
352
  return heads;
288
353
  }
289
354
 
355
+ /**
356
+ * RouterOS also accepts `file value` / `file-name value` without `=`. These
357
+ * belong to print/export/serialize, not a loop variable or a pure function's
358
+ * value. Read the command's own tokens, skipping nested expressions and
359
+ * blocks: `:serialize value=[:tostr file] file-n "out"` has one file argument
360
+ * after its nested read. Quoted text is already masked by the statement split.
361
+ * Grounded on CHR 7.26beta1, with export also checked on long-term.
362
+ */
363
+ function hasSpacedFileOutput(
364
+ statement: string,
365
+ start: number,
366
+ words: readonly string[],
367
+ closes: ReadonlyMap<number, number>,
368
+ ): boolean {
369
+ let commandWords: number;
370
+ if (words[0] === ":serialize") {
371
+ commandWords = 1;
372
+ } else {
373
+ const depth = menuDepth(words);
374
+ if (words[depth] !== "print" && words[depth] !== "export") return false;
375
+ commandWords = depth + 1;
376
+ }
377
+ let own = "";
378
+ for (let index = start; index < statement.length; index += 1) {
379
+ const char = statement[index] ?? "";
380
+ if ("[{(".includes(char)) {
381
+ own += " ";
382
+ index = closes.get(index) ?? statement.length;
383
+ } else if ("]})".includes(char)) {
384
+ break;
385
+ } else own += char;
386
+ }
387
+ let consumed = 0;
388
+ for (const token of tokenizeRouterOsCli(own)) {
389
+ if (consumed < commandWords) {
390
+ consumed += token.split("/").filter(Boolean).length;
391
+ continue;
392
+ }
393
+ if (!token.includes("=") && isFileOutputAttribute(token)) return true;
394
+ }
395
+ return false;
396
+ }
397
+
290
398
  /**
291
399
  * The path segments and verb of one command, from its head token and the bare
292
400
  * words after it. The space-separated spelling puts menu segments and the verb
@@ -299,6 +407,19 @@ function commandWords(head: string, following: readonly string[]): string[] {
299
407
  const words: string[] = [];
300
408
  const headWord = trimGroupingPunctuation(head);
301
409
  if (headWord.startsWith(":")) return [headWord];
410
+ // `/serialize …` is the root command `:serialize`: at statement start the
411
+ // sigil is optional and `/` binds the root vocabulary (CHR 7.23.2, see
412
+ // `src/explain/symbols.ts`). Only the absolute one-segment form is read
413
+ // this way. A bare `delay` may be a relative menu command after earlier
414
+ // navigation, so it keeps the menu-verb rule and fails closed.
415
+ const root = /^\/([A-Za-z0-9-]+)$/.exec(headWord)?.[1]?.toLowerCase();
416
+ if (
417
+ root !== undefined &&
418
+ READ_ONLY_SCRIPT_DIRECTIVES.has(`:${root}`) &&
419
+ !isKnownMenuPath([root])
420
+ ) {
421
+ return [`:${root}`];
422
+ }
302
423
  words.push(...headWord.split("/").filter(Boolean));
303
424
  if (headWord !== head) return words;
304
425
  for (const token of following) {
@@ -325,12 +446,16 @@ function commandWords(head: string, following: readonly string[]): string[] {
325
446
  function isReadOnlyCommand(words: readonly string[]): boolean {
326
447
  const head = words[0] ?? "";
327
448
  if (head.startsWith(":")) return READ_ONLY_SCRIPT_DIRECTIVES.has(head);
449
+ const verb = words[menuDepth(words)];
450
+ return verb !== undefined && READ_ONLY_EXECUTE_VERBS.has(verb.toLowerCase());
451
+ }
452
+
453
+ function menuDepth(words: readonly string[]): number {
328
454
  let depth = 0;
329
455
  while (depth < words.length && isKnownMenuPath(words.slice(0, depth + 1))) {
330
456
  depth += 1;
331
457
  }
332
- const verb = words[depth];
333
- return verb !== undefined && READ_ONLY_EXECUTE_VERBS.has(verb.toLowerCase());
458
+ return depth;
334
459
  }
335
460
 
336
461
  function trimGroupingPunctuation(value: string): string {