@tikoci/centrs 0.1.7 → 0.1.8-next.0

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 (79) hide show
  1. package/CHANGELOG.md +235 -0
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +2 -2
  4. package/SECURITY.md +1 -1
  5. package/commands/AGENTS.md +2 -1
  6. package/commands/api/AGENTS.md +55 -30
  7. package/commands/api/README.md +123 -41
  8. package/commands/api/examples.md +205 -17
  9. package/commands/devices/README.md +11 -3
  10. package/commands/execute/README.md +31 -3
  11. package/commands/execute/examples.md +136 -1
  12. package/commands/explain/README.md +30 -4
  13. package/commands/retrieve/README.md +348 -4
  14. package/commands/retrieve/examples.md +422 -5
  15. package/commands/terminal/README.md +14 -4
  16. package/commands/terminal/examples.md +22 -3
  17. package/commands/transfer/README.md +55 -9
  18. package/commands/transfer/examples.md +106 -7
  19. package/docs/CLI.md +28 -20
  20. package/docs/CONSTITUTION.md +30 -10
  21. package/docs/MATRIX.md +38 -1
  22. package/docs/errors/README.md +3 -1
  23. package/docs/errors/input/invalid-query.md +36 -0
  24. package/docs/errors/input/local-destination.md +20 -0
  25. package/docs/errors/input/unsupported-query.md +19 -0
  26. package/docs/errors/internal/byte-string.md +13 -0
  27. package/docs/errors/settings/invalid-timeout.md +6 -0
  28. package/docs/errors/transport/stream-overflow.md +21 -0
  29. package/docs/errors/usage/removed-flag.md +20 -0
  30. package/docs/errors/validation/menu-unavailable.md +22 -0
  31. package/docs/errors/validation/not-followable.md +23 -0
  32. package/docs/errors/validation/package-missing.md +29 -0
  33. package/docs/errors/validation/unknown-path.md +16 -4
  34. package/docs/errors/wait/deadline-exceeded.md +14 -0
  35. package/docs/errors/wait/interrupted.md +10 -0
  36. package/package.json +14 -11
  37. package/src/absent-menu.ts +280 -0
  38. package/src/api-fanout.ts +22 -12
  39. package/src/api.ts +511 -186
  40. package/src/btest.ts +19 -17
  41. package/src/cli/api.ts +126 -34
  42. package/src/cli/execute.ts +18 -5
  43. package/src/cli/retrieve.ts +216 -22
  44. package/src/cli/terminal.ts +10 -5
  45. package/src/cli/transfer.ts +23 -8
  46. package/src/core/envelope.ts +14 -0
  47. package/src/core/error-catalog.ts +56 -1
  48. package/src/core/inspect.ts +43 -5
  49. package/src/core/query.ts +508 -0
  50. package/src/core/routeros-errors.ts +1 -1
  51. package/src/devices.ts +6 -9
  52. package/src/discover.ts +7 -8
  53. package/src/errors.ts +116 -7
  54. package/src/execute-fanout.ts +13 -8
  55. package/src/execute.ts +106 -65
  56. package/src/explain/catalog.ts +58 -10
  57. package/src/explain/menus.ts +23 -2
  58. package/src/explain.ts +6 -6
  59. package/src/index.ts +29 -1
  60. package/src/mcp/tools.ts +9 -14
  61. package/src/protocols/adapter.ts +224 -118
  62. package/src/protocols/btest-session.ts +3 -2
  63. package/src/protocols/endpoint.ts +10 -0
  64. package/src/protocols/mac-telnet-console.ts +6 -3
  65. package/src/protocols/native-api.ts +223 -70
  66. package/src/protocols/sftp.ts +44 -14
  67. package/src/protocols/ssh.ts +42 -14
  68. package/src/resolver/cdb.ts +28 -7
  69. package/src/resolver/index.ts +1 -0
  70. package/src/resolver/target.ts +53 -7
  71. package/src/retrieve-fanout.ts +51 -12
  72. package/src/retrieve-follow.ts +862 -0
  73. package/src/retrieve-sample.ts +244 -0
  74. package/src/retrieve-stream.ts +115 -0
  75. package/src/retrieve-wait.ts +286 -0
  76. package/src/retrieve.ts +727 -108
  77. package/src/settings.ts +15 -15
  78. package/src/transfer-fanout.ts +40 -15
  79. package/src/transfer.ts +234 -32
package/src/btest.ts CHANGED
@@ -16,7 +16,13 @@
16
16
 
17
17
  import { createServer } from "node:net";
18
18
  import type { CentrsEnvelope, EnvelopeMeta } from "./core/envelope.ts";
19
- import { asCentrsError, CentrsError, serializeCentrsError } from "./errors.ts";
19
+ import {
20
+ asCentrsError,
21
+ CentrsError,
22
+ formatCentrsErrorText,
23
+ serializeCentrsError,
24
+ terminalSafeLine,
25
+ } from "./errors.ts";
20
26
  import {
21
27
  BTEST_PORT,
22
28
  BTEST_UDP_PORT_START,
@@ -868,7 +874,11 @@ function renderClientCsv(envelope: BtestClientEnvelope): string {
868
874
  if (envelope.ok)
869
875
  for (const r of envelope.data.reports) lines.push(btestClientCsvRow(r));
870
876
  else
871
- lines.push(`# error: [${envelope.error.code}] ${envelope.error.summary}`);
877
+ lines.push(
878
+ terminalSafeLine(
879
+ `# error: [${envelope.error.code}] ${envelope.error.summary}`,
880
+ ),
881
+ );
872
882
  return lines.join("\n");
873
883
  }
874
884
 
@@ -877,7 +887,11 @@ function renderServerCsv(envelope: BtestServerEnvelope): string {
877
887
  if (envelope.ok)
878
888
  for (const s of envelope.data.sessions) lines.push(btestServerCsvRow(s));
879
889
  else
880
- lines.push(`# error: [${envelope.error.code}] ${envelope.error.summary}`);
890
+ lines.push(
891
+ terminalSafeLine(
892
+ `# error: [${envelope.error.code}] ${envelope.error.summary}`,
893
+ ),
894
+ );
881
895
  return lines.join("\n");
882
896
  }
883
897
 
@@ -902,14 +916,14 @@ export function btestServerSummaryLine(d: BtestServerData): string {
902
916
  }
903
917
 
904
918
  function renderClientText(envelope: BtestClientEnvelope): string {
905
- if (!envelope.ok) return formatErrorText(envelope.error);
919
+ if (!envelope.ok) return formatCentrsErrorText(envelope.error);
906
920
  const lines = envelope.data.reports.map(formatBtestReportText);
907
921
  lines.push(btestClientSummaryLine(envelope.data));
908
922
  return lines.join("\n");
909
923
  }
910
924
 
911
925
  function renderServerText(envelope: BtestServerEnvelope): string {
912
- if (!envelope.ok) return formatErrorText(envelope.error);
926
+ if (!envelope.ok) return formatCentrsErrorText(envelope.error);
913
927
  const lines = [btestServerSummaryLine(envelope.data)];
914
928
  for (const s of envelope.data.sessions) lines.push(formatBtestSessionText(s));
915
929
  for (const w of envelope.warnings)
@@ -917,18 +931,6 @@ function renderServerText(envelope: BtestServerEnvelope): string {
917
931
  return lines.join("\n");
918
932
  }
919
933
 
920
- function formatErrorText(error: {
921
- code: string;
922
- summary: string;
923
- remediation?: string;
924
- detailsUrl?: string;
925
- }): string {
926
- const lines = [`[${error.code}] ${error.summary}`];
927
- if (error.remediation) lines.push(`Fix: ${error.remediation}`);
928
- if (error.detailsUrl) lines.push(`Details: ${error.detailsUrl}`);
929
- return lines.join("\n");
930
- }
931
-
932
934
  // Compact YAML renderer (per-command, matching the discover surface).
933
935
  function renderYaml(value: unknown, indent = ""): string {
934
936
  if (value === null || value === undefined) return `${indent}null`;
package/src/cli/api.ts CHANGED
@@ -2,10 +2,11 @@ import {
2
2
  type ApiOutputFormat,
3
3
  type ApiRequest,
4
4
  apiEnvelope,
5
- apiListen,
6
5
  apiOutputFormats,
6
+ apiStream,
7
7
  buildApiErrorEnvelope,
8
8
  normalizeApiEndpoint,
9
+ removedListenError,
9
10
  renderApiEnvelope,
10
11
  renderApiStreamLine,
11
12
  } from "../api.ts";
@@ -48,7 +49,8 @@ import {
48
49
 
49
50
  export const apiCommand: CliCommandMetadata = {
50
51
  name: "api",
51
- usage: "centrs api <router> <endpoint> [flags]",
52
+ usage:
53
+ "centrs api <router> <endpoint> [flags] | centrs api --host <host|url> <endpoint> [flags]",
52
54
  summary:
53
55
  "Structured RouterOS API passthrough (gh-api style) over REST or native API.",
54
56
  options: [
@@ -78,13 +80,13 @@ export const apiCommand: CliCommandMetadata = {
78
80
  flag: "--query / --filter",
79
81
  valueName: "<expr>",
80
82
  description:
81
- "RouterOS-side row filter, AND-combined, repeatable: name=value, name!=value, name>value, name<value, name.",
83
+ "Row filter written as after `print where` (`disabled`, `mtu>=1500 and !dynamic`), sent as query words; repeatable, AND-ed. No `~` or `in`.",
82
84
  },
83
85
  {
84
86
  flag: "--raw-query",
85
87
  valueName: "<word>",
86
88
  description:
87
- "Verbatim RouterOS query word (repeatable) for OR / absence / stack expressions.",
89
+ "Verbatim RouterOS query word (repeatable), e.g. has-property `name`, absence `-name`, or stack ops `#|` `#&` `#!`.",
88
90
  },
89
91
  {
90
92
  flag: "--attribute / --proplist",
@@ -102,14 +104,15 @@ export const apiCommand: CliCommandMetadata = {
102
104
  "Confirm a mutating (non-read) request in non-interactive runs.",
103
105
  },
104
106
  {
105
- flag: "--stream / --listen",
107
+ flag: "--stream",
106
108
  description:
107
- "Follow changes as an NDJSON envelope stream (native-api only; the `/listen` endpoint infers it). Ends with a summary envelope.",
109
+ "Stream the command's native replies (`!re` rows and `!empty`) as NDJSON, then a summary envelope. Never changes the command: a GET streams one print. To follow changes, request `<menu>/listen`, which implies it.",
108
110
  },
109
111
  {
110
112
  flag: "--count",
111
113
  valueName: "<n>",
112
- description: "Stop a `--stream` after N change frames.",
114
+ description:
115
+ "Stop a `--stream` after N `!re` rows; `!empty` replies do not count (not the device count= argument).",
113
116
  },
114
117
  {
115
118
  flag: "--duration",
@@ -126,7 +129,8 @@ export const apiCommand: CliCommandMetadata = {
126
129
  {
127
130
  flag: "--host",
128
131
  valueName: "<host|url>",
129
- description: "Override the resolved host or base URL for the target.",
132
+ description:
133
+ "Host or base URL to dial. With no <router> it is the target (no CDB lookup); with one it overrides the resolved host.",
130
134
  },
131
135
  {
132
136
  flag: "--port",
@@ -176,7 +180,7 @@ export const apiCommand: CliCommandMetadata = {
176
180
  },
177
181
  {
178
182
  flag: "--format",
179
- valueName: "<json|yaml|text>",
183
+ valueName: "<json|ndjson|yaml|text>",
180
184
  description:
181
185
  "Output format. Defaults to json for api; `CENTRS_FORMAT` overrides.",
182
186
  },
@@ -205,7 +209,9 @@ interface ApiCliArgs extends ApiRequest {
205
209
  export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
206
210
  const parsed: ApiCliArgs = { endpoint: "" };
207
211
  const positional: string[] = [];
212
+ const positionalIndexes: number[] = [];
208
213
  const selectionFlags = emptySelectionFlags();
214
+ let listenFlag = false;
209
215
 
210
216
  for (let index = 0; index < args.length; index += 1) {
211
217
  const arg = args[index];
@@ -234,8 +240,10 @@ export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
234
240
  parsed.raw = true;
235
241
  break;
236
242
  case "--stream":
243
+ parsed.stream = true;
244
+ break;
237
245
  case "--listen":
238
- parsed.listen = true;
246
+ listenFlag = true;
239
247
  break;
240
248
  case "--validate":
241
249
  parsed.validate = true;
@@ -345,10 +353,18 @@ export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
345
353
  throw unknownFlagError("api", arg, apiCommand.options);
346
354
  }
347
355
  positional.push(arg);
356
+ positionalIndexes.push(index);
348
357
  break;
349
358
  }
350
359
  }
351
360
  }
361
+ if (listenFlag) {
362
+ throw removedListenError(
363
+ positional.at(-1) ?? "",
364
+ "cli",
365
+ listenReplacement(args, positionalIndexes.at(-1), parsed.method),
366
+ );
367
+ }
352
368
 
353
369
  // api/retrieve positional boundary: the FINAL positional is the endpoint; every
354
370
  // preceding positional is a fan-out target.
@@ -360,6 +376,79 @@ export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
360
376
  return parsed;
361
377
  }
362
378
 
379
+ /**
380
+ * The exact command `--listen` meant (#402): a GET becomes the menu's
381
+ * `/listen` endpoint, anything else keeps its command and streams. Secrets in
382
+ * the original arguments are not echoed back. Any value can carry one (a
383
+ * `-f script=` body holds whole commands), so every request value is a
384
+ * placeholder: `-f`/query values keep their key and operator, a `-d` body and
385
+ * passwords are replaced whole. Flag names, the method and the endpoint stay.
386
+ */
387
+ function listenReplacement(
388
+ args: readonly string[],
389
+ endpointIndex: number | undefined,
390
+ method: string | undefined,
391
+ ): string {
392
+ const get = (method ?? "GET").toUpperCase() === "GET";
393
+ const words: string[] = [];
394
+ for (let index = 0; index < args.length; index += 1) {
395
+ const arg = args[index] as string;
396
+ if (arg === "--listen") {
397
+ if (!get) words.push("--stream");
398
+ continue;
399
+ }
400
+ if (index === endpointIndex && get && !normalizeApiEndpoint(arg).listen) {
401
+ words.push(shellWord(`${arg.replace(/\/+$/, "")}/listen`));
402
+ continue;
403
+ }
404
+ words.push(shellWord(arg));
405
+ const value = args[index + 1];
406
+ const placeholder =
407
+ value === undefined ? undefined : redactedValue(arg, value);
408
+ if (placeholder !== undefined) {
409
+ words.push(placeholder);
410
+ index += 1;
411
+ }
412
+ }
413
+ return `centrs api ${words.join(" ")}`;
414
+ }
415
+
416
+ /**
417
+ * The placeholder that stands in for a request value, or `undefined` for a
418
+ * flag whose value is echoed (method, transport, bounds, names). Placeholders
419
+ * are left unquoted, like `<router>`.
420
+ */
421
+ function redactedValue(flag: string, value: string): string | undefined {
422
+ switch (flag) {
423
+ case "--password":
424
+ case "--cdb-password":
425
+ return "<password>";
426
+ case "-d":
427
+ case "--data":
428
+ return "<json>";
429
+ case "-f":
430
+ case "--field":
431
+ case "--query":
432
+ case "--filter":
433
+ case "--raw-query": {
434
+ // Keep `key` plus its operator (`=`, `!=`, `<`, `>`); a bare word
435
+ // such as `#|` or `name` carries no value and is echoed.
436
+ let at = 0;
437
+ while (at < value.length && !"=<>".includes(value.charAt(at))) at += 1;
438
+ if (at === value.length) return shellWord(value);
439
+ return `${shellWord(value.slice(0, at + 1))}<value>`;
440
+ }
441
+ default:
442
+ return undefined;
443
+ }
444
+ }
445
+
446
+ function shellWord(word: string): string {
447
+ return /^[\w@%+=:,./*-]+$/.test(word)
448
+ ? word
449
+ : `'${word.replaceAll("'", "'\\''")}'`;
450
+ }
451
+
363
452
  export async function runApiCli(args: readonly string[]): Promise<number> {
364
453
  let parsed: ApiCliArgs | undefined;
365
454
  try {
@@ -404,7 +493,7 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
404
493
 
405
494
  // Fan-out mode (selector flag present, or >1 positional target) has its own
406
495
  // envelope shape, guards, and granular exit code. It runs first and rejects
407
- // `--stream`/`--listen` + fan-out itself (single-session is exclusive).
496
+ // `--stream` + fan-out itself (single-session is exclusive).
408
497
  if (isFanoutMode(selectionFlags, targetPositionals.length)) {
409
498
  return await runApiFanoutCli(
410
499
  parsed,
@@ -419,22 +508,28 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
419
508
  parsed.quickchr = selectionFlags.quickchr[0];
420
509
  }
421
510
 
422
- // Open-ended follow (`--stream`/`--listen`, or a `/listen` endpoint) consumes
511
+ // A stream (`--stream`, or a `/listen` endpoint) consumes
423
512
  // the NDJSON envelope stream instead of a single one-shot envelope.
424
513
  const streaming =
425
- parsed.listen === true ||
514
+ parsed.stream === true ||
426
515
  (parsed.endpoint.length > 0 &&
427
516
  normalizeApiEndpoint(parsed.endpoint).listen);
428
517
  if (streaming) {
429
- return await runApiListenCli(parsed, args);
518
+ return await runApiStreamCli(parsed, args);
430
519
  }
431
520
 
432
- if (!parsed.targetInput && parsed.quickchr === undefined) {
521
+ // The `--host` flag stands in for a missing `<router>` (#428), with no CDB
522
+ // lookup; `CENTRS_HOST` never counts.
523
+ if (
524
+ !parsed.targetInput &&
525
+ parsed.quickchr === undefined &&
526
+ parsed.host === undefined
527
+ ) {
433
528
  throw missingTargetError({
434
529
  command: "api",
435
530
  summary: "`centrs api` requires a <router> and an <endpoint>.",
436
531
  remediation:
437
- "Pass the router host/identity then the endpoint, e.g. `centrs api 192.0.2.10 ip/address`.",
532
+ "Pass the router host/identity then the endpoint, e.g. `centrs api 192.0.2.10 ip/address`, or name the host with `--host <host|url>`.",
438
533
  });
439
534
  }
440
535
 
@@ -477,7 +572,7 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
477
572
  verbose: parsed.verbose ?? false,
478
573
  }),
479
574
  );
480
- } else if (format === "json" || format === "yaml") {
575
+ } else if (format !== "text") {
481
576
  const envelope = withTips(
482
577
  buildApiErrorEnvelope(parsed ?? { endpoint: "" }, error),
483
578
  tips,
@@ -526,14 +621,14 @@ async function runApiFanoutCli(
526
621
  context: { flags: ["--raw", "fanout"] },
527
622
  });
528
623
  }
529
- // `--listen`/`--stream` is single-session.
624
+ // `--stream` is single-session.
530
625
  const listenRequested =
531
- (parsed.listen ?? false) || endpointInfersListen(parsed.endpoint);
626
+ (parsed.stream ?? false) || endpointInfersListen(parsed.endpoint);
532
627
  if (listenRequested) {
533
628
  throw new CentrsError({
534
629
  code: "usage/fanout-not-supported",
535
630
  summary:
536
- "`api --stream`/`--listen` is single-session and cannot fan out across multiple targets.",
631
+ "`api --stream` is single-session and cannot fan out across multiple targets.",
537
632
  remediation:
538
633
  "Follow a stream against a single router (no `--group`/`--where`/`--all`/`--default`/multiple positionals).",
539
634
  context: { capability: "listen" },
@@ -573,7 +668,7 @@ async function runApiFanoutCli(
573
668
  }
574
669
  }
575
670
 
576
- /** Whether an endpoint's trailing segment infers `--listen` (without throwing). */
671
+ /** Whether an endpoint's trailing `listen` segment implies `--stream` (without throwing). */
577
672
  function endpointInfersListen(endpoint: string): boolean {
578
673
  try {
579
674
  return normalizeApiEndpoint(endpoint).listen;
@@ -583,13 +678,11 @@ function endpointInfersListen(endpoint: string): boolean {
583
678
  }
584
679
 
585
680
  /**
586
- * Consume the open-ended `--stream` follow: print one line per change frame
587
- * (NDJSON under `json`, human rows under `text`), then the terminating summary.
588
- * Ctrl-C (SIGINT) cancels the listen and still emits the summary. The exit code
589
- * reflects whether the stream *started* cleanly — a failure before the first
590
- * frame is `1`; a mid-stream error frame leaves a started stream at `0`.
681
+ * Emit all structured stream outcomes on stdout. Any failure sets exit 1,
682
+ * including a terminal error after successful rows. Raw mode retains its
683
+ * stderr error contract; Ctrl-C requests bounded cancellation.
591
684
  */
592
- async function runApiListenCli(
685
+ async function runApiStreamCli(
593
686
  parsed: ApiCliArgs,
594
687
  args: readonly string[],
595
688
  ): Promise<number> {
@@ -598,26 +691,25 @@ async function runApiListenCli(
598
691
  const onSigint = (): void => controller.abort();
599
692
  process.on("SIGINT", onSigint);
600
693
  let exitCode = 0;
601
- let first = true;
602
694
  try {
603
- for await (const envelope of apiListen(
695
+ for await (const envelope of apiStream(
604
696
  parsed,
605
697
  Bun.env,
606
698
  controller.signal,
607
699
  )) {
700
+ // `--raw` is bare payloads only; a notice has none.
701
+ if (parsed.raw && envelope.meta.operation?.stream?.kind === "notice")
702
+ continue;
608
703
  const line = renderApiStreamLine(envelope, format, {
609
704
  raw: parsed.raw,
610
705
  verbose: parsed.verbose,
611
706
  });
612
- if (envelope.ok) {
707
+ if (envelope.ok || !parsed.raw) {
613
708
  console.log(line);
614
709
  } else {
615
710
  console.error(line);
616
- if (first) {
617
- exitCode = 1;
618
- }
619
711
  }
620
- first = false;
712
+ if (!envelope.ok) exitCode = 1;
621
713
  }
622
714
  } finally {
623
715
  process.off("SIGINT", onSigint);
@@ -43,7 +43,7 @@ import {
43
43
  export const executeCommand: CliCommandMetadata = {
44
44
  name: "execute",
45
45
  usage:
46
- "centrs execute <target> <command> [flags] | centrs execute <target...> -- <command> [flags] | centrs execute --group <name> <command> [flags]",
46
+ "centrs execute <target> <command> [flags] | centrs execute <target...> -- <command> [flags] | centrs execute --host <host|url> [--] <command> [flags] | centrs execute --group <name> <command> [flags]",
47
47
  summary:
48
48
  "Run a RouterOS read or write command via native API, REST, or mac-telnet.",
49
49
  options: [
@@ -57,7 +57,8 @@ export const executeCommand: CliCommandMetadata = {
57
57
  {
58
58
  flag: "--host",
59
59
  valueName: "<host|url>",
60
- description: "Override the resolved host or base URL for the target.",
60
+ description:
61
+ "Host or base URL to dial. With no <target> it is the target (no CDB lookup); with one it overrides the resolved host.",
61
62
  },
62
63
  {
63
64
  flag: "--port",
@@ -270,6 +271,10 @@ export function parseExecuteCliArgs(args: readonly string[]): ExecuteCliArgs {
270
271
  // - `--` present: targets are the positionals BEFORE it, command is AFTER it.
271
272
  // - selector flag (or `--quickchr`) present, no `--`: every positional is the
272
273
  // command (targets come from the flag).
274
+ // - `--host` flag with a lone positional, no `--`: that positional is the
275
+ // command and `--host` stands in for `<router>` (#428). Two or more keep
276
+ // the legacy split, so `execute <mac> <cmd> --host <udp-endpoint>` (the
277
+ // mac-telnet delivery override) still parses. `CENTRS_HOST` never counts.
273
278
  // - otherwise (no `--`, no selector): legacy single-target split — first
274
279
  // positional is the target, the rest is the command. Multiple positional
275
280
  // targets therefore REQUIRE `--`.
@@ -280,6 +285,9 @@ export function parseExecuteCliArgs(args: readonly string[]): ExecuteCliArgs {
280
285
  } else if (hasSelector) {
281
286
  parsed.targetPositionals = [];
282
287
  parsed.command = positional.join(" ");
288
+ } else if (parsed.host !== undefined && positional.length === 1) {
289
+ parsed.targetPositionals = [];
290
+ parsed.command = positional[0] ?? "";
283
291
  } else {
284
292
  parsed.targetPositionals =
285
293
  positional[0] !== undefined ? [positional[0]] : [];
@@ -331,13 +339,18 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
331
339
  // No positional at all: lead with the missing-target guidance (CDB picker /
332
340
  // discover) rather than the missing-command error — there is nothing to run
333
341
  // without a device. A lone positional is treated as the target, so the
334
- // "requires a command" path still fires for `execute <target>`.
335
- if (!parsed.targetInput && parsed.quickchr === undefined) {
342
+ // "requires a command" path still fires for `execute <target>`. The
343
+ // `--host` flag is a target of its own, with no CDB lookup.
344
+ if (
345
+ !parsed.targetInput &&
346
+ parsed.quickchr === undefined &&
347
+ parsed.host === undefined
348
+ ) {
336
349
  throw missingTargetError({
337
350
  command: "execute",
338
351
  summary: "`centrs execute` requires a <target> and a RouterOS command.",
339
352
  remediation:
340
- 'Pass the router host/identity then the command, e.g. `centrs execute 192.0.2.10 "/system/resource/print"`.',
353
+ 'Pass the router host/identity then the command, e.g. `centrs execute 192.0.2.10 "/system/resource/print"`, or name the host with `--host <host|url>`.',
341
354
  });
342
355
  }
343
356